deckz
Advanced tools
+18
| # To make this function accessible from the command line in the current shell, | ||
| # dot source them using the following command: | ||
| # . .\Makefile.ps1 | ||
| function check { | ||
| ruff check src/deckz tests | ||
| mypy src/deckz tests | ||
| } | ||
| function build-and-push-docker-image { | ||
| docker build -t shuuchuu/deckz-ci . | ||
| docker push shuuchuu/deckz-ci:latest | ||
| } | ||
| function make { | ||
| check | ||
| build-and-push-docker-image | ||
| } | ||
| from pathlib import Path | ||
| from . import app | ||
| @app.command() | ||
| def asset_deps( | ||
| *, verbose: bool = True, descending: bool = True, workdir: Path = Path() | ||
| ) -> None: | ||
| """Find unlicensed assets with output detailed by section. | ||
| Args: | ||
| verbose: Detailed output with a listing of used assets | ||
| descending: Sort sections by ascending number of unlicensed assets | ||
| workdir: Path to move into before running the command | ||
| """ | ||
| from collections.abc import Mapping, Set | ||
| from rich.console import Console | ||
| from rich.table import Table | ||
| from ..components.factory import GlobalSettingsFactory | ||
| from ..configuring.settings import GlobalSettings | ||
| from ..models import UnresolvedPath | ||
| def _display_table( | ||
| unlicensed_assets: Mapping[UnresolvedPath, Set[Path]], | ||
| console: Console, | ||
| ) -> None: | ||
| if unlicensed_assets: | ||
| table = Table("Section", "Unlicensed assets") | ||
| for section, images in unlicensed_assets.items(): | ||
| table.add_row(str(section), f"{len(images)}") | ||
| console.print(table) | ||
| else: | ||
| console.print("No unlicensed asset!") | ||
| def _display_section_assets( | ||
| unlicensed_assets: Mapping[UnresolvedPath, Set[Path]], | ||
| console: Console, | ||
| shared_dir: Path, | ||
| ) -> None: | ||
| if unlicensed_assets: | ||
| for section, images in unlicensed_assets.items(): | ||
| console.print() | ||
| console.rule( | ||
| f"[bold]{section}[/] — " | ||
| f"[red]{len(images)}[/] " | ||
| f"unlicensed asset{'s' * (len(images) > 1)}", | ||
| align="left", | ||
| ) | ||
| console.print() | ||
| for image in sorted(images): | ||
| matches = image.parent.glob(f"{image.name}.*") | ||
| console.print( | ||
| " or ".join( | ||
| f"[link=file://{m}]{m.relative_to(shared_dir)}[/link]" | ||
| for m in matches | ||
| if m.suffix != ".yml" | ||
| ) | ||
| ) | ||
| else: | ||
| console.print("No unlicensed asset!") | ||
| settings = GlobalSettings.from_yaml(workdir) | ||
| console = Console(highlight=False) | ||
| with console.status("Finding unlicensed assets"): | ||
| assets_analyzer = GlobalSettingsFactory(settings).assets_analyzer() | ||
| unlicensed_assets = assets_analyzer.sections_unlicensed_images() | ||
| sorted_unlicensed_assets = { | ||
| k: v | ||
| for k, v in sorted( | ||
| unlicensed_assets.items(), | ||
| key=lambda t: len(t[1]), | ||
| reverse=descending, | ||
| ) | ||
| if v | ||
| } | ||
| if verbose: | ||
| console.print("[bold]Sections and their unlicensed assets[/]") | ||
| _display_section_assets( | ||
| sorted_unlicensed_assets, console, settings.paths.shared_dir | ||
| ) | ||
| else: | ||
| _display_table(sorted_unlicensed_assets, console) |
| from pathlib import Path | ||
| from . import app | ||
| @app.command() | ||
| def asset_search(asset: str, /, *, workdir: Path = Path()) -> None: | ||
| """Find which files use ASSET. | ||
| Args: | ||
| asset: Asset to search in files. Specify the path relative to the shared \ | ||
| directory and whithout extension, e.g. img/turing | ||
| workdir: Path to move into before running the command | ||
| """ | ||
| from rich.console import Console | ||
| from ..components.factory import GlobalSettingsFactory | ||
| from ..configuring.settings import GlobalSettings | ||
| settings = GlobalSettings.from_yaml(workdir) | ||
| console = Console(highlight=False) | ||
| assets_searcher = GlobalSettingsFactory(settings).assets_searcher() | ||
| with console.status("Processing decks"): | ||
| result = assets_searcher.search(asset) | ||
| for path in result: | ||
| console.print( | ||
| f"[link=file://{path}]{path.relative_to(settings.paths.git_dir)}[/link]" | ||
| ) |
| from pathlib import Path | ||
| from . import app | ||
| @app.command() | ||
| def tree(workdir: Path = Path()) -> None: | ||
| """Show the WORKDIR's deck tree. | ||
| Args: | ||
| workdir: Path to move into before running the command. | ||
| """ | ||
| from rich import print as rich_print | ||
| from ..components.factory import DeckSettingsFactory | ||
| from ..components.parser import RichTreeVisitor | ||
| from ..configuring.settings import DeckSettings | ||
| settings = DeckSettings.from_yaml(workdir) | ||
| deck = ( | ||
| DeckSettingsFactory(settings) | ||
| .parser() | ||
| .from_deck_definition(settings.paths.deck_definition) | ||
| ) | ||
| tree = RichTreeVisitor(only_errors=False).process(deck) | ||
| rich_print(tree) |
| from collections.abc import Iterable, Iterator, MutableMapping, MutableSet | ||
| from functools import cached_property | ||
| from pathlib import Path, PurePath | ||
| from typing import cast | ||
| from ..models import ( | ||
| Deck, | ||
| File, | ||
| NodeVisitor, | ||
| Part, | ||
| ResolvedPath, | ||
| Section, | ||
| UnresolvedPath, | ||
| ) | ||
| from ..utils import all_decks, load_yaml | ||
| from .protocols import AssetsAnalyzerProtocol, RendererProtocol | ||
| class AssetsAnalyzer(AssetsAnalyzerProtocol): | ||
| def __init__( | ||
| self, assets_dir: Path, git_dir: Path, renderer: RendererProtocol | ||
| ) -> None: | ||
| self._assets_dir = assets_dir | ||
| self._git_dir = git_dir | ||
| self._renderer = renderer | ||
| def sections_unlicensed_images(self) -> dict[UnresolvedPath, frozenset[Path]]: | ||
| return { | ||
| s: frozenset( | ||
| i for i in self._section_assets(d) if not self._is_image_licensed(i) | ||
| ) | ||
| for s, d in self._section_dependencies.items() | ||
| } | ||
| @cached_property | ||
| def _decks(self) -> dict[Path, Deck]: | ||
| return all_decks(self._git_dir) | ||
| @property | ||
| def _section_dependencies(self) -> dict[UnresolvedPath, set[ResolvedPath]]: | ||
| section_dependencies_processor = _SectionDependenciesNodeVisitor() | ||
| result: dict[UnresolvedPath, set[ResolvedPath]] = {} | ||
| for deck in self._decks.values(): | ||
| section_dependencies = section_dependencies_processor.process(deck) | ||
| for path, deps in section_dependencies.items(): | ||
| if path not in result: | ||
| result[path] = set() | ||
| result[path].update(deps) | ||
| return result | ||
| def _section_assets(self, dependencies: Iterable[Path]) -> Iterator[Path]: | ||
| for path in dependencies: | ||
| for asset in self._renderer.render_to_str(path)[1]: | ||
| yield self._assets_dir / asset | ||
| def _is_image_licensed(self, path: Path) -> bool: | ||
| metadata_path = path.with_suffix(".yml") | ||
| if not metadata_path.exists(): | ||
| return False | ||
| return "license" in load_yaml(metadata_path) | ||
| class _SectionDependenciesNodeVisitor( | ||
| NodeVisitor[ | ||
| [MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], UnresolvedPath], None | ||
| ] | ||
| ): | ||
| def process(self, deck: Deck) -> dict[UnresolvedPath, set[ResolvedPath]]: | ||
| dependencies: dict[UnresolvedPath, set[ResolvedPath]] = {} | ||
| for part in deck.parts.values(): | ||
| self._process_part( | ||
| part, | ||
| cast( | ||
| "MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]]", | ||
| dependencies, | ||
| ), | ||
| ) | ||
| return dependencies | ||
| def _process_part( | ||
| self, | ||
| part: Part, | ||
| dependencies: MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| ) -> None: | ||
| for node in part.nodes: | ||
| node.accept(self, dependencies, UnresolvedPath(PurePath())) | ||
| def visit_file( | ||
| self, | ||
| file: File, | ||
| section_dependencies: MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| base_unresolved_path: UnresolvedPath, | ||
| ) -> None: | ||
| if base_unresolved_path not in section_dependencies: | ||
| section_dependencies[base_unresolved_path] = set() | ||
| section_dependencies[base_unresolved_path].add(file.resolved_path) | ||
| def visit_section( | ||
| self, | ||
| section: Section, | ||
| section_dependencies: MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| base_unresolved_path: UnresolvedPath, | ||
| ) -> None: | ||
| for node in section.nodes: | ||
| node.accept(self, section_dependencies, section.unresolved_path) |
| import sys | ||
| from abc import abstractmethod | ||
| from collections.abc import Callable, Iterable | ||
| from contextlib import redirect_stdout | ||
| from dataclasses import dataclass | ||
| from itertools import chain | ||
| from logging import getLogger | ||
| from multiprocessing import Pool | ||
| from pathlib import Path | ||
| from shutil import copyfile | ||
| from tempfile import TemporaryDirectory | ||
| from typing import Any, Protocol, override | ||
| from plotly.graph_objs import Figure | ||
| from ..exceptions import DeckzError | ||
| from ..utils import copy_file_if_newer, import_module_and_submodules | ||
| from .protocols import AssetsBuilderProtocol, CompilerProtocol | ||
| @dataclass(frozen=True) | ||
| class CompilePaths: | ||
| latex: Path | ||
| build_pdf: Path | ||
| output_pdf: Path | ||
| build_log: Path | ||
| output_log: Path | ||
| class AssetsBuilder(AssetsBuilderProtocol): | ||
| def __init__(self, assets_builders: Iterable[AssetsBuilderProtocol]): | ||
| self._builders = list(assets_builders) | ||
| def build_assets(self) -> None: | ||
| for assets_builder in self._builders: | ||
| assets_builder.build_assets() | ||
| _plt_registry: list[tuple[Path, Path, Callable[[], None]]] = [] | ||
| _plotly_registry: list[tuple[Path, Path, Callable[[], Figure]]] = [] | ||
| def _clear_register() -> None: | ||
| _plt_registry.clear() | ||
| _plotly_registry.clear() | ||
| class _HasModuleAndName(Protocol): | ||
| __module__: str | ||
| __name__: str | ||
| def _build_plot_path(f: _HasModuleAndName) -> tuple[Path, Path]: | ||
| _, *submodules, _ = f.__module__.split(".") | ||
| name = f.__name__.replace("_", "-") | ||
| output_path = ( | ||
| Path("/".join(s.replace("_", "-") for s in submodules)) / name | ||
| ).with_suffix(".pdf") | ||
| python_path_str = sys.modules[f.__module__].__file__ | ||
| # I don't get why this is needed for mypy. It seems from the definition of | ||
| # ModuleType that __file__ is always a str and never None | ||
| assert python_path_str is not None | ||
| python_path = Path(python_path_str) | ||
| return output_path, python_path | ||
| def _make_decorator[F: Callable[[], Any]]( | ||
| registry: list[tuple[Path, Path, F]], | ||
| ) -> Callable[[str | None], Callable[[F], F]]: | ||
| def decorator( | ||
| name: str | None = None, | ||
| ) -> Callable[[F], F]: | ||
| def worker(f: F) -> F: | ||
| output_path, python_path = _build_plot_path(f) | ||
| registry.append((output_path, python_path, f)) | ||
| return f | ||
| return worker | ||
| return decorator | ||
| register_plot = _make_decorator(_plt_registry) | ||
| register_plotly = _make_decorator(_plotly_registry) | ||
| class FunctionAssetsBuilder[T](AssetsBuilderProtocol): | ||
| def __init__(self, output_dir: Path, module_name: str, library_name: str): | ||
| self._output_dir = output_dir | ||
| self._module_name = module_name | ||
| self._library_name = library_name | ||
| self._logger = getLogger(__name__) | ||
| def build_assets(self) -> None: | ||
| self._prepare_build() | ||
| sys.dont_write_bytecode = True | ||
| _clear_register() | ||
| try: | ||
| import_module_and_submodules(self._module_name) | ||
| except ModuleNotFoundError: | ||
| self._logger.warning( | ||
| "Could not find %s module, will not produce %s plots.", | ||
| self._module_name, | ||
| self._library_name, | ||
| ) | ||
| full_items = [(self._output_dir / o, p, f) for o, p, f in _plotly_registry] | ||
| to_build = [(o, f) for o, p, f in full_items if self._needs_compile(p, o)] | ||
| if not to_build: | ||
| return | ||
| self._logger.info( | ||
| "Processing %d %s plot(s) that need recompiling", | ||
| len(to_build), | ||
| self._library_name, | ||
| ) | ||
| for output_path, function in to_build: | ||
| self._build_pdf(output_path, function) | ||
| def _prepare_build(self) -> None: | ||
| pass | ||
| @abstractmethod | ||
| def _build_pdf(self, output_path: Path, function: Callable[[], T]) -> None: | ||
| raise NotImplementedError | ||
| def _needs_compile(self, python_path: Path, output_path: Path) -> bool: | ||
| return ( | ||
| not output_path.exists() | ||
| or output_path.stat().st_mtime_ns < python_path.stat().st_mtime_ns | ||
| ) | ||
| class PltAssetsBuilder(FunctionAssetsBuilder[None]): | ||
| def __init__(self, output_dir: Path): | ||
| super().__init__( | ||
| output_dir=output_dir, module_name="plots", library_name="matplotlib" | ||
| ) | ||
| @override | ||
| def _prepare_build(self) -> None: | ||
| import matplotlib | ||
| matplotlib.use("PDF") | ||
| def _build_pdf(self, output_path: Path, function: Callable[[], None]) -> None: | ||
| import matplotlib.pyplot as plt | ||
| output_path.parent.mkdir(parents=True, exist_ok=True) | ||
| function() | ||
| plt.savefig(output_path, bbox_inches="tight") | ||
| plt.close() | ||
| class PlotlyAssetsBuilder(FunctionAssetsBuilder[Figure]): | ||
| def __init__(self, output_dir: Path): | ||
| super().__init__( | ||
| output_dir=output_dir, module_name="pltly", library_name="plotly" | ||
| ) | ||
| def _build_pdf(self, output_path: Path, function: Callable[[], Figure]) -> None: | ||
| output_path.parent.mkdir(parents=True, exist_ok=True) | ||
| fig = function() | ||
| fig.write_image(output_path) | ||
| class TikzAssetsBuilder(AssetsBuilderProtocol): | ||
| def __init__( | ||
| self, | ||
| input_dir: Path, | ||
| output_dir: Path, | ||
| assets_dir: Path, | ||
| compiler: CompilerProtocol, | ||
| ): | ||
| self._input_dir = input_dir | ||
| self._output_dir = output_dir | ||
| self._assets_dir = assets_dir | ||
| self._compiler = compiler | ||
| self._logger = getLogger(__name__) | ||
| def build_assets(self) -> None: | ||
| with TemporaryDirectory() as build_dir: | ||
| build_path = Path(build_dir) | ||
| items = [ | ||
| (input_path, paths) | ||
| for input_path in chain( | ||
| self._input_dir.rglob("*.py"), | ||
| self._input_dir.rglob("*.tex"), | ||
| ) | ||
| if self._needs_compile( | ||
| input_path, | ||
| paths := self._compute_compile_paths(input_path, build_path), | ||
| ) | ||
| ] | ||
| if not items: | ||
| return | ||
| self._logger.info(f"Processing {len(items)} tikz(s) that need recompiling") | ||
| for item in items: | ||
| self._prepare(*item) | ||
| with Pool() as pool: | ||
| results = pool.map( | ||
| self._compiler.compile, (item_path.latex for _, item_path in items) | ||
| ) | ||
| for (_, paths), result in zip(items, results, strict=True): | ||
| if result.ok: | ||
| paths.output_pdf.parent.mkdir(parents=True, exist_ok=True) | ||
| copyfile(paths.build_pdf, paths.output_pdf) | ||
| paths.output_log.unlink(missing_ok=True) | ||
| elif paths.build_log.exists(): | ||
| paths.output_pdf.parent.mkdir(parents=True, exist_ok=True) | ||
| copyfile(paths.build_log, paths.output_log) | ||
| failed = [] | ||
| for (input_path, paths), result in zip(items, results, strict=True): | ||
| if not result.ok: | ||
| failed.append((input_path, paths.output_log)) | ||
| self._logger.warning("Standalone compilation of %s errored", input_path) | ||
| self._logger.warning("Captured stderr\n%s", result.stderr) | ||
| if failed: | ||
| def linkify(path: Path) -> str: | ||
| return f"[link=file://{path}]log[/link]" | ||
| formatted_fails = "\n".join( | ||
| ( | ||
| f"- {file_path.relative_to(self._input_dir)}" | ||
| f' ({linkify(log_path) if log_path.exists() else "no log"})' | ||
| ) | ||
| for file_path, log_path in failed | ||
| ) | ||
| msg = ( | ||
| f"standalone compilation errored for {len(failed)} files:\n" | ||
| f"{formatted_fails}\n" | ||
| "Please also check the errors above." | ||
| ) | ||
| raise DeckzError(msg) | ||
| def _needs_compile(self, input_file: Path, compile_paths: CompilePaths) -> bool: | ||
| return ( | ||
| not compile_paths.output_pdf.exists() | ||
| or compile_paths.output_pdf.stat().st_mtime < input_file.stat().st_mtime | ||
| ) | ||
| def _generate_latex(self, python_file: Path, output_file: Path) -> None: | ||
| compiled = compile( | ||
| source=python_file.read_text(encoding="utf8"), | ||
| filename=python_file.name, | ||
| mode="exec", | ||
| ) | ||
| output_file.parent.mkdir(parents=True, exist_ok=True) | ||
| with output_file.open("w", encoding="utf8") as fh, redirect_stdout(fh): | ||
| exec(compiled) | ||
| def _compute_compile_paths(self, input_file: Path, build_dir: Path) -> CompilePaths: | ||
| latex = (build_dir / input_file.relative_to(self._input_dir)).with_suffix( | ||
| ".tex" | ||
| ) | ||
| build_pdf = latex.with_suffix(".pdf") | ||
| output_pdf = ( | ||
| self._output_dir / input_file.relative_to(self._input_dir) | ||
| ).with_suffix(".pdf") | ||
| build_log = latex.with_suffix(".log") | ||
| output_log = output_pdf.with_suffix(".log") | ||
| return CompilePaths( | ||
| latex=latex, | ||
| build_pdf=build_pdf, | ||
| output_pdf=output_pdf, | ||
| build_log=build_log, | ||
| output_log=output_log, | ||
| ) | ||
| def _prepare(self, input_file: Path, compile_paths: CompilePaths) -> None: | ||
| build_dir = compile_paths.latex.parent | ||
| build_dir.mkdir(parents=True, exist_ok=True) | ||
| dirs_to_link = [d for d in self._assets_dir.iterdir() if d.is_dir()] | ||
| for d in dirs_to_link: | ||
| build_d = build_dir / d.name | ||
| if not build_d.exists(): | ||
| build_d.symlink_to(d) | ||
| if input_file.suffix == ".py": | ||
| self._generate_latex(input_file, compile_paths.latex) | ||
| elif input_file.suffix == ".tex": | ||
| copy_file_if_newer(input_file, compile_paths.latex) | ||
| else: | ||
| msg = f"unsupported standalone file extension {input_file.suffix}" | ||
| raise ValueError(msg) |
| from pathlib import Path | ||
| from typing import Any | ||
| from ..models import AssetsMetadata | ||
| from ..utils import load_yaml | ||
| from .protocols import AssetsMetadataRetrieverProtocol | ||
| class AssetsMetadataRetriever(AssetsMetadataRetrieverProtocol): | ||
| def __init__(self, assets_dir: Path) -> None: | ||
| self._assets_metadata: AssetsMetadata = {} | ||
| self._assets_dir = assets_dir | ||
| @property | ||
| def assets_metadata(self) -> AssetsMetadata: | ||
| return self._assets_metadata | ||
| def __call__(self, value: str) -> dict[str, Any] | None: | ||
| metadata_path = (self._assets_dir / Path(value)).with_suffix(".yml") | ||
| metadata = load_yaml(metadata_path) if metadata_path.exists() else None | ||
| self.assets_metadata[value] = ( | ||
| *self.assets_metadata.setdefault(value, ()), | ||
| metadata, | ||
| ) | ||
| return metadata |
| from functools import partial, reduce | ||
| from multiprocessing import Pool | ||
| from pathlib import Path | ||
| from ..models import Deck, ResolvedPath | ||
| from ..utils import all_decks | ||
| from .deck_builder import PartDependenciesNodeVisitor | ||
| from .protocols import AssetsSearcherProtocol, RendererProtocol | ||
| class AssetsSearcher(AssetsSearcherProtocol): | ||
| def __init__( | ||
| self, assets_dir: Path, git_dir: Path, renderer: RendererProtocol | ||
| ) -> None: | ||
| self._assets_dir = assets_dir | ||
| self._git_dir = git_dir | ||
| self._renderer = renderer | ||
| def search(self, asset: str) -> set[ResolvedPath]: | ||
| f = partial(self._deck_asset_dependencies, asset=asset) | ||
| with Pool() as pool: | ||
| return reduce( | ||
| set.union, | ||
| pool.map(f, all_decks(self._git_dir).values()), | ||
| set(), | ||
| ) | ||
| def _deck_asset_dependencies(self, deck: Deck, asset: str) -> set[ResolvedPath]: | ||
| result = set() | ||
| deps = PartDependenciesNodeVisitor().process(deck) | ||
| for part_deps in deps.values(): | ||
| for part_dep in part_deps: | ||
| _, assets_usage = self._renderer.render_to_str(part_dep) | ||
| if asset in assets_usage: | ||
| result.add(part_dep) | ||
| return result |
| from collections.abc import Iterable | ||
| from pathlib import Path | ||
| from subprocess import run | ||
| from ..models import CompileResult | ||
| from .protocols import CompilerProtocol | ||
| class Compiler(CompilerProtocol): | ||
| def __init__(self, build_command: Iterable[str]) -> None: | ||
| self._build_command = build_command | ||
| def compile(self, file: Path) -> CompileResult: | ||
| completed_process = run( | ||
| [*self._build_command, file.name], | ||
| cwd=file.parent, | ||
| capture_output=True, | ||
| encoding="utf8", | ||
| ) | ||
| return CompileResult( | ||
| completed_process.returncode == 0, | ||
| completed_process.stdout, | ||
| completed_process.stderr, | ||
| ) |
| from collections.abc import Iterable, MutableSequence, MutableSet, Sequence, Set | ||
| from dataclasses import dataclass | ||
| from enum import Enum | ||
| from logging import getLogger | ||
| from multiprocessing import Pool, cpu_count | ||
| from pathlib import Path, PurePosixPath | ||
| from shutil import copyfile | ||
| from typing import Any | ||
| from ..exceptions import DeckzError | ||
| from ..models import ( | ||
| Deck, | ||
| File, | ||
| NodeVisitor, | ||
| Part, | ||
| PartName, | ||
| PartSlides, | ||
| ResolvedPath, | ||
| Section, | ||
| Title, | ||
| TitleOrContent, | ||
| ) | ||
| from ..utils import copy_file_if_newer | ||
| from .compiler import CompileResult | ||
| from .protocols import CompilerProtocol, DeckBuilderProtocol, RendererProtocol | ||
| class CompileType(Enum): | ||
| Handout = "handout" | ||
| Presentation = "presentation" | ||
| PrintHandout = "print-handout" | ||
| @dataclass(frozen=True) | ||
| class CompileItem: | ||
| parts: Sequence[PartSlides] | ||
| dependencies: Set[Path] | ||
| compile_type: CompileType | ||
| toc: bool | ||
| class DeckBuilder(DeckBuilderProtocol): | ||
| def __init__( | ||
| self, | ||
| variables: dict[str, Any], | ||
| deck: Deck, | ||
| build_presentation: bool, | ||
| build_handout: bool, | ||
| build_print: bool, | ||
| output_dir: Path, | ||
| build_dir: Path, | ||
| dirs_to_link: tuple[Path, ...], | ||
| template: Path, | ||
| basedirs: tuple[Path, ...], | ||
| compiler: CompilerProtocol, | ||
| renderer: RendererProtocol, | ||
| ): | ||
| self._variables = variables | ||
| self._build_presentation = build_presentation | ||
| self._build_handout = build_handout | ||
| self._build_print = build_print | ||
| self._deck_name = deck.name | ||
| self._parts_slides = _SlidesNodeVisitor(basedirs).process(deck) | ||
| self._dependencies = PartDependenciesNodeVisitor().process(deck) | ||
| self._output_dir = output_dir | ||
| self._build_dir = build_dir | ||
| self._dirs_to_link = dirs_to_link | ||
| self._template = template | ||
| self._basedirs = basedirs | ||
| self._compiler = compiler | ||
| self._renderer = renderer | ||
| self._logger = getLogger(__name__) | ||
| def build_deck(self) -> bool: | ||
| items = self._list_items() | ||
| self._logger.info(f"Building {len(items)} PDFs.") | ||
| with Pool(min(cpu_count(), len(items))) as pool: | ||
| results = pool.starmap(self._build_item, items.items()) | ||
| for item_name, result in zip(items, results, strict=True): | ||
| if not result.ok: | ||
| self._logger.warning("Compilation %s errored", item_name) | ||
| self._logger.warning("Captured %s stderr\n%s", item_name, result.stderr) | ||
| self._logger.warning("Captured %s stdout\n%s", item_name, result.stdout) | ||
| return all(result.ok for result in results) | ||
| def _name_compile_item( | ||
| self, compile_type: CompileType, name: PartName | None = None | ||
| ) -> str: | ||
| return ( | ||
| f"{self._deck_name}-{name}-{compile_type.value}" | ||
| if name | ||
| else f"{self._deck_name}-{compile_type.value}" | ||
| ).lower() | ||
| def _list_items(self) -> dict[str, CompileItem]: | ||
| to_compile = {} | ||
| all_slides = list(self._parts_slides.values()) | ||
| all_dependencies = frozenset().union(*self._dependencies.values()) | ||
| if self._build_handout: | ||
| to_compile[self._name_compile_item(CompileType.Handout)] = CompileItem( | ||
| all_slides, all_dependencies, CompileType.Handout, True | ||
| ) | ||
| if self._build_print: | ||
| to_compile[self._name_compile_item(CompileType.PrintHandout)] = CompileItem( | ||
| all_slides, all_dependencies, CompileType.Handout, True | ||
| ) | ||
| for name, slides in self._parts_slides.items(): | ||
| dependencies = self._dependencies[name] | ||
| if self._build_presentation: | ||
| to_compile[self._name_compile_item(CompileType.Presentation, name)] = ( | ||
| CompileItem([slides], dependencies, CompileType.Presentation, False) | ||
| ) | ||
| if self._build_handout: | ||
| to_compile[self._name_compile_item(CompileType.Handout, name)] = ( | ||
| CompileItem([slides], dependencies, CompileType.Handout, False) | ||
| ) | ||
| return to_compile | ||
| def _build_item(self, name: str, item: CompileItem) -> CompileResult: | ||
| build_dir = self._setup_build_dir(name) | ||
| latex_path = build_dir / f"{name}.tex" | ||
| build_pdf_path = latex_path.with_suffix(".pdf") | ||
| output_pdf_path = self._output_dir / f"{name}.pdf" | ||
| self._render_latex(item, latex_path) | ||
| copied = self._copy_dependencies(item.dependencies, build_dir) | ||
| self._render_dependencies(copied) | ||
| result = self._compiler.compile(latex_path) | ||
| if result.ok: | ||
| self._output_dir.mkdir(parents=True, exist_ok=True) | ||
| copyfile(build_pdf_path, output_pdf_path) | ||
| return result | ||
| def _setup_build_dir(self, name: str) -> Path: | ||
| target_build_dir = self._build_dir / name | ||
| target_build_dir.mkdir(parents=True, exist_ok=True) | ||
| for item in self._dirs_to_link: | ||
| self._setup_link(target_build_dir / item.name, item) | ||
| return target_build_dir | ||
| def _render_latex(self, item: CompileItem, output_path: Path) -> None: | ||
| self._renderer.render_to_path( | ||
| self._template, | ||
| output_path, | ||
| variables=self._variables, | ||
| parts=item.parts, | ||
| handout=item.compile_type | ||
| in [CompileType.Handout, CompileType.PrintHandout], | ||
| toc=item.toc, | ||
| print=item.compile_type is CompileType.PrintHandout, | ||
| ) | ||
| def _copy_dependencies( | ||
| self, dependencies: Set[Path], target_build_dir: Path | ||
| ) -> list[Path]: | ||
| copied = [] | ||
| for dependency in dependencies: | ||
| for basedir in self._basedirs: | ||
| if dependency.is_relative_to(basedir): | ||
| relative_path = dependency.relative_to(basedir) | ||
| break | ||
| else: | ||
| raise ValueError | ||
| build_path = (target_build_dir / relative_path).with_suffix(".tex.j2") | ||
| if copy_file_if_newer(dependency, build_path): | ||
| copied.append(build_path) | ||
| return copied | ||
| def _render_dependencies(self, to_render: list[Path]) -> None: | ||
| for item in to_render: | ||
| self._renderer.render_to_path(item, item.with_suffix("")) | ||
| def _setup_link(self, source: Path, target: Path) -> None: | ||
| if not target.exists(): | ||
| msg = ( | ||
| f"{target} could not be found. Please make sure it exists before " | ||
| "proceeding" | ||
| ) | ||
| raise DeckzError(msg) | ||
| target = target.resolve() | ||
| if source.is_symlink(): | ||
| if source.resolve().samefile(target): | ||
| return | ||
| msg = ( | ||
| f"{source} already exists in the build directory and does not point to " | ||
| f"{target}. Please clean the build directory" | ||
| ) | ||
| raise DeckzError(msg) | ||
| if source.exists(): | ||
| msg = ( | ||
| f"{source} already exists in the build directory. Please clean the " | ||
| "build directory" | ||
| ) | ||
| raise DeckzError(msg) | ||
| source.parent.mkdir(parents=True, exist_ok=True) | ||
| source.symlink_to(target) | ||
| class PartDependenciesNodeVisitor(NodeVisitor[[MutableSet[ResolvedPath]], None]): | ||
| def process(self, deck: Deck) -> dict[PartName, set[ResolvedPath]]: | ||
| return { | ||
| part_name: self._process_part(part) | ||
| for part_name, part in deck.parts.items() | ||
| } | ||
| def _process_part(self, part: Part) -> set[ResolvedPath]: | ||
| dependencies: set[ResolvedPath] = set() | ||
| for node in part.nodes: | ||
| node.accept(self, dependencies) | ||
| return dependencies | ||
| def visit_file(self, file: File, dependencies: MutableSet[ResolvedPath]) -> None: | ||
| dependencies.add(file.resolved_path) | ||
| def visit_section( | ||
| self, section: Section, dependencies: MutableSet[ResolvedPath] | ||
| ) -> None: | ||
| for node in section.nodes: | ||
| node.accept(self, dependencies) | ||
| class _SlidesNodeVisitor(NodeVisitor[[MutableSequence[TitleOrContent], int], None]): | ||
| def __init__(self, basedirs: Iterable[Path]) -> None: | ||
| self._basedirs = tuple(basedirs) | ||
| def process(self, deck: Deck) -> dict[PartName, PartSlides]: | ||
| return { | ||
| part_name: self._process_part(part) | ||
| for part_name, part in deck.parts.items() | ||
| } | ||
| def _process_part(self, part: Part) -> PartSlides: | ||
| sections: list[TitleOrContent] = [] | ||
| for node in part.nodes: | ||
| node.accept(self, sections, 0) | ||
| return PartSlides(part.title, sections) | ||
| def visit_file( | ||
| self, file: File, sections: MutableSequence[TitleOrContent], level: int | ||
| ) -> None: | ||
| if file.title: | ||
| sections.append(Title(file.title, level)) | ||
| for basedir in self._basedirs: | ||
| if file.resolved_path.is_relative_to(basedir): | ||
| path = file.resolved_path.relative_to(basedir) | ||
| break | ||
| else: | ||
| raise ValueError | ||
| path = path.with_suffix("") | ||
| sections.append(str(PurePosixPath(path))) | ||
| def visit_section( | ||
| self, section: Section, sections: MutableSequence[TitleOrContent], level: int | ||
| ) -> None: | ||
| if section.title: | ||
| sections.append(Title(section.title, level)) | ||
| for node in section.nodes: | ||
| node.accept(self, sections, level + 1) |
| from typing import TYPE_CHECKING, Any | ||
| from .protocols import ( | ||
| AssetsAnalyzerProtocol, | ||
| AssetsBuilderProtocol, | ||
| AssetsMetadataRetrieverProtocol, | ||
| AssetsSearcherProtocol, | ||
| CompilerProtocol, | ||
| DeckBuilderProtocol, | ||
| DeckFactoryProtocol, | ||
| GlobalFactoryProtocol, | ||
| ParserProtocol, | ||
| RendererProtocol, | ||
| ) | ||
| if TYPE_CHECKING: | ||
| from ..configuring.settings import DeckSettings, GlobalSettings | ||
| from ..models import Deck | ||
| class GlobalSettingsFactory[T: "GlobalSettings"](GlobalFactoryProtocol): | ||
| def __init__(self, settings: T) -> None: | ||
| self._settings = settings | ||
| def renderer(self) -> RendererProtocol: | ||
| from .renderer import Renderer | ||
| return Renderer( | ||
| default_img_values=self._settings.default_img_values, | ||
| assets_dir=self._settings.paths.shared_dir, | ||
| global_factory=self, | ||
| ) | ||
| def compiler(self) -> CompilerProtocol: | ||
| from .compiler import Compiler | ||
| return Compiler(build_command=self._settings.build_command) | ||
| def assets_builder(self) -> AssetsBuilderProtocol: | ||
| from .assets_builder import ( | ||
| AssetsBuilder, | ||
| PlotlyAssetsBuilder, | ||
| PltAssetsBuilder, | ||
| TikzAssetsBuilder, | ||
| ) | ||
| return AssetsBuilder( | ||
| assets_builders=( | ||
| PltAssetsBuilder(output_dir=self._settings.paths.shared_plt_pdf_dir), | ||
| PlotlyAssetsBuilder( | ||
| output_dir=self._settings.paths.shared_plotly_pdf_dir | ||
| ), | ||
| TikzAssetsBuilder( | ||
| input_dir=self._settings.paths.tikz_dir, | ||
| output_dir=self._settings.paths.shared_tikz_pdf_dir, | ||
| assets_dir=self._settings.paths.shared_dir, | ||
| compiler=self.compiler(), | ||
| ), | ||
| ) | ||
| ) | ||
| def assets_metadata_retriever(self) -> AssetsMetadataRetrieverProtocol: | ||
| from .assets_metadata_retriever import AssetsMetadataRetriever | ||
| return AssetsMetadataRetriever(assets_dir=self._settings.paths.shared_dir) | ||
| def assets_searcher(self) -> AssetsSearcherProtocol: | ||
| from .assets_searcher import AssetsSearcher | ||
| return AssetsSearcher( | ||
| assets_dir=self._settings.paths.shared_dir, | ||
| git_dir=self._settings.paths.git_dir, | ||
| renderer=self.renderer(), | ||
| ) | ||
| def assets_analyzer(self) -> AssetsAnalyzerProtocol: | ||
| from .assets_analyzer import AssetsAnalyzer | ||
| return AssetsAnalyzer( | ||
| assets_dir=self._settings.paths.shared_dir, | ||
| git_dir=self._settings.paths.git_dir, | ||
| renderer=self.renderer(), | ||
| ) | ||
| class DeckSettingsFactory(GlobalSettingsFactory["DeckSettings"], DeckFactoryProtocol): | ||
| def __init__(self, settings: "DeckSettings") -> None: | ||
| super().__init__(settings) | ||
| def parser(self) -> ParserProtocol: | ||
| from .parser import Parser | ||
| return Parser( | ||
| local_latex_dir=self._settings.paths.local_latex_dir, | ||
| shared_latex_dir=self._settings.paths.shared_latex_dir, | ||
| file_extension=self._settings.file_extension, | ||
| ) | ||
| def deck_builder( | ||
| self, | ||
| variables: dict[str, Any], | ||
| deck: "Deck", | ||
| build_presentation: bool, | ||
| build_handout: bool, | ||
| build_print: bool, | ||
| ) -> DeckBuilderProtocol: | ||
| from .deck_builder import DeckBuilder | ||
| return DeckBuilder( | ||
| variables=variables, | ||
| deck=deck, | ||
| build_presentation=build_presentation, | ||
| build_handout=build_handout, | ||
| build_print=build_print, | ||
| output_dir=self._settings.paths.pdf_dir, | ||
| build_dir=self._settings.paths.build_dir, | ||
| dirs_to_link=( | ||
| self._settings.paths.shared_img_dir, | ||
| self._settings.paths.shared_tikz_pdf_dir, | ||
| self._settings.paths.shared_plt_pdf_dir, | ||
| self._settings.paths.shared_plotly_pdf_dir, | ||
| self._settings.paths.shared_code_dir, | ||
| ), | ||
| template=self._settings.paths.jinja2_main_template, | ||
| basedirs=( | ||
| self._settings.paths.shared_dir, | ||
| self._settings.paths.current_dir, | ||
| ), | ||
| renderer=self.renderer(), | ||
| compiler=self.compiler(), | ||
| ) |
| from collections.abc import Iterable | ||
| from pathlib import Path, PurePath | ||
| from sys import stderr | ||
| from typing import Literal | ||
| from pydantic import ValidationError | ||
| from rich import print as rich_print | ||
| from rich.tree import Tree | ||
| from ..exceptions import DeckzError | ||
| from ..models import ( | ||
| Deck, | ||
| DeckDefinition, | ||
| File, | ||
| FileInclude, | ||
| FlavorName, | ||
| IncludePath, | ||
| Node, | ||
| NodeInclude, | ||
| NodeVisitor, | ||
| Part, | ||
| PartDefinition, | ||
| PartName, | ||
| ResolvedPath, | ||
| Section, | ||
| SectionDefinition, | ||
| SectionInclude, | ||
| UnresolvedPath, | ||
| ) | ||
| from ..utils import load_yaml | ||
| from .protocols import ParserProtocol | ||
| class Parser(ParserProtocol): | ||
| """Build a deck from a definition. | ||
| The definition can be a complete deck definition obtained from a yaml file or a \ | ||
| simpler one obtained from a single section or file. | ||
| """ | ||
| def __init__( | ||
| self, local_latex_dir: Path, shared_latex_dir: Path, file_extension: str | ||
| ) -> None: | ||
| """Initialize an instance with the necessary path information. | ||
| Args: | ||
| local_latex_dir: Path to the local latex directory. Used during the \ | ||
| includes resolving process | ||
| shared_latex_dir: Path to the shared latex directory. Used during the \ | ||
| includes resolving process | ||
| file_extension: Extensions to consider during file resolving. | ||
| """ | ||
| self._local_latex_dir = local_latex_dir | ||
| self._shared_latex_dir = shared_latex_dir | ||
| self._file_extension = file_extension | ||
| def from_deck_definition(self, deck_definition_path: Path) -> Deck: | ||
| """Parse a deck from a yaml definition. | ||
| Args: | ||
| deck_definition_path: Path to the yaml definition. It should be parsable \ | ||
| into a [`DeckDefinition`][deckz.models.DeckDefinition] by Pydantic | ||
| Returns: | ||
| The parsed deck | ||
| """ | ||
| deck_definition = DeckDefinition.model_validate(load_yaml(deck_definition_path)) | ||
| return Deck( | ||
| name=deck_definition.name, parts=self._parse_parts(deck_definition.parts) | ||
| ) | ||
| def from_section(self, section: str, flavor: FlavorName) -> Deck: | ||
| return Deck( | ||
| name="deck", | ||
| parts=self._parse_parts( | ||
| [ | ||
| PartDefinition.model_construct( | ||
| name=PartName("part_name"), | ||
| sections=[ | ||
| SectionInclude( | ||
| path=IncludePath(PurePath(section)), flavor=flavor | ||
| ) | ||
| ], | ||
| ) | ||
| ] | ||
| ), | ||
| ) | ||
| def from_file(self, latex: str) -> Deck: | ||
| return Deck( | ||
| name="deck", | ||
| parts=self._parse_parts( | ||
| [ | ||
| PartDefinition.model_construct( | ||
| name=PartName("part_name"), | ||
| sections=[FileInclude(path=IncludePath(PurePath(latex)))], | ||
| ) | ||
| ] | ||
| ), | ||
| ) | ||
| def _parse_parts( | ||
| self, part_definitions: list[PartDefinition] | ||
| ) -> dict[PartName, Part]: | ||
| parts = {} | ||
| for part_definition in part_definitions: | ||
| part_nodes: list[Node] = [] | ||
| for node_include in part_definition.sections: | ||
| if isinstance(node_include, SectionInclude): | ||
| part_nodes.append( | ||
| self._parse_section( | ||
| base_unresolved_path=UnresolvedPath(PurePath()), | ||
| include_path=node_include.path, | ||
| title=node_include.title, | ||
| title_unset="title" not in node_include.model_fields_set, | ||
| flavor=node_include.flavor, | ||
| ) | ||
| ) | ||
| else: | ||
| part_nodes.append( | ||
| self._parse_file( | ||
| base_unresolved_path=UnresolvedPath(PurePath()), | ||
| include_path=node_include.path, | ||
| title=node_include.title, | ||
| ) | ||
| ) | ||
| parts[part_definition.name] = Part( | ||
| title=part_definition.title, | ||
| nodes=part_nodes, | ||
| ) | ||
| return parts | ||
| def _parse_section( | ||
| self, | ||
| base_unresolved_path: UnresolvedPath, | ||
| include_path: IncludePath, | ||
| title: str | None, | ||
| title_unset: bool, | ||
| flavor: FlavorName, | ||
| ) -> Section: | ||
| unresolved_path = self._compute_unresolved_path( | ||
| base_unresolved_path, include_path | ||
| ) | ||
| section = Section( | ||
| title=title, | ||
| unresolved_path=unresolved_path, | ||
| resolved_path=ResolvedPath(Path()), | ||
| parsing_error=None, | ||
| flavor=flavor, | ||
| nodes=[], | ||
| ) | ||
| definition_logical_path = (unresolved_path / unresolved_path.name).with_suffix( | ||
| ".yml" | ||
| ) | ||
| definition_resolved_path = self._resolve( | ||
| definition_logical_path.with_suffix(".yml"), "file" | ||
| ) | ||
| if definition_resolved_path is None: | ||
| section.parsing_error = ( | ||
| f"unresolvable section definition path {definition_logical_path}" | ||
| ) | ||
| return section | ||
| section.resolved_path = definition_resolved_path.parent | ||
| try: | ||
| content = load_yaml(definition_resolved_path) | ||
| except Exception as e: | ||
| section.parsing_error = f"{e}" | ||
| return section | ||
| try: | ||
| section_definition = SectionDefinition.model_validate(content) | ||
| except ValidationError as e: | ||
| section.parsing_error = f"{e}" | ||
| return section | ||
| for flavor_definition in section_definition.flavors: | ||
| if flavor_definition.name == flavor: | ||
| break | ||
| else: | ||
| section.parsing_error = f"flavor {flavor} not found" | ||
| return section | ||
| if title_unset: | ||
| if "title" in flavor_definition.model_fields_set: | ||
| section.title = flavor_definition.title | ||
| else: | ||
| section.title = section_definition.title | ||
| section.nodes.extend( | ||
| self._parse_nodes( | ||
| flavor_definition.includes, | ||
| default_titles=section_definition.default_titles, | ||
| base_unresolved_path=unresolved_path, | ||
| ) | ||
| ) | ||
| return section | ||
| def _parse_nodes( | ||
| self, | ||
| node_includes: Iterable[NodeInclude], | ||
| default_titles: dict[IncludePath, str] | None, | ||
| base_unresolved_path: UnresolvedPath, | ||
| ) -> list[Node]: | ||
| nodes: list[Node] = [] | ||
| for node_include in node_includes: | ||
| if node_include.title: | ||
| title = node_include.title | ||
| elif ( | ||
| "title" not in node_include.model_fields_set | ||
| and default_titles | ||
| and node_include.path in default_titles | ||
| ): | ||
| title = default_titles[node_include.path] | ||
| else: | ||
| title = None | ||
| if isinstance(node_include, FileInclude): | ||
| nodes.append( | ||
| self._parse_file( | ||
| base_unresolved_path=base_unresolved_path, | ||
| include_path=node_include.path, | ||
| title=title, | ||
| ) | ||
| ) | ||
| if isinstance(node_include, SectionInclude): | ||
| nodes.append( | ||
| self._parse_section( | ||
| base_unresolved_path=base_unresolved_path, | ||
| include_path=node_include.path, | ||
| title=title, | ||
| title_unset="title" not in node_include.model_fields_set, | ||
| flavor=node_include.flavor, | ||
| ) | ||
| ) | ||
| return nodes | ||
| def _parse_file( | ||
| self, | ||
| base_unresolved_path: UnresolvedPath, | ||
| include_path: IncludePath, | ||
| title: str | None, | ||
| ) -> File: | ||
| unresolved_path = self._compute_unresolved_path( | ||
| base_unresolved_path, include_path | ||
| ) | ||
| file = File( | ||
| title=title, | ||
| unresolved_path=unresolved_path, | ||
| resolved_path=ResolvedPath(Path()), | ||
| parsing_error=None, | ||
| ) | ||
| resolved_path = self._resolve( | ||
| unresolved_path.with_suffix(self._file_extension), "file" | ||
| ) | ||
| if resolved_path: | ||
| file.resolved_path = resolved_path | ||
| else: | ||
| file.parsing_error = f"unresolvable file path {unresolved_path}" | ||
| return file | ||
| @staticmethod | ||
| def _compute_unresolved_path( | ||
| base_unresolved_path: UnresolvedPath, include_path: IncludePath | ||
| ) -> UnresolvedPath: | ||
| return UnresolvedPath( | ||
| include_path.relative_to("/") | ||
| if include_path.root | ||
| else base_unresolved_path / include_path | ||
| ) | ||
| def _resolve( | ||
| self, unresolved_path: UnresolvedPath, resolve_target: Literal["file", "dir"] | ||
| ) -> ResolvedPath | None: | ||
| local_path = self._local_latex_dir / unresolved_path | ||
| shared_path = self._shared_latex_dir / unresolved_path | ||
| existence_tester = Path.is_file if resolve_target == "file" else Path.is_dir | ||
| for path in [local_path, shared_path]: | ||
| if existence_tester(path): | ||
| return ResolvedPath(path.resolve()) | ||
| return None | ||
| def _validate(self, deck: Deck) -> None: | ||
| tree = RichTreeVisitor().process(deck) | ||
| if tree is not None: | ||
| rich_print(tree, file=stderr) | ||
| msg = "deck parsing failed" | ||
| raise DeckzError(msg) | ||
| class RichTreeVisitor(NodeVisitor[[UnresolvedPath], tuple[Tree | None, bool]]): | ||
| def __init__(self, only_errors: bool = True) -> None: | ||
| self._only_errors = only_errors | ||
| def process(self, deck: Deck) -> Tree | None: | ||
| part_trees = [] | ||
| for part_name, part in deck.parts.items(): | ||
| part_tree = self._process_part(part_name, part) | ||
| if part_tree is not None: | ||
| part_trees.append(part_tree) | ||
| if part_trees: | ||
| tree = Tree(deck.name) | ||
| tree.children.extend(part_trees) | ||
| return tree | ||
| return None | ||
| def _process_part(self, part_name: PartName, part: Part) -> Tree | None: | ||
| error = False | ||
| children_trees = [] | ||
| for child in part.nodes: | ||
| child_tree, child_error = child.accept(self, UnresolvedPath(PurePath())) | ||
| error = error or child_error | ||
| if child_tree is not None: | ||
| children_trees.append(child_tree) | ||
| if self._only_errors and not error: | ||
| return None | ||
| tree = Tree(part_name) | ||
| tree.children.extend(children_trees) | ||
| return tree | ||
| def visit_file( | ||
| self, file: File, base_path: UnresolvedPath | ||
| ) -> tuple[Tree | None, bool]: | ||
| if self._only_errors and file.parsing_error is None: | ||
| return None, False | ||
| path = ( | ||
| file.unresolved_path.relative_to(base_path) | ||
| if file.unresolved_path.is_relative_to(base_path) | ||
| else file.unresolved_path | ||
| ) | ||
| if file.parsing_error is None: | ||
| return Tree(str(path)), False | ||
| return Tree(f"[red]{path} ({file.parsing_error})[/]"), True | ||
| def visit_section( | ||
| self, section: Section, base_path: UnresolvedPath | ||
| ) -> tuple[Tree | None, bool]: | ||
| error = section.parsing_error is not None | ||
| children_trees = [] | ||
| for child in section.nodes: | ||
| child_tree, child_error = child.accept(self, section.unresolved_path) | ||
| error = error or child_error | ||
| if child_tree is not None: | ||
| children_trees.append(child_tree) | ||
| if self._only_errors and not error: | ||
| return None, False | ||
| path = ( | ||
| section.unresolved_path.relative_to(base_path) | ||
| if section.unresolved_path.is_relative_to(base_path) | ||
| else section.unresolved_path | ||
| ) | ||
| if section.parsing_error is not None: | ||
| label = f"[red]{path}@{section.flavor} ({section.parsing_error})[/]" | ||
| else: | ||
| label = f"{path}@{section.flavor}" | ||
| tree = Tree(label) | ||
| tree.children.extend(children_trees) | ||
| return tree, error |
| from pathlib import Path | ||
| from typing import TYPE_CHECKING, Any, Protocol | ||
| if TYPE_CHECKING: | ||
| from ..models import ( | ||
| AssetsMetadata, | ||
| CompileResult, | ||
| Deck, | ||
| FlavorName, | ||
| ResolvedPath, | ||
| UnresolvedPath, | ||
| ) | ||
| class ParserProtocol(Protocol): | ||
| """Build a deck from a definition. | ||
| The definition can be a complete deck definition obtained from a yaml file or a \ | ||
| simpler one obtained from a single section or file. | ||
| """ | ||
| def from_deck_definition(self, deck_definition_path: Path) -> "Deck": | ||
| """Parse a deck from a yaml definition. | ||
| Args: | ||
| deck_definition_path: Path to the yaml definition. It should be parsable \ | ||
| into a [`DeckDefinition`][deckz.models.DeckDefinition] by Pydantic. | ||
| Returns: | ||
| The parsed deck. | ||
| """ | ||
| def from_section(self, section: str, flavor: "FlavorName") -> "Deck": ... | ||
| def from_file(self, latex: str) -> "Deck": ... | ||
| class DeckBuilderProtocol(Protocol): | ||
| def build_deck(self) -> bool: ... | ||
| class AssetsBuilderProtocol(Protocol): | ||
| def build_assets(self) -> None: ... | ||
| class CompilerProtocol(Protocol): | ||
| def compile(self, file: Path) -> "CompileResult": ... | ||
| class RendererProtocol(Protocol): | ||
| def render_to_str( | ||
| self, template_path: Path, /, **template_kwargs: Any | ||
| ) -> tuple[str, "AssetsMetadata"]: ... | ||
| def render_to_path( | ||
| self, template_path: Path, output_path: Path, /, **template_kwargs: Any | ||
| ) -> "AssetsMetadata": ... | ||
| class AssetsMetadataRetrieverProtocol(Protocol): | ||
| @property | ||
| def assets_metadata(self) -> "AssetsMetadata": ... | ||
| def __call__(self, value: str) -> dict[str, Any] | None: ... | ||
| class AssetsSearcherProtocol(Protocol): | ||
| def search(self, asset: str) -> set["ResolvedPath"]: ... | ||
| class AssetsAnalyzerProtocol(Protocol): | ||
| def sections_unlicensed_images(self) -> dict["UnresolvedPath", frozenset[Path]]: ... | ||
| class GlobalFactoryProtocol(Protocol): | ||
| def renderer(self) -> RendererProtocol: ... | ||
| def compiler(self) -> CompilerProtocol: ... | ||
| def assets_builder(self) -> AssetsBuilderProtocol: ... | ||
| def assets_metadata_retriever(self) -> AssetsMetadataRetrieverProtocol: ... | ||
| def assets_searcher(self) -> AssetsSearcherProtocol: ... | ||
| def assets_analyzer(self) -> AssetsAnalyzerProtocol: ... | ||
| class DeckFactoryProtocol(GlobalFactoryProtocol, Protocol): | ||
| def deck_builder( | ||
| self, | ||
| variables: dict[str, Any], | ||
| deck: "Deck", | ||
| build_presentation: bool, | ||
| build_handout: bool, | ||
| build_print: bool, | ||
| ) -> DeckBuilderProtocol: ... | ||
| def parser(self) -> ParserProtocol: ... |
| from abc import ABC, abstractmethod | ||
| from collections.abc import Callable | ||
| from functools import cached_property | ||
| from os.path import join as path_join | ||
| from pathlib import Path | ||
| from typing import Any | ||
| from jinja2 import BaseLoader, Environment, TemplateNotFound, pass_context | ||
| from jinja2.runtime import Context | ||
| from ..configuring.settings import DefaultImageValues | ||
| from ..models import AssetsMetadata | ||
| from .protocols import GlobalFactoryProtocol, RendererProtocol | ||
| class _BaseRenderer(ABC, RendererProtocol): | ||
| @abstractmethod | ||
| def render_to_str( | ||
| self, template_path: Path, /, **template_kwargs: Any | ||
| ) -> tuple[str, AssetsMetadata]: | ||
| raise NotImplementedError | ||
| def render_to_path( | ||
| self, template_path: Path, output_path: Path, /, **template_kwargs: Any | ||
| ) -> AssetsMetadata: | ||
| from contextlib import suppress | ||
| from filecmp import cmp | ||
| from shutil import move | ||
| from tempfile import NamedTemporaryFile | ||
| try: | ||
| with NamedTemporaryFile("w", encoding="utf8", delete=False) as fh: | ||
| rendered, assets_metadata = self.render_to_str( | ||
| template_path, **template_kwargs | ||
| ) | ||
| fh.write(rendered) | ||
| fh.write("\n") | ||
| if not output_path.exists() or not cmp(fh.name, str(output_path)): | ||
| move(fh.name, output_path) | ||
| finally: | ||
| with suppress(FileNotFoundError): | ||
| Path(fh.name).unlink() | ||
| return assets_metadata | ||
| class _AbsoluteLoader(BaseLoader): | ||
| def get_source( | ||
| self, environment: Environment, template: str | ||
| ) -> tuple[str, str, Callable[[], bool]]: | ||
| template_path = Path(template) | ||
| if not template_path.exists(): | ||
| raise TemplateNotFound(template) | ||
| mtime = template_path.stat().st_mtime | ||
| source = template_path.read_text(encoding="utf8") | ||
| return ( | ||
| source, | ||
| str(template_path), | ||
| lambda: mtime == template_path.stat().st_mtime, | ||
| ) | ||
| class Renderer(_BaseRenderer): | ||
| def __init__( | ||
| self, | ||
| default_img_values: DefaultImageValues, | ||
| assets_dir: Path, | ||
| global_factory: GlobalFactoryProtocol, | ||
| ) -> None: | ||
| self._default_img_values = default_img_values | ||
| self._assets_dir = assets_dir | ||
| self._global_factory = global_factory | ||
| def render_to_str( | ||
| self, template_path: Path, /, **template_kwargs: Any | ||
| ) -> tuple[str, AssetsMetadata]: | ||
| template = self._env.get_template(str(template_path)) | ||
| assets_metadata_retriever = self._global_factory.assets_metadata_retriever() | ||
| return ( | ||
| template.render( | ||
| assets_metadata_retriever=assets_metadata_retriever, | ||
| **template_kwargs, | ||
| ), | ||
| assets_metadata_retriever.assets_metadata, | ||
| ) | ||
| @cached_property | ||
| def _env(self) -> Environment: | ||
| env = Environment( | ||
| loader=_AbsoluteLoader(), | ||
| block_start_string=r"\BLOCK{", | ||
| block_end_string="}", | ||
| variable_start_string=r"\V{", | ||
| variable_end_string="}", | ||
| comment_start_string=r"\#{", | ||
| comment_end_string="}", | ||
| line_statement_prefix="%%", | ||
| line_comment_prefix="%#", | ||
| trim_blocks=True, | ||
| autoescape=False, | ||
| ) | ||
| env.filters["camelcase"] = self._to_camel_case | ||
| env.filters["path_join"] = lambda paths: path_join(*paths) # noqa: PTH118 | ||
| env.filters["image"] = self._img | ||
| return env | ||
| def _to_camel_case(self, string: str) -> str: | ||
| return "".join(substring.capitalize() or "_" for substring in string.split("_")) | ||
| @pass_context | ||
| def _img( | ||
| self, | ||
| context: Context, | ||
| value: str, | ||
| modifier: str = "", | ||
| scale: float = 1.0, | ||
| lang: str = "fr", | ||
| ) -> str: | ||
| metadata = context["assets_metadata_retriever"](value) | ||
| if metadata is not None: | ||
| def get_en_or_fr(key: str) -> str: | ||
| if lang != "fr": | ||
| key_en = f"{key}_en" | ||
| return metadata[key_en] if key_en in metadata else metadata[key] | ||
| return metadata[key] | ||
| title = self._default_img_values.title.get_default( | ||
| get_en_or_fr("title"), lang | ||
| ) | ||
| author = self._default_img_values.author.get_default( | ||
| get_en_or_fr("author"), lang | ||
| ) | ||
| license_name = self._default_img_values.license.get_default( | ||
| get_en_or_fr("license"), lang | ||
| ) | ||
| info = f"[{title}, {author}, {license_name}.]" | ||
| else: | ||
| info = "" | ||
| return f"\\img{modifier}{info}{{{value}}}{{{scale:.2f}}}" |
| """Model classes. | ||
| There are several kinds of types defined in this module: | ||
| - Simple scalar types to disambiguate multi-usage types, e.g. Path and str: | ||
| - [`IncludePath`][deckz.models.IncludePath] | ||
| - [`UnresolvedPath`][deckz.models.UnresolvedPath] | ||
| - [`ResolvedPath`][deckz.models.ResolvedPath] | ||
| - [`PartName`][deckz.models.PartName] | ||
| - [`FlavorName`][deckz.models.FlavorName] | ||
| - Types used to represent deck definitions (typically what's in `deck.yml`). All those \ | ||
| classes are defined using the Pydantic library and can be easily instantiated from \ | ||
| yaml files: | ||
| - [`NodeInclude`][deckz.models.NodeInclude] | ||
| - [`SectionInclude`][deckz.models.SectionInclude] | ||
| - [`FileInclude`][deckz.models.FileInclude] | ||
| - [`FlavorDefinition`][deckz.models.FlavorDefinition] | ||
| - [`SectionDefinition`][deckz.models.SectionDefinition] | ||
| - [`PartDefinition`][deckz.models.PartDefinition] | ||
| - [`DeckDefinition`][deckz.models.DeckDefinition] | ||
| The only tricky part in those classes is the format used to define includes. The \ | ||
| intent is that it should be possible, both to include files and sections, to \ | ||
| specify only a path, or a path and a title. The path can be relative to the \ | ||
| current element being parsed or to a base directory. This tricky logic is \ | ||
| implemented in the `_normalize_include` function. | ||
| The yaml syntax used is the following: | ||
| - for a file without a title | ||
| path/relative/to/current/element | ||
| /path/relative/to/basedir | ||
| - for a file with a title | ||
| path/relative/to/current/element: title | ||
| /path/relative/to/basedir: title | ||
| - for a section without a title | ||
| $path/relative/to/current/element@flavor | ||
| $/path/relative/to/basedir@flavor | ||
| - for a section with a title | ||
| $path/relative/to/current/element@flavor: title | ||
| $/path/relative/to/basedir@flavor: title | ||
| - Types used to represent and process instantiated decks (in opposition to deck \ | ||
| definitions): | ||
| - [`Deck`][deckz.models.Deck] | ||
| - [`Part`][deckz.models.Part] | ||
| - [`Section`][deckz.models.Section] | ||
| - [`File`][deckz.models.File] | ||
| - [`Node`][deckz.models.Node] | ||
| A [`Deck`][deckz.models.Deck] deck is comprised of one or several \ | ||
| [`Part`][deckz.models.Part]s, themselves comprised of \ | ||
| [`Node`][deckz.models.Node]s ([`Section`][deckz.models.Section]s and \ | ||
| [`File`][deckz.models.File]s). [`Node`][deckz.models.Node]s have an `accept` \ | ||
| method that allows for dispatching processing when writing deck processing code, \ | ||
| following the \ | ||
| [Visitor](https://refactoring.guru/design-patterns/visitor/python/example) pattern. | ||
| The [`NodeVisitor`][deckz.models.NodeVisitor] protocol can be used to specify the \ | ||
| types in play when writing such code. | ||
| - Types representing slides that will be used by the rendering component (jinja2 by \ | ||
| default): | ||
| - [`Title`][deckz.models.Title] | ||
| - [`Content`][deckz.models.Content] | ||
| - [`TitleOrContent`][deckz.models.TitleOrContent] | ||
| - [`PartSlides`][deckz.models.PartSlides] | ||
| - Type representing the output of compilation step (output, error, etc): | ||
| - [`CompileResult`][deckz.models.CompileResult] | ||
| - Type to collect stats on assets: | ||
| - [`AssetsUsage`][deckz.models.AssetsUsage] | ||
| """ | ||
| from abc import ABC, abstractmethod | ||
| from collections.abc import Iterable | ||
| from dataclasses import dataclass, field | ||
| from pathlib import Path, PurePath | ||
| from typing import Annotated, Any, NewType, Protocol | ||
| from pydantic import BaseModel | ||
| from pydantic.functional_validators import BeforeValidator | ||
| ######################################################################################## | ||
| # Simple scalars # | ||
| ######################################################################################## | ||
| IncludePath = NewType("IncludePath", PurePath) | ||
| """Derived from PurePath to be used only to specify an include in a deck definition.""" | ||
| UnresolvedPath = NewType("UnresolvedPath", PurePath) | ||
| """Derived from PurePath to represent any path that has not been resolved yet. | ||
| Resolving in deckz code means mainly picking between two options for a given resource: \ | ||
| loading it from the shared directory or from the local directory. | ||
| """ | ||
| ResolvedPath = NewType("ResolvedPath", Path) | ||
| """Derived from Path to represent any path that has already been resolved. | ||
| Resolving in deckz code means mainly picking between two options for a given resource: \ | ||
| loading it from the shared directory or from the local directory. | ||
| """ | ||
| PartName = NewType("PartName", str) | ||
| """Derived from str to represent specifically a part name.""" | ||
| FlavorName = NewType("FlavorName", str) | ||
| """Derived from str to represent specifically a flavor name.""" | ||
| ######################################################################################## | ||
| # Deck definition types # | ||
| ######################################################################################## | ||
| class NodeInclude(BaseModel): | ||
| """Specify a file or section include.""" | ||
| path: IncludePath | ||
| """Path of the file or section to include.""" | ||
| title: str | None = None | ||
| """The title of the node. Will override the ones defined in the section \ | ||
| definition and the flavor definition. | ||
| """ | ||
| class SectionInclude(NodeInclude): | ||
| """Specify a section to include. | ||
| See its parent for further details on the available attributes. | ||
| """ | ||
| flavor: FlavorName | ||
| """Flavor of the section to include.""" | ||
| class FileInclude(NodeInclude): | ||
| """Specify a file to include. | ||
| See its parent for further details on the available attributes. | ||
| """ | ||
| def _normalize_include( | ||
| v: str | dict[str, str] | NodeInclude, | ||
| ) -> NodeInclude: | ||
| if isinstance(v, NodeInclude): | ||
| return v | ||
| if isinstance(v, str): | ||
| left = v | ||
| title_unset = True | ||
| else: | ||
| assert len(v) == 1 | ||
| left, title = next(iter(v.items())) | ||
| title_unset = False | ||
| if left.startswith("$"): | ||
| path, flavor = left[1:].split("@") | ||
| else: | ||
| path = left | ||
| flavor = None | ||
| if flavor is None and title_unset: | ||
| return FileInclude(path=IncludePath(PurePath(path))) | ||
| if flavor is None: | ||
| return FileInclude(path=IncludePath(PurePath(path)), title=title) | ||
| if title_unset: | ||
| return SectionInclude( | ||
| path=IncludePath(PurePath(path)), flavor=FlavorName(flavor) | ||
| ) | ||
| return SectionInclude( | ||
| path=IncludePath(PurePath(path)), flavor=FlavorName(flavor), title=title | ||
| ) | ||
| class FlavorDefinition(BaseModel): | ||
| """Specify the different attributes of a flavor.""" | ||
| name: FlavorName | ||
| """The name of the flavor. Used in parts and sections definitions.""" | ||
| title: str | None = None | ||
| """The title of the section. Will override the one defined in the section \ | ||
| definition.""" | ||
| includes: list[Annotated[NodeInclude, BeforeValidator(_normalize_include)]] | ||
| """The includes pointing to the sections and files in this section.""" | ||
| class SectionDefinition(BaseModel): | ||
| """Specify the different attributes of a section.""" | ||
| title: str | ||
| """The title of the section. Will be given as input to the rendering code.""" | ||
| default_titles: dict[IncludePath, str] | None = None | ||
| """Default titles to use for the includes of the section.""" | ||
| flavors: list[FlavorDefinition] | ||
| """Different flavors of the section (each flavor can define a different \ | ||
| title and a different list of includes).""" | ||
| class PartDefinition(BaseModel): | ||
| """Specify the different attributes of a deck part.""" | ||
| name: PartName | ||
| """The name of the part. Will be a part of the output file name if partial \ | ||
| outputs are built.""" | ||
| title: str | None = None | ||
| """The title of the part. Will be given as input to the rendering code.""" | ||
| sections: list[Annotated[NodeInclude, BeforeValidator(_normalize_include)]] | ||
| """The includes pointing to the sections and files in this part.""" | ||
| class DeckDefinition(BaseModel): | ||
| """Specify the different attributes of a deck.""" | ||
| name: str | ||
| """The name of the deck. Will be a part of the output file name.""" | ||
| parts: list[PartDefinition] | ||
| """The definition of each part of the deck.""" | ||
| ######################################################################################## | ||
| # Deck representation and processing types # | ||
| ######################################################################################## | ||
| class NodeVisitor[**P, T](Protocol): | ||
| """Dispatch actions on [`Node`][deckz.models.Node]s.""" | ||
| def visit_file(self, file: "File", *args: P.args, **kwargs: P.kwargs) -> T: | ||
| """Dispatched method for [`File`][deckz.models.File]s.""" | ||
| ... | ||
| def visit_section(self, section: "Section", *args: P.args, **kwargs: P.kwargs) -> T: | ||
| """Dispatched method for [`Section`][deckz.models.Section]s.""" | ||
| ... | ||
| @dataclass | ||
| class Node(ABC): | ||
| """Node in a section or part. | ||
| A node is anything that can be included in a [`Part`][deckz.models.Part] or a \ | ||
| [`Section`][deckz.models.Section]: it can be either a \ | ||
| [`Section`][deckz.models.Section] or a [`File`][deckz.models.File]. | ||
| """ | ||
| title: str | None | ||
| unresolved_path: UnresolvedPath | ||
| # resolved_path and parsing_error could benefit from a refactoring using something | ||
| # like Either because we cannot have both a ResolvedPath and a parsing_error at the | ||
| # same time. | ||
| resolved_path: ResolvedPath | ||
| parsing_error: str | None | ||
| @abstractmethod | ||
| def accept[**P, T]( | ||
| self, visitor: NodeVisitor[P, T], *args: P.args, **kwargs: P.kwargs | ||
| ) -> T: | ||
| """Dispatch method for visitors. | ||
| Args: | ||
| visitor: The visitor asking for the dispatch | ||
| args: Arguments to send back to the visitor untouched | ||
| kwargs: Keyword arguments to send back to the visitor untouched | ||
| Returns: | ||
| The return type is the same as the return type of the corresponding \ | ||
| [`visit_file`][deckz.models.NodeVisitor.visit_file] or \ | ||
| [`visit_section`][deckz.models.NodeVisitor.visit_section] method of \ | ||
| the visitor. | ||
| """ | ||
| raise NotImplementedError | ||
| @dataclass | ||
| class File(Node): | ||
| """File in a section or part.""" | ||
| def accept[**P, T]( | ||
| self, visitor: NodeVisitor[P, T], *args: P.args, **kwargs: P.kwargs | ||
| ) -> T: | ||
| """Dispatch method for visitors. | ||
| Args: | ||
| visitor: The visitor asking for the dispatch | ||
| args: Arguments to send back to the visitor untouched | ||
| kwargs: Keyword arguments to send back to the visitor untouched | ||
| Returns: | ||
| The return type is the same as the return type of the \ | ||
| [`visit_file`][deckz.models.NodeVisitor.visit_file] method of the \ | ||
| visitor. | ||
| """ | ||
| return visitor.visit_file(self, *args, **kwargs) | ||
| @dataclass | ||
| class Section(Node): | ||
| """Section in a section or part.""" | ||
| flavor: FlavorName | ||
| """Name of the flavor of the section.""" | ||
| nodes: list[Node] | ||
| """Nodes included in the section.""" | ||
| def accept[**P, T]( | ||
| self, visitor: NodeVisitor[P, T], *args: P.args, **kwargs: P.kwargs | ||
| ) -> T: | ||
| """Dispatch method for visitors. | ||
| Args: | ||
| visitor: The visitor asking for the dispatch | ||
| args: Arguments to send back to the visitor untouched | ||
| kwargs: Keyword arguments to send back to the visitor untouched | ||
| Returns: | ||
| The return type is the same as the return type of the \ | ||
| [`visit_section`][deckz.models.NodeVisitor.visit_section] method of \ | ||
| the visitor. | ||
| """ | ||
| return visitor.visit_section(self, *args, **kwargs) | ||
| @dataclass | ||
| class Part: | ||
| """Part in a deck.""" | ||
| title: str | None | ||
| """Title of the part.""" | ||
| nodes: list[Node] | ||
| """Nodes included in the part.""" | ||
| @dataclass | ||
| class Deck: | ||
| """Top of the hierarchy for deck parsing.""" | ||
| name: str | ||
| """The name of the deck. Will be a part of the output file name.""" | ||
| parts: dict[PartName, Part] | ||
| """Parts included in the deck.""" | ||
| def filter(self, whitelist: Iterable[PartName]) -> None: | ||
| """Filter out the parts that don't have their name listed in `whitelist`. | ||
| Args: | ||
| whitelist: Parts to keep. | ||
| Raises: | ||
| ValueError: Raised if an element of `whitelist` matches no part name in \ | ||
| the deck. | ||
| """ | ||
| if frozenset(whitelist).difference(self.parts): | ||
| msg = "provided whitelist has part names not in the deck" | ||
| raise ValueError(msg) | ||
| to_remove = frozenset(self.parts).difference(whitelist) | ||
| for part_name in to_remove: | ||
| del self.parts[part_name] | ||
| ######################################################################################## | ||
| # Slides representation # | ||
| ######################################################################################## | ||
| @dataclass(frozen=True) | ||
| class Title: | ||
| """Define a title slide and its level. | ||
| The lower the level, the more important the title. Similar to how h1 in HTML is \ | ||
| a more important title than h2. | ||
| """ | ||
| title: str | ||
| """The title string to display during rendering.""" | ||
| level: int | ||
| """The level of the title.""" | ||
| Content = str | ||
| """Alias to string to denote slide content path.""" | ||
| TitleOrContent = Title | Content | ||
| """Alias to title or content to denote any slide.""" | ||
| @dataclass(frozen=True) | ||
| class PartSlides: | ||
| """Title and slides comprising a part.""" | ||
| title: str | None | ||
| """Title of the part.""" | ||
| sections: list[TitleOrContent] = field(default_factory=list) | ||
| """Slides of the part.""" | ||
| ######################################################################################## | ||
| # Compilation representation # | ||
| ######################################################################################## | ||
| @dataclass(frozen=True) | ||
| class CompileResult: | ||
| """Result of a compilation.""" | ||
| ok: bool | ||
| """True if the compilation finished with a non-error code, False otherwise.""" | ||
| stdout: str | None = "" | ||
| """The complete stdout output during compilation.""" | ||
| stderr: str | None = "" | ||
| """The complete stderr output during compilation.""" | ||
| ######################################################################################## | ||
| # Assets usage stats # | ||
| ######################################################################################## | ||
| type AssetsMetadata = dict[str, tuple[dict[str, Any] | None, ...]] | ||
| """Assets and the number of time they appear in a given render.""" |
Sorry, the diff of this file is not supported yet
@@ -30,9 +30,7 @@ name: CI | ||
| - name: Run the tests | ||
| run: uv run pytest --cov=./ --cov-report=xml | ||
| run: uv run pytest --cov --cov-report=xml | ||
| - name: Upload coverage report to Codecov | ||
| uses: codecov/codecov-action@v3 | ||
| uses: codecov/codecov-action@v5 | ||
| with: | ||
| fail_ci_if_error: true | ||
| root_dir: ./ | ||
| token: ${{ secrets.CODECOV_TOKEN }} |
+1
-0
@@ -6,2 +6,3 @@ __pycache__/ | ||
| .venv/ | ||
| .vscode/ | ||
| build/ | ||
@@ -8,0 +9,0 @@ dist/ |
+1
-1
@@ -10,2 +10,3 @@ FROM python:3.12-slim-bullseye | ||
| curl \ | ||
| git \ | ||
| latexmk \ | ||
@@ -19,3 +20,2 @@ make \ | ||
| texlive-xetex \ | ||
| && apt-get remove -y .*-doc .*-man >/dev/null \ | ||
| && apt-get autoremove --purge -y \ | ||
@@ -22,0 +22,0 @@ && apt-get clean \ |
@@ -1,8 +0,5 @@ | ||
| from __future__ import annotations | ||
| import ast | ||
| import re | ||
| from functools import partial | ||
| from pathlib import Path | ||
| from typing import Any | ||
| from typing import TYPE_CHECKING, Any | ||
@@ -13,2 +10,6 @@ from griffe import Extension, Inspector, ObjectNode, Visitor, get_logger | ||
| if TYPE_CHECKING: | ||
| import ast | ||
| DOCS_PATH = Path(__file__).parent.parent | ||
@@ -103,3 +104,3 @@ slugifier = slugify(case="lower") | ||
| *, | ||
| node: ast.AST | ObjectNode, | ||
| node: "ast.AST | ObjectNode", | ||
| obj: GriffeObject, | ||
@@ -106,0 +107,0 @@ agent: Visitor | Inspector, |
+2
-0
@@ -27,2 +27,4 @@ site_name: Deckz Documentation | ||
| options: | ||
| docstring_style: google | ||
| show_symbol_type_heading: true | ||
| members_order: source | ||
@@ -29,0 +31,0 @@ separate_signature: true |
+5
-3
| Metadata-Version: 2.4 | ||
| Name: deckz | ||
| Version: 22.0.4 | ||
| Version: 23.0.0 | ||
| Summary: Tool to handle multiple beamer decks. | ||
| Author-email: m09 <142691+m09@users.noreply.github.com> | ||
| Author-email: m09 <142691+m09@users.noreply.github.com>, NyxAether <contact.nyxhemera@gmail.com> | ||
| License-File: LICENSE | ||
@@ -16,3 +16,5 @@ Classifier: License :: OSI Approved :: Apache Software License | ||
| Requires-Dist: jinja2<4,>=3 | ||
| Requires-Dist: kaleido<1,>=0.4.0rc1 | ||
| Requires-Dist: matplotlib<4,>=3 | ||
| Requires-Dist: plotly<6,>=5 | ||
| Requires-Dist: pydantic<3,>=2 | ||
@@ -30,3 +32,3 @@ Requires-Dist: pygit2<2,>=1 | ||
| [](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACI) | ||
| [](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD) | ||
| [](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD) | ||
| [](https://codecov.io/gh/shuuchuu/deckz) | ||
@@ -33,0 +35,0 @@ [](https://pypi.org/project/deckz/) |
+6
-3
| [project] | ||
| authors = [ | ||
| {name = "m09", email = "142691+m09@users.noreply.github.com"}, | ||
| {name = "NyxAether", email = "contact.nyxhemera@gmail.com"}, | ||
| ] | ||
@@ -16,3 +17,5 @@ classifiers = [ | ||
| "jinja2 >= 3, < 4", | ||
| "kaleido >= 0.4.0rc1, < 1", | ||
| "matplotlib >= 3, < 4", | ||
| "plotly >= 5, < 6", | ||
| "pydantic >= 2, < 3", | ||
@@ -27,7 +30,7 @@ "pygit2 >= 1, < 2", | ||
| description = "Tool to handle multiple beamer decks." | ||
| homepage = "https://github.com/mlambda/deckz" | ||
| homepage = "https://github.com/shuuchuu/deckz" | ||
| name = "deckz" | ||
| readme = "README.md" | ||
| requires-python = ">= 3.12" | ||
| version = "22.0.4" | ||
| version = "23.0.0" | ||
@@ -60,3 +63,3 @@ [dependency-groups] | ||
| preview = true | ||
| select = ["A", "B", "C", "D", "DOC", "E", "EM", "F", "N", "PIE", "PTH", "RET", "RUF", "SIM", "SLF", "UP", "W"] | ||
| select = ["A", "B", "C", "D", "DOC", "E", "EM", "F", "N", "PIE", "PTH", "RET", "RUF", "SIM", "SLF", "TC", "UP", "W"] | ||
@@ -63,0 +66,0 @@ [tool.ruff.lint.pydocstyle] |
+1
-1
| # `deckz` | ||
| [](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACI) | ||
| [](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD) | ||
| [](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD) | ||
| [](https://codecov.io/gh/shuuchuu/deckz) | ||
@@ -6,0 +6,0 @@ [](https://pypi.org/project/deckz/) |
@@ -1,3 +0,4 @@ | ||
| from deckz.components.assets_building import register_plot as register_plot | ||
| from .components.assets_builder import register_plot as register_plot | ||
| from .components.assets_builder import register_plotly as register_plotly | ||
| app_name = "deckz" |
@@ -0,8 +1,17 @@ | ||
| from collections.abc import MutableMapping, MutableSet | ||
| from functools import cached_property | ||
| from pathlib import Path, PurePath | ||
| from typing import cast | ||
| from ..models.deck import Deck | ||
| from ..models.definitions import SectionDefinition | ||
| from ..models.scalars import FlavorName, PartName, UnresolvedPath | ||
| from ..processing.sections_usage import SectionsUsageProcessor | ||
| from ..models import ( | ||
| Deck, | ||
| File, | ||
| FlavorName, | ||
| NodeVisitor, | ||
| Part, | ||
| PartName, | ||
| Section, | ||
| SectionDefinition, | ||
| UnresolvedPath, | ||
| ) | ||
| from ..utils import all_decks, load_yaml | ||
@@ -69,3 +78,3 @@ | ||
| """ | ||
| section_stats_processor = SectionsUsageProcessor(self._shared_latex_dir) | ||
| section_stats_processor = _SectionsUsageNodeVisitor(self._shared_latex_dir) | ||
| return { | ||
@@ -75,1 +84,48 @@ deck_path: section_stats_processor.process(deck) | ||
| } | ||
| class _SectionsUsageNodeVisitor( | ||
| NodeVisitor[[MutableMapping[UnresolvedPath, MutableSet[FlavorName]]], None] | ||
| ): | ||
| def __init__(self, shared_latex_dir: Path) -> None: | ||
| self._shared_latex_dir = shared_latex_dir | ||
| def process( | ||
| self, deck: Deck | ||
| ) -> dict[PartName, dict[UnresolvedPath, set[FlavorName]]]: | ||
| return { | ||
| part_name: self._process_part(part) | ||
| for part_name, part in deck.parts.items() | ||
| } | ||
| def _process_part(self, part: Part) -> dict[UnresolvedPath, set[FlavorName]]: | ||
| section_stats: dict[UnresolvedPath, set[FlavorName]] = {} | ||
| for node in part.nodes: | ||
| node.accept( | ||
| self, | ||
| # Not sure why we need a cast here :/ | ||
| cast( | ||
| "MutableMapping[UnresolvedPath, MutableSet[FlavorName]]", | ||
| section_stats, | ||
| ), | ||
| ) | ||
| return section_stats | ||
| def visit_file( | ||
| self, | ||
| file: File, | ||
| section_stats: MutableMapping[UnresolvedPath, MutableSet[FlavorName]], | ||
| ) -> None: | ||
| pass | ||
| def visit_section( | ||
| self, | ||
| section: Section, | ||
| section_stats: MutableMapping[UnresolvedPath, MutableSet[FlavorName]], | ||
| ) -> None: | ||
| if section.resolved_path.is_relative_to(self._shared_latex_dir): | ||
| if section.unresolved_path not in section_stats: | ||
| section_stats[section.unresolved_path] = set() | ||
| section_stats[section.unresolved_path].add(section.flavor) | ||
| for node in section.nodes: | ||
| node.accept(self, section_stats) |
@@ -6,4 +6,2 @@ from logging import INFO, basicConfig | ||
| from ..utils import import_module_and_submodules | ||
| app = App() | ||
@@ -19,3 +17,5 @@ | ||
| ) | ||
| from ..utils import import_module_and_submodules | ||
| import_module_and_submodules(__name__) | ||
| app() |
@@ -11,3 +11,3 @@ from pathlib import Path | ||
| from ..models.scalars import UnresolvedPath | ||
| from ..models import UnresolvedPath | ||
@@ -14,0 +14,0 @@ |
| from pathlib import Path | ||
| from ..models.scalars import PartName | ||
| from ..models import PartName | ||
| from . import app | ||
@@ -5,0 +5,0 @@ |
@@ -5,3 +5,3 @@ from pathlib import Path | ||
| from ..models.scalars import FlavorName, PartName | ||
| from ..models import FlavorName, PartName | ||
| from . import app | ||
@@ -224,7 +224,14 @@ | ||
| minimum_delay, | ||
| frozenset([settings.paths.tikz_dir, settings.paths.plt_dir]), | ||
| frozenset( | ||
| [ | ||
| settings.paths.tikz_dir, | ||
| settings.paths.plt_dir, | ||
| settings.paths.plotly_dir | ||
| ] | ||
| ), | ||
| frozenset( | ||
| [ | ||
| settings.paths.shared_tikz_pdf_dir, | ||
| settings.paths.shared_plt_pdf_dir, | ||
| settings.paths.shared_plotly_pdf_dir, | ||
| ] | ||
@@ -231,0 +238,0 @@ ), |
@@ -16,3 +16,2 @@ from functools import reduce | ||
| from .. import app_name | ||
| from ..components import BuilderConfig, ParserConfig | ||
| from ..exceptions import DeckzError | ||
@@ -66,4 +65,6 @@ from ..utils import get_git_dir, intermediate_dirs, load_all_yamls | ||
| shared_plt_pdf_dir: _Path = "{shared_dir}/plt" | ||
| shared_plotly_pdf_dir: _Path = "{shared_dir}/pltly" | ||
| templates_dir: _Path = "{git_dir}/templates" | ||
| plt_dir: _Path = "{figures_dir}/plots" | ||
| plotly_dir: _Path = "{figures_dir}/pltly" | ||
| tikz_dir: _Path = "{figures_dir}/tikz" | ||
@@ -113,17 +114,7 @@ jinja2_dir: _Path = "{templates_dir}/jinja2" | ||
| class GlobalComponents(BaseModel): | ||
| model_config = ConfigDict(validate_default=True) | ||
| class DeckComponents(BaseModel): | ||
| model_config = ConfigDict(validate_default=True) | ||
| parser_config: ParserConfig = {"config_key": "default_parser"} | ||
| builder_config: BuilderConfig = {"config_key": "default_builder"} | ||
| class GlobalSettings(BaseModel): | ||
| build_command: list[str] | ||
| build_command: tuple[str, ...] | ||
| file_extension: str = ".tex" | ||
| default_img_values: DefaultImageValues = Field(default_factory=DefaultImageValues) | ||
| paths: GlobalPaths = Field(default_factory=GlobalPaths) | ||
| components: GlobalComponents = Field(default_factory=GlobalComponents) | ||
@@ -153,2 +144,1 @@ @classmethod | ||
| paths: DeckPaths = Field(default_factory=DeckPaths) | ||
| components: DeckComponents = Field(default_factory=DeckComponents) |
+17
-33
@@ -12,8 +12,7 @@ from collections.abc import Callable, Iterable, Set | ||
| from .components.assets_building import Assets | ||
| from .components.factory import DeckSettingsFactory, GlobalSettingsFactory | ||
| from .configuring.settings import DeckSettings, GlobalSettings | ||
| from .configuring.variables import get_variables | ||
| from .exceptions import DeckzError | ||
| from .models.deck import Deck | ||
| from .models.scalars import FlavorName, PartName | ||
| from .models import Deck, FlavorName, PartName | ||
| from .utils import all_deck_settings | ||
@@ -32,7 +31,6 @@ | ||
| variables = get_variables(settings) | ||
| Assets(settings).build() | ||
| builder_config = settings.components.builder_config | ||
| return builder_config.get_model_class()( | ||
| factory = DeckSettingsFactory(settings) | ||
| factory.assets_builder().build_assets() | ||
| return factory.deck_builder( | ||
| variables=variables, | ||
| settings=settings, | ||
| deck=deck, | ||
@@ -42,3 +40,3 @@ build_handout=build_handout, | ||
| build_print=build_print, | ||
| ).build() | ||
| ).build_deck() | ||
@@ -53,6 +51,4 @@ | ||
| ) -> None: | ||
| parser_config = settings.components.parser_config | ||
| deck = parser_config.get_model_class()( | ||
| settings.paths.local_latex_dir, settings.paths.shared_latex_dir, parser_config | ||
| ).from_deck_definition(settings.paths.deck_definition) | ||
| parser = DeckSettingsFactory(settings).parser() | ||
| deck = parser.from_deck_definition(settings.paths.deck_definition) | ||
| if parts_whitelist is not None: | ||
@@ -76,8 +72,4 @@ deck.filter(parts_whitelist) | ||
| ) -> None: | ||
| parser_config = settings.components.parser_config | ||
| deck = parser_config.get_model_class()( | ||
| settings.paths.local_latex_dir, settings.paths.shared_latex_dir, parser_config | ||
| ).from_file(latex) | ||
| _build( | ||
| deck=deck, | ||
| deck=DeckSettingsFactory(settings).parser().from_file(latex), | ||
| settings=settings, | ||
@@ -98,8 +90,4 @@ build_handout=build_handout, | ||
| ) -> None: | ||
| parser_config = settings.components.parser_config | ||
| deck = parser_config.get_model_class()( | ||
| settings.paths.local_latex_dir, settings.paths.shared_latex_dir, parser_config | ||
| ).from_section(section, flavor) | ||
| _build( | ||
| deck=deck, | ||
| deck=DeckSettingsFactory(settings).parser().from_section(section, flavor), | ||
| settings=settings, | ||
@@ -119,3 +107,3 @@ build_handout=build_handout, | ||
| global_settings = GlobalSettings.from_yaml(directory) | ||
| Assets(global_settings).build() | ||
| GlobalSettingsFactory(global_settings).assets_builder().build_assets() | ||
| decks_settings = list(all_deck_settings(global_settings.paths.git_dir)) | ||
@@ -129,10 +117,6 @@ with Progress( | ||
| for deck_settings in decks_settings: | ||
| parser_config = deck_settings.components.parser_config | ||
| deck = parser_config.get_model_class()( | ||
| deck_settings.paths.local_latex_dir, | ||
| deck_settings.paths.shared_latex_dir, | ||
| parser_config, | ||
| ).from_deck_definition(deck_settings.paths.deck_definition) | ||
| result = _build( | ||
| deck=deck, | ||
| deck=DeckSettingsFactory(deck_settings) | ||
| .parser() | ||
| .from_deck_definition(deck_settings.paths.deck_definition), | ||
| settings=deck_settings, | ||
@@ -155,5 +139,5 @@ build_handout=build_handout, | ||
| """ | ||
| settings = GlobalSettings.from_yaml(directory) | ||
| standalones_builder = Assets(settings) | ||
| standalones_builder.build() | ||
| GlobalSettingsFactory( | ||
| GlobalSettings.from_yaml(directory) | ||
| ).assets_builder().build_assets() | ||
@@ -160,0 +144,0 @@ |
+13
-10
@@ -10,6 +10,6 @@ """Provide general utility functions that would not fit in other modules.""" | ||
| from .configuring.settings import DeckSettings | ||
| from .parsing import Deck | ||
| from .models import Deck | ||
| def copy_file_if_newer(original: Path, copy: Path) -> None: | ||
| def copy_file_if_newer(original: Path, copy: Path) -> bool: | ||
| """Copy `original` to `copy` if `copy` is older than `original` or does not exist. | ||
@@ -23,2 +23,5 @@ | ||
| copy: Path of the destination. | ||
| Returns: | ||
| True if the file was copied, False if it wasn't needed. | ||
| """ | ||
@@ -28,5 +31,6 @@ from shutil import copyfile | ||
| if copy.exists() and copy.stat().st_mtime > original.stat().st_mtime: | ||
| return | ||
| return False | ||
| copy.parent.mkdir(parents=True, exist_ok=True) | ||
| copyfile(original, copy) | ||
| return True | ||
@@ -112,11 +116,10 @@ | ||
| def _build_deck(settings: "DeckSettings") -> tuple[Path, "Deck"]: | ||
| parser_config = settings.components.parser_config | ||
| deck = parser_config.get_model_class()( | ||
| settings.paths.local_latex_dir, settings.paths.shared_latex_dir, parser_config | ||
| ).from_deck_definition(settings.paths.deck_definition) | ||
| def _parse_deck(settings: "DeckSettings") -> tuple[Path, "Deck"]: | ||
| from .components.factory import DeckSettingsFactory | ||
| return ( | ||
| settings.paths.deck_definition.parent.relative_to(settings.paths.git_dir), | ||
| deck, | ||
| DeckSettingsFactory(settings) | ||
| .parser() | ||
| .from_deck_definition(settings.paths.deck_definition), | ||
| ) | ||
@@ -129,3 +132,3 @@ | ||
| with Pool() as pool: | ||
| return dict(pool.map(_build_deck, list(all_deck_settings(git_dir)))) | ||
| return dict(pool.map(_parse_deck, list(all_deck_settings(git_dir)))) | ||
@@ -132,0 +135,0 @@ |
| from collections.abc import Iterable, Iterator | ||
| from functools import cached_property | ||
| from pathlib import Path | ||
| from re import VERBOSE | ||
| from re import compile as re_compile | ||
| from ..models.deck import Deck | ||
| from ..models.scalars import ResolvedPath, UnresolvedPath | ||
| from ..processing.section_dependencies import SectionDependenciesProcessor | ||
| from ..utils import all_decks, load_yaml | ||
| class ImagesAnalyzer: | ||
| def __init__(self, shared_dir: Path, git_dir: Path) -> None: | ||
| self._shared_dir = shared_dir | ||
| self._git_dir = git_dir | ||
| def sections_unlicensed_images(self) -> dict[UnresolvedPath, frozenset[Path]]: | ||
| return { | ||
| s: frozenset( | ||
| i for i in self._section_images(d) if not self._is_image_licensed(i) | ||
| ) | ||
| for s, d in self._section_dependencies.items() | ||
| } | ||
| @cached_property | ||
| def _decks(self) -> dict[Path, Deck]: | ||
| return all_decks(self._git_dir) | ||
| @property | ||
| def _section_dependencies(self) -> dict[UnresolvedPath, set[ResolvedPath]]: | ||
| section_dependencies_processor = SectionDependenciesProcessor() | ||
| result: dict[UnresolvedPath, set[ResolvedPath]] = {} | ||
| for deck in self._decks.values(): | ||
| section_dependencies = section_dependencies_processor.process(deck) | ||
| for path, deps in section_dependencies.items(): | ||
| if path not in result: | ||
| result[path] = set() | ||
| result[path].update(deps) | ||
| return result | ||
| _pattern = re_compile( | ||
| r""" | ||
| \\V{ | ||
| \s* | ||
| "(.+?)" | ||
| \s* | ||
| \| | ||
| \s* | ||
| image | ||
| \s* | ||
| (?:\([^)]*\))? | ||
| \s* | ||
| } | ||
| """, | ||
| VERBOSE, | ||
| ) | ||
| def _section_images(self, dependencies: Iterable[Path]) -> Iterator[Path]: | ||
| for path in dependencies: | ||
| for match in ImagesAnalyzer._pattern.finditer( | ||
| path.read_text(encoding="utf8") | ||
| ): | ||
| if match is not None: | ||
| yield self._shared_dir / match.group(1) | ||
| def _is_image_licensed(self, path: Path) -> bool: | ||
| metadata_path = path.with_suffix(".yml") | ||
| if not metadata_path.exists(): | ||
| return False | ||
| return "license" in load_yaml(metadata_path) |
| from collections.abc import Iterable | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from subprocess import run | ||
| @dataclass(frozen=True) | ||
| class CompileResult: | ||
| ok: bool | ||
| stdout: str | None = "" | ||
| stderr: str | None = "" | ||
| def compile(latex_path: Path, build_command: Iterable[str]) -> CompileResult: # noqa: A001 | ||
| completed_process = run( | ||
| [*build_command, latex_path.name], | ||
| cwd=latex_path.parent, | ||
| capture_output=True, | ||
| encoding="utf8", | ||
| ) | ||
| return CompileResult( | ||
| completed_process.returncode == 0, | ||
| completed_process.stdout, | ||
| completed_process.stderr, | ||
| ) |
| from collections.abc import Callable | ||
| from contextlib import suppress | ||
| from filecmp import cmp | ||
| from functools import cached_property | ||
| from os.path import join as path_join | ||
| from pathlib import Path | ||
| from shutil import move | ||
| from tempfile import NamedTemporaryFile | ||
| from typing import TYPE_CHECKING, Any | ||
| from jinja2 import BaseLoader, Environment, TemplateNotFound | ||
| from ..utils import load_yaml | ||
| if TYPE_CHECKING: | ||
| from ..configuring.settings import DeckSettings | ||
| class AbsoluteLoader(BaseLoader): | ||
| def get_source( | ||
| self, environment: Environment, template: str | ||
| ) -> tuple[str, str, Callable[[], bool]]: | ||
| template_path = Path(template) | ||
| if not template_path.exists(): | ||
| raise TemplateNotFound(template) | ||
| mtime = template_path.stat().st_mtime | ||
| source = template_path.read_text(encoding="utf8") | ||
| return ( | ||
| source, | ||
| str(template_path), | ||
| lambda: mtime == template_path.stat().st_mtime, | ||
| ) | ||
| class Renderer: | ||
| def __init__(self, settings: "DeckSettings"): | ||
| self._settings = settings | ||
| def render( | ||
| self, *, template_path: Path, output_path: Path, **template_kwargs: Any | ||
| ) -> None: | ||
| template = self._env.get_template(str(template_path)) | ||
| try: | ||
| with NamedTemporaryFile("w", encoding="utf8", delete=False) as fh: | ||
| fh.write(template.render(**template_kwargs)) | ||
| fh.write("\n") | ||
| if not output_path.exists() or not cmp(fh.name, str(output_path)): | ||
| move(fh.name, output_path) | ||
| finally: | ||
| with suppress(FileNotFoundError): | ||
| Path(fh.name).unlink() | ||
| @cached_property | ||
| def _env(self) -> Environment: | ||
| env = Environment( | ||
| loader=AbsoluteLoader(), | ||
| block_start_string=r"\BLOCK{", | ||
| block_end_string="}", | ||
| variable_start_string=r"\V{", | ||
| variable_end_string="}", | ||
| comment_start_string=r"\#{", | ||
| comment_end_string="}", | ||
| line_statement_prefix="%%", | ||
| line_comment_prefix="%#", | ||
| trim_blocks=True, | ||
| autoescape=False, | ||
| ) | ||
| env.filters["camelcase"] = self._to_camel_case | ||
| env.filters["path_join"] = lambda paths: path_join(*paths) # noqa: PTH118 | ||
| env.filters["image"] = self._img | ||
| return env | ||
| def _to_camel_case(self, string: str) -> str: | ||
| return "".join(substring.capitalize() or "_" for substring in string.split("_")) | ||
| def _img( | ||
| self, value: str, modifier: str = "", scale: float = 1.0, lang: str = "fr" | ||
| ) -> str: | ||
| metadata_path = (self._settings.paths.shared_dir / Path(value)).with_suffix( | ||
| ".yml" | ||
| ) | ||
| if metadata_path.exists(): | ||
| metadata = load_yaml(metadata_path) | ||
| def get_en_or_fr(key: str) -> str: | ||
| if lang != "fr": | ||
| key_en = f"{key}_en" | ||
| return metadata[key_en] if key_en in metadata else metadata[key] | ||
| return metadata[key] | ||
| title = self._settings.default_img_values.title.get_default( | ||
| get_en_or_fr("title"), lang | ||
| ) | ||
| author = self._settings.default_img_values.author.get_default( | ||
| get_en_or_fr("author"), lang | ||
| ) | ||
| license_name = self._settings.default_img_values.license.get_default( | ||
| get_en_or_fr("license"), lang | ||
| ) | ||
| info = f"[{title}, {author}, {license_name}.]" | ||
| else: | ||
| info = "" | ||
| return f"\\img{modifier}{info}{{{value}}}{{{scale:.2f}}}" |
| from pathlib import Path | ||
| from . import app | ||
| @app.command() | ||
| def img_deps( | ||
| sections: list[str] | None = None, | ||
| /, | ||
| *, | ||
| verbose: bool = True, | ||
| descending: bool = True, | ||
| workdir: Path = Path(), | ||
| ) -> None: | ||
| """Find unlicensed images with output detailed by section. | ||
| You can display info only about specific SECTIONS, like nn/cnn or tools." | ||
| Args: | ||
| sections: Restrict the output to these sections | ||
| verbose: Detailed output with a listing of used images | ||
| descending: Sort sections by ascending number of unlicensed images | ||
| workdir: Path to move into before running the command | ||
| """ | ||
| from collections.abc import Mapping, Set | ||
| from rich.console import Console | ||
| from rich.table import Table | ||
| from ..analyzing.images_analyzer import ImagesAnalyzer | ||
| from ..configuring.settings import GlobalSettings | ||
| from ..models.scalars import UnresolvedPath | ||
| def _display_table( | ||
| unlicensed_images: Mapping[UnresolvedPath, Set[Path]], | ||
| console: Console, | ||
| ) -> None: | ||
| if unlicensed_images: | ||
| table = Table("Section", "Unlicensed images") | ||
| for section, images in unlicensed_images.items(): | ||
| table.add_row(str(section), f"{len(images)}") | ||
| console.print(table) | ||
| else: | ||
| console.print("No unlicensed image!") | ||
| def _display_section_images( | ||
| unlicensed_images: Mapping[UnresolvedPath, Set[Path]], | ||
| console: Console, | ||
| shared_dir: Path, | ||
| ) -> None: | ||
| if unlicensed_images: | ||
| for section, images in unlicensed_images.items(): | ||
| console.print() | ||
| console.rule( | ||
| f"[bold]{section}[/] — " | ||
| f"[red]{len(images)}[/] " | ||
| f"unlicensed image{'s' * (len(images) > 1)}", | ||
| align="left", | ||
| ) | ||
| console.print() | ||
| for image in sorted(images): | ||
| matches = image.parent.glob(f"{image.name}.*") | ||
| console.print( | ||
| " or ".join( | ||
| f"[link=file://{m}]{m.relative_to(shared_dir)}[/link]" | ||
| for m in matches | ||
| if m.suffix != ".yml" | ||
| ) | ||
| ) | ||
| else: | ||
| console.print("No unlicensed image!") | ||
| settings = GlobalSettings.from_yaml(workdir) | ||
| console = Console(highlight=False) | ||
| with console.status("Finding unlicensed images"): | ||
| images_analyzer = ImagesAnalyzer( | ||
| settings.paths.shared_dir, settings.paths.git_dir | ||
| ) | ||
| unlicensed_images = images_analyzer.sections_unlicensed_images() | ||
| sorted_unlicensed_images = { | ||
| k: v | ||
| for k, v in sorted( | ||
| unlicensed_images.items(), | ||
| key=lambda t: len(t[1]), | ||
| reverse=descending, | ||
| ) | ||
| if v | ||
| } | ||
| if verbose: | ||
| console.print("[bold]Sections and their unlicensed images[/]") | ||
| _display_section_images( | ||
| sorted_unlicensed_images, console, settings.paths.shared_dir | ||
| ) | ||
| else: | ||
| _display_table(sorted_unlicensed_images, console) |
| from pathlib import Path | ||
| from . import app | ||
| @app.command() | ||
| def img_search( | ||
| image: str, | ||
| /, | ||
| *, | ||
| workdir: Path = Path(), | ||
| ) -> None: | ||
| """Find which latex files use IMAGE. | ||
| Args: | ||
| image: Image to search in LaTeX files. Specify the path relative to the shared \ | ||
| directory and whithout extension, e.g. img/turing | ||
| workdir: Path to move into before running the command | ||
| """ | ||
| from re import VERBOSE | ||
| from re import compile as re_compile | ||
| from rich.console import Console | ||
| from ..configuring.settings import GlobalSettings | ||
| from ..utils import latex_dirs | ||
| settings = GlobalSettings.from_yaml(workdir) | ||
| console = Console(highlight=False) | ||
| pattern = re_compile( | ||
| rf""" | ||
| \\V{{ | ||
| \s* | ||
| "{image}" | ||
| \s* | ||
| \| | ||
| \s* | ||
| image | ||
| \s* | ||
| (?:\([^)]*\))? | ||
| \s* | ||
| }} | ||
| """, | ||
| VERBOSE, | ||
| ) | ||
| for latex_dir in latex_dirs( | ||
| settings.paths.git_dir, settings.paths.shared_latex_dir | ||
| ): | ||
| for f in latex_dir.rglob("*.tex"): | ||
| if pattern.search(f.read_text(encoding="utf8")): | ||
| console.print( | ||
| f"[link=file://{f}]{f.relative_to(settings.paths.git_dir)}[/link]" | ||
| ) |
| from pathlib import Path | ||
| from . import app | ||
| # ruff: noqa: C901 | ||
| @app.command() | ||
| def upgrade(*, workdir: Path = Path()) -> None: | ||
| """Transform a deckz repo to match the new conventions. | ||
| Args: | ||
| workdir: Path to move into before running the command. | ||
| Raises: | ||
| ValueError: When the format to handle in modified files is non-conform to \ | ||
| expectations. | ||
| """ | ||
| from itertools import chain | ||
| from shutil import move, rmtree | ||
| from sys import stderr | ||
| from typing import cast | ||
| from rich.console import Console | ||
| from yaml import safe_dump | ||
| from ..configuring.settings import DeckSettings, GlobalSettings | ||
| from ..utils import load_yaml | ||
| console = Console(file=stderr) | ||
| old_settings = Path("settings.yml") | ||
| if old_settings.exists(): | ||
| move(old_settings, "deckz.yml") | ||
| settings = GlobalSettings.from_yaml(workdir) | ||
| console.print("Deleting templates") | ||
| yml_templates_dir = settings.paths.templates_dir / "yml" | ||
| if yml_templates_dir.is_dir(): | ||
| rmtree(yml_templates_dir) | ||
| console.print( | ||
| " :white_check_mark: Removed " | ||
| f"{yml_templates_dir.relative_to(settings.paths.git_dir)} " | ||
| ) | ||
| console.print("Renaming files (config -> variables, targets -> deck)") | ||
| decks_settings = [ | ||
| DeckSettings.from_yaml(path.parent) | ||
| for path in settings.paths.git_dir.rglob("targets.yml") | ||
| ] | ||
| for deck_settings in decks_settings: | ||
| for path, old_name in ( | ||
| (deck_settings.paths.deck_definition, "targets.yml"), | ||
| (deck_settings.paths.global_variables, "global-config.yml"), | ||
| (deck_settings.paths.user_variables, "user-config.yml"), | ||
| (deck_settings.paths.company_variables, "company-config.yml"), | ||
| (deck_settings.paths.deck_variables, "deck-config.yml"), | ||
| (deck_settings.paths.session_variables, "session-config.yml"), | ||
| ): | ||
| old_path = path.parent / old_name | ||
| if old_path.exists(): | ||
| move(old_path, path) | ||
| console.print( | ||
| " :white_check_mark:" | ||
| f"{old_path}\n" | ||
| f" → [link=file://{path}]{path}[/link]" | ||
| ) | ||
| console.print("Changing decks format and transfer deck name inside deck definition") | ||
| UNSET = cast(str, object()) # noqa: N806 | ||
| def normalize_part_content( | ||
| v: str | dict[str, str], | ||
| ) -> tuple[str, str | None, str | None]: | ||
| title = UNSET | ||
| flavor = None | ||
| if isinstance(v, str): | ||
| path = v | ||
| elif isinstance(v, dict) and "path" not in v: | ||
| assert len(v) == 1 | ||
| path, flavor = next(iter(v.items())) | ||
| else: | ||
| path, flavor, title = v["path"], v.get("flavor"), v.get("title", UNSET) | ||
| return path, title, flavor | ||
| decks_settings = [ | ||
| DeckSettings.from_yaml(path.parent) | ||
| for path in settings.paths.git_dir.rglob("deck.yml") | ||
| ] | ||
| for deck_settings in decks_settings: | ||
| variables = load_yaml(deck_settings.paths.deck_variables) | ||
| try: | ||
| deck_name = variables["deck_acronym"] | ||
| except KeyError as e: | ||
| msg = f"{deck_settings.paths.deck_variables} does not contain deck_acronym" | ||
| raise ValueError(msg) from e | ||
| del variables["deck_acronym"] | ||
| with deck_settings.paths.deck_variables.open("w", encoding="utf8") as fh: | ||
| safe_dump( | ||
| variables, fh, encoding="utf8", allow_unicode=True, sort_keys=False | ||
| ) | ||
| deck_definition = deck_settings.paths.deck_definition | ||
| content = load_yaml(deck_definition) | ||
| for part in content: | ||
| new_sections: list[dict[str, str | None] | str] = [] | ||
| for item in part["sections"]: | ||
| path_str, title, flavor = normalize_part_content(item) | ||
| if flavor is None: | ||
| if title is UNSET: | ||
| new_sections.append(path_str) | ||
| else: | ||
| new_sections.append({path_str: title}) | ||
| else: | ||
| key = f"${path_str}@{flavor}" | ||
| if title is UNSET: | ||
| new_sections.append(key) | ||
| else: | ||
| new_sections.append({key: title}) | ||
| part["sections"] = new_sections | ||
| with deck_definition.open("w", encoding="utf8") as fh: | ||
| new_content = {"name": deck_name, "parts": content} | ||
| safe_dump( | ||
| new_content, | ||
| fh, | ||
| allow_unicode=True, | ||
| encoding="utf8", | ||
| sort_keys=False, | ||
| ) | ||
| console.print( | ||
| f" :white_check_mark: [link=file://{deck_definition}]" | ||
| f"{deck_definition.relative_to(settings.paths.git_dir)}" | ||
| "[/link]" | ||
| ) | ||
| console.print("Changing sections format to allow title definition in each flavor") | ||
| def normalize_section_content( | ||
| v: str | dict[str, str], | ||
| ) -> tuple[str, str | None, str | None]: | ||
| title = UNSET | ||
| flavor = None | ||
| if isinstance(v, str): | ||
| path = v | ||
| else: | ||
| assert len(v) == 1 | ||
| left, right = next(iter(v.items())) | ||
| if left.startswith("$"): | ||
| path = left[1:] | ||
| flavor = right | ||
| else: | ||
| path = left | ||
| title = right | ||
| return path, title, flavor | ||
| for section_file in chain( | ||
| settings.paths.shared_latex_dir.rglob("*.yml"), | ||
| ( | ||
| p | ||
| for deck_settings in decks_settings | ||
| for p in deck_settings.paths.local_latex_dir.rglob("*.yml") | ||
| ), | ||
| ): | ||
| content = load_yaml(section_file) | ||
| if ( | ||
| not isinstance(content, dict) | ||
| or "flavors" not in content | ||
| or "title" not in content | ||
| ): | ||
| continue | ||
| if "version" in content: | ||
| del content["version"] | ||
| new_flavors = [] | ||
| for flavor_name, includes in content["flavors"].items(): | ||
| new_includes = [] | ||
| for include in includes: | ||
| path_str, title, flavor = normalize_section_content(include) | ||
| left = path_str if flavor is None else f"${path_str}@{flavor}" | ||
| new_includes.append(left if title is UNSET else {left: title}) | ||
| new_flavors.append({"name": flavor_name, "includes": new_includes}) | ||
| content["flavors"] = new_flavors | ||
| if "default_titles" in content and not content["default_titles"]: | ||
| del content["default_titles"] | ||
| with section_file.open("w", encoding="utf8") as fh: | ||
| safe_dump(content, fh, allow_unicode=True, encoding="utf8", sort_keys=False) | ||
| console.print( | ||
| f" :white_check_mark: [link=file://{section_file}]" | ||
| f"{section_file.relative_to(settings.paths.git_dir)}" | ||
| "[/link]" | ||
| ) |
| import sys | ||
| from collections.abc import Callable | ||
| from contextlib import redirect_stdout | ||
| from dataclasses import dataclass | ||
| from functools import partial | ||
| from itertools import chain | ||
| from logging import getLogger | ||
| from multiprocessing import Pool | ||
| from pathlib import Path | ||
| from shutil import copyfile | ||
| from tempfile import TemporaryDirectory | ||
| from typing import TYPE_CHECKING | ||
| from ..building.compiling import compile as compiling_compile | ||
| from ..exceptions import DeckzError | ||
| from ..utils import copy_file_if_newer, import_module_and_submodules | ||
| if TYPE_CHECKING: | ||
| from ..configuring.settings import GlobalSettings | ||
| @dataclass(frozen=True) | ||
| class CompilePaths: | ||
| latex: Path | ||
| build_pdf: Path | ||
| output_pdf: Path | ||
| build_log: Path | ||
| output_log: Path | ||
| class Assets: | ||
| def __init__(self, settings: "GlobalSettings"): | ||
| self.plt_builder = PltBuilder(settings) | ||
| self.tikz_builder = TikzBuilder(settings) | ||
| def build(self) -> None: | ||
| self.plt_builder.build() | ||
| self.tikz_builder.build() | ||
| _plt_registry: list[tuple[Path, Path, Callable[[], None]]] = [] | ||
| def _clear_register() -> None: | ||
| _plt_registry.clear() | ||
| def register_plot( | ||
| name: str | None = None, | ||
| ) -> Callable[[Callable[[], None]], Callable[[], None]]: | ||
| def worker(f: Callable[[], None]) -> Callable[[], None]: | ||
| _, *submodules, _ = f.__module__.split(".") | ||
| name = f.__name__.replace("_", "-") | ||
| output_path = ( | ||
| Path("/".join(s.replace("_", "-") for s in submodules)) / name | ||
| ).with_suffix(".pdf") | ||
| python_path_str = sys.modules[f.__module__].__file__ | ||
| # I don't get why this is needed for mypy. It seems from the definition of | ||
| # ModuleType that __file__ is always a str and never None | ||
| assert python_path_str is not None | ||
| python_path = Path(python_path_str) | ||
| _plt_registry.append((output_path, python_path, f)) | ||
| return f | ||
| return worker | ||
| class PltBuilder: | ||
| def __init__(self, settings: "GlobalSettings"): | ||
| self._settings = settings | ||
| self._logger = getLogger(__name__) | ||
| def build(self) -> None: | ||
| import matplotlib | ||
| matplotlib.use("PDF") | ||
| sys.dont_write_bytecode = True | ||
| _clear_register() | ||
| try: | ||
| import_module_and_submodules("plots") | ||
| except ModuleNotFoundError: | ||
| self._logger.warning("Could not find plots module, will not produce plots.") | ||
| full_items = [ | ||
| (self._settings.paths.shared_plt_pdf_dir / o, p, f) | ||
| for o, p, f in _plt_registry | ||
| ] | ||
| to_build = [(o, p, f) for o, p, f in full_items if self._needs_compile(p, o)] | ||
| if not to_build: | ||
| return | ||
| self._logger.info(f"Processing {len(to_build)} plot(s) that need recompiling") | ||
| for output_path, python_path, function in to_build: | ||
| self._build_pdf(python_path, output_path, function) | ||
| def _build_pdf( | ||
| self, python_path: Path, output_path: Path, function: Callable[[], None] | ||
| ) -> None: | ||
| import matplotlib.pyplot as plt | ||
| output_path.parent.mkdir(parents=True, exist_ok=True) | ||
| function() | ||
| plt.savefig(output_path, bbox_inches="tight") | ||
| plt.close() | ||
| def _needs_compile(self, python_path: Path, output_path: Path) -> bool: | ||
| return ( | ||
| not output_path.exists() | ||
| or output_path.stat().st_mtime_ns < python_path.stat().st_mtime_ns | ||
| ) | ||
| class TikzBuilder: | ||
| def __init__(self, settings: "GlobalSettings"): | ||
| self._settings = settings | ||
| self._logger = getLogger(__name__) | ||
| def build(self) -> None: | ||
| with TemporaryDirectory() as build_dir: | ||
| build_path = Path(build_dir) | ||
| items = [ | ||
| (input_path, paths) | ||
| for input_path in chain( | ||
| self._settings.paths.tikz_dir.rglob("*.py"), | ||
| self._settings.paths.tikz_dir.rglob("*.tex"), | ||
| ) | ||
| if self._needs_compile( | ||
| input_path, | ||
| paths := self._compute_compile_paths(input_path, build_path), | ||
| ) | ||
| ] | ||
| if not items: | ||
| return | ||
| self._logger.info(f"Processing {len(items)} tikz(s) that need recompiling") | ||
| for item in items: | ||
| self._prepare(*item) | ||
| with Pool() as pool: | ||
| results = pool.map( | ||
| partial( | ||
| compiling_compile, build_command=self._settings.build_command | ||
| ), | ||
| (item_path.latex for _, item_path in items), | ||
| ) | ||
| for (_, paths), result in zip(items, results, strict=True): | ||
| if result.ok: | ||
| paths.output_pdf.parent.mkdir(parents=True, exist_ok=True) | ||
| copyfile(paths.build_pdf, paths.output_pdf) | ||
| paths.output_log.unlink(missing_ok=True) | ||
| elif paths.build_log.exists(): | ||
| paths.output_pdf.parent.mkdir(parents=True, exist_ok=True) | ||
| copyfile(paths.build_log, paths.output_log) | ||
| failed = [] | ||
| for (input_path, paths), result in zip(items, results, strict=True): | ||
| if not result.ok: | ||
| failed.append((input_path, paths.output_log)) | ||
| self._logger.warning("Standalone compilation of %s errored", input_path) | ||
| self._logger.warning("Captured stderr\n%s", result.stderr) | ||
| if failed: | ||
| def linkify(path: Path) -> str: | ||
| return f"[link=file://{path}]log[/link]" | ||
| formatted_fails = "\n".join( | ||
| ( | ||
| f"- {file_path.relative_to(self._settings.paths.shared_dir)}" | ||
| f' ({linkify(log_path) if log_path.exists() else "no log"})' | ||
| ) | ||
| for file_path, log_path in failed | ||
| ) | ||
| msg = ( | ||
| f"standalone compilation errored for {len(failed)} files:\n" | ||
| f"{formatted_fails}\n" | ||
| "Please also check the errors above." | ||
| ) | ||
| raise DeckzError(msg) | ||
| def _needs_compile(self, input_file: Path, compile_paths: CompilePaths) -> bool: | ||
| return ( | ||
| not compile_paths.output_pdf.exists() | ||
| or compile_paths.output_pdf.stat().st_mtime < input_file.stat().st_mtime | ||
| ) | ||
| def _generate_latex(self, python_file: Path, output_file: Path) -> None: | ||
| compiled = compile( | ||
| source=python_file.read_text(encoding="utf8"), | ||
| filename=python_file.name, | ||
| mode="exec", | ||
| ) | ||
| output_file.parent.mkdir(parents=True, exist_ok=True) | ||
| with output_file.open("w", encoding="utf8") as fh, redirect_stdout(fh): | ||
| exec(compiled) | ||
| def _compute_compile_paths(self, input_file: Path, build_dir: Path) -> CompilePaths: | ||
| latex = ( | ||
| build_dir / input_file.relative_to(self._settings.paths.tikz_dir) | ||
| ).with_suffix(".tex") | ||
| build_pdf = latex.with_suffix(".pdf") | ||
| output_pdf = ( | ||
| self._settings.paths.shared_tikz_pdf_dir | ||
| / input_file.relative_to(self._settings.paths.tikz_dir) | ||
| ).with_suffix(".pdf") | ||
| build_log = latex.with_suffix(".log") | ||
| output_log = output_pdf.with_suffix(".log") | ||
| return CompilePaths( | ||
| latex=latex, | ||
| build_pdf=build_pdf, | ||
| output_pdf=output_pdf, | ||
| build_log=build_log, | ||
| output_log=output_log, | ||
| ) | ||
| def _prepare(self, input_file: Path, compile_paths: CompilePaths) -> None: | ||
| build_dir = compile_paths.latex.parent | ||
| build_dir.mkdir(parents=True, exist_ok=True) | ||
| dirs_to_link = [ | ||
| d for d in self._settings.paths.shared_dir.iterdir() if d.is_dir() | ||
| ] | ||
| for d in dirs_to_link: | ||
| build_d = build_dir / d.name | ||
| if not build_d.exists(): | ||
| build_d.symlink_to(d) | ||
| if input_file.suffix == ".py": | ||
| self._generate_latex(input_file, compile_paths.latex) | ||
| elif input_file.suffix == ".tex": | ||
| copy_file_if_newer(input_file, compile_paths.latex) | ||
| else: | ||
| msg = f"unsupported standalone file extension {input_file.suffix}" | ||
| raise ValueError(msg) |
| from collections.abc import MutableSequence, MutableSet, Sequence, Set | ||
| from dataclasses import dataclass | ||
| from enum import Enum | ||
| from logging import getLogger | ||
| from multiprocessing import Pool, cpu_count | ||
| from pathlib import Path, PurePosixPath | ||
| from shutil import copyfile | ||
| from typing import TYPE_CHECKING, Any, ClassVar, Literal | ||
| from ..building.compiling import CompileResult | ||
| from ..building.compiling import compile as compiling_compile | ||
| from ..building.rendering import Renderer | ||
| from ..exceptions import DeckzError | ||
| from ..models.deck import Deck, File, Part, Section | ||
| from ..models.scalars import PartName, ResolvedPath | ||
| from ..models.slides import PartSlides, Title, TitleOrContent | ||
| from ..processing import NodeVisitor | ||
| from ..utils import copy_file_if_newer | ||
| from . import Builder, BuilderConfig | ||
| if TYPE_CHECKING: | ||
| from ..configuring.settings import DeckSettings | ||
| class CompileType(Enum): | ||
| Handout = "handout" | ||
| Presentation = "presentation" | ||
| PrintHandout = "print-handout" | ||
| @dataclass(frozen=True) | ||
| class CompileItem: | ||
| parts: Sequence[PartSlides] | ||
| dependencies: Set[Path] | ||
| compile_type: CompileType | ||
| toc: bool | ||
| class DefaultBuilder(Builder): | ||
| def __init__( | ||
| self, | ||
| variables: dict[str, Any], | ||
| settings: "DeckSettings", | ||
| deck: Deck, | ||
| build_presentation: bool, | ||
| build_handout: bool, | ||
| build_print: bool, | ||
| ): | ||
| self._variables = variables | ||
| self._settings = settings | ||
| self._deck_name = deck.name | ||
| self._parts_slides = _SlidesNodeVisitor( | ||
| settings.paths.shared_dir, settings.paths.current_dir | ||
| ).process(deck) | ||
| self._dependencies = _PartDependenciesNodeVisitor().process(deck) | ||
| self._presentation = build_presentation | ||
| self._handout = build_handout | ||
| self._print = build_print | ||
| self._logger = getLogger(__name__) | ||
| self._renderer = Renderer(settings) | ||
| def _name_compile_item( | ||
| self, compile_type: CompileType, name: PartName | None = None | ||
| ) -> str: | ||
| return ( | ||
| f"{self._deck_name}-{name}-{compile_type.value}" | ||
| if name | ||
| else f"{self._deck_name}-{compile_type.value}" | ||
| ).lower() | ||
| def build(self) -> bool: | ||
| items = self._list_items() | ||
| self._logger.info(f"Building {len(items)} PDFs.") | ||
| with Pool(min(cpu_count(), len(items))) as pool: | ||
| results = pool.starmap(self._build_item, items.items()) | ||
| for item_name, result in zip(items, results, strict=True): | ||
| if not result.ok: | ||
| self._logger.warning("Compilation %s errored", item_name) | ||
| self._logger.warning("Captured %s stderr\n%s", item_name, result.stderr) | ||
| self._logger.warning("Captured %s stdout\n%s", item_name, result.stdout) | ||
| return all(result.ok for result in results) | ||
| def _list_items(self) -> dict[str, CompileItem]: | ||
| to_compile = {} | ||
| all_slides = list(self._parts_slides.values()) | ||
| all_dependencies = frozenset().union(*self._dependencies.values()) | ||
| if self._handout: | ||
| to_compile[self._name_compile_item(CompileType.Handout)] = CompileItem( | ||
| all_slides, all_dependencies, CompileType.Handout, True | ||
| ) | ||
| if self._print: | ||
| to_compile[self._name_compile_item(CompileType.PrintHandout)] = CompileItem( | ||
| all_slides, all_dependencies, CompileType.Handout, True | ||
| ) | ||
| for name, slides in self._parts_slides.items(): | ||
| dependencies = self._dependencies[name] | ||
| if self._presentation: | ||
| to_compile[self._name_compile_item(CompileType.Presentation, name)] = ( | ||
| CompileItem([slides], dependencies, CompileType.Presentation, False) | ||
| ) | ||
| if self._handout: | ||
| to_compile[self._name_compile_item(CompileType.Handout, name)] = ( | ||
| CompileItem([slides], dependencies, CompileType.Handout, False) | ||
| ) | ||
| return to_compile | ||
| def _build_item(self, name: str, item: CompileItem) -> CompileResult: | ||
| build_dir = self._setup_build_dir(name) | ||
| latex_path = build_dir / f"{name}.tex" | ||
| build_pdf_path = latex_path.with_suffix(".pdf") | ||
| output_pdf_path = self._settings.paths.pdf_dir / f"{name}.pdf" | ||
| self._render_latex(item, latex_path) | ||
| copied = self._copy_dependencies(item.dependencies, build_dir) | ||
| self._render_dependencies(copied) | ||
| result = compiling_compile(latex_path, self._settings.build_command) | ||
| if result.ok: | ||
| self._settings.paths.pdf_dir.mkdir(parents=True, exist_ok=True) | ||
| copyfile(build_pdf_path, output_pdf_path) | ||
| return result | ||
| def _setup_build_dir(self, name: str) -> Path: | ||
| target_build_dir = self._settings.paths.build_dir / name | ||
| target_build_dir.mkdir(parents=True, exist_ok=True) | ||
| for item in [ | ||
| self._settings.paths.shared_img_dir, | ||
| self._settings.paths.shared_tikz_pdf_dir, | ||
| self._settings.paths.shared_plt_pdf_dir, | ||
| self._settings.paths.shared_code_dir, | ||
| ]: | ||
| self._setup_link(target_build_dir / item.name, item) | ||
| return target_build_dir | ||
| def _render_latex(self, item: CompileItem, output_path: Path) -> None: | ||
| self._renderer.render( | ||
| template_path=self._settings.paths.jinja2_main_template, | ||
| output_path=output_path, | ||
| variables=self._variables, | ||
| parts=item.parts, | ||
| handout=item.compile_type | ||
| in [CompileType.Handout, CompileType.PrintHandout], | ||
| toc=item.toc, | ||
| print=item.compile_type is CompileType.PrintHandout, | ||
| ) | ||
| def _copy_dependencies( | ||
| self, dependencies: Set[Path], target_build_dir: Path | ||
| ) -> list[Path]: | ||
| copied = [] | ||
| for dependency in dependencies: | ||
| try: | ||
| link_dir = ( | ||
| target_build_dir | ||
| / dependency.relative_to(self._settings.paths.shared_dir).parent | ||
| ) | ||
| except ValueError: | ||
| link_dir = ( | ||
| target_build_dir | ||
| / dependency.relative_to(self._settings.paths.current_dir).parent | ||
| ) | ||
| link_dir.mkdir(parents=True, exist_ok=True) | ||
| destination = (link_dir / dependency.name).with_suffix(".tex.j2") | ||
| if ( | ||
| not destination.exists() | ||
| or destination.stat().st_mtime < dependency.stat().st_mtime | ||
| ): | ||
| copy_file_if_newer(dependency, destination) | ||
| copied.append(destination) | ||
| return copied | ||
| def _render_dependencies(self, to_render: list[Path]) -> None: | ||
| for item in to_render: | ||
| self._renderer.render(template_path=item, output_path=item.with_suffix("")) | ||
| def _setup_link(self, source: Path, target: Path) -> None: | ||
| if not target.exists(): | ||
| msg = ( | ||
| f"{target} could not be found. Please make sure it exists before " | ||
| "proceeding" | ||
| ) | ||
| raise DeckzError(msg) | ||
| target = target.resolve() | ||
| if source.is_symlink(): | ||
| if source.resolve().samefile(target): | ||
| return | ||
| msg = ( | ||
| f"{source} already exists in the build directory and does not point to " | ||
| f"{target}. Please clean the build directory" | ||
| ) | ||
| raise DeckzError(msg) | ||
| if source.exists(): | ||
| msg = ( | ||
| f"{source} already exists in the build directory. Please clean the " | ||
| "build directory" | ||
| ) | ||
| raise DeckzError(msg) | ||
| source.parent.mkdir(parents=True, exist_ok=True) | ||
| source.symlink_to(target) | ||
| class DefaultParserConfig(BuilderConfig, component=DefaultBuilder): | ||
| file_extension: str = ".tex" | ||
| config_key: ClassVar[Literal["default_builder"]] = "default_builder" | ||
| class _SlidesNodeVisitor(NodeVisitor[[MutableSequence[TitleOrContent], int], None]): | ||
| def __init__(self, shared_dir: Path, current_dir: Path) -> None: | ||
| self._shared_dir = shared_dir | ||
| self._current_dir = current_dir | ||
| def process(self, deck: Deck) -> dict[PartName, PartSlides]: | ||
| return { | ||
| part_name: self._process_part(part) | ||
| for part_name, part in deck.parts.items() | ||
| } | ||
| def _process_part(self, part: Part) -> PartSlides: | ||
| sections: list[TitleOrContent] = [] | ||
| for node in part.nodes: | ||
| node.accept(self, sections, 0) | ||
| return PartSlides(part.title, sections) | ||
| def visit_file( | ||
| self, file: File, sections: MutableSequence[TitleOrContent], level: int | ||
| ) -> None: | ||
| if file.title: | ||
| sections.append(Title(file.title, level)) | ||
| if file.resolved_path.is_relative_to(self._shared_dir): | ||
| path = file.resolved_path.relative_to(self._shared_dir) | ||
| elif file.resolved_path.is_relative_to(self._current_dir): | ||
| path = file.resolved_path.relative_to(self._current_dir) | ||
| else: | ||
| raise ValueError | ||
| path = path.with_suffix("") | ||
| sections.append(str(PurePosixPath(path))) | ||
| def visit_section( | ||
| self, section: Section, sections: MutableSequence[TitleOrContent], level: int | ||
| ) -> None: | ||
| if section.title: | ||
| sections.append(Title(section.title, level)) | ||
| for node in section.nodes: | ||
| node.accept(self, sections, level + 1) | ||
| class _PartDependenciesNodeVisitor(NodeVisitor[[MutableSet[ResolvedPath]], None]): | ||
| def process(self, deck: Deck) -> dict[PartName, set[ResolvedPath]]: | ||
| return { | ||
| part_name: self._process_part(part) | ||
| for part_name, part in deck.parts.items() | ||
| } | ||
| def _process_part(self, part: Part) -> set[ResolvedPath]: | ||
| dependencies: set[ResolvedPath] = set() | ||
| for node in part.nodes: | ||
| node.accept(self, dependencies) | ||
| return dependencies | ||
| def visit_file(self, file: File, dependencies: MutableSet[ResolvedPath]) -> None: | ||
| dependencies.add(file.resolved_path) | ||
| def visit_section( | ||
| self, section: Section, dependencies: MutableSet[ResolvedPath] | ||
| ) -> None: | ||
| for node in section.nodes: | ||
| node.accept(self, dependencies) |
| from collections.abc import Iterable | ||
| from pathlib import Path, PurePath | ||
| from sys import stderr | ||
| from typing import ClassVar, Literal | ||
| from pydantic import ValidationError | ||
| from rich import print as rich_print | ||
| from rich.tree import Tree | ||
| from ..exceptions import DeckzError | ||
| from ..models.deck import Deck, File, Node, Part, Section | ||
| from ..models.definitions import ( | ||
| DeckDefinition, | ||
| FileInclude, | ||
| NodeInclude, | ||
| PartDefinition, | ||
| SectionDefinition, | ||
| SectionInclude, | ||
| ) | ||
| from ..models.scalars import ( | ||
| FlavorName, | ||
| IncludePath, | ||
| PartName, | ||
| ResolvedPath, | ||
| UnresolvedPath, | ||
| ) | ||
| from ..processing import NodeVisitor | ||
| from ..utils import load_yaml | ||
| from . import Parser, ParserConfig | ||
| class DefaultParser(Parser): | ||
| """Build a deck from a definition. | ||
| The definition can be a complete deck definition obtained from a yaml file or a \ | ||
| simpler one obtained from a single section or file. | ||
| """ | ||
| def __init__( | ||
| self, | ||
| local_latex_dir: Path, | ||
| shared_latex_dir: Path, | ||
| config: "DefaultParserConfig", | ||
| ) -> None: | ||
| """Initialize an instance with the necessary path information. | ||
| Args: | ||
| local_latex_dir: Path to the local latex directory. Used during the \ | ||
| includes resolving process | ||
| shared_latex_dir: Path to the shared latex directory. Used during the \ | ||
| includes resolving process | ||
| config: Configuration retrieved from configuration files for this component. | ||
| """ | ||
| self._local_latex_dir = local_latex_dir | ||
| self._shared_latex_dir = shared_latex_dir | ||
| self._config = config | ||
| def from_deck_definition(self, deck_definition_path: Path) -> Deck: | ||
| """Parse a deck from a yaml definition. | ||
| Args: | ||
| deck_definition_path: Path to the yaml definition. It should be parsable \ | ||
| into a [deckz.models.definitions.DeckDefinition][] by Pydantic | ||
| Returns: | ||
| The parsed deck | ||
| """ | ||
| deck_definition = DeckDefinition.model_validate(load_yaml(deck_definition_path)) | ||
| return Deck( | ||
| name=deck_definition.name, parts=self._parse_parts(deck_definition.parts) | ||
| ) | ||
| def from_section(self, section: str, flavor: FlavorName) -> Deck: | ||
| return Deck( | ||
| name="deck", | ||
| parts=self._parse_parts( | ||
| [ | ||
| PartDefinition.model_construct( | ||
| name=PartName("part_name"), | ||
| sections=[ | ||
| SectionInclude( | ||
| path=IncludePath(PurePath(section)), flavor=flavor | ||
| ) | ||
| ], | ||
| ) | ||
| ] | ||
| ), | ||
| ) | ||
| def from_file(self, latex: str) -> Deck: | ||
| return Deck( | ||
| name="deck", | ||
| parts=self._parse_parts( | ||
| [ | ||
| PartDefinition.model_construct( | ||
| name=PartName("part_name"), | ||
| sections=[FileInclude(path=IncludePath(PurePath(latex)))], | ||
| ) | ||
| ] | ||
| ), | ||
| ) | ||
| def _parse_parts( | ||
| self, part_definitions: list[PartDefinition] | ||
| ) -> dict[PartName, Part]: | ||
| parts = {} | ||
| for part_definition in part_definitions: | ||
| part_nodes: list[Node] = [] | ||
| for node_include in part_definition.sections: | ||
| if isinstance(node_include, SectionInclude): | ||
| part_nodes.append( | ||
| self._parse_section( | ||
| base_unresolved_path=UnresolvedPath(PurePath()), | ||
| include_path=node_include.path, | ||
| title=node_include.title, | ||
| title_unset="title" not in node_include.model_fields_set, | ||
| flavor=node_include.flavor, | ||
| ) | ||
| ) | ||
| else: | ||
| part_nodes.append( | ||
| self._parse_file( | ||
| base_unresolved_path=UnresolvedPath(PurePath()), | ||
| include_path=node_include.path, | ||
| title=node_include.title, | ||
| ) | ||
| ) | ||
| parts[part_definition.name] = Part( | ||
| title=part_definition.title, | ||
| nodes=part_nodes, | ||
| ) | ||
| return parts | ||
| def _parse_section( | ||
| self, | ||
| base_unresolved_path: UnresolvedPath, | ||
| include_path: IncludePath, | ||
| title: str | None, | ||
| title_unset: bool, | ||
| flavor: FlavorName, | ||
| ) -> Section: | ||
| unresolved_path = self._compute_unresolved_path( | ||
| base_unresolved_path, include_path | ||
| ) | ||
| section = Section( | ||
| title=title, | ||
| unresolved_path=unresolved_path, | ||
| resolved_path=ResolvedPath(Path()), | ||
| parsing_error=None, | ||
| flavor=flavor, | ||
| nodes=[], | ||
| ) | ||
| definition_logical_path = (unresolved_path / unresolved_path.name).with_suffix( | ||
| ".yml" | ||
| ) | ||
| definition_resolved_path = self._resolve( | ||
| definition_logical_path.with_suffix(".yml"), "file" | ||
| ) | ||
| if definition_resolved_path is None: | ||
| section.parsing_error = ( | ||
| f"unresolvable section definition path {definition_logical_path}" | ||
| ) | ||
| return section | ||
| section.resolved_path = definition_resolved_path.parent | ||
| try: | ||
| content = load_yaml(definition_resolved_path) | ||
| except Exception as e: | ||
| section.parsing_error = f"{e}" | ||
| return section | ||
| try: | ||
| section_definition = SectionDefinition.model_validate(content) | ||
| except ValidationError as e: | ||
| section.parsing_error = f"{e}" | ||
| return section | ||
| for flavor_definition in section_definition.flavors: | ||
| if flavor_definition.name == flavor: | ||
| break | ||
| else: | ||
| section.parsing_error = f"flavor {flavor} not found" | ||
| return section | ||
| if title_unset: | ||
| if "title" in flavor_definition.model_fields_set: | ||
| section.title = flavor_definition.title | ||
| else: | ||
| section.title = section_definition.title | ||
| section.nodes.extend( | ||
| self._parse_nodes( | ||
| flavor_definition.includes, | ||
| default_titles=section_definition.default_titles, | ||
| base_unresolved_path=unresolved_path, | ||
| ) | ||
| ) | ||
| return section | ||
| def _parse_nodes( | ||
| self, | ||
| node_includes: Iterable[NodeInclude], | ||
| default_titles: dict[IncludePath, str] | None, | ||
| base_unresolved_path: UnresolvedPath, | ||
| ) -> list[Node]: | ||
| nodes: list[Node] = [] | ||
| for node_include in node_includes: | ||
| if node_include.title: | ||
| title = node_include.title | ||
| elif ( | ||
| "title" not in node_include.model_fields_set | ||
| and default_titles | ||
| and node_include.path in default_titles | ||
| ): | ||
| title = default_titles[node_include.path] | ||
| else: | ||
| title = None | ||
| if isinstance(node_include, FileInclude): | ||
| nodes.append( | ||
| self._parse_file( | ||
| base_unresolved_path=base_unresolved_path, | ||
| include_path=node_include.path, | ||
| title=title, | ||
| ) | ||
| ) | ||
| if isinstance(node_include, SectionInclude): | ||
| nodes.append( | ||
| self._parse_section( | ||
| base_unresolved_path=base_unresolved_path, | ||
| include_path=node_include.path, | ||
| title=title, | ||
| title_unset="title" not in node_include.model_fields_set, | ||
| flavor=node_include.flavor, | ||
| ) | ||
| ) | ||
| return nodes | ||
| def _parse_file( | ||
| self, | ||
| base_unresolved_path: UnresolvedPath, | ||
| include_path: IncludePath, | ||
| title: str | None, | ||
| ) -> File: | ||
| unresolved_path = self._compute_unresolved_path( | ||
| base_unresolved_path, include_path | ||
| ) | ||
| file = File( | ||
| title=title, | ||
| unresolved_path=unresolved_path, | ||
| resolved_path=ResolvedPath(Path()), | ||
| parsing_error=None, | ||
| ) | ||
| resolved_path = self._resolve( | ||
| unresolved_path.with_suffix(self._config.file_extension), "file" | ||
| ) | ||
| if resolved_path: | ||
| file.resolved_path = resolved_path | ||
| else: | ||
| file.parsing_error = f"unresolvable file path {unresolved_path}" | ||
| return file | ||
| @staticmethod | ||
| def _compute_unresolved_path( | ||
| base_unresolved_path: UnresolvedPath, include_path: IncludePath | ||
| ) -> UnresolvedPath: | ||
| return UnresolvedPath( | ||
| include_path.relative_to("/") | ||
| if include_path.root | ||
| else base_unresolved_path / include_path | ||
| ) | ||
| def _resolve( | ||
| self, unresolved_path: UnresolvedPath, resolve_target: Literal["file", "dir"] | ||
| ) -> ResolvedPath | None: | ||
| local_path = self._local_latex_dir / unresolved_path | ||
| shared_path = self._shared_latex_dir / unresolved_path | ||
| existence_tester = Path.is_file if resolve_target == "file" else Path.is_dir | ||
| for path in [local_path, shared_path]: | ||
| if existence_tester(path): | ||
| return ResolvedPath(path.resolve()) | ||
| return None | ||
| def _validate(self, deck: Deck) -> None: | ||
| tree = _RichTreeVisitor().process(deck) | ||
| if tree is not None: | ||
| rich_print(tree, file=stderr) | ||
| msg = "deck parsing failed" | ||
| raise DeckzError(msg) | ||
| class DefaultParserConfig(ParserConfig, component=DefaultParser): | ||
| file_extension: str = ".tex" | ||
| config_key: ClassVar[Literal["default_parser"]] = "default_parser" | ||
| class _RichTreeVisitor(NodeVisitor[[UnresolvedPath], tuple[Tree | None, bool]]): | ||
| def __init__(self, only_errors: bool = True) -> None: | ||
| self._only_errors = only_errors | ||
| def process(self, deck: Deck) -> Tree | None: | ||
| part_trees = [] | ||
| for part_name, part in deck.parts.items(): | ||
| part_tree = self._process_part(part_name, part) | ||
| if part_tree is not None: | ||
| part_trees.append(part_tree) | ||
| if part_trees: | ||
| tree = Tree(deck.name) | ||
| tree.children.extend(part_trees) | ||
| return tree | ||
| return None | ||
| def _process_part(self, part_name: PartName, part: Part) -> Tree | None: | ||
| error = False | ||
| children_trees = [] | ||
| for child in part.nodes: | ||
| child_tree, child_error = child.accept(self, UnresolvedPath(PurePath())) | ||
| error = error or child_error | ||
| if child_tree is not None: | ||
| children_trees.append(child_tree) | ||
| if self._only_errors and not error: | ||
| return None | ||
| tree = Tree(part_name) | ||
| tree.children.extend(children_trees) | ||
| return tree | ||
| def visit_file( | ||
| self, file: File, base_path: UnresolvedPath | ||
| ) -> tuple[Tree | None, bool]: | ||
| if self._only_errors and file.parsing_error is None: | ||
| return None, False | ||
| path = ( | ||
| file.unresolved_path.relative_to(base_path) | ||
| if file.unresolved_path.is_relative_to(base_path) | ||
| else file.unresolved_path | ||
| ) | ||
| if file.parsing_error is None: | ||
| return Tree(str(path)), False | ||
| return Tree(f"[red]{path} ({file.parsing_error})[/]"), True | ||
| def visit_section( | ||
| self, section: Section, base_path: UnresolvedPath | ||
| ) -> tuple[Tree | None, bool]: | ||
| error = section.parsing_error is not None | ||
| children_trees = [] | ||
| for child in section.nodes: | ||
| child_tree, child_error = child.accept(self, section.unresolved_path) | ||
| error = error or child_error | ||
| if child_tree is not None: | ||
| children_trees.append(child_tree) | ||
| if self._only_errors and not error: | ||
| return None, False | ||
| path = ( | ||
| section.unresolved_path.relative_to(base_path) | ||
| if section.unresolved_path.is_relative_to(base_path) | ||
| else section.unresolved_path | ||
| ) | ||
| if section.parsing_error is not None: | ||
| label = f"[red]{path}@{section.flavor} ({section.parsing_error})[/]" | ||
| else: | ||
| label = f"{path}@{section.flavor}" | ||
| tree = Tree(label) | ||
| tree.children.extend(children_trees) | ||
| return tree, error |
| # mypy: disable-error-code="attr-defined" | ||
| from abc import ABC | ||
| from typing import Any, ClassVar | ||
| from pydantic import GetCoreSchemaHandler | ||
| from pydantic_core import core_schema | ||
| class Config[T](ABC): | ||
| config_key: ClassVar[str] | ||
| @classmethod | ||
| def get_model_class(cls) -> type[T]: | ||
| return cls._registry[cls.config_key][0] | ||
| def configurable[T](cls: type[T]) -> type[T]: | ||
| cls._registry = {} | ||
| original_init_subclass = getattr(cls, "__init_subclass__", lambda **kwargs: None) | ||
| def new_init_subclass[U: T]( | ||
| subclass: type[U], component: Any, **kwargs: Any | ||
| ) -> None: | ||
| original_init_subclass(**kwargs) | ||
| cls._registry[subclass.config_key] = (component, subclass) | ||
| cls.__init_subclass__ = classmethod(new_init_subclass) # type: ignore | ||
| def __get_pydantic_core_schema__( # noqa: N807 | ||
| cls_: type[T], source_type: type[Any], handler: GetCoreSchemaHandler | ||
| ) -> core_schema.CoreSchema: | ||
| return core_schema.tagged_union_schema( | ||
| discriminator="config_key", | ||
| choices={ | ||
| key: handler(subclass) for key, (_, subclass) in cls._registry.items() | ||
| }, | ||
| ) | ||
| cls.__get_pydantic_core_schema__ = classmethod(__get_pydantic_core_schema__) | ||
| return cls |
| """Modules containing model classes for different parts of deckz. | ||
| The intent is that the classes defined in this package should not end up containing \ | ||
| too much logic. However some logic is still present, when writing it elsewhere seemed \ | ||
| worse than not respecting this intent 100%. | ||
| - [`deck`][deckz.models.deck] contains models that represent parsed decks and their \ | ||
| constituents | ||
| - [`definitions`][deckz.models.definitions] contains models that represent decks \ | ||
| definitions | ||
| - [`scalars`][deckz.models.scalars] contains models for non-container types. Mostly \ | ||
| NewTypes that help disambiguate types that are used a lot in different contexts \ | ||
| (e.g. Path and str) | ||
| - [`slides`][deckz.models.slides] contains models that are fed to the rendering part \ | ||
| of deckz | ||
| """ |
| """Model classes for parsed decks. | ||
| The main class is [`Deck`][deckz.models.deck.Deck]. It's comprised of \ | ||
| [`Part`][deckz.models.deck.Part]s containing [`Section`][deckz.models.deck.Section]s \ | ||
| and [`File`][deckz.models.deck.File]s, both of which are \ | ||
| [`Node`][deckz.models.deck.Node]s and have an \ | ||
| [`accept`][deckz.models.deck.Node.accept] method to allow visitors to be defined. | ||
| """ | ||
| from abc import ABC, abstractmethod | ||
| from collections.abc import Iterable | ||
| from dataclasses import dataclass | ||
| from ..processing import NodeVisitor | ||
| from .scalars import FlavorName, PartName, ResolvedPath, UnresolvedPath | ||
| @dataclass | ||
| class Node(ABC): | ||
| """Node in a section or part. | ||
| A node is anything that can be included in a [`Part`][deckz.models.deck.Part] or a \ | ||
| [`Section`][deckz.models.deck.Section]: it can be either a \ | ||
| [`Section`][deckz.models.deck.Section] or a [`File`][deckz.models.deck.File]. | ||
| """ | ||
| title: str | None | ||
| unresolved_path: UnresolvedPath | ||
| # resolved_path and parsing_error could benefit from a refactoring using something | ||
| # like Either because we cannot have both a ResolvedPath and a parsing_error at the | ||
| # same time. | ||
| resolved_path: ResolvedPath | ||
| parsing_error: str | None | ||
| @abstractmethod | ||
| def accept[**P, T]( | ||
| self, visitor: NodeVisitor[P, T], *args: P.args, **kwargs: P.kwargs | ||
| ) -> T: | ||
| """Dispatch method for visitors. | ||
| Args: | ||
| visitor: The visitor asking for the dispatch | ||
| args: Arguments to send back to the visitor untouched | ||
| kwargs: Keyword arguments to send back to the visitor untouched | ||
| Returns: | ||
| The return type is the same as the return type of the corresponding \ | ||
| [`visit_file`][deckz.processing.NodeVisitor.visit_file] or \ | ||
| [`visit_section`][deckz.processing.NodeVisitor.visit_section] method of \ | ||
| the visitor. | ||
| """ | ||
| raise NotImplementedError | ||
| @dataclass | ||
| class File(Node): | ||
| """File in a section or part.""" | ||
| def accept[**P, T]( | ||
| self, visitor: NodeVisitor[P, T], *args: P.args, **kwargs: P.kwargs | ||
| ) -> T: | ||
| """Dispatch method for visitors. | ||
| Args: | ||
| visitor: The visitor asking for the dispatch | ||
| args: Arguments to send back to the visitor untouched | ||
| kwargs: Keyword arguments to send back to the visitor untouched | ||
| Returns: | ||
| The return type is the same as the return type of the \ | ||
| [`visit_file`][deckz.processing.NodeVisitor.visit_file] method of the \ | ||
| visitor. | ||
| """ | ||
| return visitor.visit_file(self, *args, **kwargs) | ||
| @dataclass | ||
| class Section(Node): | ||
| """Section in a section or part.""" | ||
| flavor: FlavorName | ||
| """Name of the flavor of the section.""" | ||
| nodes: list[Node] | ||
| """Nodes included in the section.""" | ||
| def accept[**P, T]( | ||
| self, visitor: NodeVisitor[P, T], *args: P.args, **kwargs: P.kwargs | ||
| ) -> T: | ||
| """Dispatch method for visitors. | ||
| Args: | ||
| visitor: The visitor asking for the dispatch | ||
| args: Arguments to send back to the visitor untouched | ||
| kwargs: Keyword arguments to send back to the visitor untouched | ||
| Returns: | ||
| The return type is the same as the return type of the \ | ||
| [`visit_section`][deckz.processing.NodeVisitor.visit_section] method of \ | ||
| the visitor. | ||
| """ | ||
| return visitor.visit_section(self, *args, **kwargs) | ||
| @dataclass | ||
| class Part: | ||
| """Part in a deck.""" | ||
| title: str | None | ||
| """Title of the part.""" | ||
| nodes: list[Node] | ||
| """Nodes included in the part.""" | ||
| @dataclass | ||
| class Deck: | ||
| """Top of the hierarchy for deck parsing.""" | ||
| name: str | ||
| """The name of the deck. Will be a part of the output file name.""" | ||
| parts: dict[PartName, Part] | ||
| """Parts included in the deck.""" | ||
| def filter(self, whitelist: Iterable[PartName]) -> None: | ||
| """Filter out the parts that don't have their name listed in `whitelist`. | ||
| Args: | ||
| whitelist: Parts to keep. | ||
| Raises: | ||
| ValueError: Raised if an element of `whitelist` matches no part name in \ | ||
| the deck. | ||
| """ | ||
| if frozenset(whitelist).difference(self.parts): | ||
| msg = "provided whitelist has part names not in the deck" | ||
| raise ValueError(msg) | ||
| to_remove = frozenset(self.parts).difference(whitelist) | ||
| for part_name in to_remove: | ||
| del self.parts[part_name] |
| """Model classes to define decks. | ||
| All classes in this module are using the Pydantic library and can be easily \ | ||
| instantiated from yaml files. | ||
| The only tricky part in those classes is the format used to define includes. The \ | ||
| intent is that it should be possible, both to include files and sections, to specify \ | ||
| only a path, or a path and a title. The path can be relative to the current element \ | ||
| being parsed or to a base directory. | ||
| The yaml syntax used is the following: | ||
| - for a path without a title | ||
| path/relative/to/current/element | ||
| /path/relative/to/basedir | ||
| - for a path with a title | ||
| path/relative/to/current/element: title | ||
| /path/relative/to/basedir: title | ||
| - for a section without a title | ||
| $path/relative/to/current/element@flavor | ||
| $/path/relative/to/basedir@flavor | ||
| - for a section with a title | ||
| $path/relative/to/current/element@flavor: title | ||
| $/path/relative/to/basedir@flavor: title | ||
| """ | ||
| from pathlib import PurePath | ||
| from typing import Annotated | ||
| from pydantic import BaseModel | ||
| from pydantic.functional_validators import BeforeValidator | ||
| from .scalars import FlavorName, IncludePath, PartName | ||
| class NodeInclude(BaseModel): | ||
| """Specify a file or section include.""" | ||
| path: IncludePath | ||
| """Path of the file or section to include.""" | ||
| title: str | None = None | ||
| """The title of the node. Will override the ones defined in the section \ | ||
| definition and the flavor definition. | ||
| """ | ||
| class SectionInclude(NodeInclude): | ||
| """Specify a section to include. | ||
| See its parent for further details on the available attributes. | ||
| """ | ||
| flavor: FlavorName | ||
| """Flavor of the section to include.""" | ||
| class FileInclude(NodeInclude): | ||
| """Specify a file to include. | ||
| See its parent for further details on the available attributes. | ||
| """ | ||
| def _normalize_include( | ||
| v: str | dict[str, str] | NodeInclude, | ||
| ) -> NodeInclude: | ||
| if isinstance(v, NodeInclude): | ||
| return v | ||
| if isinstance(v, str): | ||
| left = v | ||
| title_unset = True | ||
| else: | ||
| assert len(v) == 1 | ||
| left, title = next(iter(v.items())) | ||
| title_unset = False | ||
| if left.startswith("$"): | ||
| path, flavor = left[1:].split("@") | ||
| else: | ||
| path = left | ||
| flavor = None | ||
| if flavor is None and title_unset: | ||
| return FileInclude(path=IncludePath(PurePath(path))) | ||
| if flavor is None: | ||
| return FileInclude(path=IncludePath(PurePath(path)), title=title) | ||
| if title_unset: | ||
| return SectionInclude( | ||
| path=IncludePath(PurePath(path)), flavor=FlavorName(flavor) | ||
| ) | ||
| return SectionInclude( | ||
| path=IncludePath(PurePath(path)), flavor=FlavorName(flavor), title=title | ||
| ) | ||
| class FlavorDefinition(BaseModel): | ||
| """Specify the different attributes of a flavor.""" | ||
| name: FlavorName | ||
| """The name of the flavor. Used in parts and sections definitions.""" | ||
| title: str | None = None | ||
| """The title of the section. Will override the one defined in the section \ | ||
| definition.""" | ||
| includes: list[Annotated[NodeInclude, BeforeValidator(_normalize_include)]] | ||
| """The includes pointing to the sections and files in this section.""" | ||
| class SectionDefinition(BaseModel): | ||
| """Specify the different attributes of a section.""" | ||
| title: str | ||
| """The title of the section. Will be given as input to the rendering code.""" | ||
| default_titles: dict[IncludePath, str] | None = None | ||
| """Default titles to use for the includes of the section.""" | ||
| flavors: list[FlavorDefinition] | ||
| """Different flavors of the section (each flavor can define a different \ | ||
| title and a different list of includes).""" | ||
| class PartDefinition(BaseModel): | ||
| """Specify the different attributes of a deck part.""" | ||
| name: PartName | ||
| """The name of the part. Will be a part of the output file name if partial \ | ||
| outputs are built.""" | ||
| title: str | None = None | ||
| """The title of the part. Will be given as input to the rendering code.""" | ||
| sections: list[Annotated[NodeInclude, BeforeValidator(_normalize_include)]] | ||
| """The includes pointing to the sections and files in this part.""" | ||
| class DeckDefinition(BaseModel): | ||
| """Specify the different attributes of a deck.""" | ||
| name: str | ||
| """The name of the deck. Will be a part of the output file name.""" | ||
| parts: list[PartDefinition] | ||
| """The definition of each part of the deck.""" |
| """Model NewTypes to disambiguate multi-usage types.""" | ||
| from pathlib import Path, PurePath | ||
| from typing import NewType | ||
| IncludePath = NewType("IncludePath", PurePath) | ||
| """Derived from PurePath to be used only to specify an include in a deck definition.""" | ||
| UnresolvedPath = NewType("UnresolvedPath", PurePath) | ||
| """Derived from PurePath to represent any path that has not been resolved yet. | ||
| Resolving in deckz code means mainly picking between two options for a given resource: \ | ||
| loading it from the shared directory or from the local directory. | ||
| """ | ||
| ResolvedPath = NewType("ResolvedPath", Path) | ||
| """Derived from Path to represent any path that has already been resolved. | ||
| Resolving in deckz code means mainly picking between two options for a given resource: \ | ||
| loading it from the shared directory or from the local directory. | ||
| """ | ||
| PartName = NewType("PartName", str) | ||
| """Derived from str to represent specifically a part name.""" | ||
| FlavorName = NewType("FlavorName", str) | ||
| """Derived from str to represent specifically a flavor name.""" |
| """Model classes to define decks for the rendering side of deckz.""" | ||
| from dataclasses import dataclass, field | ||
| @dataclass(frozen=True) | ||
| class Title: | ||
| """Define a title slide and its level. | ||
| The lower the level, the more important the title. Similar to how h1 in HTML is \ | ||
| a more important title than h2. | ||
| """ | ||
| title: str | ||
| """The title string to display during rendering.""" | ||
| level: int | ||
| """The level of the title.""" | ||
| Content = str | ||
| """Alias to string to denote slide content path.""" | ||
| TitleOrContent = Title | Content | ||
| """Alias to title or content to denote any slide.""" | ||
| @dataclass(frozen=True) | ||
| class PartSlides: | ||
| """Title and slides comprising a part.""" | ||
| title: str | None | ||
| """Title of the part.""" | ||
| sections: list[TitleOrContent] = field(default_factory=list) | ||
| """Slides of the part.""" |
| """Provide protocols to better type-check deck processing code.""" | ||
| from typing import TYPE_CHECKING, Protocol | ||
| # Necessary to avoid circular imports with ..models.deck | ||
| if TYPE_CHECKING: | ||
| from ..models.deck import Deck, File, Section | ||
| class NodeVisitor[**P, T](Protocol): | ||
| """Dispatch actions on [`Node`][deckz.models.deck.Node]s.""" | ||
| def visit_file(self, file: "File", *args: P.args, **kwargs: P.kwargs) -> T: | ||
| """Dispatched method for [`File`][deckz.models.deck.File]s.""" | ||
| ... | ||
| def visit_section(self, section: "Section", *args: P.args, **kwargs: P.kwargs) -> T: | ||
| """Dispatched method for [`Section`][deckz.models.deck.Section]s.""" | ||
| ... | ||
| class Processor[T](Protocol): | ||
| def process(self, deck: "Deck") -> T: | ||
| """Process a deck.""" | ||
| ... |
| from collections.abc import MutableMapping, MutableSet | ||
| from pathlib import PurePath | ||
| from typing import cast | ||
| from ..models.deck import Deck, File, Part, Section | ||
| from ..models.scalars import ResolvedPath, UnresolvedPath | ||
| from . import NodeVisitor, Processor | ||
| class SectionDependenciesProcessor(Processor[dict[UnresolvedPath, set[ResolvedPath]]]): | ||
| def __init__(self) -> None: | ||
| self._node_visitor = _SectionDependenciesNodeVisitor() | ||
| def process(self, deck: Deck) -> dict[UnresolvedPath, set[ResolvedPath]]: | ||
| dependencies: dict[UnresolvedPath, set[ResolvedPath]] = {} | ||
| for part in deck.parts.values(): | ||
| self._process_part( | ||
| part, | ||
| cast( | ||
| MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| dependencies, | ||
| ), | ||
| ) | ||
| return dependencies | ||
| def _process_part( | ||
| self, | ||
| part: Part, | ||
| dependencies: MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| ) -> None: | ||
| for node in part.nodes: | ||
| node.accept(self._node_visitor, dependencies, UnresolvedPath(PurePath())) | ||
| class _SectionDependenciesNodeVisitor( | ||
| NodeVisitor[ | ||
| [MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], UnresolvedPath], None | ||
| ] | ||
| ): | ||
| def visit_file( | ||
| self, | ||
| file: File, | ||
| section_dependencies: MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| base_unresolved_path: UnresolvedPath, | ||
| ) -> None: | ||
| if base_unresolved_path not in section_dependencies: | ||
| section_dependencies[base_unresolved_path] = set() | ||
| section_dependencies[base_unresolved_path].add(file.resolved_path) | ||
| def visit_section( | ||
| self, | ||
| section: Section, | ||
| section_dependencies: MutableMapping[UnresolvedPath, MutableSet[ResolvedPath]], | ||
| base_unresolved_path: UnresolvedPath, | ||
| ) -> None: | ||
| for node in section.nodes: | ||
| node.accept(self, section_dependencies, section.unresolved_path) |
| from collections.abc import MutableMapping, MutableSet | ||
| from pathlib import Path | ||
| from typing import cast | ||
| from ..models.deck import Deck, File, Part, Section | ||
| from ..models.scalars import FlavorName, PartName, UnresolvedPath | ||
| from . import NodeVisitor, Processor | ||
| class SectionsUsageProcessor( | ||
| Processor[dict[PartName, dict[UnresolvedPath, set[FlavorName]]]] | ||
| ): | ||
| def __init__(self, shared_latex_dir: Path) -> None: | ||
| self._node_visitor = _SectionsUsageNodeVisitor(shared_latex_dir) | ||
| def process( | ||
| self, deck: Deck | ||
| ) -> dict[PartName, dict[UnresolvedPath, set[FlavorName]]]: | ||
| return { | ||
| part_name: self._process_part(part) | ||
| for part_name, part in deck.parts.items() | ||
| } | ||
| def _process_part(self, part: Part) -> dict[UnresolvedPath, set[FlavorName]]: | ||
| section_stats: dict[UnresolvedPath, set[FlavorName]] = {} | ||
| for node in part.nodes: | ||
| node.accept( | ||
| self._node_visitor, | ||
| # Not sure why we need a cast here :/ | ||
| cast( | ||
| MutableMapping[UnresolvedPath, MutableSet[FlavorName]], | ||
| section_stats, | ||
| ), | ||
| ) | ||
| return section_stats | ||
| class _SectionsUsageNodeVisitor( | ||
| NodeVisitor[[MutableMapping[UnresolvedPath, MutableSet[FlavorName]]], None] | ||
| ): | ||
| def __init__(self, shared_latex_dir: Path) -> None: | ||
| self._shared_latex_dir = shared_latex_dir | ||
| def visit_file( | ||
| self, | ||
| file: File, | ||
| section_stats: MutableMapping[UnresolvedPath, MutableSet[FlavorName]], | ||
| ) -> None: | ||
| pass | ||
| def visit_section( | ||
| self, | ||
| section: Section, | ||
| section_stats: MutableMapping[UnresolvedPath, MutableSet[FlavorName]], | ||
| ) -> None: | ||
| if section.resolved_path.is_relative_to(self._shared_latex_dir): | ||
| if section.unresolved_path not in section_stats: | ||
| section_stats[section.unresolved_path] = set() | ||
| section_stats[section.unresolved_path].add(section.flavor) | ||
| for node in section.nodes: | ||
| node.accept(self, section_stats) |
Sorry, the diff of this file is too big to display
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
375895
3.44%3272
0.37%85
-3.41%