Latest Threat Research:SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains.Details
Socket
Book a DemoInstallSign in
Socket

deckz

Package Overview
Dependencies
Maintainers
1
Versions
115
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

deckz - npm Package Compare versions

Comparing version
22.0.4
to
23.0.0
+18
Makefile.ps1
# 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

+2
-4

@@ -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 }}

@@ -6,2 +6,3 @@ __pycache__/

.venv/
.vscode/
build/

@@ -8,0 +9,0 @@ dist/

@@ -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,

@@ -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

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

[![CI Status](https://img.shields.io/github/actions/workflow/status/shuuchuu/deckz/ci.yml?branch=main&label=CI&style=for-the-badge)](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACI)
[![CD Status](https://img.shields.io/github/actions/workflow/status/shuuchuu/deckz/cd.yml?branch=main&label=CD&style=for-the-badge)](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD)
[![CD Status](https://img.shields.io/github/actions/workflow/status/shuuchuu/deckz/cd.yml?label=CD&style=for-the-badge)](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD)
[![Test Coverage](https://img.shields.io/codecov/c/github/shuuchuu/deckz?style=for-the-badge)](https://codecov.io/gh/shuuchuu/deckz)

@@ -33,0 +35,0 @@ [![PyPI Project](https://img.shields.io/pypi/v/deckz?style=for-the-badge)](https://pypi.org/project/deckz/)

[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]

# `deckz`
[![CI Status](https://img.shields.io/github/actions/workflow/status/shuuchuu/deckz/ci.yml?branch=main&label=CI&style=for-the-badge)](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACI)
[![CD Status](https://img.shields.io/github/actions/workflow/status/shuuchuu/deckz/cd.yml?branch=main&label=CD&style=for-the-badge)](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD)
[![CD Status](https://img.shields.io/github/actions/workflow/status/shuuchuu/deckz/cd.yml?label=CD&style=for-the-badge)](https://github.com/shuuchuu/deckz/actions?query=workflow%3ACD)
[![Test Coverage](https://img.shields.io/codecov/c/github/shuuchuu/deckz?style=for-the-badge)](https://codecov.io/gh/shuuchuu/deckz)

@@ -6,0 +6,0 @@ [![PyPI Project](https://img.shields.io/pypi/v/deckz?style=for-the-badge)](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)

@@ -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 @@

@@ -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