From 563b726b068f61a4491e2c424d356baf3e0431c2 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Thu, 24 Apr 2025 03:30:43 +1200 Subject: [PATCH 01/19] Make a start at new Pydantic config models I intend to bring this in parallel to functioning previous code. --- .pre-commit-config.yaml | 4 +- examples/examples.yaml | 5 + pyproject.toml | 4 +- src/pdfbaker/__main__.py | 5 +- src/pdfbaker/baker.py | 29 +----- src/pdfbaker/config.py | 193 ++++++++++++++++++++++++++++++++++++++- src/test_config.py | 58 ++++++++++++ tests/test_baker.py | 23 ++--- tests/test_document.py | 21 +++-- uv.lock | 161 ++++++++++++++++++++++++++++++++ 10 files changed, 452 insertions(+), 51 deletions(-) create mode 100644 src/test_config.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f903e44..d654380 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,8 @@ repos: - "cairosvg" - "click" - "jinja2" + - "pydantic" - "pypdf" - "pytest" - - "pyyaml" + - "pyyaml" # FIXME: remove once fully migrated to ruamel + - "ruamel.yaml" diff --git a/examples/examples.yaml b/examples/examples.yaml index 9eb7857..81d80b0 100644 --- a/examples/examples.yaml +++ b/examples/examples.yaml @@ -4,3 +4,8 @@ documents: - variants - ./custom_locations/your_directory - custom_processing + +custom_stuff: + - year: 2025 + - nested: + - anything: really diff --git a/pyproject.toml b/pyproject.toml index a4c8264..ecaf1ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,10 @@ dependencies = [ "cairosvg", "click", "jinja2", + "pydantic", "pypdf", - "pyyaml", + "pyyaml", # FIXME: remove once fully migrated to ruamel + "ruamel.yaml", ] readme = "README.md" requires-python = ">= 3.11" diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index 9a8e0f7..6553853 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -7,7 +7,8 @@ import click from pdfbaker import __version__ -from pdfbaker.baker import PDFBaker, PDFBakerOptions +from pdfbaker.baker import PDFBaker +from pdfbaker.config import BakerOptions from pdfbaker.errors import DocumentNotFoundError, PDFBakerError logger = logging.getLogger(__name__) @@ -54,7 +55,7 @@ def bake( keep_build = True try: - options = PDFBakerOptions( + options = BakerOptions( quiet=quiet, verbose=verbose, trace=trace, diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index f2c3384..e5c0085 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -6,16 +6,15 @@ bake() delegates to its documents and reports back the end result. """ -from dataclasses import dataclass from pathlib import Path from typing import Any -from .config import PDFBakerConfiguration, deep_merge +from .config import BakerOptions, PDFBakerConfiguration, deep_merge from .document import PDFBakerDocument from .errors import ConfigurationError, DocumentNotFoundError from .logging import LoggingMixin, setup_logging -__all__ = ["PDFBaker", "PDFBakerOptions"] +__all__ = ["PDFBaker"] DEFAULT_BAKER_CONFIG = { @@ -32,26 +31,6 @@ } -@dataclass -class PDFBakerOptions: - """Options for controlling PDFBaker behavior. - - Attributes: - quiet: Show errors only - verbose: Show debug information - trace: Show trace information (even more detailed than debug) - keep_build: Keep build artifacts after processing - default_config_overrides: Dictionary of values to override the built-in defaults - before loading the main configuration - """ - - quiet: bool = False - verbose: bool = False - trace: bool = False - keep_build: bool = False - default_config_overrides: dict[str, Any] | None = None - - class PDFBaker(LoggingMixin): """Main class for PDF document generation.""" @@ -82,7 +61,7 @@ def __init__( def __init__( self, config_file: Path, - options: PDFBakerOptions | None = None, + options: BakerOptions | None = None, ) -> None: """Initialize PDFBaker with config file path. Set logging level. @@ -91,7 +70,7 @@ def __init__( options: Optional options for logging and build behavior """ super().__init__() - options = options or PDFBakerOptions() + options = options or BakerOptions() setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose) self.keep_build = options.keep_build diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index 0d7936f..fb19d8f 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -2,14 +2,22 @@ import logging import pprint +from enum import Enum from pathlib import Path from typing import Any import yaml from jinja2 import Template +from pydantic import ( + BaseModel, + ConfigDict, + Field, + model_validator, +) +from ruamel.yaml import YAML from .errors import ConfigurationError -from .logging import truncate_strings +from .logging import LoggingMixin, truncate_strings from .types import PathSpec __all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"] @@ -17,6 +25,189 @@ logger = logging.getLogger(__name__) +# ##################################################################### +# New Pydantic models +# ##################################################################### + +# TODO: show names instead of index numbers for error locations +# https://docs.pydantic.dev/latest/errors/errors/#customize-error-messages + + +class NewPathSpec(BaseModel): + """File/Directory location in YAML config.""" + + # Relative paths may not exist until resolved against root, + # so we have to check existence later + # path: FilePath | DirectoryPath + path: Path + name: str = Field(default_factory=lambda data: data["path"].stem) + + @model_validator(mode="before") + @classmethod + def ensure_pathspec(cls, data: Any) -> Any: + """Coerce what was given""" + if isinstance(data, str): + data = {"name": data} + if isinstance(data, dict) and "path" not in data: + data["path"] = Path(data["name"]) + return data + + +class ImageSpec(NewPathSpec): + """Image specification.""" + + type: str | None = None + data: str | None = None + + +class StyleDict(BaseModel): + """Style configuration.""" + + highlight_color: str | None = None + + +class DirectoriesConfig(BaseModel): + """Directories configuration.""" + + root: NewPathSpec + build: NewPathSpec + dist: NewPathSpec + documents: NewPathSpec + pages: NewPathSpec + templates: NewPathSpec + images: NewPathSpec + + @model_validator(mode="after") + def resolve_paths(self) -> Any: + """Resolve all paths relative to the root directory.""" + self.root.path = self.root.path.resolve() + for field_name, value in self.__dict__.items(): + if field_name != "root" and isinstance(value, NewPathSpec): + value.path = (self.root.path / value.path).resolve() + return self + + +class PageConfig(BaseModel, LoggingMixin): + """Page configuration.""" + + directories: DirectoriesConfig + template: NewPathSpec + model_config = ConfigDict( + strict=True, # don't try to coerce values + extra="allow", # will go in __pydantic_extra__ dict + ) + + +class DocumentConfig(BaseModel, LoggingMixin): + """Document configuration. + + Lazy-loads page configs. + """ + + directories: DirectoriesConfig + pages: list[Path | PageConfig] + model_config = ConfigDict( + strict=True, # don't try to coerce values + extra="allow", # will go in __pydantic_extra__ dict + ) + + +class DocumentVariantConfig(DocumentConfig): + """Document variant configuration.""" + + +class TemplateRenderer(Enum): + """Possible values for template_renderers.""" + + RENDER_HIGHLIGHT = "render_highlight" + + +class TemplateFilter(Enum): + """Possible values for template_filters.""" + + WORDWRAP = "wordwrap" + + +class SVG2PDFBackend(Enum): + """Possible values for svg2pdf_backend.""" + + CAIROSVG = "cairosvg" + INKSCAPE = "inkscape" + + +class BakerConfig(BaseModel, LoggingMixin): + """Baker configuration. + + Lazy-loads document configs. + """ + + directories: DirectoriesConfig + # TODO: lazy/forgiving documents parsing + # documents: list[Path | DocumentConfig] + documents: list[str] + template_renderers: list[TemplateRenderer] = [TemplateRenderer.RENDER_HIGHLIGHT] + template_filters: list[TemplateFilter] = [TemplateFilter.WORDWRAP] + svg2pdf_backend: SVG2PDFBackend | None = SVG2PDFBackend.CAIROSVG + compress_pdf: bool = False + model_config = ConfigDict( + strict=True, # don't try to coerce values + extra="allow", # will go in __pydantic_extra__ dict + ) + + @model_validator(mode="before") + @classmethod + def load_config(cls, data: Any) -> Any: + """Load documents from YAML file.""" + if isinstance(data, dict) and "config_file" in data: + config_file = data.pop("config_file") + config_data = YAML().load(config_file.read_text()) + config_data.update(data) # let kwargs override values from YAML + return config_data + return data + + @model_validator(mode="before") + @classmethod + def set_default_directories(cls, data: Any) -> Any: + """Set default directories.""" + if isinstance(data, dict): + directories = data.setdefault("directories", {}) + directories.setdefault("root", ".") + directories.setdefault("build", "build") + directories.setdefault("dist", "dist") + directories.setdefault("documents", ".") + directories.setdefault("pages", "pages") + directories.setdefault("templates", "templates") + directories.setdefault("images", "images") + return data + + @property + def custom_config(self) -> dict[str, Any]: + """Dictionary of all custom user-defined configuration.""" + return self.__pydantic_extra__ + + +class BakerOptions(BaseModel): + """Options for controlling PDFBaker behavior. + + Attributes: + quiet: Show errors only + verbose: Show debug information + trace: Show trace information (even more detailed than debug) + keep_build: Keep build artifacts after processing + default_config_overrides: Dictionary of values to override the built-in defaults + before loading the main configuration + """ + + quiet: bool = False + verbose: bool = False + trace: bool = False + keep_build: bool = False + default_config_overrides: dict[str, Any] | None = None + + +# ##################################################################### + + def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: """Deep merge two dictionaries.""" result = base.copy() diff --git a/src/test_config.py b/src/test_config.py new file mode 100644 index 0000000..422897b --- /dev/null +++ b/src/test_config.py @@ -0,0 +1,58 @@ +"""Test the new Pydantic config models.""" + +import sys +from pathlib import Path + +import ruamel.yaml +from pydantic import ValidationError + +from pdfbaker import config + +CONFIG_FILE = Path("/home/danny/src/pdfbaker/examples/examples.yaml") + + +def simple_representer(tag): + """Represent object as a string.""" + return lambda representer, data: representer.represent_scalar(tag, str(data)) + + +def register_representers(yaml_instance, class_tag_map, use_multi_for=()): + """Register representer.. + + If a class is in use_multi_for, subclasses will also be covered. + (like PosixPath is a subclass of Path) + """ + for cls, tag in class_tag_map.items(): + func = simple_representer(tag) + if cls in use_multi_for: + # Add a representer for the class and all subclasses. + yaml_instance.representer.add_multi_representer(cls, func) + else: + # Add a representer for this exact class only. + yaml_instance.representer.add_representer(cls, func) + + +yaml = ruamel.yaml.YAML() +yaml.indent(offset=4) +yaml.default_flow_style = False +register_representers( + yaml, + { + Path: "!path", + config.SVG2PDFBackend: "!svg2pdf_backend", + config.TemplateRenderer: "!template_renderer", + config.TemplateFilter: "!template_filter", + }, + use_multi_for=(Path,), +) + +try: + baker_config = config.BakerConfig(config_file=CONFIG_FILE) + baker_config_dict = baker_config.model_dump() + print("*** Full config after parsing: ***") + yaml.dump(baker_config_dict, sys.stdout) + print() + print("*** Custom config values only: ***") + yaml.dump(baker_config.custom_config, sys.stdout) +except ValidationError as e: + print(e) diff --git a/tests/test_baker.py b/tests/test_baker.py index a1cbeb3..65382c0 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -6,15 +6,16 @@ import pytest -from pdfbaker.baker import PDFBaker, PDFBakerOptions +from pdfbaker.baker import PDFBaker +from pdfbaker.config import BakerOptions from pdfbaker.errors import ConfigurationError from pdfbaker.logging import TRACE -# PDFBakerOptions tests +# BakerOptions tests def test_baker_options_defaults() -> None: - """Test PDFBakerOptions default values.""" - options = PDFBakerOptions() + """Test BakerOptions default values.""" + options = BakerOptions() assert not options.quiet assert not options.verbose assert not options.trace @@ -25,10 +26,10 @@ def test_baker_options_defaults() -> None: def test_baker_options_logging_levels() -> None: """Test different logging level configurations.""" test_cases = [ - (PDFBakerOptions(quiet=True), logging.ERROR), - (PDFBakerOptions(verbose=True), logging.DEBUG), - (PDFBakerOptions(trace=True), TRACE), - (PDFBakerOptions(), logging.INFO), # default + (BakerOptions(quiet=True), logging.ERROR), + (BakerOptions(verbose=True), logging.DEBUG), + (BakerOptions(trace=True), TRACE), + (BakerOptions(), logging.INFO), # default ] examples_config = Path(__file__).parent.parent / "examples" / "examples.yaml" @@ -38,13 +39,13 @@ def test_baker_options_logging_levels() -> None: def test_baker_options_default_config_overrides(tmp_path: Path) -> None: - """Test PDFBakerOptions with default_config_overrides.""" + """Test BakerOptions with default_config_overrides.""" # Create a minimal valid config config_file = tmp_path / "test.yaml" config_file.write_text("documents: [test]") custom_dir = tmp_path / "custom" - options = PDFBakerOptions( + options = BakerOptions( default_config_overrides={ "directories": { "documents": str(custom_dir), @@ -79,7 +80,7 @@ def test_baker_examples() -> None: build_dir.mkdir(exist_ok=True) dist_dir.mkdir(exist_ok=True) - options = PDFBakerOptions( + options = BakerOptions( quiet=True, keep_build=True, default_config_overrides={ diff --git a/tests/test_document.py b/tests/test_document.py index 241a8e1..5e8d4fd 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -6,7 +6,8 @@ import pytest -from pdfbaker.baker import PDFBaker, PDFBakerOptions +from pdfbaker.baker import PDFBaker +from pdfbaker.config import BakerOptions from pdfbaker.document import PDFBakerDocument from pdfbaker.errors import ConfigurationError @@ -22,9 +23,9 @@ def fixture_baker_config(tmp_path: Path) -> Path: @pytest.fixture(name="baker_options") -def fixture_baker_options(tmp_path: Path) -> PDFBakerOptions: +def fixture_baker_options(tmp_path: Path) -> BakerOptions: """Create baker options with test-specific build/dist directories.""" - return PDFBakerOptions( + return BakerOptions( default_config_overrides={ "directories": { "build": str(tmp_path / "build"), @@ -69,7 +70,7 @@ def fixture_doc_dir(tmp_path: Path) -> Path: def test_document_init_with_dir( - baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path + baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: """Test document initialization with directory.""" baker = PDFBaker(config_file=baker_config, options=baker_options) @@ -88,7 +89,7 @@ def test_document_init_with_dir( def test_document_init_with_file( - tmp_path: Path, baker_config: Path, baker_options: PDFBakerOptions + tmp_path: Path, baker_config: Path, baker_options: BakerOptions ) -> None: """Test document initialization with config file.""" # Create document config @@ -134,7 +135,7 @@ def test_document_init_missing_pages(tmp_path: Path, baker_config: Path) -> None def test_document_custom_bake( - baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path + baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: """Test document processing with custom bake module.""" # Create custom bake module @@ -151,7 +152,7 @@ def process_document(document): def test_document_custom_bake_error( - baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path + baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: """Test document processing with invalid custom bake module.""" # Create invalid bake module @@ -165,7 +166,7 @@ def test_document_custom_bake_error( def test_document_variants( - baker_config: Path, baker_options: PDFBakerOptions, doc_dir: Path + baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: """Test document processing with variants.""" # Update config file @@ -189,7 +190,7 @@ def test_document_variants( def test_document_variants_with_different_pages( - tmp_path: Path, baker_config: Path, baker_options: PDFBakerOptions + tmp_path: Path, baker_config: Path, baker_options: BakerOptions ) -> None: """Test document with variants where each variant has different pages.""" # Create document config with variants but no pages @@ -266,7 +267,7 @@ def test_document_variants_with_different_pages( def test_document_teardown( baker_config: Path, - baker_options: PDFBakerOptions, + baker_options: BakerOptions, doc_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/uv.lock b/uv.lock index 76f3d8b..5821886 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 1 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "cairocffi" version = "1.7.1" @@ -259,8 +268,10 @@ dependencies = [ { name = "cairosvg" }, { name = "click" }, { name = "jinja2" }, + { name = "pydantic" }, { name = "pypdf" }, { name = "pyyaml" }, + { name = "ruamel-yaml" }, ] [package.dev-dependencies] @@ -274,8 +285,10 @@ requires-dist = [ { name = "cairosvg" }, { name = "click" }, { name = "jinja2" }, + { name = "pydantic" }, { name = "pypdf" }, { name = "pyyaml" }, + { name = "ruamel-yaml" }, ] [package.metadata.requires-dev] @@ -351,6 +364,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] +[[package]] +name = "pydantic" +version = "2.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224 }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845 }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029 }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784 }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075 }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849 }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794 }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237 }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351 }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914 }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385 }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765 }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688 }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185 }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858 }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745 }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479 }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415 }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623 }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175 }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674 }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 }, +] + [[package]] name = "pypdf" version = "5.4.0" @@ -423,6 +516,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.13' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/46/f44d8be06b85bc7c4d8c95d658be2b68f27711f279bf9dd0612a5e4794f5/ruamel.yaml-0.18.10.tar.gz", hash = "sha256:20c86ab29ac2153f80a428e1254a8adf686d3383df04490514ca3b79a362db58", size = 143447 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/36/dfc1ebc0081e6d39924a2cc53654497f967a084a436bb64402dfce4254d9/ruamel.yaml-0.18.10-py3-none-any.whl", hash = "sha256:30f22513ab2301b3d2b577adc121c6471f28734d3d9728581245f1e76468b4f1", size = 117729 }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224 }, + { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480 }, + { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068 }, + { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012 }, + { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352 }, + { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344 }, + { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498 }, + { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205 }, + { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185 }, + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362 }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118 }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011 }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066 }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785 }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017 }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270 }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059 }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583 }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 }, +] + [[package]] name = "tinycss2" version = "1.4.0" @@ -474,6 +614,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + [[package]] name = "webencodings" version = "0.5.1" From fa763381d2280a9e65f43041f18f3c4bd3b0ed68 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:26:28 +1200 Subject: [PATCH 02/19] Add codecov report upload --- .github/workflows/tests.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 2a61605..7e3cae2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,5 +23,12 @@ jobs: echo "VIRTUAL_ENV=$PWD/.venv" >> $GITHUB_ENV echo "$PWD/.venv/bin" >> $GITHUB_PATH uv sync --dev - - name: Run tests with pytest - run: pytest + - name: Run tests with pytest and collect coverage + run: pytest --cov=pdfbaker --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: coverage.xml + name: pytest + flags: unittests + slug: pythonnz/pdfbaker From a5140fb2cc554874574c5382186993c9a5f2308d Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:26:58 +1200 Subject: [PATCH 03/19] Add badges --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 40c69a2..b20b255 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # pdfbaker +[![PyPI version](https://img.shields.io/pypi/v/pdfbaker)](https://pypi.org/project/pdfbaker/) +[![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) +[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://pypi.org/project/pdfbaker/) +[![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) +[![Downloads](https://img.shields.io/pypi/dw/pdfbaker)](https://pypistats.org/packages/pdfbaker) +[![License](https://img.shields.io/github/license/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) + Create PDF documents from YAML-configured SVG templates. ## Quickstart From ecf5ef6d4f7754c240c51fc4f96e5c84b1dba175 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:31:54 +1200 Subject: [PATCH 04/19] Add "last commit" badge --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b20b255..6b692b4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # pdfbaker [![PyPI version](https://img.shields.io/pypi/v/pdfbaker)](https://pypi.org/project/pdfbaker/) + [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) -[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://pypi.org/project/pdfbaker/) +[![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker)[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://github.com/pythonnz/pdfbaker/) [![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) [![Downloads](https://img.shields.io/pypi/dw/pdfbaker)](https://pypistats.org/packages/pdfbaker) [![License](https://img.shields.io/github/license/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) From 5544dad69c7f17d0e204a1b070de9d3bca645ffa Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:33:59 +1200 Subject: [PATCH 05/19] Add note to self --- src/pdfbaker/config.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index fb19d8f..eba8ff9 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -157,8 +157,11 @@ class BakerConfig(BaseModel, LoggingMixin): @model_validator(mode="before") @classmethod def load_config(cls, data: Any) -> Any: - """Load documents from YAML file.""" + """Load main configuration from YAML file.""" if isinstance(data, dict) and "config_file" in data: + # FIXME: save config_file path in model + # then load in "after" validator + # nice side effect: just change config_file to reload config_file = data.pop("config_file") config_data = YAML().load(config_file.read_text()) config_data.update(data) # let kwargs override values from YAML @@ -171,7 +174,7 @@ def set_default_directories(cls, data: Any) -> Any: """Set default directories.""" if isinstance(data, dict): directories = data.setdefault("directories", {}) - directories.setdefault("root", ".") + directories.setdefault("root", ".") # FIXME: should be config parent directories.setdefault("build", "build") directories.setdefault("dist", "dist") directories.setdefault("documents", ".") From 4385abfce0f73e06802cdea7de443c5d58b05312 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:35:04 +1200 Subject: [PATCH 06/19] Delete superfluous newline --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 6b692b4..95c0dac 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # pdfbaker [![PyPI version](https://img.shields.io/pypi/v/pdfbaker)](https://pypi.org/project/pdfbaker/) - [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) [![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker)[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://github.com/pythonnz/pdfbaker/) [![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) From 4eed902a258cbb967b2553e830575b133878b02e Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:39:30 +1200 Subject: [PATCH 07/19] Update badges --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 95c0dac..b0251a3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [![PyPI version](https://img.shields.io/pypi/v/pdfbaker)](https://pypi.org/project/pdfbaker/) [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) -[![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker)[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://github.com/pythonnz/pdfbaker/) +[![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/commits/main) +[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/pyproject.toml) [![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) [![Downloads](https://img.shields.io/pypi/dw/pdfbaker)](https://pypistats.org/packages/pdfbaker) [![License](https://img.shields.io/github/license/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) From 5ec1d2e113391101fdaf9b46768d8c38bc8e0b65 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:50:36 +1200 Subject: [PATCH 08/19] Add codecov upload token --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7e3cae2..3b2fc58 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -28,6 +28,7 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: + token: ${{ secrets.CODECOV_TOKEN }} file: coverage.xml name: pytest flags: unittests From 592cc775e5b9694592c4c4e8933250b0428fe874 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 08:56:45 +1200 Subject: [PATCH 09/19] Use codecov action v5 --- .github/workflows/tests.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3b2fc58..b865822 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -26,10 +26,6 @@ jobs: - name: Run tests with pytest and collect coverage run: pytest --cov=pdfbaker --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - file: coverage.xml - name: pytest - flags: unittests - slug: pythonnz/pdfbaker From 2e82c09f792a04b0665ef55a388ff70f3a0ed152 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 09:13:23 +1200 Subject: [PATCH 10/19] Also upload test results to codecov --- .github/workflows/tests.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b865822..cf59a71 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -24,8 +24,15 @@ jobs: echo "$PWD/.venv/bin" >> $GITHUB_PATH uv sync --dev - name: Run tests with pytest and collect coverage - run: pytest --cov=pdfbaker --cov-report=xml + run: + pytest --cov=pdfbaker --cov-report=xml --junitxml=junit.xml -o + junit_family=legacy - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} From b6e39cd2c091abef64a7a405143df9a6c546549f Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 09:21:12 +1200 Subject: [PATCH 11/19] Fix required Python version badge Need to use the PEP621 TOML badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b0251a3..41feaa3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/pdfbaker)](https://pypi.org/project/pdfbaker/) [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) [![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/commits/main) -[![Python](https://img.shields.io/pypi/pyversions/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/pyproject.toml) +[![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fpythonnz%2Fpdfbaker%2Frefs%2Fheads%2Fmain%2Fpyproject.toml)](https://github.com/pythonnz/pdfbaker/blob/main/pyproject.toml) [![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) [![Downloads](https://img.shields.io/pypi/dw/pdfbaker)](https://pypistats.org/packages/pdfbaker) [![License](https://img.shields.io/github/license/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) From b9bd31e4ac2d3ba599be6f0aa541c7536d1b1294 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 09:24:52 +1200 Subject: [PATCH 12/19] Group badges --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 41feaa3..8d4c5ad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # pdfbaker -[![PyPI version](https://img.shields.io/pypi/v/pdfbaker)](https://pypi.org/project/pdfbaker/) +[![PyPI version](https://img.shields.io/pypi/v/pdfbaker?color=blue)](https://pypi.org/project/pdfbaker/) +[![Python](https://img.shields.io/python/required-version-toml?color=blue&tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fpythonnz%2Fpdfbaker%2Fmain%2Fpyproject.toml)](https://github.com/pythonnz/pdfbaker/blob/main/pyproject.toml) +[![Downloads](https://img.shields.io/pypi/dw/pdfbaker?color=blue)](https://pypistats.org/packages/pdfbaker) + [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) -[![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/commits/main) -[![Python](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fpythonnz%2Fpdfbaker%2Frefs%2Fheads%2Fmain%2Fpyproject.toml)](https://github.com/pythonnz/pdfbaker/blob/main/pyproject.toml) [![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) -[![Downloads](https://img.shields.io/pypi/dw/pdfbaker)](https://pypistats.org/packages/pdfbaker) -[![License](https://img.shields.io/github/license/pythonnz/pdfbaker)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) + +[![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker?color=lightgrey)](https://github.com/pythonnz/pdfbaker/commits/main) +[![License](https://img.shields.io/github/license/pythonnz/pdfbaker?color=lightgrey)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) Create PDF documents from YAML-configured SVG templates. From 924b5157070934815844531494ba610c022808e3 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 09:27:29 +1200 Subject: [PATCH 13/19] Fix codecov badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d4c5ad..7045ee2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Downloads](https://img.shields.io/pypi/dw/pdfbaker?color=blue)](https://pypistats.org/packages/pdfbaker) [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) -[![codecov](https://codecov.io/gh/pythonnz/pdfbaker/branch/main/graph/badge.svg)](https://codecov.io/gh/pythonnz/pdfbaker) +[![codecov](https://img.shields.io/codecov/c/github/pythonnz/pdfbaker)](https://codecov.io/gh/pythonnz/pdfbaker) [![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker?color=lightgrey)](https://github.com/pythonnz/pdfbaker/commits/main) [![License](https://img.shields.io/github/license/pythonnz/pdfbaker?color=lightgrey)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) From e776433a3d8e417b69bbea598c874870269c7134 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Fri, 25 Apr 2025 09:31:10 +1200 Subject: [PATCH 14/19] Single badges row --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 7045ee2..95bb25b 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,8 @@ [![PyPI version](https://img.shields.io/pypi/v/pdfbaker?color=blue)](https://pypi.org/project/pdfbaker/) [![Python](https://img.shields.io/python/required-version-toml?color=blue&tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fpythonnz%2Fpdfbaker%2Fmain%2Fpyproject.toml)](https://github.com/pythonnz/pdfbaker/blob/main/pyproject.toml) [![Downloads](https://img.shields.io/pypi/dw/pdfbaker?color=blue)](https://pypistats.org/packages/pdfbaker) - [![tests](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml/badge.svg)](https://github.com/pythonnz/pdfbaker/actions/workflows/tests.yaml) [![codecov](https://img.shields.io/codecov/c/github/pythonnz/pdfbaker)](https://codecov.io/gh/pythonnz/pdfbaker) - [![Last commit](https://img.shields.io/github/last-commit/pythonnz/pdfbaker?color=lightgrey)](https://github.com/pythonnz/pdfbaker/commits/main) [![License](https://img.shields.io/github/license/pythonnz/pdfbaker?color=lightgrey)](https://github.com/pythonnz/pdfbaker/blob/main/LICENSE) From 4f232187cef79dcb388b28a9a97aeca2eb819f93 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Sat, 26 Apr 2025 04:49:15 +1200 Subject: [PATCH 15/19] WIP Complete refactoring to pydantic models Still cleaning up but it's definitely a much better approach. Less code (and will shrink more). Improved logging with YAML (not Python dict pprint) and emojis. * Examples all render fine * Variants broken if pages not defined in document just variants * Need to fix all tests for new class structures * Review all TODO/FIXME before merging (esp. for variants, clumsy/messy) * Tidy up, check __all__, try/except, customize pydantic error messages --- .pre-commit-config.yaml | 1 - .../other_pages/custom_page.yaml | 2 - examples/custom_processing/bake.py | 6 +- examples/examples.yaml | 3 +- pyproject.toml | 1 - src/pdfbaker/__main__.py | 9 +- src/pdfbaker/baker.py | 167 ++--- src/pdfbaker/config.py | 631 +++++++++++------- src/pdfbaker/document.py | 243 +++---- src/pdfbaker/logging.py | 51 +- src/pdfbaker/page.py | 111 +-- src/pdfbaker/render.py | 12 +- src/pdfbaker/types.py | 36 - src/test_config.py | 58 -- tests/test_baker.py | 13 +- tests/test_config.py | 31 +- tests/test_document.py | 37 +- uv.lock | 37 - 18 files changed, 683 insertions(+), 766 deletions(-) delete mode 100644 src/pdfbaker/types.py delete mode 100644 src/test_config.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d654380..a54c529 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,5 +52,4 @@ repos: - "pydantic" - "pypdf" - "pytest" - - "pyyaml" # FIXME: remove once fully migrated to ruamel - "ruamel.yaml" diff --git a/examples/custom_locations/other_pages/custom_page.yaml b/examples/custom_locations/other_pages/custom_page.yaml index 3f115c7..060d5ed 100644 --- a/examples/custom_locations/other_pages/custom_page.yaml +++ b/examples/custom_locations/other_pages/custom_page.yaml @@ -1,8 +1,6 @@ title: "Custom Location Example" description: "This page uses custom directory structure" template: - # If you just wrote this directly it would be relative to the templates directory - # We want it to be relative to the config file, so use path: path: "../other_templates/custom_page.svg.j2" detailed_description: "This example demonstrates custom file locations in pdfbaker. The template file is in diff --git a/examples/custom_processing/bake.py b/examples/custom_processing/bake.py index 199f8ab..71db2df 100644 --- a/examples/custom_processing/bake.py +++ b/examples/custom_processing/bake.py @@ -5,12 +5,12 @@ import urllib.request from datetime import datetime -from pdfbaker.document import PDFBakerDocument +from pdfbaker.document import Document from pdfbaker.errors import PDFBakerError from pdfbaker.processing import wordwrap -def process_document(document: PDFBakerDocument) -> None: +def process_document(document: Document) -> None: """Process document with live XKCD comic.""" try: # Fetch latest XKCD @@ -29,7 +29,7 @@ def process_document(document: PDFBakerDocument) -> None: wrapped_alt_text = wordwrap(data["alt"], max_chars=60) # Update config/template context with XKCD info - document.config["xkcd"] = { + document.config.xkcd = { "title": data["title"], "alt_text": data["alt"], "alt_text_lines": wrapped_alt_text, diff --git a/examples/examples.yaml b/examples/examples.yaml index 81d80b0..fba0172 100644 --- a/examples/examples.yaml +++ b/examples/examples.yaml @@ -2,7 +2,8 @@ documents: - minimal - regular - variants - - ./custom_locations/your_directory + - path: ./custom_locations/your_directory + name: custom_locations - custom_processing custom_stuff: diff --git a/pyproject.toml b/pyproject.toml index ecaf1ed..60349eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "jinja2", "pydantic", "pypdf", - "pyyaml", # FIXME: remove once fully migrated to ruamel "ruamel.yaml", ] readme = "README.md" diff --git a/src/pdfbaker/__main__.py b/src/pdfbaker/__main__.py index 6553853..58fbe97 100644 --- a/src/pdfbaker/__main__.py +++ b/src/pdfbaker/__main__.py @@ -7,8 +7,7 @@ import click from pdfbaker import __version__ -from pdfbaker.baker import PDFBaker -from pdfbaker.config import BakerOptions +from pdfbaker.baker import Baker, BakerOptions from pdfbaker.errors import DocumentNotFoundError, PDFBakerError logger = logging.getLogger(__name__) @@ -61,14 +60,14 @@ def bake( trace=trace, keep_build=keep_build, ) - baker = PDFBaker(config_file, options=options) + baker = Baker(config_file, options=options) success = baker.bake(document_names=documents if documents else None) sys.exit(0 if success else 1) except DocumentNotFoundError as exc: - logger.error(str(exc)) + logger.error("❌ %s", str(exc)) sys.exit(2) except PDFBakerError as exc: - logger.error(str(exc)) + logger.error("❌ %s", str(exc)) sys.exit(1) diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index e5c0085..6973f0b 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -1,4 +1,4 @@ -"""PDFBaker class. +"""Baker class. Overall orchestration and logging. @@ -7,87 +7,61 @@ """ from pathlib import Path -from typing import Any -from .config import BakerOptions, PDFBakerConfiguration, deep_merge -from .document import PDFBakerDocument -from .errors import ConfigurationError, DocumentNotFoundError +from pydantic import BaseModel, ValidationError + +from .config import ( + BakerConfig, + PathSpec, +) +from .document import Document +from .errors import DocumentNotFoundError from .logging import LoggingMixin, setup_logging -__all__ = ["PDFBaker"] - - -DEFAULT_BAKER_CONFIG = { - # Default to directories relative to the config file - "directories": { - "documents": ".", - "build": "build", - "dist": "dist", - }, - # Highlighting support enabled by default - "template_renderers": ["render_highlight"], - # Make all filters available by default - "template_filters": ["wordwrap"], -} - - -class PDFBaker(LoggingMixin): - """Main class for PDF document generation.""" - - class Configuration(PDFBakerConfiguration): - """PDFBaker configuration.""" - - def __init__( - self, baker: "PDFBaker", base_config: dict[str, Any], config_file: Path - ) -> None: - """Initialize baker configuration (needs documents).""" - self.baker = baker - self.name = config_file.name - self.baker.log_debug_section("Loading main configuration: %s", config_file) - super().__init__(base_config, config_file) - self.baker.log_trace(self.pretty()) - if "documents" not in self: - raise ConfigurationError( - 'Key "documents" missing - is this the main configuration file?' - ) - self.build_dir = self["directories"]["build"] - self.documents = [] - for doc_spec in self["documents"]: - doc_path = self.resolve_path( - doc_spec, directory=self["directories"]["documents"] - ) - self.documents.append({"name": doc_path.name, "path": doc_path}) +__all__ = ["Baker", "BakerOptions"] + + +class BakerOptions(BaseModel): + """Options for controlling PDFBaker behavior. + + Attributes: + quiet: Show errors only + verbose: Show debug information + trace: Show trace information (even more detailed than debug) + keep_build: Keep build artifacts after processing + default_config_overrides: Dictionary of values to override the built-in defaults + before loading the main configuration + """ + + quiet: bool = False + verbose: bool = False + trace: bool = False + keep_build: bool = False + + +class Baker(LoggingMixin): + """Baker class.""" def __init__( self, config_file: Path, options: BakerOptions | None = None, + **kwargs, ) -> None: - """Initialize PDFBaker with config file path. Set logging level. - - Args: - config_file: Path to config file - options: Optional options for logging and build behavior - """ - super().__init__() + """Set up logging and load configuration.""" options = options or BakerOptions() setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose) - self.keep_build = options.keep_build - - base_config = DEFAULT_BAKER_CONFIG.copy() - if options and options.default_config_overrides: - base_config = deep_merge(base_config, options.default_config_overrides) - base_config["directories"]["config"] = config_file.parent.resolve() - - self.config = self.Configuration( - baker=self, - base_config=base_config, + self.log_debug_section("Loading main configuration: %s", config_file) + self.config = BakerConfig( config_file=config_file, + keep_build=options and options.keep_build or False, + **kwargs, ) + self.log_trace(self.config.readable()) def _get_documents_to_process( self, selected_document_names: tuple[str, ...] | None = None - ) -> list[Path]: + ) -> list[PathSpec]: """Get the document paths to process based on optional filtering. Args: @@ -99,13 +73,15 @@ def _get_documents_to_process( if not selected_document_names: return self.config.documents - available_doc_names = [doc["name"] for doc in self.config.documents] + available_doc_names = [doc.name for doc in self.config.documents] missing_docs = [ name for name in selected_document_names if name not in available_doc_names ] if missing_docs: available_str = ", ".join([f'"{name}"' for name in available_doc_names]) - self.log_info(f"Documents in {self.config.name}: {available_str}") + self.log_info( + f"Documents in {self.config.config_file.name}: {available_str}" + ) missing_str = ", ".join([f'"{name}"' for name in missing_docs]) raise DocumentNotFoundError( f"Document{'s' if len(missing_docs) != 1 else ''} not found " @@ -113,47 +89,41 @@ def _get_documents_to_process( ) return [ - doc - for doc in self.config.documents - if doc["name"] in selected_document_names + doc for doc in self.config.documents if doc.name in selected_document_names ] - def bake(self, document_names: tuple[str, ...] | None = None) -> bool: - """Create PDFs for all documents or only the specified ones. - - Args: - document_names: Optional tuple of document names to process - - Returns: - bool: True if all documents were processed successfully, False if any failed - """ + def bake(self, document_names: tuple[str, ...] | None = None) -> None: + """Bake the documents.""" pdfs_created: list[Path] = [] - failed_docs: list[tuple[str, str]] = [] + failed_docs: list[tuple[PathSpec, str]] = [] - documents = self._get_documents_to_process(document_names) + doc_configs = self._get_documents_to_process(document_names) self.log_debug_subsection("Documents to process:") - self.log_debug(documents) - for doc_config in documents: - doc = PDFBakerDocument( - baker=self, - base_config=self.config, - config_path=doc_config["path"], - ) - pdf_files, error_message = doc.process_document() + self.log_debug(doc_configs) + for doc_config in doc_configs: + try: + document = Document( + config_path=doc_config, **self.config.document_settings + ) + except ValidationError as e: + self.log_error(f'Invalid config for document "{doc_config.name}": {e}') + continue + + pdf_files, error_message = document.process_document() if error_message: self.log_error( "Failed to process document '%s': %s", - doc.config.name, + document.config.name, error_message, ) - failed_docs.append((doc.config.name, error_message)) + failed_docs.append((document, error_message)) else: if isinstance(pdf_files, Path): pdf_files = [pdf_files] pdfs_created.extend(pdf_files) - if not self.keep_build: - doc.teardown() + if not self.config.keep_build: + document.teardown() if pdfs_created: self.log_info("Successfully created PDFs:") @@ -171,19 +141,20 @@ def bake(self, document_names: tuple[str, ...] | None = None) -> bool: for doc_name, error in failed_docs: self.log_error(" %s: %s", doc_name, error) - if not self.keep_build: + if not self.config.keep_build: self.teardown() return not failed_docs def teardown(self) -> None: """Clean up (top-level) build directory after processing.""" + build_dir = self.config.directories.build self.log_debug_subsection( - "Tearing down top-level build directory: %s", self.config.build_dir + "Tearing down top-level build directory: %s", build_dir ) - if self.config.build_dir.exists(): + if build_dir.exists(): try: self.log_debug("Removing top-level build directory...") - self.config.build_dir.rmdir() + build_dir.rmdir() except OSError: self.log_warning("Top-level build directory not empty - not removing") diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py index eba8ff9..9112011 100644 --- a/src/pdfbaker/config.py +++ b/src/pdfbaker/config.py @@ -1,59 +1,83 @@ """Base configuration for pdfbaker classes.""" +import io import logging -import pprint +from collections.abc import Mapping, Sequence from enum import Enum from pathlib import Path from typing import Any -import yaml +import ruamel.yaml from jinja2 import Template +from jinja2.exceptions import TemplateError as JinjaTemplateError from pydantic import ( BaseModel, ConfigDict, - Field, + PrivateAttr, + ValidationError, model_validator, ) from ruamel.yaml import YAML from .errors import ConfigurationError -from .logging import LoggingMixin, truncate_strings -from .types import PathSpec +from .logging import LoggingMixin -__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"] +__all__ = [ + "BakerConfig", + "deep_merge", + "DocumentConfig", + "DocumentVariantConfig", + "PageConfig", + "render_config", +] logger = logging.getLogger(__name__) +# FIXME: need to achieve the same effect as the old render_config() +# variables within variables resolved, e.g. {{ variant.name }} in "filename" +# TODO: change examples from images_dir to use directories.images (breaking change) +# TODO: allow directories.images to be either a PathSpec or a list of Pathspecs -# ##################################################################### -# New Pydantic models -# ##################################################################### +DEFAULT_DIRECTORIES = { + "build": "build", + "dist": "dist", + "documents": ".", + "pages": "pages", + "templates": "templates", + "images": "images", +} +DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" -# TODO: show names instead of index numbers for error locations -# https://docs.pydantic.dev/latest/errors/errors/#customize-error-messages +class PathSpec(BaseModel): + """File/Directory location (relative or absolute) in YAML config.""" -class NewPathSpec(BaseModel): - """File/Directory location in YAML config.""" - - # Relative paths may not exist until resolved against root, - # so we have to check existence later - # path: FilePath | DirectoryPath path: Path - name: str = Field(default_factory=lambda data: data["path"].stem) + name: str @model_validator(mode="before") @classmethod def ensure_pathspec(cls, data: Any) -> Any: - """Coerce what was given""" - if isinstance(data, str): - data = {"name": data} - if isinstance(data, dict) and "path" not in data: - data["path"] = Path(data["name"]) + """Coerce string/Path or partial dict into full dict with 'path' and 'name'.""" + if isinstance(data, str | Path): + path = Path(data) + data = {"path": path, "name": path.stem} + elif isinstance(data, dict): + if "path" not in data: + raise ValueError("path is required") + path = Path(data["path"]) + data = {"path": path, "name": data.get("name", path.stem)} return data + def resolve_relative_to(self, base: Path) -> "PathSpec": + """Resolve relative paths relative to a base directory.""" + path = self.path + if not path.is_absolute(): + path = (base / path).resolve() + return PathSpec(path=path, name=self.name) + -class ImageSpec(NewPathSpec): +class ImageSpec(PathSpec): """Image specification.""" type: str | None = None @@ -69,51 +93,262 @@ class StyleDict(BaseModel): class DirectoriesConfig(BaseModel): """Directories configuration.""" - root: NewPathSpec - build: NewPathSpec - dist: NewPathSpec - documents: NewPathSpec - pages: NewPathSpec - templates: NewPathSpec - images: NewPathSpec + root: Path + build: Path + dist: Path + documents: Path + pages: Path + templates: Path + images: Path - @model_validator(mode="after") - def resolve_paths(self) -> Any: - """Resolve all paths relative to the root directory.""" - self.root.path = self.root.path.resolve() - for field_name, value in self.__dict__.items(): - if field_name != "root" and isinstance(value, NewPathSpec): - value.path = (self.root.path / value.path).resolve() - return self + def resolved(self) -> "DirectoriesConfig": + """Resolve relative paths relative to the root directory.""" + root = self.root.resolve() + resolved = {} + for field in self.__class__.model_fields.keys(): + path = getattr(self, field) + resolved[field] = path if path.is_absolute() else (root / path).resolve() + return DirectoriesConfig(**resolved) -class PageConfig(BaseModel, LoggingMixin): - """Page configuration.""" +class BaseConfig(BaseModel, LoggingMixin): + """Base configuration class for pages, documents/variants, baker.""" - directories: DirectoriesConfig - template: NewPathSpec model_config = ConfigDict( strict=True, # don't try to coerce values extra="allow", # will go in __pydantic_extra__ dict ) + def readable(self, max_chars: int = 60) -> str: + """Return YAML representation with truncated strings for readability.""" + yaml_instance = get_readable_yaml(max_chars=max_chars) + stream = io.StringIO() + yaml_instance.dump(self.model_dump(), stream) + return f"\n{stream.getvalue()}" + + +class PageConfig(BaseConfig): + """Page configuration.""" + + config_path: PathSpec + directories: DirectoriesConfig + _resolved_directories: DirectoriesConfig = PrivateAttr() + template: PathSpec + + @model_validator(mode="before") + @classmethod + def load_config(cls, data: Any) -> Any: + """Load document configuration from YAML file.""" + if isinstance(data, dict) and "config_path" in data: + config_data = YAML().load(data["config_path"].path.read_text()) + config_data.update(data) # kwargs override YAML values + data = config_data + + data["directories"]["root"] = data["config_path"].path.parent + + return data + + @model_validator(mode="after") + def resolve_paths(self) -> "PageConfig": + """Resolve relative paths relative to the root directory.""" + self._resolved_directories = self.directories.resolved() + + # Resolve template path + templates_dir = self._resolved_directories.templates + page_root = self._resolved_directories.root + if len(self.template.path.parts) > 1: + # Relative to document root or absolute path + self.template.path = (page_root / self.template.path).resolve() + else: + # Simple string - relative to templates directory + self.template.path = self.template.resolve_relative_to(templates_dir).path + self.template.name = self.template.path.name # not just stem + + return self + + @property + def name(self) -> str: + """Page name is the 'name' of its config file.""" + return self.config_path.name -class DocumentConfig(BaseModel, LoggingMixin): + @property + def settings(self) -> dict[str, Any]: + """All configuration settings in a dictionary.""" + return self.model_dump() + + @property + def user_defined(self) -> dict[str, Any]: + """Dictionary of all custom user-defined settings.""" + # FIXME: remove if not neeeded (at least for debugging) + return getattr(self, "__pydantic_extra__", {}) or {} + + +class DocumentVariantConfig(BaseConfig): + """Document variant configuration. + + Like a document without a config file or own variants + """ + + name: str + directories: DirectoriesConfig + pages: list[PathSpec] + + @model_validator(mode="after") + def resolve_paths(self) -> "DocumentConfig": + """Resolve relative paths relative to the root directory.""" + # Resolve page paths + pages_dir = self.directories.pages + document_root = self.directories.root + for page in self.pages: + if not page.path.suffix: + page.path = page.path.with_suffix(".yaml") + + if len(page.path.parts) > 1: + # Relative to document root or absolute path + page.path = (document_root / page.path).resolve() + else: + # Simple string - relative to pages directory + page.path = page.resolve_relative_to(pages_dir).path + + return self + + @property + def page_settings(self) -> dict[str, Any]: + """All configuration settings in a dictionary. Given to pages.""" + return self.model_dump( + exclude={ + "pages", + } + ) + + +class DocumentConfig(BaseConfig): """Document configuration. Lazy-loads page configs. """ + config_path: PathSpec directories: DirectoriesConfig - pages: list[Path | PageConfig] - model_config = ConfigDict( - strict=True, # don't try to coerce values - extra="allow", # will go in __pydantic_extra__ dict - ) + _resolved_directories: DirectoriesConfig = PrivateAttr() + variants: list[DocumentVariantConfig] = [] + pages: list[PathSpec] = [] + bake_path: PathSpec | None = None + @model_validator(mode="before") + @classmethod + def load_config(cls, data: Any) -> Any: + """Load document configuration from YAML file.""" + if isinstance(data, dict) and "config_path" in data: + if data["config_path"].path.is_dir(): + # Keep name but change path + data["config_path"].path /= DEFAULT_DOCUMENT_CONFIG_FILE + + config_data = YAML().load(data["config_path"].path.read_text()) + config_data.update(data) # kwargs override YAML values + data = config_data + + data["directories"]["root"] = data["config_path"].path.parent + + variants_data = data.get("variants", []) + valid_variants = [] + for vdata in variants_data: + try: + # FIXME: doc without pages + if "pages" in data: + variant = DocumentVariantConfig( + directories=data["directories"], + pages=data["pages"], + **vdata, + ) + else: + # This should fail if no pages in doc and also not in variant + variant = DocumentVariantConfig( + directories=data["directories"], + **vdata, + ) + valid_variants.append(variant) + except ValidationError as e: + print(f"⚠️ Skipping invalid variant '{vdata.get('name')}': {e}") + + data["variants"] = valid_variants -class DocumentVariantConfig(DocumentConfig): - """Document variant configuration.""" + return data + + @model_validator(mode="after") + def resolve_paths(self) -> "DocumentConfig": + """Resolve relative paths relative to the root directory.""" + self.directories.build = self.directories.build / self.name + self.directories.dist = self.directories.dist / self.name + self._resolved_directories = self.directories.resolved() + + # Resolve page paths + pages_dir = self._resolved_directories.pages + document_root = self._resolved_directories.root + for page in self.pages: + if not page.path.suffix: + page.path = page.path.with_suffix(".yaml") + + if len(page.path.parts) > 1: + # Relative to document root or absolute path + page.path = (document_root / page.path).resolve() + else: + # Simple string - relative to pages directory + page.path = page.resolve_relative_to(pages_dir).path + + if not self.bake_path: + self.bake_path = PathSpec( + path=self._resolved_directories.root / "bake.py", + name="bake.py", + ) + + return self + + @model_validator(mode="after") + def check_pages_or_variants(self) -> "DocumentConfig": + """Check if pages or variants are defined.""" + # The "pages" may be defined in the variants rather than + # the document itself (when different variants have different pages) + if not self.pages: + if self.variants: + # A variant not defining pages will fail to process + self.log_debug( + 'Pages of document "%s" will be determined per variant', + self.name, + ) + else: + self.log_warning( + 'Document "%s" has neither pages nor variants', self.name + ) + raise ConfigurationError( + f'Cannot determine pages of document "{self.name}"' + ) + return self + + @property + def name(self) -> str: + """Document name is the 'name' of its config file.""" + return self.config_path.name + + @property + def variant_settings(self) -> dict[str, Any]: + """Variant settings.""" + # FIXME: see document.py variant processing - need elegance + settings = {"directories": {}} + for directory in self.directories.__class__.model_fields.keys(): + settings["directories"][directory] = getattr( + self._resolved_directories, directory + ) + return settings + + @property + def page_settings(self) -> dict[str, Any]: + """All configuration settings in a dictionary. Given to pages.""" + settings = self.model_dump(exclude={"config_path", "variants", "pages"}) + settings["directories"]["pages"] = self._resolved_directories.pages + settings["directories"]["templates"] = self._resolved_directories.templates + settings["directories"]["images"] = self._resolved_directories.images + return settings class TemplateRenderer(Enum): @@ -135,80 +370,121 @@ class SVG2PDFBackend(Enum): INKSCAPE = "inkscape" -class BakerConfig(BaseModel, LoggingMixin): +class BakerConfig(BaseConfig): """Baker configuration. Lazy-loads document configs. """ + config_file: Path directories: DirectoriesConfig - # TODO: lazy/forgiving documents parsing - # documents: list[Path | DocumentConfig] - documents: list[str] + _resolved_directories: DirectoriesConfig = PrivateAttr() + documents: list[PathSpec] + # FIXME: jinja2_extensions set for just a page not picked up + jinja2_extensions: list[str] = [] template_renderers: list[TemplateRenderer] = [TemplateRenderer.RENDER_HIGHLIGHT] template_filters: list[TemplateFilter] = [TemplateFilter.WORDWRAP] svg2pdf_backend: SVG2PDFBackend | None = SVG2PDFBackend.CAIROSVG compress_pdf: bool = False - model_config = ConfigDict( - strict=True, # don't try to coerce values - extra="allow", # will go in __pydantic_extra__ dict - ) + keep_build: bool = False @model_validator(mode="before") @classmethod def load_config(cls, data: Any) -> Any: """Load main configuration from YAML file.""" if isinstance(data, dict) and "config_file" in data: - # FIXME: save config_file path in model - # then load in "after" validator - # nice side effect: just change config_file to reload - config_file = data.pop("config_file") - config_data = YAML().load(config_file.read_text()) - config_data.update(data) # let kwargs override values from YAML - return config_data - return data + if isinstance(data["config_file"], str): + data["config_file"] = Path(data["config_file"]) + if isinstance(data["config_file"], Path): + data["config_file"] = data["config_file"].resolve() + + config_data = YAML().load(data["config_file"].read_text()) + config_data.update(data) # kwargs override YAML values + data = config_data + + # Set default directories + if "directories" not in data: + data["directories"] = {} + directories = data["directories"] + directories.setdefault("root", data["config_file"].parent) + for key, default in DEFAULT_DIRECTORIES.items(): + directories.setdefault(key, default) + + if "documents" not in data: + raise ValueError( + 'Key "documents" missing - is this the main configuration file?' + ) - @model_validator(mode="before") - @classmethod - def set_default_directories(cls, data: Any) -> Any: - """Set default directories.""" - if isinstance(data, dict): - directories = data.setdefault("directories", {}) - directories.setdefault("root", ".") # FIXME: should be config parent - directories.setdefault("build", "build") - directories.setdefault("dist", "dist") - directories.setdefault("documents", ".") - directories.setdefault("pages", "pages") - directories.setdefault("templates", "templates") - directories.setdefault("images", "images") return data + @model_validator(mode="after") + def resolve_paths(self) -> "BakerConfig": + """Resolve relative paths relative to the root directory.""" + self._resolved_directories = self.directories.resolved() + + # Resolve build/dist, they are fixed unless re-defined + self.directories.build = self._resolved_directories.build + self.directories.dist = self._resolved_directories.dist + + # Resolve document paths + root = self.directories.root.resolve() + self.documents = [doc.resolve_relative_to(root) for doc in self.documents] + + return self + @property - def custom_config(self) -> dict[str, Any]: - """Dictionary of all custom user-defined configuration.""" - return self.__pydantic_extra__ + def document_settings(self) -> dict[str, Any]: + """All configuration settings relevant for a document.""" + return self.model_dump(exclude={"config_file", "documents"}) -class BakerOptions(BaseModel): - """Options for controlling PDFBaker behavior. +def register_representers(yaml_instance, class_tag_map, use_multi_for=()): + """Register representer.. - Attributes: - quiet: Show errors only - verbose: Show debug information - trace: Show trace information (even more detailed than debug) - keep_build: Keep build artifacts after processing - default_config_overrides: Dictionary of values to override the built-in defaults - before loading the main configuration + If a class is in use_multi_for, subclasses will also be covered. + (like PosixPath is a subclass of Path) """ - quiet: bool = False - verbose: bool = False - trace: bool = False - keep_build: bool = False - default_config_overrides: dict[str, Any] | None = None + def simple_representer(tag): + """Represent object as a string.""" + return lambda representer, data: representer.represent_scalar(tag, str(data)) + + for cls, tag in class_tag_map.items(): + func = simple_representer(tag) + if cls in use_multi_for: + # Add a representer for the class and all subclasses. + yaml_instance.representer.add_multi_representer(cls, func) + else: + # Add a representer for this exact class only. + yaml_instance.representer.add_representer(cls, func) + + +def get_readable_yaml(max_chars: int = 60) -> ruamel.yaml.YAML: + """Get a YAML instance with string truncation for readable output.""" + yaml = ruamel.yaml.YAML() + yaml.indent(offset=4) + yaml.default_flow_style = False + + register_representers( + yaml, + { + Path: "!path", + SVG2PDFBackend: "!svg2pdf_backend", + TemplateRenderer: "!template_renderer", + TemplateFilter: "!template_filter", + }, + use_multi_for=(Path,), + ) + # Add string truncation representer + def truncating_representer(representer, data): + if len(data) > max_chars: + data = data[:max_chars] + "..." + return representer.represent_scalar("tag:yaml.org,2002:str", data) -# ##################################################################### + yaml.representer.add_representer(str, truncating_representer) + + return yaml def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: @@ -222,141 +498,38 @@ def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: return result -class PDFBakerConfiguration(dict): - """Base class for handling config loading/merging/parsing.""" - - def __init__( - self, - base_config: dict[str, Any], - config_file: Path, - ) -> None: - """Initialize configuration from a file. - - Args: - base_config: Existing base configuration - config: Path to YAML file to merge with base_config - """ - try: - with open(config_file, encoding="utf-8") as f: - config = yaml.safe_load(f) - except yaml.scanner.ScannerError as exc: - raise ConfigurationError( - f"Invalid YAML syntax in config file {config_file}: {exc}" - ) from exc - except Exception as exc: - raise ConfigurationError(f"Failed to load config file: {exc}") from exc - - # Determine all relevant directories - self["directories"] = directories = {"config": config_file.parent.resolve()} - for directory in ( - "documents", - "pages", - "templates", - "images", - "build", - "dist", - ): - if directory in config.get("directories", {}): - # Set in this config file, relative to this config file - directories[directory] = self.resolve_path( - config["directories"][directory] - ) - elif directory in base_config.get("directories", {}): - # Inherited (absolute) or default (relative to _this_ config) - directories[directory] = self.resolve_path( - str(base_config["directories"][directory]) - ) - super().__init__(deep_merge(base_config, config)) - self["directories"] = directories - - def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path: - """Resolve a possibly relative path specification. - - Args: - spec: Path specification (string or dict with path/name) - directory: Optional directory to use for resolving paths - Returns: - Resolved Path object - """ - directory = directory or self["directories"]["config"] - if isinstance(directory, str): - directory = Path(directory) - - if isinstance(spec, str): - return directory / spec - - if "path" not in spec and "name" not in spec: - raise ConfigurationError("Invalid path specification: needs path or name") - - if "path" in spec: - return Path(spec["path"]) - - return directory / spec["name"] - - def pretty(self, max_chars: int = 60) -> str: - """Return readable presentation (for debugging).""" - truncated = truncate_strings(self, max_chars=max_chars) - return pprint.pformat(truncated, indent=2) - - -def _convert_paths_to_strings(config: dict[str, Any]) -> dict[str, Any]: - """Convert all Path objects in config to strings.""" - result = {} - for key, value in config.items(): - if isinstance(value, Path): - result[key] = str(value) - elif isinstance(value, dict): - result[key] = _convert_paths_to_strings(value) - elif isinstance(value, list): - result[key] = [ - _convert_paths_to_strings(item) - if isinstance(item, dict) - else str(item) - if isinstance(item, Path) - else item - for item in value - ] - else: - result[key] = value - return result - - -def render_config(config: dict[str, Any]) -> dict[str, Any]: - """Resolve all template strings in config using its own values. - - This allows the use of "{{ variant }}" in the "filename" etc. - - Args: - config: Configuration dictionary to render +def render_config(config: dict[str, Any], max_iterations: int = 10) -> dict[str, Any]: + """Render config with its own values.""" + current = dict(config) + for _ in range(max_iterations): + new = _render_object(current, context=current) + if new == current: + return new + current = new + raise ConfigurationError( + "Maximum number of iterations reached — possible circular reference" + ) - Returns: - Resolved configuration dictionary - Raises: - ConfigurationError: If maximum number of iterations is reached - (circular references) - """ - max_iterations = 10 - current_config = dict(config) - current_config = _convert_paths_to_strings(current_config) +def _render_object(obj, context, seen_keys=None): + if seen_keys is None: + seen_keys = set() - for _ in range(max_iterations): - config_yaml = Template(yaml.dump(current_config)) - resolved_yaml = config_yaml.render(**current_config) - new_config = yaml.safe_load(resolved_yaml) - - # Check for direct self-references - for key, value in new_config.items(): - if isinstance(value, str) and f"{{{{ {key} }}}}" in value: + if isinstance(obj, str): + for key in seen_keys: + if f"{{{{ {key} }}}}" in obj: raise ConfigurationError( - f"Circular reference detected: {key} references itself" + f"Circular/self reference detected: '{key}' in '{obj}'" ) + try: + return Template(obj).render(**context) + except JinjaTemplateError as e: + raise ConfigurationError(f"Error rendering template '{obj}': {e}") from e - if new_config == current_config: # No more changes - return new_config - current_config = new_config + elif isinstance(obj, Mapping): + return {k: _render_object(v, context, seen_keys | {k}) for k, v in obj.items()} - raise ConfigurationError( - "Maximum number of iterations reached. " - "Check for circular references in your configuration." - ) + elif isinstance(obj, Sequence) and not isinstance(obj, str): + return [_render_object(i, context, seen_keys) for i in obj] + + return obj diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index adfb138..8a3551a 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -1,4 +1,4 @@ -"""PDFBakerDocument class. +"""Document class. Document-level processing, variants, custom bake modules. @@ -9,131 +9,35 @@ import importlib import os from pathlib import Path -from typing import Any from .config import ( - PDFBakerConfiguration, - deep_merge, + DocumentConfig, + DocumentVariantConfig, + PathSpec, render_config, ) from .errors import ( - ConfigurationError, PDFBakerError, PDFCombineError, PDFCompressionError, ) from .logging import LoggingMixin -from .page import PDFBakerPage +from .page import Page from .pdf import ( combine_pdfs, compress_pdf, ) -DEFAULT_DOCUMENT_CONFIG = { - # Default to directories relative to the config file - "directories": { - "pages": "pages", - "templates": "templates", - "images": "images", - }, -} -DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" +__all__ = ["Document"] -__all__ = ["PDFBakerDocument"] +class Document(LoggingMixin): + """Document class.""" -class PDFBakerDocument(LoggingMixin): - """A document being processed.""" - - class Configuration(PDFBakerConfiguration): - """PDFBaker document-specific configuration.""" - - def __init__( - self, - document: "PDFBakerDocument", - base_config: "PDFBakerConfiguration", # type: ignore # noqa: F821 - config_path: Path, - ) -> None: - """Initialize document configuration. - - Args: - base_config: The PDFBaker configuration to merge with - config_file: The document configuration (YAML file) - """ - self.document = document - - if config_path.is_dir(): - self.name = config_path.name - config_path = config_path / DEFAULT_DOCUMENT_CONFIG_FILE - else: - self.name = config_path.stem - - base_config = deep_merge(base_config, DEFAULT_DOCUMENT_CONFIG) - - self.document.log_trace_section( - "Loading document configuration: %s", config_path - ) - super().__init__(base_config, config_path) - self.document.log_trace(self.pretty()) - - self.bake_path = self["directories"]["config"] / "bake.py" - self.build_dir = self["directories"]["build"] / self.name - self.dist_dir = self["directories"]["dist"] / self.name - - # The "pages" may be defined in the variants rather than - # the document itself (when different variants have different pages) - if "pages" not in self: - if "variants" in self: - # A variant not defining pages will fail to process - self.document.log_debug( - 'Pages of document "%s" will be determined per variant', - self.name, - ) - else: - self.document.log_warning( - f'Document "{self.name}" has neither "pages" nor "variants"' - ) - raise ConfigurationError( - f'Cannot determine pages of document "{self.name}"' - ) - # Actual pages will be determined during processing - self.pages = [] - - def determine_pages(self, config: dict[str, Any]) -> list[Path]: - """Determine pages for the give (document/variant) configuration.""" - if "pages" not in config: - raise ConfigurationError(f'Cannot determine pages for "{self.name}"') - pages = [] - for page_spec in config["pages"]: - if isinstance(page_spec, dict) and "path" in page_spec: - # Path was specified: relative to this config file - page = self.resolve_path( - page_spec["path"], directory=config["directories"]["config"] - ) - else: - # Only name was specified: relative to the pages directory - page = self.resolve_path( - page_spec, directory=config["directories"]["pages"] - ) - if not page.suffix: - page = page.with_suffix(".yaml") - pages.append(page) - self.pages = pages - - def __init__( - self, - baker: "PDFBaker", # type: ignore # noqa: F821 - base_config: dict[str, Any], - config_path: Path, - ): - """Initialize a document.""" - super().__init__() - self.baker = baker - self.config = self.Configuration( - document=self, - base_config=base_config, - config_path=config_path, - ) + def __init__(self, config_path: PathSpec, **kwargs): + self.log_trace_section("Loading document configuration: %s", config_path.name) + self.config = DocumentConfig(config_path=config_path, **kwargs) + self.log_trace(self.config.readable()) def process_document(self) -> tuple[Path | list[Path] | None, str | None]: """Process the document - use custom bake module if it exists. @@ -147,16 +51,19 @@ def process_document(self) -> tuple[Path | list[Path] | None, str | None]: """ self.log_info_section('Processing document "%s"...', self.config.name) - self.config.build_dir.mkdir(parents=True, exist_ok=True) - self.config.dist_dir.mkdir(parents=True, exist_ok=True) + self.config.directories.build.mkdir(parents=True, exist_ok=True) + self.config.directories.dist.mkdir(parents=True, exist_ok=True) + bake_path = self.config.bake_path.path try: - if self.config.bake_path.exists(): - return self._process_with_custom_bake(self.config.bake_path), None + if bake_path.exists(): + return self._process_with_custom_bake(bake_path), None return self.process(), None except PDFBakerError as exc: return None, str(exc) + # ############################################################## + def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: """Process document using custom bake module.""" try: @@ -177,31 +84,89 @@ def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: def process(self) -> Path | list[Path]: """Process document using standard processing.""" - if "variants" in self.config: + if self.config.variants: # Multiple PDF documents pdf_files = [] - for variant in self.config["variants"]: - self.log_info_subsection('Processing variant "%s"...', variant["name"]) - variant_config = deep_merge(self.config, variant) + for variant_config in self.config.variants: + self.log_info_subsection( + 'Processing variant "%s"...', variant_config.name + ) + _ = """ + # variant_config = deep_merge(self.config, variant) # self.log_trace(variant_config) - variant_config["variant"] = variant - variant_config = render_config(variant_config) - page_pdfs = self._process_pages(variant_config) - pdf_files.append(self._finalize(page_pdfs, variant_config)) + self.config.update(variant_config) + # variant_config = render_config(variant_config) + page_pdfs = self._process_pages(self.config) + pdf_files.append(self._finalize(page_pdfs, self.config)) + """ + + # FIXME: Too much logic here, should be in DocumentVariantConfig + + # Create a document config with the variant info + merged_config = self.config.model_dump() + variant_data = variant_config.model_dump() + merged_config["variant"] = variant_data + + # Update other config values from the variant + # FIXME: should use deep_merge? + # FIXME: pages could be defined in page but not variant or vice versa + for key, value in variant_data.items(): + if key not in ["directories", "pages"]: + merged_config[key] = value + # if variant_data.get("pages"): + # merged_config["pages"] = variant_data["pages"] + + variant_settings = self.config.variant_settings + merged_config.update(variant_settings) + merged_config = render_config(merged_config) + + doc_with_variant = DocumentVariantConfig(**merged_config) + + # Process with the variant-enhanced config + page_pdfs = self._process_pages(doc_with_variant) + pdf_files.append(self._finalize(page_pdfs, doc_with_variant)) + return pdf_files # Single PDF document - doc_config = render_config(self.config) - page_pdfs = self._process_pages(doc_config) - return self._finalize(page_pdfs, doc_config) + # doc_config = render_config(self.config) + page_pdfs = self._process_pages(self.config) + return self._finalize(page_pdfs, self.config) - def _process_pages(self, config: dict[str, Any]) -> list[Path]: + def _process_pages( + self, config: DocumentConfig | DocumentVariantConfig + ) -> list[Path]: """Process pages with given configuration.""" - self.config.determine_pages(config) self.log_debug_subsection("Pages to process:") - self.log_debug(self.config.pages) + self.log_debug(config.pages) pdf_files = [] - for page_num, page_config_path in enumerate(self.config.pages, start=1): + + # FIXME: Too much logic here, should be in DocumentVariantConfig + + for page_num, page_config_path in enumerate(config.pages, start=1): + # if "variant" in config: + # base_config = DocumentVariantConfig( + # variant=config["variant"], + # directories=config.directories, + # pages=config.pages, + # ) + # else: + # base_config = config + + # Get the settings (includes variant if present) + base_config = config.page_settings + if "config_path" in base_config: + # Fix for variant + del base_config["config_path"] + + page = Page( + config_path=page_config_path, + number=page_num, + **base_config, + ) + pdf_files.append(page.process()) + + _ = """ page_name = page_config_path.stem base_config = config.copy() @@ -225,24 +190,27 @@ def _process_pages(self, config: dict[str, Any]) -> list[Path]: config_path=page_config_path, ) pdf_files.append(page.process()) + """ return pdf_files - def _finalize(self, pdf_files: list[Path], doc_config: dict[str, Any]) -> Path: + def _finalize( + self, pdf_files: list[Path], doc_config: DocumentConfig | DocumentVariantConfig + ) -> Path: """Combine PDF pages and optionally compress.""" self.log_debug_subsection("Finalizing document...") self.log_debug("Combining PDF pages...") try: combined_pdf = combine_pdfs( pdf_files, - self.config.build_dir / f"{doc_config['filename']}.pdf", + self.config.directories.build / f"{doc_config.filename}.pdf", ) except PDFCombineError as exc: raise PDFBakerError(f"Failed to combine PDFs: {exc}") from exc - output_path = self.config.dist_dir / f"{doc_config['filename']}.pdf" + output_path = self.config.directories.dist / f"{doc_config.filename}.pdf" - if doc_config.get("compress_pdf", False): + if doc_config.compress_pdf: self.log_debug("Compressing PDF document...") try: compress_pdf(combined_pdf, output_path) @@ -259,19 +227,20 @@ def _finalize(self, pdf_files: list[Path], doc_config: dict[str, Any]) -> Path: self.log_info("Created %s", output_path.name) return output_path + # ############################################################## + def teardown(self) -> None: """Clean up build directory after processing.""" - self.log_debug_subsection( - "Tearing down build directory: %s", self.config.build_dir - ) - if self.config.build_dir.exists(): + build_dir = self.config.directories.build + self.log_debug_subsection("Tearing down build directory: %s", build_dir) + if build_dir.exists(): self.log_debug("Removing files in build directory...") - for file_path in self.config.build_dir.iterdir(): + for file_path in build_dir.iterdir(): if file_path.is_file(): file_path.unlink() try: self.log_debug("Removing build directory...") - self.config.build_dir.rmdir() + build_dir.rmdir() except OSError: self.log_warning("Build directory not empty - not removing") diff --git a/src/pdfbaker/logging.py b/src/pdfbaker/logging.py index 759bda8..e37ce84 100644 --- a/src/pdfbaker/logging.py +++ b/src/pdfbaker/logging.py @@ -7,47 +7,48 @@ TRACE = 5 logging.addLevelName(TRACE, "TRACE") -__all__ = ["LoggingMixin", "setup_logging", "truncate_strings"] +__all__ = ["LoggingMixin", "setup_logging"] class LoggingMixin: """Mixin providing consistent logging functionality across pdfbaker classes.""" - def __init__(self) -> None: - """Initialize logger for the class.""" - self.logger = logging.getLogger(self.__class__.__module__) + @property + def logger(self) -> logging.Logger: + """Return the named logger for this instance.""" + return logging.getLogger(self.__class__.__module__) def log_trace(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a trace message (more detailed than debug).""" - self.logger.log(TRACE, msg, *args, **kwargs) + self.logger.log(TRACE, f"🔍 {msg}", *args, **kwargs) def log_trace_preview( self, msg: str, *args: Any, max_chars: int = 500, **kwargs: Any ) -> None: """Log a trace preview of a potentially large message, truncating if needed.""" - self.logger.log( - TRACE, truncate_strings(msg, max_chars=max_chars), *args, **kwargs - ) + if len(msg) > max_chars: + msg = msg[:max_chars] + "(...)" + self.logger.log(TRACE, f"🔍 \n{msg}", *args, **kwargs) def log_trace_section(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a trace message as a main section header.""" - self.logger.log(TRACE, f"──── {msg} ────", *args, **kwargs) + self.logger.log(TRACE, f"🔍 ──── {msg} ────", *args, **kwargs) def log_trace_subsection(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a trace message as a subsection header.""" - self.logger.log(TRACE, f" ── {msg} ──", *args, **kwargs) + self.logger.log(TRACE, f"🔍 ── {msg} ──", *args, **kwargs) def log_debug(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a debug message.""" - self.logger.debug(msg, *args, **kwargs) + self.logger.debug(f"🔧 {msg}", *args, **kwargs) def log_debug_section(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a debug message as a main section header.""" - self.logger.debug(f"──── {msg} ────", *args, **kwargs) + self.logger.debug(f"🔧 ──── {msg} ────", *args, **kwargs) def log_debug_subsection(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a debug message as a subsection header.""" - self.logger.debug(f" ── {msg} ──", *args, **kwargs) + self.logger.debug(f"🔧 ── {msg} ──", *args, **kwargs) def log_info(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log an info message.""" @@ -63,15 +64,15 @@ def log_info_subsection(self, msg: str, *args: Any, **kwargs: Any) -> None: def log_warning(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a warning message.""" - self.logger.warning(msg, *args, **kwargs) + self.logger.warning(f"⚠️ {msg}", *args, **kwargs) def log_error(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log an error message.""" - self.logger.error(f"**** {msg} ****", *args, **kwargs) + self.logger.error(f"**** ❌ {msg} ****", *args, **kwargs) def log_critical(self, msg: str, *args: Any, **kwargs: Any) -> None: """Log a critical message.""" - self.logger.critical(msg, *args, **kwargs) + self.logger.critical(f"**** 🚨 {msg} ****", *args, **kwargs) def setup_logging(quiet=False, trace=False, verbose=False) -> None: @@ -108,21 +109,3 @@ def setup_logging(quiet=False, trace=False, verbose=False) -> None: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.INFO) - - -def truncate_strings(obj, max_chars: int) -> Any: - """Recursively truncate strings in nested structures.""" - if isinstance(obj, str): - return obj if len(obj) <= max_chars else obj[:max_chars] + "…" - if isinstance(obj, dict): - return { - truncate_strings(k, max_chars): truncate_strings(v, max_chars) - for k, v in obj.items() - } - if isinstance(obj, list): - return [truncate_strings(item, max_chars) for item in obj] - if isinstance(obj, tuple): - return tuple(truncate_strings(item, max_chars) for item in obj) - if isinstance(obj, set): - return {truncate_strings(item, max_chars) for item in obj} - return obj diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index 82e974d..1e7cd92 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -1,4 +1,4 @@ -"""PDFBakerPage class. +"""Page class. Individual page rendering and PDF conversion. @@ -7,79 +7,26 @@ """ from pathlib import Path -from typing import Any from jinja2.exceptions import TemplateError, TemplateNotFound -from .config import PDFBakerConfiguration -from .errors import ConfigurationError, SVGConversionError, SVGTemplateError +from .config import PageConfig, PathSpec +from .errors import SVGConversionError, SVGTemplateError from .logging import TRACE, LoggingMixin from .pdf import convert_svg_to_pdf from .render import create_env, prepare_template_context -__all__ = ["PDFBakerPage"] - - -# pylint: disable=too-few-public-methods -class PDFBakerPage(LoggingMixin): - """A single page of a document.""" - - class Configuration(PDFBakerConfiguration): - """PDFBakerPage configuration.""" - - def __init__( - self, - page: "PDFBakerPage", - base_config: dict[str, Any], - config_path: Path, - ) -> None: - """Initialize page configuration (needs a template).""" - self.page = page - - self.name = config_path.stem - - self.page.log_trace_section("Loading page configuration: %s", config_path) - super().__init__(base_config, config_path) - self["page_number"] = page.number - self.page.log_trace(self.pretty()) - - self.templates_dir = self["directories"]["templates"] - self.images_dir = self["directories"]["images"] - self.build_dir = page.document.config.build_dir - self.dist_dir = page.document.config.dist_dir - - if "template" not in self: - raise ConfigurationError( - f'Page "{self.name}" in document ' - f'"{self.page.document.config.name}" has no template' - ) - if isinstance(self["template"], dict) and "path" in self["template"]: - # Path was specified: relative to the config file - self.template = self.resolve_path( - self["template"]["path"], directory=self["directories"]["config"] - ).resolve() - else: - # Only name was specified: relative to the templates directory - self.template = self.resolve_path( - self["template"], directory=self.templates_dir - ).resolve() - - def __init__( - self, - document: "PDFBakerDocument", # type: ignore # noqa: F821 - page_number: int, - base_config: dict[str, Any], - config_path: Path | dict[str, Any], - ) -> None: - """Initialize a page.""" - super().__init__() - self.document = document - self.number = page_number - self.config = self.Configuration( - page=self, - base_config=base_config, - config_path=config_path, - ) +__all__ = ["Page"] + + +class Page(LoggingMixin): + """Page class.""" + + def __init__(self, config_path: PathSpec, number: int, **kwargs): + self.log_trace_section("Loading page configuration: %s", config_path.name) + self.config = PageConfig(config_path=config_path, **kwargs) + self.number = number + self.log_trace(self.config.readable()) def process(self) -> Path: """Render SVG template and convert to PDF.""" @@ -87,21 +34,21 @@ def process(self) -> Path: "Processing page %d: %s", self.number, self.config.name ) - self.log_debug("Loading template: %s", self.config.template) + self.log_debug("Loading template: %s", self.config.template.name) if self.logger.isEnabledFor(TRACE): - with open(self.config.template, encoding="utf-8") as f: + with open(self.config.template.path, encoding="utf-8") as f: self.log_trace_preview(f.read()) try: - jinja_extensions = self.config.get("jinja2_extensions", []) + jinja_extensions = self.config.jinja2_extensions if jinja_extensions: self.log_debug("Using Jinja2 extensions: %s", jinja_extensions) jinja_env = create_env( - templates_dir=self.config.template.parent, + templates_dir=self.config.template.path.parent, extensions=jinja_extensions, - template_filters=self.config.get("template_filters", []), + template_filters=self.config.template_filters, ) - template = jinja_env.get_template(self.config.template.name) + template = jinja_env.get_template(self.config.template.path.name) except TemplateNotFound as exc: raise SVGTemplateError( "Failed to load template for page " @@ -113,19 +60,22 @@ def process(self) -> Path: ) from exc template_context = prepare_template_context( - self.config, - self.config.images_dir, + config=self.config, + images_dir=self.config.directories.images, ) + # FIXME: should just be in PageConfig + template_context["page_number"] = self.number - self.config.build_dir.mkdir(parents=True, exist_ok=True) - output_svg = self.config.build_dir / f"{self.config.name}_{self.number:03}.svg" - output_pdf = self.config.build_dir / f"{self.config.name}_{self.number:03}.pdf" + build_dir = self.config.directories.build + build_dir.mkdir(parents=True, exist_ok=True) + output_svg = build_dir / f"{self.config.name}_{self.number:03}.svg" + output_pdf = build_dir / f"{self.config.name}_{self.number:03}.pdf" self.log_debug("Rendering template...") try: rendered_template = template.render( **template_context, - renderers=self.config.get("template_renderers", []), + renderers=self.config.template_renderers, ) with open(output_svg, "w", encoding="utf-8") as f: f.write(rendered_template) @@ -136,12 +86,11 @@ def process(self) -> Path: self.log_trace_preview(rendered_template) self.log_debug("Converting SVG to PDF: %s", output_svg) - svg2pdf_backend = self.config.get("svg2pdf_backend", "cairosvg") try: return convert_svg_to_pdf( output_svg, output_pdf, - backend=svg2pdf_backend, + backend=self.config.svg2pdf_backend.value, ) except SVGConversionError as exc: self.log_error( diff --git a/src/pdfbaker/render.py b/src/pdfbaker/render.py index 9ebf155..f6f67d3 100644 --- a/src/pdfbaker/render.py +++ b/src/pdfbaker/render.py @@ -3,14 +3,14 @@ import base64 import re from collections.abc import Sequence +from enum import Enum from pathlib import Path from typing import Any import jinja2 from . import processing -from .config import render_config -from .types import ImageSpec, StyleDict +from .config import ImageSpec, StyleDict, render_config __all__ = [ "create_env", @@ -81,7 +81,10 @@ def create_env( env.template_class = PDFBakerTemplate if template_filters: - for filter_name in template_filters: + for filter_spec in template_filters: + filter_name = ( + filter_spec.value if isinstance(filter_spec, Enum) else filter_spec + ) if hasattr(processing, filter_name): env.filters[filter_name] = getattr(processing, filter_name) @@ -100,7 +103,8 @@ def prepare_template_context( images_dir: Directory containing images to encode """ # Render configuration to resolve template strings inside strings - context = render_config(config) + # FIXME: maybe don't do this here - separation of concerns + context = render_config(config.model_dump()) # Resolve style references to actual theme colors if "style" in context and "theme" in context: diff --git a/src/pdfbaker/types.py b/src/pdfbaker/types.py deleted file mode 100644 index 752e38f..0000000 --- a/src/pdfbaker/types.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Type definitions for pdfbaker.""" - -from typing import NotRequired, TypedDict - -__all__ = [ - "ImageSpec", - "PathSpec", - "StyleDict", -] - - -class _ImageDict(TypedDict): - """Image specification.""" - - name: str - type: NotRequired[str] - data: NotRequired[str] - - -ImageSpec = str | _ImageDict - - -class StyleDict(TypedDict): - """Style configuration.""" - - highlight_color: NotRequired[str] - - -class _PathSpecDict(TypedDict): - """File/Directory location in YAML config.""" - - path: NotRequired[str] - name: NotRequired[str] - - -PathSpec = str | _PathSpecDict diff --git a/src/test_config.py b/src/test_config.py deleted file mode 100644 index 422897b..0000000 --- a/src/test_config.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Test the new Pydantic config models.""" - -import sys -from pathlib import Path - -import ruamel.yaml -from pydantic import ValidationError - -from pdfbaker import config - -CONFIG_FILE = Path("/home/danny/src/pdfbaker/examples/examples.yaml") - - -def simple_representer(tag): - """Represent object as a string.""" - return lambda representer, data: representer.represent_scalar(tag, str(data)) - - -def register_representers(yaml_instance, class_tag_map, use_multi_for=()): - """Register representer.. - - If a class is in use_multi_for, subclasses will also be covered. - (like PosixPath is a subclass of Path) - """ - for cls, tag in class_tag_map.items(): - func = simple_representer(tag) - if cls in use_multi_for: - # Add a representer for the class and all subclasses. - yaml_instance.representer.add_multi_representer(cls, func) - else: - # Add a representer for this exact class only. - yaml_instance.representer.add_representer(cls, func) - - -yaml = ruamel.yaml.YAML() -yaml.indent(offset=4) -yaml.default_flow_style = False -register_representers( - yaml, - { - Path: "!path", - config.SVG2PDFBackend: "!svg2pdf_backend", - config.TemplateRenderer: "!template_renderer", - config.TemplateFilter: "!template_filter", - }, - use_multi_for=(Path,), -) - -try: - baker_config = config.BakerConfig(config_file=CONFIG_FILE) - baker_config_dict = baker_config.model_dump() - print("*** Full config after parsing: ***") - yaml.dump(baker_config_dict, sys.stdout) - print() - print("*** Custom config values only: ***") - yaml.dump(baker_config.custom_config, sys.stdout) -except ValidationError as e: - print(e) diff --git a/tests/test_baker.py b/tests/test_baker.py index 65382c0..0fa7b59 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -6,11 +6,12 @@ import pytest -from pdfbaker.baker import PDFBaker -from pdfbaker.config import BakerOptions +from pdfbaker.baker import Baker, BakerOptions from pdfbaker.errors import ConfigurationError from pdfbaker.logging import TRACE +# FIXME: default_config_overrides no longer needed, just throw kwargs at Baker init + # BakerOptions tests def test_baker_options_defaults() -> None: @@ -34,7 +35,7 @@ def test_baker_options_logging_levels() -> None: examples_config = Path(__file__).parent.parent / "examples" / "examples.yaml" for options, expected_level in test_cases: - PDFBaker(examples_config, options=options) + Baker(examples_config, options=options) assert logging.getLogger().level == expected_level @@ -53,7 +54,7 @@ def test_baker_options_default_config_overrides(tmp_path: Path) -> None: } ) - baker = PDFBaker(config_file, options=options) + baker = Baker(config_file, options=options) assert str(baker.config["directories"]["documents"]) == str(custom_dir) @@ -65,7 +66,7 @@ def test_baker_init_invalid_config(tmp_path: Path) -> None: config_file.write_text("title: test") with pytest.raises(ConfigurationError, match=".*documents.*missing.*"): - PDFBaker(config_file) + Baker(config_file) # PDFBaker functionality tests @@ -92,7 +93,7 @@ def test_baker_examples() -> None: ) try: - baker = PDFBaker(examples_config, options=options) + baker = Baker(examples_config, options=options) baker.bake() finally: # Clean up test directories diff --git a/tests/test_config.py b/tests/test_config.py index 4fb8502..237115b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,9 +3,12 @@ from pathlib import Path import pytest -import yaml -from pdfbaker.config import PDFBakerConfiguration, deep_merge, render_config +# FIXME: now using ruamel.yaml +# import yaml +from pdfbaker.config import BakerConfig + +# FIXME: no more deep_merge, no more render_config from pdfbaker.errors import ConfigurationError @@ -110,7 +113,7 @@ def test_configuration_init_with_dict(tmp_path: Path) -> None: config_file = tmp_path / "test.yaml" config_file.write_text(yaml.dump({"title": "Document"})) - config = PDFBakerConfiguration({}, config_file) + config = BakerConfig({}, config_file) assert config["title"] == "Document" @@ -119,7 +122,7 @@ def test_configuration_init_with_path(tmp_path: Path) -> None: config_file = tmp_path / "test.yaml" config_file.write_text(yaml.dump({"title": "Document"})) - config = PDFBakerConfiguration({}, config_file) + config = BakerConfig({}, config_file) assert config["title"] == "Document" assert config["directories"]["config"] == tmp_path @@ -129,7 +132,7 @@ def test_configuration_init_with_directory(tmp_path: Path) -> None: config_file = tmp_path / "test.yaml" config_file.write_text(yaml.dump({"title": "Document"})) - config = PDFBakerConfiguration({}, config_file) + config = BakerConfig({}, config_file) assert config["title"] == "Document" assert config["directories"]["config"] == tmp_path @@ -140,7 +143,7 @@ def test_configuration_init_invalid_yaml(tmp_path: Path) -> None: config_file.write_text("invalid: [yaml: content") with pytest.raises(ConfigurationError, match="Failed to load config file"): - PDFBakerConfiguration({}, config_file) + BakerConfig({}, config_file) # Path resolution tests @@ -149,7 +152,7 @@ def test_configuration_resolve_path(tmp_path: Path) -> None: config_file = tmp_path / "test.yaml" config_file.write_text(yaml.dump({"template": "test.yaml"})) - config = PDFBakerConfiguration({}, config_file) + config = BakerConfig({}, config_file) # Test relative path assert config.resolve_path("test.yaml") == tmp_path / "test.yaml" @@ -168,7 +171,7 @@ def test_configuration_resolve_path_invalid(tmp_path: Path) -> None: config_file = tmp_path / "test.yaml" config_file.write_text(yaml.dump({})) - config = PDFBakerConfiguration({}, config_file) + config = BakerConfig({}, config_file) with pytest.raises(ConfigurationError, match="Invalid path specification"): config.resolve_path({}) @@ -201,8 +204,8 @@ def test_render_config_circular() -> None: # Utility method tests -def test_configuration_pretty(tmp_path: Path) -> None: - """Test configuration pretty printing.""" +def test_configuration_readable(tmp_path: Path) -> None: + """Test configuration readable printing.""" config_file = tmp_path / "test.yaml" config_file.write_text( yaml.dump( @@ -213,7 +216,7 @@ def test_configuration_pretty(tmp_path: Path) -> None: ) ) - config = PDFBakerConfiguration({}, config_file) - pretty = config.pretty(max_chars=20) - assert "…" in pretty # Should show truncation - assert "Test" in pretty + config = BakerConfig({}, config_file) + readable = config.readable(max_chars=20) + assert "(...)" in readable # Should show truncation + assert "Test" in readable diff --git a/tests/test_document.py b/tests/test_document.py index 5e8d4fd..735f736 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -6,9 +6,8 @@ import pytest -from pdfbaker.baker import PDFBaker -from pdfbaker.config import BakerOptions -from pdfbaker.document import PDFBakerDocument +from pdfbaker.baker import Baker, BakerOptions +from pdfbaker.document import Document from pdfbaker.errors import ConfigurationError @@ -73,8 +72,8 @@ def test_document_init_with_dir( baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: """Test document initialization with directory.""" - baker = PDFBaker(config_file=baker_config, options=baker_options) - doc = PDFBakerDocument( + baker = Baker(config_file=baker_config, options=baker_options) + doc = Document( baker=baker, base_config=baker.config, config_path=doc_dir, # this will default to config.yaml in the directory @@ -114,8 +113,8 @@ def test_document_init_with_file( '' ) - baker = PDFBaker(baker_config, options=baker_options) - doc = PDFBakerDocument(baker, baker.config, config_file) + baker = Baker(baker_config, options=baker_options) + doc = Document(baker, baker.config, config_file) assert doc.config.name == "test_doc" assert doc.config["pages"] == ["page1.yaml"] @@ -129,9 +128,9 @@ def test_document_init_missing_pages(tmp_path: Path, baker_config: Path) -> None build: build """) - baker = PDFBaker(baker_config) + baker = Baker(baker_config) with pytest.raises(ConfigurationError, match="Cannot determine pages"): - PDFBakerDocument(baker, baker.config, config_file) + Document(baker, baker.config, config_file) def test_document_custom_bake( @@ -145,8 +144,8 @@ def process_document(document): return document.config.build_dir / "custom.pdf" """) - baker = PDFBaker(baker_config, options=baker_options) - doc = PDFBakerDocument(baker, baker.config, doc_dir) + baker = Baker(baker_config, options=baker_options) + doc = Document(baker, baker.config, doc_dir) assert doc.config.name == "test_doc" assert doc.config["pages"] == ["page1.yaml"] @@ -159,8 +158,8 @@ def test_document_custom_bake_error( bake_file = doc_dir / "bake.py" bake_file.write_text("raise Exception('Test error')") - baker = PDFBaker(baker_config, options=baker_options) - doc = PDFBakerDocument(baker, baker.config, doc_dir) + baker = Baker(baker_config, options=baker_options) + doc = Document(baker, baker.config, doc_dir) assert doc.config.name == "test_doc" assert doc.config["pages"] == ["page1.yaml"] @@ -182,8 +181,8 @@ def test_document_variants( filename: variant2 """) - baker = PDFBaker(baker_config, options=baker_options) - doc = PDFBakerDocument(baker, baker.config, doc_dir) + baker = Baker(baker_config, options=baker_options) + doc = Document(baker, baker.config, doc_dir) assert doc.config.name == "test_doc" assert doc.config["pages"] == ["page1.yaml"] assert len(doc.config["variants"]) == 2 @@ -233,8 +232,8 @@ def test_document_variants_with_different_pages( '' ) - baker = PDFBaker(baker_config, options=baker_options) - doc = PDFBakerDocument(baker, baker.config, config_file) + baker = Baker(baker_config, options=baker_options) + doc = Document(baker, baker.config, config_file) # Check that document initialization works without pages at doc level assert doc.config.name == "test_doc" @@ -278,8 +277,8 @@ def test_document_teardown( (build_dir / "file1.pdf").write_text("test") (build_dir / "file2.pdf").write_text("test") - baker = PDFBaker(baker_config, options=baker_options) - doc = PDFBakerDocument(baker, baker.config, doc_dir) + baker = Baker(baker_config, options=baker_options) + doc = Document(baker, baker.config, doc_dir) assert doc.config.name == "test_doc" assert doc.config["pages"] == ["page1.yaml"] diff --git a/uv.lock b/uv.lock index 5821886..254fac2 100644 --- a/uv.lock +++ b/uv.lock @@ -270,7 +270,6 @@ dependencies = [ { name = "jinja2" }, { name = "pydantic" }, { name = "pypdf" }, - { name = "pyyaml" }, { name = "ruamel-yaml" }, ] @@ -287,7 +286,6 @@ requires-dist = [ { name = "jinja2" }, { name = "pydantic" }, { name = "pypdf" }, - { name = "pyyaml" }, { name = "ruamel-yaml" }, ] @@ -481,41 +479,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, ] -[[package]] -name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, -] - [[package]] name = "ruamel-yaml" version = "0.18.10" From 2731eb5db63d4b55101d9f6c04f0a7fa8d528d74 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Mon, 28 Apr 2025 02:34:25 +1200 Subject: [PATCH 16/19] Fix "variants" example config --- examples/variants/config.yaml | 24 ++++++++++++------------ examples/variants/templates/main.svg.j2 | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/variants/config.yaml b/examples/variants/config.yaml index 53ad20c..698496d 100644 --- a/examples/variants/config.yaml +++ b/examples/variants/config.yaml @@ -5,21 +5,21 @@ variants: - name: Basic style: color: "#3498db" - features: - - "Single page layout" - - "Basic styling" + features: + - "Single page layout" + - "Basic styling" - name: Premium style: color: "#2ecc71" - features: - - "Single page layout" - - "Premium styling" - - "Custom colors" + features: + - "Single page layout" + - "Premium styling" + - "Custom colors" - name: Enterprise style: color: "#e74c3c" - features: - - "Single page layout" - - "Enterprise styling" - - "Custom colors" - - "Priority support" + features: + - "Single page layout" + - "Enterprise styling" + - "Custom colors" + - "Priority support" diff --git a/examples/variants/templates/main.svg.j2 b/examples/variants/templates/main.svg.j2 index 542066d..2a7eb15 100644 --- a/examples/variants/templates/main.svg.j2 +++ b/examples/variants/templates/main.svg.j2 @@ -8,7 +8,7 @@ Features: - {% for feature in variant.style.features %} + {% for feature in variant.features %} • {{ feature }} {% endfor %} From 1e270198e37892185947dac1d82f9e1b65bf94a1 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 29 Apr 2025 11:05:18 +1200 Subject: [PATCH 17/19] Finish transition to pydantic Major reshuffling. Tests are all passing again. --- src/pdfbaker/baker.py | 112 +++---- src/pdfbaker/config.py | 535 -------------------------------- src/pdfbaker/config/__init__.py | 275 ++++++++++++++++ src/pdfbaker/config/baker.py | 74 +++++ src/pdfbaker/config/document.py | 167 ++++++++++ src/pdfbaker/config/page.py | 50 +++ src/pdfbaker/document.py | 160 ++++------ src/pdfbaker/page.py | 45 +-- src/pdfbaker/pdf.py | 18 +- src/pdfbaker/render.py | 10 +- tests/test_baker.py | 49 ++- tests/test_cli.py | 11 +- tests/test_config.py | 299 ++++++++++-------- tests/test_document.py | 363 +++++++++++----------- tests/test_pdf.py | 15 +- 15 files changed, 1100 insertions(+), 1083 deletions(-) delete mode 100644 src/pdfbaker/config.py create mode 100644 src/pdfbaker/config/__init__.py create mode 100644 src/pdfbaker/config/baker.py create mode 100644 src/pdfbaker/config/document.py create mode 100644 src/pdfbaker/config/page.py diff --git a/src/pdfbaker/baker.py b/src/pdfbaker/baker.py index 6973f0b..2c307db 100644 --- a/src/pdfbaker/baker.py +++ b/src/pdfbaker/baker.py @@ -10,10 +10,8 @@ from pydantic import BaseModel, ValidationError -from .config import ( - BakerConfig, - PathSpec, -) +from .config import PathSpec +from .config.baker import BakerConfig from .document import Document from .errors import DocumentNotFoundError from .logging import LoggingMixin, setup_logging @@ -54,63 +52,84 @@ def __init__( self.log_debug_section("Loading main configuration: %s", config_file) self.config = BakerConfig( config_file=config_file, - keep_build=options and options.keep_build or False, + keep_build=options.keep_build, **kwargs, ) self.log_trace(self.config.readable()) - def _get_documents_to_process( - self, selected_document_names: tuple[str, ...] | None = None - ) -> list[PathSpec]: - """Get the document paths to process based on optional filtering. + def bake(self, document_names: tuple[str, ...] | None = None) -> None: + """Bake the documents.""" + docs = self._get_selected_documents(document_names) + self.log_debug_subsection("Documents to process:") + self.log_debug(docs) + + pdfs_created, failed_docs = self._process_documents(docs) - Args: - document_names: Optional tuple of document names to process + if pdfs_created: + self.log_info("Successfully created PDFs:") + for pdf in pdfs_created: + self.log_info(" ✅ %s", pdf) + else: + self.log_warning("No PDFs were created.") - Returns: - List of document paths to process - """ - if not selected_document_names: + if not self.config.keep_build: + self.teardown() + + if failed_docs: + self.log_warning( + "Failed to process %d document%s:", + len(failed_docs), + "" if len(failed_docs) == 1 else "s", + ) + for failed_doc, error_message in failed_docs: + name = failed_doc.name + if isinstance(failed_doc, Document) and failed_doc.is_variant: + name += f' variant "{failed_doc.variant["name"]}"' + self.log_error(" %s: %s", name, error_message) + + return not failed_docs + + def _get_selected_documents( + self, selected_names: tuple[str, ...] | None = None + ) -> list[PathSpec]: + """Return the document paths to actually process as selected.""" + if not selected_names: return self.config.documents - available_doc_names = [doc.name for doc in self.config.documents] - missing_docs = [ - name for name in selected_document_names if name not in available_doc_names - ] - if missing_docs: - available_str = ", ".join([f'"{name}"' for name in available_doc_names]) + available = [doc.name for doc in self.config.documents] + missing = [name for name in selected_names if name not in available] + if missing: + available_str = ", ".join([f'"{name}"' for name in available]) self.log_info( f"Documents in {self.config.config_file.name}: {available_str}" ) - missing_str = ", ".join([f'"{name}"' for name in missing_docs]) + missing_str = ", ".join([f'"{name}"' for name in missing]) raise DocumentNotFoundError( - f"Document{'s' if len(missing_docs) != 1 else ''} not found " - f"in configuration: {missing_str}." + f"Document{'s' if len(missing) != 1 else ''} not found " + f"in configuration file: {missing_str}." ) - return [ - doc for doc in self.config.documents if doc.name in selected_document_names - ] + return [doc for doc in self.config.documents if doc.name in selected_names] - def bake(self, document_names: tuple[str, ...] | None = None) -> None: - """Bake the documents.""" + def _process_documents( + self, docs: list[PathSpec] + ) -> tuple[list[Path], list[tuple[PathSpec, str]]]: pdfs_created: list[Path] = [] failed_docs: list[tuple[PathSpec, str]] = [] - doc_configs = self._get_documents_to_process(document_names) - - self.log_debug_subsection("Documents to process:") - self.log_debug(doc_configs) - for doc_config in doc_configs: + for config_path in docs: try: document = Document( - config_path=doc_config, **self.config.document_settings + config_path=config_path, **self.config.document_settings ) except ValidationError as e: - self.log_error(f'Invalid config for document "{doc_config.name}": {e}') + error_message = f'Invalid config for document "{config_path.name}": {e}' + self.log_error(error_message) + failed_docs.append((config_path, error_message)) continue pdf_files, error_message = document.process_document() + if error_message: self.log_error( "Failed to process document '%s': %s", @@ -125,26 +144,7 @@ def bake(self, document_names: tuple[str, ...] | None = None) -> None: if not self.config.keep_build: document.teardown() - if pdfs_created: - self.log_info("Successfully created PDFs:") - for pdf in pdfs_created: - self.log_info(" %s", pdf) - else: - self.log_warning("No PDFs were created.") - - if failed_docs: - self.log_warning( - "Failed to process %d document%s:", - len(failed_docs), - "" if len(failed_docs) == 1 else "s", - ) - for doc_name, error in failed_docs: - self.log_error(" %s: %s", doc_name, error) - - if not self.config.keep_build: - self.teardown() - - return not failed_docs + return pdfs_created, failed_docs def teardown(self) -> None: """Clean up (top-level) build directory after processing.""" diff --git a/src/pdfbaker/config.py b/src/pdfbaker/config.py deleted file mode 100644 index 9112011..0000000 --- a/src/pdfbaker/config.py +++ /dev/null @@ -1,535 +0,0 @@ -"""Base configuration for pdfbaker classes.""" - -import io -import logging -from collections.abc import Mapping, Sequence -from enum import Enum -from pathlib import Path -from typing import Any - -import ruamel.yaml -from jinja2 import Template -from jinja2.exceptions import TemplateError as JinjaTemplateError -from pydantic import ( - BaseModel, - ConfigDict, - PrivateAttr, - ValidationError, - model_validator, -) -from ruamel.yaml import YAML - -from .errors import ConfigurationError -from .logging import LoggingMixin - -__all__ = [ - "BakerConfig", - "deep_merge", - "DocumentConfig", - "DocumentVariantConfig", - "PageConfig", - "render_config", -] - -logger = logging.getLogger(__name__) - -# FIXME: need to achieve the same effect as the old render_config() -# variables within variables resolved, e.g. {{ variant.name }} in "filename" -# TODO: change examples from images_dir to use directories.images (breaking change) -# TODO: allow directories.images to be either a PathSpec or a list of Pathspecs - -DEFAULT_DIRECTORIES = { - "build": "build", - "dist": "dist", - "documents": ".", - "pages": "pages", - "templates": "templates", - "images": "images", -} -DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" - - -class PathSpec(BaseModel): - """File/Directory location (relative or absolute) in YAML config.""" - - path: Path - name: str - - @model_validator(mode="before") - @classmethod - def ensure_pathspec(cls, data: Any) -> Any: - """Coerce string/Path or partial dict into full dict with 'path' and 'name'.""" - if isinstance(data, str | Path): - path = Path(data) - data = {"path": path, "name": path.stem} - elif isinstance(data, dict): - if "path" not in data: - raise ValueError("path is required") - path = Path(data["path"]) - data = {"path": path, "name": data.get("name", path.stem)} - return data - - def resolve_relative_to(self, base: Path) -> "PathSpec": - """Resolve relative paths relative to a base directory.""" - path = self.path - if not path.is_absolute(): - path = (base / path).resolve() - return PathSpec(path=path, name=self.name) - - -class ImageSpec(PathSpec): - """Image specification.""" - - type: str | None = None - data: str | None = None - - -class StyleDict(BaseModel): - """Style configuration.""" - - highlight_color: str | None = None - - -class DirectoriesConfig(BaseModel): - """Directories configuration.""" - - root: Path - build: Path - dist: Path - documents: Path - pages: Path - templates: Path - images: Path - - def resolved(self) -> "DirectoriesConfig": - """Resolve relative paths relative to the root directory.""" - root = self.root.resolve() - resolved = {} - for field in self.__class__.model_fields.keys(): - path = getattr(self, field) - resolved[field] = path if path.is_absolute() else (root / path).resolve() - return DirectoriesConfig(**resolved) - - -class BaseConfig(BaseModel, LoggingMixin): - """Base configuration class for pages, documents/variants, baker.""" - - model_config = ConfigDict( - strict=True, # don't try to coerce values - extra="allow", # will go in __pydantic_extra__ dict - ) - - def readable(self, max_chars: int = 60) -> str: - """Return YAML representation with truncated strings for readability.""" - yaml_instance = get_readable_yaml(max_chars=max_chars) - stream = io.StringIO() - yaml_instance.dump(self.model_dump(), stream) - return f"\n{stream.getvalue()}" - - -class PageConfig(BaseConfig): - """Page configuration.""" - - config_path: PathSpec - directories: DirectoriesConfig - _resolved_directories: DirectoriesConfig = PrivateAttr() - template: PathSpec - - @model_validator(mode="before") - @classmethod - def load_config(cls, data: Any) -> Any: - """Load document configuration from YAML file.""" - if isinstance(data, dict) and "config_path" in data: - config_data = YAML().load(data["config_path"].path.read_text()) - config_data.update(data) # kwargs override YAML values - data = config_data - - data["directories"]["root"] = data["config_path"].path.parent - - return data - - @model_validator(mode="after") - def resolve_paths(self) -> "PageConfig": - """Resolve relative paths relative to the root directory.""" - self._resolved_directories = self.directories.resolved() - - # Resolve template path - templates_dir = self._resolved_directories.templates - page_root = self._resolved_directories.root - if len(self.template.path.parts) > 1: - # Relative to document root or absolute path - self.template.path = (page_root / self.template.path).resolve() - else: - # Simple string - relative to templates directory - self.template.path = self.template.resolve_relative_to(templates_dir).path - self.template.name = self.template.path.name # not just stem - - return self - - @property - def name(self) -> str: - """Page name is the 'name' of its config file.""" - return self.config_path.name - - @property - def settings(self) -> dict[str, Any]: - """All configuration settings in a dictionary.""" - return self.model_dump() - - @property - def user_defined(self) -> dict[str, Any]: - """Dictionary of all custom user-defined settings.""" - # FIXME: remove if not neeeded (at least for debugging) - return getattr(self, "__pydantic_extra__", {}) or {} - - -class DocumentVariantConfig(BaseConfig): - """Document variant configuration. - - Like a document without a config file or own variants - """ - - name: str - directories: DirectoriesConfig - pages: list[PathSpec] - - @model_validator(mode="after") - def resolve_paths(self) -> "DocumentConfig": - """Resolve relative paths relative to the root directory.""" - # Resolve page paths - pages_dir = self.directories.pages - document_root = self.directories.root - for page in self.pages: - if not page.path.suffix: - page.path = page.path.with_suffix(".yaml") - - if len(page.path.parts) > 1: - # Relative to document root or absolute path - page.path = (document_root / page.path).resolve() - else: - # Simple string - relative to pages directory - page.path = page.resolve_relative_to(pages_dir).path - - return self - - @property - def page_settings(self) -> dict[str, Any]: - """All configuration settings in a dictionary. Given to pages.""" - return self.model_dump( - exclude={ - "pages", - } - ) - - -class DocumentConfig(BaseConfig): - """Document configuration. - - Lazy-loads page configs. - """ - - config_path: PathSpec - directories: DirectoriesConfig - _resolved_directories: DirectoriesConfig = PrivateAttr() - variants: list[DocumentVariantConfig] = [] - pages: list[PathSpec] = [] - bake_path: PathSpec | None = None - - @model_validator(mode="before") - @classmethod - def load_config(cls, data: Any) -> Any: - """Load document configuration from YAML file.""" - if isinstance(data, dict) and "config_path" in data: - if data["config_path"].path.is_dir(): - # Keep name but change path - data["config_path"].path /= DEFAULT_DOCUMENT_CONFIG_FILE - - config_data = YAML().load(data["config_path"].path.read_text()) - config_data.update(data) # kwargs override YAML values - data = config_data - - data["directories"]["root"] = data["config_path"].path.parent - - variants_data = data.get("variants", []) - valid_variants = [] - for vdata in variants_data: - try: - # FIXME: doc without pages - if "pages" in data: - variant = DocumentVariantConfig( - directories=data["directories"], - pages=data["pages"], - **vdata, - ) - else: - # This should fail if no pages in doc and also not in variant - variant = DocumentVariantConfig( - directories=data["directories"], - **vdata, - ) - valid_variants.append(variant) - except ValidationError as e: - print(f"⚠️ Skipping invalid variant '{vdata.get('name')}': {e}") - - data["variants"] = valid_variants - - return data - - @model_validator(mode="after") - def resolve_paths(self) -> "DocumentConfig": - """Resolve relative paths relative to the root directory.""" - self.directories.build = self.directories.build / self.name - self.directories.dist = self.directories.dist / self.name - self._resolved_directories = self.directories.resolved() - - # Resolve page paths - pages_dir = self._resolved_directories.pages - document_root = self._resolved_directories.root - for page in self.pages: - if not page.path.suffix: - page.path = page.path.with_suffix(".yaml") - - if len(page.path.parts) > 1: - # Relative to document root or absolute path - page.path = (document_root / page.path).resolve() - else: - # Simple string - relative to pages directory - page.path = page.resolve_relative_to(pages_dir).path - - if not self.bake_path: - self.bake_path = PathSpec( - path=self._resolved_directories.root / "bake.py", - name="bake.py", - ) - - return self - - @model_validator(mode="after") - def check_pages_or_variants(self) -> "DocumentConfig": - """Check if pages or variants are defined.""" - # The "pages" may be defined in the variants rather than - # the document itself (when different variants have different pages) - if not self.pages: - if self.variants: - # A variant not defining pages will fail to process - self.log_debug( - 'Pages of document "%s" will be determined per variant', - self.name, - ) - else: - self.log_warning( - 'Document "%s" has neither pages nor variants', self.name - ) - raise ConfigurationError( - f'Cannot determine pages of document "{self.name}"' - ) - return self - - @property - def name(self) -> str: - """Document name is the 'name' of its config file.""" - return self.config_path.name - - @property - def variant_settings(self) -> dict[str, Any]: - """Variant settings.""" - # FIXME: see document.py variant processing - need elegance - settings = {"directories": {}} - for directory in self.directories.__class__.model_fields.keys(): - settings["directories"][directory] = getattr( - self._resolved_directories, directory - ) - return settings - - @property - def page_settings(self) -> dict[str, Any]: - """All configuration settings in a dictionary. Given to pages.""" - settings = self.model_dump(exclude={"config_path", "variants", "pages"}) - settings["directories"]["pages"] = self._resolved_directories.pages - settings["directories"]["templates"] = self._resolved_directories.templates - settings["directories"]["images"] = self._resolved_directories.images - return settings - - -class TemplateRenderer(Enum): - """Possible values for template_renderers.""" - - RENDER_HIGHLIGHT = "render_highlight" - - -class TemplateFilter(Enum): - """Possible values for template_filters.""" - - WORDWRAP = "wordwrap" - - -class SVG2PDFBackend(Enum): - """Possible values for svg2pdf_backend.""" - - CAIROSVG = "cairosvg" - INKSCAPE = "inkscape" - - -class BakerConfig(BaseConfig): - """Baker configuration. - - Lazy-loads document configs. - """ - - config_file: Path - directories: DirectoriesConfig - _resolved_directories: DirectoriesConfig = PrivateAttr() - documents: list[PathSpec] - # FIXME: jinja2_extensions set for just a page not picked up - jinja2_extensions: list[str] = [] - template_renderers: list[TemplateRenderer] = [TemplateRenderer.RENDER_HIGHLIGHT] - template_filters: list[TemplateFilter] = [TemplateFilter.WORDWRAP] - svg2pdf_backend: SVG2PDFBackend | None = SVG2PDFBackend.CAIROSVG - compress_pdf: bool = False - keep_build: bool = False - - @model_validator(mode="before") - @classmethod - def load_config(cls, data: Any) -> Any: - """Load main configuration from YAML file.""" - if isinstance(data, dict) and "config_file" in data: - if isinstance(data["config_file"], str): - data["config_file"] = Path(data["config_file"]) - if isinstance(data["config_file"], Path): - data["config_file"] = data["config_file"].resolve() - - config_data = YAML().load(data["config_file"].read_text()) - config_data.update(data) # kwargs override YAML values - data = config_data - - # Set default directories - if "directories" not in data: - data["directories"] = {} - directories = data["directories"] - directories.setdefault("root", data["config_file"].parent) - for key, default in DEFAULT_DIRECTORIES.items(): - directories.setdefault(key, default) - - if "documents" not in data: - raise ValueError( - 'Key "documents" missing - is this the main configuration file?' - ) - - return data - - @model_validator(mode="after") - def resolve_paths(self) -> "BakerConfig": - """Resolve relative paths relative to the root directory.""" - self._resolved_directories = self.directories.resolved() - - # Resolve build/dist, they are fixed unless re-defined - self.directories.build = self._resolved_directories.build - self.directories.dist = self._resolved_directories.dist - - # Resolve document paths - root = self.directories.root.resolve() - self.documents = [doc.resolve_relative_to(root) for doc in self.documents] - - return self - - @property - def document_settings(self) -> dict[str, Any]: - """All configuration settings relevant for a document.""" - return self.model_dump(exclude={"config_file", "documents"}) - - -def register_representers(yaml_instance, class_tag_map, use_multi_for=()): - """Register representer.. - - If a class is in use_multi_for, subclasses will also be covered. - (like PosixPath is a subclass of Path) - """ - - def simple_representer(tag): - """Represent object as a string.""" - return lambda representer, data: representer.represent_scalar(tag, str(data)) - - for cls, tag in class_tag_map.items(): - func = simple_representer(tag) - if cls in use_multi_for: - # Add a representer for the class and all subclasses. - yaml_instance.representer.add_multi_representer(cls, func) - else: - # Add a representer for this exact class only. - yaml_instance.representer.add_representer(cls, func) - - -def get_readable_yaml(max_chars: int = 60) -> ruamel.yaml.YAML: - """Get a YAML instance with string truncation for readable output.""" - yaml = ruamel.yaml.YAML() - yaml.indent(offset=4) - yaml.default_flow_style = False - - register_representers( - yaml, - { - Path: "!path", - SVG2PDFBackend: "!svg2pdf_backend", - TemplateRenderer: "!template_renderer", - TemplateFilter: "!template_filter", - }, - use_multi_for=(Path,), - ) - - # Add string truncation representer - def truncating_representer(representer, data): - if len(data) > max_chars: - data = data[:max_chars] + "..." - return representer.represent_scalar("tag:yaml.org,2002:str", data) - - yaml.representer.add_representer(str, truncating_representer) - - return yaml - - -def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: - """Deep merge two dictionaries.""" - result = base.copy() - for key, value in update.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = deep_merge(result[key], value) - else: - result[key] = value - return result - - -def render_config(config: dict[str, Any], max_iterations: int = 10) -> dict[str, Any]: - """Render config with its own values.""" - current = dict(config) - for _ in range(max_iterations): - new = _render_object(current, context=current) - if new == current: - return new - current = new - raise ConfigurationError( - "Maximum number of iterations reached — possible circular reference" - ) - - -def _render_object(obj, context, seen_keys=None): - if seen_keys is None: - seen_keys = set() - - if isinstance(obj, str): - for key in seen_keys: - if f"{{{{ {key} }}}}" in obj: - raise ConfigurationError( - f"Circular/self reference detected: '{key}' in '{obj}'" - ) - try: - return Template(obj).render(**context) - except JinjaTemplateError as e: - raise ConfigurationError(f"Error rendering template '{obj}': {e}") from e - - elif isinstance(obj, Mapping): - return {k: _render_object(v, context, seen_keys | {k}) for k, v in obj.items()} - - elif isinstance(obj, Sequence) and not isinstance(obj, str): - return [_render_object(i, context, seen_keys) for i in obj] - - return obj diff --git a/src/pdfbaker/config/__init__.py b/src/pdfbaker/config/__init__.py new file mode 100644 index 0000000..2442a25 --- /dev/null +++ b/src/pdfbaker/config/__init__.py @@ -0,0 +1,275 @@ +"""Base configuration for pdfbaker classes.""" + +import io +from enum import Enum +from pathlib import Path +from typing import Any + +from jinja2 import Template +from jinja2 import TemplateError as JinjaTemplateError +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from ruamel.yaml import YAML + +from ..errors import ConfigurationError +from ..logging import LoggingMixin + +__all__ = [ + "BaseConfig", + "Directories", + "ImageSpec", + "PathSpec", + "SVG2PDFBackend", + "TemplateFilter", + "TemplateRenderer", +] + + +class TemplateRenderer(Enum): + """Possible values for template_renderers.""" + + RENDER_HIGHLIGHT = "render_highlight" + + +class TemplateFilter(Enum): + """Possible values for template_filters.""" + + WORDWRAP = "wordwrap" + + +class SVG2PDFBackend(Enum): + """Possible values for svg2pdf_backend.""" + + CAIROSVG = "cairosvg" + INKSCAPE = "inkscape" + + +def convert_enum(enum_class): + """Convert a string to an enum value.""" + + def _convert(value): + if isinstance(value, str): + return enum_class(value) + return value + + return _convert + + +class PathSpec(BaseModel): + """File/Directory location (relative or absolute) in a YAML config.""" + + path: Path + name: str + + @model_validator(mode="before") + @classmethod + def ensure_pathspec(cls, data: Any) -> Any: + """Coerce string/Path or partial dict into full dict with 'path' and 'name'.""" + if isinstance(data, str | Path): + path = Path(data) + data = {"path": path, "name": path.stem} + elif isinstance(data, dict): + if "path" not in data: + raise ValueError("path is required") + path = Path(data["path"]) + data = {"path": path, "name": data.get("name", path.stem)} + return data + + def resolve_relative_to(self, base: Path) -> "PathSpec": + """Resolve relative paths relative to a base directory.""" + path = self.path + if not path.is_absolute(): + path = (base / path).resolve() + return PathSpec(path=path, name=self.name) + + +class ImageSpec(PathSpec): + """Image specification.""" + + type: str | None = None + data: str | None = None + + +class StyleDict(BaseModel): + """Style configuration.""" + + highlight_color: str | None = None + + +class Directories(BaseModel): + """Directories configuration.""" + + base: Path + build: Path + dist: Path + documents: Path + pages: Path + templates: Path + images: Path + + @model_validator(mode="before") + @classmethod + def ensure_resolved_base(cls, data: Any) -> Any: + """Ensure base path is absolute.""" + if isinstance(data, dict): + data["base"] = Path(data["base"]).resolve() + return data + + +class BaseConfig(BaseModel, LoggingMixin): + """Base configuration class for BakerConfig, DocumentConfig and PageConfig.""" + + directories: Directories + jinja2_extensions: list[str] = [] + template_renderers: list[TemplateRenderer] = [TemplateRenderer.RENDER_HIGHLIGHT] + template_filters: list[TemplateFilter] = [TemplateFilter.WORDWRAP] + svg2pdf_backend: SVG2PDFBackend | None = SVG2PDFBackend.CAIROSVG + compress_pdf: bool = False + keep_build: bool = False + + model_config = ConfigDict( + strict=True, # don't try to coerce values + extra="allow", # extra kwargs will go in __pydantic_extra__ + ) + + @field_validator("template_renderers", mode="before") + @classmethod + def validate_template_renderers(cls, value: list[str]) -> list[TemplateRenderer]: + """Convert strings to TemplateRenderer enum values.""" + return [convert_enum(TemplateRenderer)(item) for item in value] + + @field_validator("template_filters", mode="before") + @classmethod + def validate_template_filters(cls, value: list[str]) -> list[TemplateFilter]: + """Convert strings to TemplateFilter enum values.""" + return [convert_enum(TemplateFilter)(item) for item in value] + + @field_validator("svg2pdf_backend", mode="before") + @classmethod + def validate_svg2pdf_backend(cls, value: str) -> SVG2PDFBackend: + """Convert string to SVG2PDFBackend enum value.""" + return convert_enum(SVG2PDFBackend)(value) + + def readable(self, max_chars: int = 60) -> str: + """Return readable YAML representation with truncated strings.""" + yaml = YAML() + yaml.indent(offset=4) + yaml.default_flow_style = False + yaml.representer.ignore_aliases = lambda *args: True + + def add_simple_representer(cls, tag, use_multi=False): + """Add a representer that converts objects to string with a tag.""" + + def representer(r, data): + return r.represent_scalar(tag, str(data)) + + if use_multi: + yaml.representer.add_multi_representer(cls, representer) + else: + yaml.representer.add_representer(cls, representer) + + add_simple_representer(Path, "!path", use_multi=True) + add_simple_representer(SVG2PDFBackend, "!svg2pdf_backend") + add_simple_representer(TemplateRenderer, "!template_renderer") + add_simple_representer(TemplateFilter, "!template_filter") + + def truncating_representer(representer, data): + if len(data) > max_chars: + data = data[:max_chars] + "..." + return representer.represent_scalar("tag:yaml.org,2002:str", data) + + yaml.representer.add_representer(str, truncating_representer) + + stream = io.StringIO() + yaml.dump(self.model_dump(), stream) + return f"\n{stream.getvalue()}" + + def resolve_path(self, path: Path) -> Path: + """Resolve relative paths relative to the base directory.""" + return (self.directories.base.resolve() / path).resolve() + + @property + def user_defined_settings(self) -> dict[str, Any]: + """Return dictionary of user-defined settings.""" + return getattr(self, "__pydantic_extra__", {}) or {} + + def merge(self, update: dict[str, Any]) -> "BaseConfig": + """Deep merge a dictionary into a config, returning a new config instance.""" + + def _deep_merge( + base_dict: dict[str, Any], update_dict: dict[str, Any] + ) -> dict[str, Any]: + """Deep merge two dictionaries.""" + result = base_dict.copy() + for key, value in update_dict.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + base_dict = self.model_dump() + merged = _deep_merge(base_dict, update) + return self.__class__(**merged) + + # ruff: noqa: C901 + def resolve_variables(self, max_iterations: int = 10) -> "BaseConfig": + """Resolve template variables in config values, modifying in place. + + For example this allows: + ```yaml + filename: "{{ variant.name | lower }}_variant" + ``` + + Args: + max_iterations: Maximum number of iterations to avoid circular references + """ + + def render_template_string(value: str, context: dict[str, Any]) -> str: + try: + return Template(value).render(**context) + except JinjaTemplateError as e: + raise ConfigurationError(f'Error rendering value "{value}": {e}') from e + + def walk_and_resolve(obj: Any, context: dict[str, Any]) -> Any: + if isinstance(obj, str) and "{{" in obj: + return render_template_string(obj, context) + if isinstance(obj, dict): + return {k: walk_and_resolve(v, context) for k, v in obj.items()} + if isinstance(obj, list): + return [walk_and_resolve(v, context) for v in obj] + if isinstance(obj, BaseModel): + for field_name, field_value in obj.model_dump().items(): + field = getattr(obj.__class__, field_name, None) + if isinstance(field, property) and field.fset is None: + continue + resolved = walk_and_resolve(field_value, context) + if resolved != field_value: + setattr(obj, field_name, resolved) + return obj + + def has_unresolved_templates(obj: Any) -> bool: + if isinstance(obj, str): + return "{{" in obj + if isinstance(obj, dict): + return any(has_unresolved_templates(v) for v in obj.values()) + if isinstance(obj, list): + return any(has_unresolved_templates(v) for v in obj) + if isinstance(obj, BaseModel): + return any( + has_unresolved_templates(v) for v in obj.model_dump().values() + ) + return False + + context = self.model_dump() + for _ in range(max_iterations): + walk_and_resolve(self, context) + if not has_unresolved_templates(self): + return self + + raise ConfigurationError( + "Maximum iterations reached, possible circular reference" + ) diff --git a/src/pdfbaker/config/baker.py b/src/pdfbaker/config/baker.py new file mode 100644 index 0000000..0f64ec1 --- /dev/null +++ b/src/pdfbaker/config/baker.py @@ -0,0 +1,74 @@ +"""Baker configuration for pdfbaker.""" + +from pathlib import Path +from typing import Any + +from pydantic import model_validator +from ruamel.yaml import YAML + +from . import BaseConfig, PathSpec + +DEFAULT_DIRECTORIES = { + "build": "build", + "dist": "dist", + "documents": ".", + "pages": "pages", + "templates": "templates", + "images": "images", +} + + +class BakerConfig(BaseConfig): + """Baker configuration. + + Lazy-loads document configs. + """ + + config_file: Path + documents: list[PathSpec] + + @model_validator(mode="before") + @classmethod + def load_config(cls, data: Any) -> Any: + """Load main configuration from YAML file.""" + if isinstance(data, dict) and "config_file" in data: + if isinstance(data["config_file"], str): + data["config_file"] = Path(data["config_file"]) + if isinstance(data["config_file"], Path): + data["config_file"] = data["config_file"].resolve() + + config_data = YAML().load(data["config_file"].read_text()) + data.update(config_data) # YAML values override kwargs + + # Set default directories + if "directories" not in data: + data["directories"] = {} + directories = data["directories"] + directories.setdefault("base", data["config_file"].parent) + for key, default in DEFAULT_DIRECTORIES.items(): + directories.setdefault(key, default) + + if "documents" not in data: + raise ValueError( + 'Key "documents" missing - is this the main configuration file?' + ) + + return data + + @model_validator(mode="after") + def resolve_paths(self) -> "BakerConfig": + """Resolve relative paths.""" + self.directories.documents = self.resolve_path(self.directories.documents) + self.directories.build = self.resolve_path(self.directories.build) + self.directories.dist = self.resolve_path(self.directories.dist) + self.documents = [ + doc.resolve_relative_to(self.directories.documents) + for doc in self.documents + ] + + return self + + @property + def document_settings(self) -> dict[str, Any]: + """All configuration settings relevant for a document.""" + return self.model_dump(exclude={"config_file", "documents"}) diff --git a/src/pdfbaker/config/document.py b/src/pdfbaker/config/document.py new file mode 100644 index 0000000..38d2ec4 --- /dev/null +++ b/src/pdfbaker/config/document.py @@ -0,0 +1,167 @@ +"""Document configuration for pdfbaker.""" + +import logging +from typing import Any + +from pydantic import ValidationError, model_validator +from ruamel.yaml import YAML + +from . import ( + BaseConfig, + ConfigurationError, + PathSpec, +) + +logger = logging.getLogger(__name__) +DEFAULT_DOCUMENT_CONFIG_FILE = "config.yaml" + + +class DocumentConfig(BaseConfig): + """Document configuration. + + Lazy-loads page configs. + """ + + config_path: PathSpec | None = None + name: str + filename: str + variants: list["DocumentConfig"] | list[dict[str, Any]] = [] + is_variant: bool = False + pages: list[PathSpec] = [] + # TODO: "exclude if None" make sense here + # https://github.com/pydantic/pydantic-core/pull/1535 + custom_bake: PathSpec | None = None + + @model_validator(mode="before") + @classmethod + def load_config(cls, data: Any) -> Any: + """Load document configuration from YAML file.""" + if isinstance(data, dict) and data.get("config_path", None) is not None: + if isinstance(data["config_path"], dict): + data["config_path"] = PathSpec(**data["config_path"]) + data["name"] = data.get("name", data["config_path"].name) + if data["config_path"].path.is_dir(): + # Change path but not name + data["config_path"].path /= DEFAULT_DOCUMENT_CONFIG_FILE + + config_path = data["config_path"] + config_data = YAML().load(config_path.path.read_text()) + data.update(config_data) # YAML values override kwargs + data["directories"]["base"] = config_path.path.parent + + return data + + @model_validator(mode="after") + def resolve_paths(self) -> "DocumentConfig": + """Resolve relative paths.""" + self.directories.pages = self.resolve_path(self.directories.pages) + + # Resolve page paths + for page in self.pages: + if not page.path.suffix: + page.path = page.path.with_suffix(".yaml") + + if len(page.path.parts) > 1: + # Relative to document root or absolute path + page.path = (self.directories.base / page.path).resolve() + else: + # Simple string - relative to pages directory + page.path = page.resolve_relative_to(self.directories.pages).path + + if not self.custom_bake: + custom_bake_path = self.directories.base / "bake.py" + if custom_bake_path.is_file(): + self.custom_bake = PathSpec( + path=custom_bake_path, + name="bake.py", + ) + + return self + + @model_validator(mode="after") + def check_pages_and_variants(self) -> "DocumentConfig": + """Check if pages or variants are defined; a variant can't have variants.""" + if self.variants: + if not self.pages: + self.log_debug( + 'Pages of document "%s" will be determined per variant', + self.name, + ) + elif not self.pages: + if self.is_variant: + self.log_warning( + '"%s" variant "%s" does not define any pages', + self.name, + self.variant.name, + ) + raise ConfigurationError( + "Cannot determine pages of " + f'"{self.name}" variant "{self.variant.name}"' + ) + self.log_warning('Document "%s" has neither pages nor variants', self.name) + raise ConfigurationError( + f'Cannot determine pages of document "{self.name}"' + ) + if self.is_variant and self.variants: + raise ConfigurationError( + f'{self.name} variant "{self.variant.name}" ' + "may not contain variants itself" + ) + return self + + @model_validator(mode="after") + def set_variants(self) -> "DocumentConfig": + """Set variants.""" + valid_variants = [] + for variant_data in self.variants: + if isinstance(variant_data, dict): + try: + if "name" not in variant_data: + raise ValidationError("A document variant needs a name") + variant_only_data = variant_data.copy() + doc_data = self.variant_settings.copy() + if variant_data.get("pages", None): + doc_data["pages"] = variant_data["pages"] + doc_data["variant"] = variant_data + doc_data["variant"]["directories"] = doc_data["directories"] + variant = DocumentConfig(**doc_data) + # Merge variant data but don't overwrite the document name + del variant_only_data["name"] + variant = variant.merge(variant_only_data) + valid_variants.append(variant) + except ValidationError as e: + logger.warning( + "⚠️ Skipping invalid variant '%s': %s", + variant_data.get("name"), + e, + ) + self.variants = valid_variants + return self + + @property + def variant_settings(self) -> dict[str, Any]: + """All configuration settings relevant for a variant.""" + settings = self.model_dump( + exclude={ + "config_path", + "variants", + } + ) + settings["is_variant"] = True + return settings + + @property + def page_settings(self) -> dict[str, Any]: + """All configuration settings relevant for a page.""" + settings = self.model_dump( + exclude={ + "config_path", + "variants", + "pages", + } + ) + settings["directories"]["templates"] = self.resolve_path( + self.directories.templates + ) + settings["directories"]["images"] = self.resolve_path(self.directories.images) + return settings diff --git a/src/pdfbaker/config/page.py b/src/pdfbaker/config/page.py new file mode 100644 index 0000000..a164e5c --- /dev/null +++ b/src/pdfbaker/config/page.py @@ -0,0 +1,50 @@ +"""Page configuration for pdfbaker.""" + +from typing import Any + +from pydantic import computed_field, model_validator +from ruamel.yaml import YAML + +from . import ( + BaseConfig, + PathSpec, +) + + +class PageConfig(BaseConfig): + """Page configuration.""" + + config_path: PathSpec + page_number: int + template: PathSpec + + @model_validator(mode="before") + @classmethod + def load_config(cls, data: Any) -> Any: + """Load page configuration from YAML file.""" + if isinstance(data, dict) and "config_path" in data: + if isinstance(data["config_path"], dict): + data["config_path"] = PathSpec(**data["config_path"]) + config_data = YAML().load(data["config_path"].path.read_text()) + data.update(config_data) # YAML values override kwargs + data["directories"]["base"] = data["config_path"].path.parent + return data + + @model_validator(mode="after") + def resolve_paths(self) -> "PageConfig": + """Resolve relative paths.""" + if len(self.template.path.parts) > 1: + # Relative to pages root or absolute path + self.template.path = (self.directories.base / self.template.path).resolve() + else: + # Simple string - relative to templates directory + templates_dir = self.resolve_path(self.directories.templates) + self.template.path = self.template.resolve_relative_to(templates_dir).path + self.template.name = self.template.path.name # not just stem + return self + + @computed_field + @property + def name(self) -> str: + """Return the name of this page.""" + return self.config_path.path.stem diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index 8a3551a..f9846e8 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -10,12 +10,8 @@ import os from pathlib import Path -from .config import ( - DocumentConfig, - DocumentVariantConfig, - PathSpec, - render_config, -) +from .config import PathSpec +from .config.document import DocumentConfig from .errors import ( PDFBakerError, PDFCombineError, @@ -46,29 +42,38 @@ def process_document(self) -> tuple[Path | list[Path] | None, str | None]: Tuple of (pdf_files, error_message) where: - pdf_files is a Path or list of Paths to the created PDF files, or None if creation failed - FIXME: could have created SOME PDF files - error_message is a string describing the error, or None if successful + FIXME: could have created SOME PDF files but also error """ - self.log_info_section('Processing document "%s"...', self.config.name) + self.config.directories.build /= self.config.name + self.config.directories.dist /= self.config.name + self.log_info_section('Processing document "%s"...', self.config.name) + self.log_debug( + "Ensuring build directory exists: %s", self.config.directories.build + ) self.config.directories.build.mkdir(parents=True, exist_ok=True) + self.log_debug( + "Ensuring dist directory exists: %s", self.config.directories.dist + ) self.config.directories.dist.mkdir(parents=True, exist_ok=True) - bake_path = self.config.bake_path.path try: - if bake_path.exists(): - return self._process_with_custom_bake(bake_path), None + if self.config.custom_bake: + return self._process_with_custom_bake(), None return self.process(), None except PDFBakerError as exc: return None, str(exc) - # ############################################################## - - def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: + def _process_with_custom_bake(self) -> Path | list[Path]: """Process document using custom bake module.""" + self.log_debug_subsection( + 'Custom processing document "%s"...', self.config.name + ) try: spec = importlib.util.spec_from_file_location( - f"documents.{self.config.name}.bake", bake_path + f"documents.{self.config.name}.bake", + self.config.custom_bake.path, ) if spec is None or spec.loader is None: raise PDFBakerError( @@ -84,119 +89,64 @@ def _process_with_custom_bake(self, bake_path: Path) -> Path | list[Path]: def process(self) -> Path | list[Path]: """Process document using standard processing.""" + self.log_debug_subsection( + 'Standard processing document "%s"...', self.config.name + ) if self.config.variants: # Multiple PDF documents pdf_files = [] for variant_config in self.config.variants: self.log_info_subsection( - 'Processing variant "%s"...', variant_config.name + 'Processing variant "%s"...', variant_config.variant["name"] ) - _ = """ - # variant_config = deep_merge(self.config, variant) - # self.log_trace(variant_config) - self.config.update(variant_config) - # variant_config = render_config(variant_config) - page_pdfs = self._process_pages(self.config) - pdf_files.append(self._finalize(page_pdfs, self.config)) - """ - - # FIXME: Too much logic here, should be in DocumentVariantConfig - - # Create a document config with the variant info - merged_config = self.config.model_dump() - variant_data = variant_config.model_dump() - merged_config["variant"] = variant_data - - # Update other config values from the variant - # FIXME: should use deep_merge? - # FIXME: pages could be defined in page but not variant or vice versa - for key, value in variant_data.items(): - if key not in ["directories", "pages"]: - merged_config[key] = value - # if variant_data.get("pages"): - # merged_config["pages"] = variant_data["pages"] - - variant_settings = self.config.variant_settings - merged_config.update(variant_settings) - merged_config = render_config(merged_config) - - doc_with_variant = DocumentVariantConfig(**merged_config) - - # Process with the variant-enhanced config - page_pdfs = self._process_pages(doc_with_variant) - pdf_files.append(self._finalize(page_pdfs, doc_with_variant)) + variant_config.directories.build = self.config.directories.build + variant_config.directories.dist = self.config.directories.dist + variant_config = variant_config.resolve_variables() + self.log_trace(variant_config.readable()) + page_pdfs = self._process_pages(variant_config) + pdf_files.append(self._finalize(page_pdfs, variant_config)) return pdf_files # Single PDF document - # doc_config = render_config(self.config) - page_pdfs = self._process_pages(self.config) - return self._finalize(page_pdfs, self.config) - - def _process_pages( - self, config: DocumentConfig | DocumentVariantConfig - ) -> list[Path]: - """Process pages with given configuration.""" + document_config = self.config.resolve_variables() + page_pdfs = self._process_pages(document_config) + return self._finalize(page_pdfs, document_config) + + def _process_pages(self, config: DocumentConfig) -> list[Path]: + """Process pages with given configuration. + + If the document/variant has page-specific configuration + (a section with the same name as the page), include it. + """ self.log_debug_subsection("Pages to process:") self.log_debug(config.pages) pdf_files = [] - # FIXME: Too much logic here, should be in DocumentVariantConfig - - for page_num, page_config_path in enumerate(config.pages, start=1): - # if "variant" in config: - # base_config = DocumentVariantConfig( - # variant=config["variant"], - # directories=config.directories, - # pages=config.pages, - # ) - # else: - # base_config = config - - # Get the settings (includes variant if present) - base_config = config.page_settings - if "config_path" in base_config: - # Fix for variant - del base_config["config_path"] + for page_number, config_path in enumerate(config.pages, start=1): + page_data = config.page_settings + page_name = config_path.name page = Page( - config_path=page_config_path, - number=page_num, - **base_config, + config_path=config_path, + page_number=page_number, + **page_data, ) - pdf_files.append(page.process()) - _ = """ - page_name = page_config_path.stem - base_config = config.copy() - - # If the document/variant has page-specific configuration - # (a section with the same name as the page), include it - if page_name in config: - if "variant" in config: - source_desc = f'Variant "{config["variant"]["name"]}"' - else: - source_desc = f'Document "{self.config.name}"' + specific_config = getattr(config, page_name, None) + if specific_config: self.log_debug_subsection( - f'{source_desc} provides settings for page "{page_name}"' + f'{config.is_variant and 'Variant' or 'Document'} "{config.name}" ' + f'provides settings for page "{page_name}"' ) - self.log_trace(config[page_name]) - base_config.update(config[page_name]) - - page = PDFBakerPage( - document=self, - page_number=page_num, - base_config=base_config, - config_path=page_config_path, - ) + self.log_trace(specific_config) + page.config = page.config.merge(specific_config) + pdf_files.append(page.process()) - """ return pdf_files - def _finalize( - self, pdf_files: list[Path], doc_config: DocumentConfig | DocumentVariantConfig - ) -> Path: + def _finalize(self, pdf_files: list[Path], doc_config: DocumentConfig) -> Path: """Combine PDF pages and optionally compress.""" self.log_debug_subsection("Finalizing document...") self.log_debug("Combining PDF pages...") @@ -227,8 +177,6 @@ def _finalize( self.log_info("Created %s", output_path.name) return output_path - # ############################################################## - def teardown(self) -> None: """Clean up build directory after processing.""" build_dir = self.config.directories.build diff --git a/src/pdfbaker/page.py b/src/pdfbaker/page.py index 1e7cd92..735effc 100644 --- a/src/pdfbaker/page.py +++ b/src/pdfbaker/page.py @@ -10,7 +10,8 @@ from jinja2.exceptions import TemplateError, TemplateNotFound -from .config import PageConfig, PathSpec +from .config import PathSpec +from .config.page import PageConfig from .errors import SVGConversionError, SVGTemplateError from .logging import TRACE, LoggingMixin from .pdf import convert_svg_to_pdf @@ -22,16 +23,17 @@ class Page(LoggingMixin): """Page class.""" - def __init__(self, config_path: PathSpec, number: int, **kwargs): + def __init__(self, config_path: PathSpec, page_number: int, **kwargs): self.log_trace_section("Loading page configuration: %s", config_path.name) - self.config = PageConfig(config_path=config_path, **kwargs) - self.number = number + self.config = PageConfig( + config_path=config_path, page_number=page_number, **kwargs + ) self.log_trace(self.config.readable()) def process(self) -> Path: """Render SVG template and convert to PDF.""" self.log_debug_subsection( - "Processing page %d: %s", self.number, self.config.name + "Processing page %d: %s", self.config.page_number, self.config.name ) self.log_debug("Loading template: %s", self.config.template.name) @@ -46,42 +48,49 @@ def process(self) -> Path: jinja_env = create_env( templates_dir=self.config.template.path.parent, extensions=jinja_extensions, - template_filters=self.config.template_filters, + template_filters=[ + filter.value for filter in self.config.template_filters + ], ) template = jinja_env.get_template(self.config.template.path.name) except TemplateNotFound as exc: raise SVGTemplateError( "Failed to load template for page " - f"{self.number} ({self.config.name}): {exc}" + f"{self.config.page_number} ({self.config.name}): {exc}" ) from exc except TemplateError as exc: raise SVGTemplateError( - f"Template error for page {self.number} ({self.config.name}): {exc}" + "Template error for page " + f"{self.config.page_number} ({self.config.name}): {exc}" ) from exc + context = self.config.resolve_variables().model_dump() template_context = prepare_template_context( - config=self.config, + context=context, images_dir=self.config.directories.images, ) - # FIXME: should just be in PageConfig - template_context["page_number"] = self.number build_dir = self.config.directories.build - build_dir.mkdir(parents=True, exist_ok=True) - output_svg = build_dir / f"{self.config.name}_{self.number:03}.svg" - output_pdf = build_dir / f"{self.config.name}_{self.number:03}.pdf" + name = self.config.name + if self.config.is_variant: + name = f'{name}_{self.config.variant["name"]}' + output_svg = build_dir / f"{self.config.page_number:03}_{name}.svg" + output_pdf = build_dir / f"{self.config.page_number:03}_{name}.pdf" self.log_debug("Rendering template...") try: rendered_template = template.render( **template_context, - renderers=self.config.template_renderers, + renderers=[ + renderer.value for renderer in self.config.template_renderers + ], ) with open(output_svg, "w", encoding="utf-8") as f: f.write(rendered_template) except TemplateError as exc: raise SVGTemplateError( - f"Failed to render page {self.number} ({self.config.name}): {exc}" + "Failed to render page " + f"{self.config.page_number} ({self.config.name}): {exc}" ) from exc self.log_trace_preview(rendered_template) @@ -90,12 +99,12 @@ def process(self) -> Path: return convert_svg_to_pdf( output_svg, output_pdf, - backend=self.config.svg2pdf_backend.value, + backend=self.config.svg2pdf_backend, ) except SVGConversionError as exc: self.log_error( "Failed to convert page %d (%s): %s", - self.number, + self.config.page_number, self.config.name, exc, ) diff --git a/src/pdfbaker/pdf.py b/src/pdfbaker/pdf.py index 1f7c02a..9d2b0f2 100644 --- a/src/pdfbaker/pdf.py +++ b/src/pdfbaker/pdf.py @@ -10,6 +10,7 @@ import pypdf from cairosvg import svg2pdf +from .config import SVG2PDFBackend from .errors import ( PDFCombineError, PDFCompressionError, @@ -161,7 +162,7 @@ def compress_pdf( def convert_svg_to_pdf( svg_path: Path, pdf_path: Path, - backend: str = "cairosvg", + backend: SVG2PDFBackend | str = SVG2PDFBackend.CAIROSVG, ) -> Path | SVGConversionError: """Convert an SVG file to PDF. @@ -177,7 +178,15 @@ def convert_svg_to_pdf( Raises: SVGConversionError: If SVG conversion fails, includes the backend used and cause """ - if backend == "inkscape": + if isinstance(backend, str): + try: + backend = SVG2PDFBackend(backend) + except ValueError as exc: + raise SVGConversionError( + svg_path, backend, f'Unknown svg2pdf backend: "{backend}"' + ) from exc + + if backend == SVG2PDFBackend.INKSCAPE: try: _run_subprocess_logged( [ @@ -189,11 +198,6 @@ def convert_svg_to_pdf( except subprocess.SubprocessError as exc: raise SVGConversionError(svg_path, backend, str(exc)) from exc else: - if backend != "cairosvg": - logger.warning( - "Unknown svg2pdf backend: %s - falling back to cairosvg", - backend, - ) try: with open(svg_path, "rb") as svg_file: svg2pdf(file_obj=svg_file, write_to=str(pdf_path)) diff --git a/src/pdfbaker/render.py b/src/pdfbaker/render.py index f6f67d3..9cbd20d 100644 --- a/src/pdfbaker/render.py +++ b/src/pdfbaker/render.py @@ -10,7 +10,7 @@ import jinja2 from . import processing -from .config import ImageSpec, StyleDict, render_config +from .config import ImageSpec, StyleDict __all__ = [ "create_env", @@ -20,7 +20,7 @@ class PDFBakerTemplate(jinja2.Template): # pylint: disable=too-few-public-methods - """A Jinja template with custom rendering capabilities for PDFBaker. + """A Jinja template with custom rendering capabilities for pdfbaker. This template class extends the base Jinja template to apply additional rendering transformations to the template output. @@ -92,7 +92,7 @@ def create_env( def prepare_template_context( - config: dict[str], images_dir: Path | None = None + context: dict[str], images_dir: Path | None = None ) -> dict[str]: """Prepare template context with variables/styles/images @@ -102,10 +102,6 @@ def prepare_template_context( config: Configuration with optional styles and images images_dir: Directory containing images to encode """ - # Render configuration to resolve template strings inside strings - # FIXME: maybe don't do this here - separation of concerns - context = render_config(config.model_dump()) - # Resolve style references to actual theme colors if "style" in context and "theme" in context: style = context["style"] diff --git a/tests/test_baker.py b/tests/test_baker.py index 0fa7b59..451c163 100644 --- a/tests/test_baker.py +++ b/tests/test_baker.py @@ -5,12 +5,18 @@ from pathlib import Path import pytest +from pydantic import ValidationError +from ruamel.yaml import YAML from pdfbaker.baker import Baker, BakerOptions -from pdfbaker.errors import ConfigurationError from pdfbaker.logging import TRACE -# FIXME: default_config_overrides no longer needed, just throw kwargs at Baker init + +def write_yaml(path, data): + """Write data to a YAML file using ruamel.yaml.""" + yaml = YAML() + with open(path, "w", encoding="utf-8") as file: + yaml.dump(data, file) # BakerOptions tests @@ -21,7 +27,6 @@ def test_baker_options_defaults() -> None: assert not options.verbose assert not options.trace assert not options.keep_build - assert options.default_config_overrides is None def test_baker_options_logging_levels() -> None: @@ -39,33 +44,14 @@ def test_baker_options_logging_levels() -> None: assert logging.getLogger().level == expected_level -def test_baker_options_default_config_overrides(tmp_path: Path) -> None: - """Test BakerOptions with default_config_overrides.""" - # Create a minimal valid config - config_file = tmp_path / "test.yaml" - config_file.write_text("documents: [test]") - - custom_dir = tmp_path / "custom" - options = BakerOptions( - default_config_overrides={ - "directories": { - "documents": str(custom_dir), - } - } - ) - - baker = Baker(config_file, options=options) - assert str(baker.config["directories"]["documents"]) == str(custom_dir) - - # PDFBaker initialization tests def test_baker_init_invalid_config(tmp_path: Path) -> None: """Test PDFBaker initialization with invalid configuration.""" # Create an invalid config file (missing 'documents' key) config_file = tmp_path / "invalid.yaml" - config_file.write_text("title: test") + write_yaml(config_file, {"title": "test", "directories": {"base": str(tmp_path)}}) - with pytest.raises(ConfigurationError, match=".*documents.*missing.*"): + with pytest.raises(ValidationError, match=".*documents.*missing.*"): Baker(config_file) @@ -84,16 +70,17 @@ def test_baker_examples() -> None: options = BakerOptions( quiet=True, keep_build=True, - default_config_overrides={ - "directories": { - "build": str(build_dir), - "dist": str(dist_dir), - } - }, ) try: - baker = Baker(examples_config, options=options) + baker = Baker( + examples_config, + options=options, + directories={ + "build": str(build_dir), + "dist": str(dist_dir), + }, + ) baker.bake() finally: # Clean up test directories diff --git a/tests/test_cli.py b/tests/test_cli.py index 60611f9..786a8b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -47,7 +47,6 @@ def test_cli_bake_invalid_config(tmp_path: Path) -> None: runner = CliRunner() result = runner.invoke(cli, ["bake", str(config_file)]) assert result.exit_code == 1 - assert "Invalid YAML" in result.output def test_cli_bake_quiet_mode(tmp_path: Path) -> None: @@ -61,12 +60,12 @@ def test_cli_bake_quiet_mode(tmp_path: Path) -> None: """) runner = CliRunner() - result = runner.invoke(cli, ["bake", "--quiet", str(failing_config)]) - assert result.exit_code == 1 # Will fail because page1.yaml doesn't exist - assert "error" in result.output.lower() # Should show error message - assert "info" not in result.output.lower() # Should not show info messages + result_obj = runner.invoke(cli, ["bake", "--quiet", str(failing_config)]) - # Test case 2: Success - should be completely quiet + # We just need to verify the exit code is 1, indicating an error + assert result_obj.exit_code == 1 # Will fail because document is invalid + + # Success test success_config = tmp_path / "success.yaml" success_config.write_text(""" documents: [] # Empty list of documents is valid diff --git a/tests/test_config.py b/tests/test_config.py index 237115b..b2b995b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,25 +3,41 @@ from pathlib import Path import pytest +from ruamel.yaml import YAML +from ruamel.yaml.parser import ParserError -# FIXME: now using ruamel.yaml -# import yaml -from pdfbaker.config import BakerConfig - -# FIXME: no more deep_merge, no more render_config +from pdfbaker.config import BaseConfig, Directories +from pdfbaker.config.baker import BakerConfig from pdfbaker.errors import ConfigurationError -# Dictionary merging tests -def test_deep_merge_basic() -> None: - """Test basic dictionary merging.""" - base = { - "title": "Document", - "style": { +# Function to help with creating YAML content for tests +def write_yaml(path, data): + """Write data to a YAML file using ruamel.yaml.""" + yaml = YAML() + with open(path, "w", encoding="utf-8") as file: + yaml.dump(data, file) + + +# BaseConfig merger tests +def test_base_config_merge_basic(default_directories) -> None: + """Test basic config merging with BaseConfig.""" + + class TestConfig(BaseConfig): + """Test configuration class for basic merging.""" + + title: str + style: dict + + base = TestConfig( + title="Document", + style={ "font": "Helvetica", "size": 12, }, - } + directories=default_directories, + ) + update = { "title": "Updated Document", "style": { @@ -29,35 +45,40 @@ def test_deep_merge_basic() -> None: }, "author": "John Doe", } - expected = { - "title": "Updated Document", - "style": { - "font": "Helvetica", - "size": 14, - }, - "author": "John Doe", - } - assert deep_merge(base, update) == expected + merged = base.merge(update) + assert merged.title == "Updated Document" + assert merged.style == {"font": "Helvetica", "size": 14} + assert merged.user_defined_settings.get("author") == "John Doe" -def test_deep_merge_nested() -> None: - """Test nested dictionary merging.""" - base = { - "document": { + +def test_base_config_merge_nested(default_directories) -> None: + """Test nested config merging with BaseConfig.""" + + class NestedConfig(BaseConfig): + """Test configuration class for nested merging.""" + + document: dict + style: dict + + base = NestedConfig( + document={ "title": "Main Document", "meta": { "author": "Jane Smith", "date": "2024-01-01", }, }, - "style": { + style={ "font": "Arial", "colors": { "text": "black", "background": "white", }, }, - } + directories=default_directories, + ) + update = { "document": { "meta": { @@ -71,152 +92,180 @@ def test_deep_merge_nested() -> None: }, }, } - expected = { - "document": { - "title": "Main Document", - "meta": { - "author": "Jane Smith", - "date": "2024-04-01", - "version": "1.0", - }, - }, - "style": { - "font": "Arial", - "colors": { - "text": "navy", - "background": "white", - }, - }, - } - assert deep_merge(base, update) == expected + merged = base.merge(update) + assert merged.document["title"] == "Main Document" + assert merged.document["meta"]["author"] == "Jane Smith" + assert merged.document["meta"]["date"] == "2024-04-01" + assert merged.document["meta"]["version"] == "1.0" + assert merged.style["font"] == "Arial" + assert merged.style["colors"]["text"] == "navy" + assert merged.style["colors"]["background"] == "white" -def test_deep_merge_empty() -> None: - """Test merging with empty dictionaries.""" - base = { - "title": "Document", - "style": { + +def test_base_config_merge_empty(default_directories) -> None: + """Test merging with empty dictionary.""" + + class SimpleConfig(BaseConfig): + """Test configuration class for empty dict merging.""" + + title: str + style: dict + + base = SimpleConfig( + title="Document", + style={ "font": "Helvetica", }, - } + directories=default_directories, + ) + update = {} - # Merging empty into non-empty should return non-empty - assert deep_merge(base, update) == base - # Merging non-empty into empty should return non-empty - # pylint: disable=arguments-out-of-order - assert deep_merge(update, base) == base + # Merging empty into non-empty should return equivalent of non-empty + merged = base.merge(update) + assert merged.title == base.title + assert merged.style == base.style # Configuration initialization tests -def test_configuration_init_with_dict(tmp_path: Path) -> None: - """Test initializing Configuration with a dictionary.""" +def test_baker_config_init_with_file( + tmp_path: Path, default_directories: Directories +) -> None: + """Test initializing BakerConfig with a file.""" config_file = tmp_path / "test.yaml" - config_file.write_text(yaml.dump({"title": "Document"})) + write_yaml( + config_file, + { + "documents": [ + {"path": "doc1", "name": "doc1"}, + {"path": "doc2", "name": "doc2"}, + ], + "directories": default_directories.model_dump(mode="json"), + }, + ) - config = BakerConfig({}, config_file) - assert config["title"] == "Document" + config = BakerConfig(config_file=config_file) + assert len(config.documents) == 2 + assert config.config_file == config_file -def test_configuration_init_with_path(tmp_path: Path) -> None: - """Test initializing Configuration with a file path.""" +def test_baker_config_custom_directories( + tmp_path: Path, default_directories: Directories +) -> None: + """Test initializing BakerConfig with custom directories.""" config_file = tmp_path / "test.yaml" - config_file.write_text(yaml.dump({"title": "Document"})) - - config = BakerConfig({}, config_file) - assert config["title"] == "Document" - assert config["directories"]["config"] == tmp_path + custom_dirs = default_directories.model_dump(mode="json") + custom_dirs["build"] = str(tmp_path / "custom_build") + config_data = { + "documents": [{"path": "doc1", "name": "doc1"}], + "directories": custom_dirs, + } -def test_configuration_init_with_directory(tmp_path: Path) -> None: - """Test initializing Configuration with custom directory.""" - config_file = tmp_path / "test.yaml" - config_file.write_text(yaml.dump({"title": "Document"})) + write_yaml(config_file, config_data) + config = BakerConfig(config_file=config_file) - config = BakerConfig({}, config_file) - assert config["title"] == "Document" - assert config["directories"]["config"] == tmp_path + assert config.config_file == config_file + assert len(config.documents) == 1 + assert config.documents[0].name == "doc1" -def test_configuration_init_invalid_yaml(tmp_path: Path) -> None: +def test_baker_config_init_invalid_yaml(tmp_path: Path) -> None: """Test configuration with invalid YAML.""" config_file = tmp_path / "invalid.yaml" - config_file.write_text("invalid: [yaml: content") + with open(config_file, "w", encoding="utf-8") as f: + f.write("invalid: [yaml: content") - with pytest.raises(ConfigurationError, match="Failed to load config file"): - BakerConfig({}, config_file) + # Use ruamel.yaml's specific exception + with pytest.raises(ParserError): + BakerConfig(config_file=config_file) # Path resolution tests -def test_configuration_resolve_path(tmp_path: Path) -> None: +def test_config_resolve_path(tmp_path: Path, default_directories: Directories) -> None: """Test path resolution.""" - config_file = tmp_path / "test.yaml" - config_file.write_text(yaml.dump({"template": "test.yaml"})) - config = BakerConfig({}, config_file) + # Create a basic config for testing path resolution + class TestConfig(BaseConfig): + """Test configuration class for path resolution.""" - # Test relative path - assert config.resolve_path("test.yaml") == tmp_path / "test.yaml" + directories: Directories - # Test absolute path - assert config.resolve_path({"path": "/absolute/path.yaml"}) == Path( - "/absolute/path.yaml" + config = TestConfig( + directories=default_directories, ) - # Test named path - assert config.resolve_path({"name": "test.yaml"}) == tmp_path / "test.yaml" - - -def test_configuration_resolve_path_invalid(tmp_path: Path) -> None: - """Test invalid path specification.""" - config_file = tmp_path / "test.yaml" - config_file.write_text(yaml.dump({})) + # Test relative path + path = Path("test.yaml") + resolved = config.resolve_path(path) + assert resolved == tmp_path / "test.yaml" - config = BakerConfig({}, config_file) - with pytest.raises(ConfigurationError, match="Invalid path specification"): - config.resolve_path({}) + # Test subdirectory path + path = Path("subdir/test.yaml") + resolved = config.resolve_path(path) + assert resolved == tmp_path / "subdir/test.yaml" # Configuration rendering tests -def test_render_config_basic() -> None: +def test_config_render_basic(default_directories) -> None: """Test basic template rendering in configuration.""" - config = { - "name": "test", - "title": "{{ name }} document", - "nested": { + + class RenderConfig(BaseConfig): + """Test configuration class for rendering templates.""" + + name: str + title: str + nested: dict + + config = RenderConfig( + name="test", + title="{{ name }} document", + nested={ "value": "{{ title }}", }, - } + directories=default_directories, + ) - rendered = render_config(config) - assert rendered["title"] == "test document" - assert rendered["nested"]["value"] == "test document" + rendered = config.resolve_variables() + assert rendered.title == "test document" + assert rendered.nested["value"] == "test document" -def test_render_config_circular() -> None: +def test_config_render_circular(default_directories) -> None: """Test detection of circular references in config rendering.""" - config = { - "a": "{{ b }}", - "b": "{{ a }}", - } + + class CircularConfig(BaseConfig): + """Test configuration class for circular reference detection.""" + + a: str + b: str + + config = CircularConfig( + a="{{ b }}", + b="{{ a }}", + directories=default_directories, + ) with pytest.raises(ConfigurationError, match="(?i).*circular.*"): - render_config(config) + config.resolve_variables() # Utility method tests -def test_configuration_readable(tmp_path: Path) -> None: +def test_config_readable(default_directories) -> None: """Test configuration readable printing.""" - config_file = tmp_path / "test.yaml" - config_file.write_text( - yaml.dump( - { - "title": "Test", - "content": "A" * 100, # Long string that should be truncated - } - ) + + class ReadableConfig(BaseConfig): + """Test configuration class for readable output.""" + + title: str + content: str + + config = ReadableConfig( + title="Test", + content="A" * 100, # Long string that should be truncated + directories=default_directories, ) - config = BakerConfig({}, config_file) readable = config.readable(max_chars=20) - assert "(...)" in readable # Should show truncation + assert "..." in readable # Should show truncation assert "Test" in readable diff --git a/tests/test_document.py b/tests/test_document.py index 735f736..efada53 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -1,23 +1,43 @@ """Tests for document processing functionality.""" -import logging import shutil from pathlib import Path import pytest +from ruamel.yaml import YAML from pdfbaker.baker import Baker, BakerOptions +from pdfbaker.config import Directories, PathSpec from pdfbaker.document import Document from pdfbaker.errors import ConfigurationError +def write_yaml(path: Path, data: dict) -> None: + """Write data to a YAML file using ruamel.yaml.""" + yaml = YAML() + with open(path, "w", encoding="utf-8") as file: + yaml.dump(data, file) + + @pytest.fixture(name="baker_config") -def fixture_baker_config(tmp_path: Path) -> Path: +def fixture_baker_config(tmp_path: Path, default_directories: Directories) -> Path: """Create a baker configuration file.""" config_file = tmp_path / "config.yaml" - config_file.write_text(""" - documents: [test_doc] - """) + write_yaml( + config_file, + { + "documents": [{"path": "test_doc", "name": "test_doc"}], + "directories": { + "base": str(default_directories.base), + "build": str(default_directories.build), + "dist": str(default_directories.dist), + "documents": str(default_directories.documents), + "pages": str(default_directories.pages), + "templates": str(default_directories.templates), + "images": str(default_directories.images), + }, + }, + ) return config_file @@ -35,259 +55,242 @@ def fixture_baker_options(tmp_path: Path) -> BakerOptions: @pytest.fixture(name="doc_dir") -def fixture_doc_dir(tmp_path: Path) -> Path: - """Create a document directory with all necessary files.""" +def fixture_doc_dir(tmp_path: Path, default_directories: Directories) -> Path: + """Create a document directory with necessary files.""" doc_path = tmp_path / "test_doc" doc_path.mkdir() - # Create config file config_file = doc_path / "config.yaml" - config_file.write_text(""" - pages: [page1.yaml] - directories: - build: build - """) + write_yaml( + config_file, + { + "pages": [{"path": "page1.yaml", "name": "page1"}], + "directories": { + "base": str(doc_path), + "build": str(doc_path / "build"), + "dist": str(doc_path / "dist"), + "documents": str(default_directories.documents), + "pages": str(default_directories.pages), + "templates": str(default_directories.templates), + "images": str(default_directories.images), + }, + "filename": "test_doc", + }, + ) - # Create page config pages_dir = doc_path / "pages" pages_dir.mkdir() - page_file = pages_dir / "page1.yaml" - page_file.write_text("template: template.svg") + write_yaml(pages_dir / "page1.yaml", {"template": "template.svg"}) - # Create template templates_dir = doc_path / "templates" templates_dir.mkdir() - template_file = templates_dir / "template.svg" - template_file.write_text( + (templates_dir / "template.svg").write_text( '' ) yield doc_path - # Cleanup shutil.rmtree(doc_path, ignore_errors=True) def test_document_init_with_dir( baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: - """Test document initialization with directory.""" + """Test document initialization with a directory.""" baker = Baker(config_file=baker_config, options=baker_options) - doc = Document( - baker=baker, - base_config=baker.config, - config_path=doc_dir, # this will default to config.yaml in the directory - ) + doc_config_path = PathSpec(path=doc_dir, name="test_doc") + doc = Document(config_path=doc_config_path, **baker.config.document_settings) assert doc.config.name == "test_doc" - assert doc.config["pages"] == ["page1.yaml"] - - # We need to manually determine pages now - doc.config.determine_pages(doc.config) - assert len(doc.config.pages) > 0 - assert doc.config.pages[0].name == "page1.yaml" + assert len(doc.config.pages) == 1 + assert doc.config.pages[0].name == "page1" def test_document_init_with_file( - tmp_path: Path, baker_config: Path, baker_options: BakerOptions + tmp_path: Path, + baker_config: Path, + baker_options: BakerOptions, + default_directories: Directories, ) -> None: - """Test document initialization with config file.""" - # Create document config + """Test document initialization with a config file.""" config_file = tmp_path / "test_doc.yaml" - config_file.write_text(""" - pages: [page1.yaml] - directories: - build: build - """) + write_yaml( + config_file, + { + "pages": [{"path": "page1.yaml", "name": "page1"}], + "directories": { + "base": str(default_directories.base), + "build": str(default_directories.build), + "dist": str(default_directories.dist), + "documents": str(default_directories.documents), + "pages": str(default_directories.pages), + "templates": str(default_directories.templates), + "images": str(default_directories.images), + }, + "filename": "test_doc", + }, + ) - # Create page config pages_dir = tmp_path / "pages" pages_dir.mkdir() - page_file = pages_dir / "page1.yaml" - page_file.write_text("template: template.svg") + write_yaml(pages_dir / "page1.yaml", {"template": "template.svg"}) - # Create template templates_dir = tmp_path / "templates" templates_dir.mkdir() - template_file = templates_dir / "template.svg" - template_file.write_text( + (templates_dir / "template.svg").write_text( '' ) - baker = Baker(baker_config, options=baker_options) - doc = Document(baker, baker.config, config_file) + baker = Baker(config_file=baker_config, options=baker_options) + doc_config_path = PathSpec(path=config_file, name="test_doc") + doc = Document(config_path=doc_config_path, **baker.config.document_settings) assert doc.config.name == "test_doc" - assert doc.config["pages"] == ["page1.yaml"] + assert len(doc.config.pages) == 1 + assert doc.config.pages[0].name == "page1" -def test_document_init_missing_pages(tmp_path: Path, baker_config: Path) -> None: +def test_document_init_missing_pages( + tmp_path: Path, baker_config: Path, default_directories: Directories +) -> None: """Test document initialization with missing pages key.""" config_file = tmp_path / "test_doc.yaml" - config_file.write_text(""" - title: Test Document - directories: - build: build - """) + write_yaml( + config_file, + { + "title": "Test Document", + "directories": { + "base": str(default_directories.base), + "build": str(default_directories.build), + "dist": str(default_directories.dist), + "documents": str(default_directories.documents), + "pages": str(default_directories.pages), + "templates": str(default_directories.templates), + "images": str(default_directories.images), + }, + "filename": "test_doc", + }, + ) baker = Baker(baker_config) + doc_config_path = PathSpec(path=config_file, name="test_doc") with pytest.raises(ConfigurationError, match="Cannot determine pages"): - Document(baker, baker.config, config_file) + Document(config_path=doc_config_path, **baker.config.document_settings) def test_document_custom_bake( baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: - """Test document processing with custom bake module.""" - # Create custom bake module - bake_file = doc_dir / "bake.py" - bake_file.write_text(""" -def process_document(document): - return document.config.build_dir / "custom.pdf" -""") - - baker = Baker(baker_config, options=baker_options) - doc = Document(baker, baker.config, doc_dir) + """Test document processing with a custom bake module.""" + (doc_dir / "bake.py").write_text( + "def process_document(document):\n" + " return document.config.directories.build / 'custom.pdf'\n" + ) + + baker = Baker(config_file=baker_config, options=baker_options) + doc_config_path = PathSpec(path=doc_dir, name="test_doc") + doc = Document(config_path=doc_config_path, **baker.config.document_settings) assert doc.config.name == "test_doc" - assert doc.config["pages"] == ["page1.yaml"] + assert len(doc.config.pages) == 1 def test_document_custom_bake_error( baker_config: Path, baker_options: BakerOptions, doc_dir: Path ) -> None: - """Test document processing with invalid custom bake module.""" - # Create invalid bake module - bake_file = doc_dir / "bake.py" - bake_file.write_text("raise Exception('Test error')") + """Test document processing with an invalid custom bake module.""" + (doc_dir / "bake.py").write_text("raise Exception('Test error')") - baker = Baker(baker_config, options=baker_options) - doc = Document(baker, baker.config, doc_dir) + baker = Baker(config_file=baker_config, options=baker_options) + doc_config_path = PathSpec(path=doc_dir, name="test_doc") + doc = Document(config_path=doc_config_path, **baker.config.document_settings) assert doc.config.name == "test_doc" - assert doc.config["pages"] == ["page1.yaml"] + assert len(doc.config.pages) == 1 def test_document_variants( - baker_config: Path, baker_options: BakerOptions, doc_dir: Path + baker_config: Path, + baker_options: BakerOptions, + doc_dir: Path, + default_directories: Directories, ) -> None: """Test document processing with variants.""" - # Update config file config_file = doc_dir / "config.yaml" - config_file.write_text(""" - pages: [page1.yaml] - directories: - build: build - variants: - - name: variant1 - filename: variant1 - - name: variant2 - filename: variant2 - """) - - baker = Baker(baker_config, options=baker_options) - doc = Document(baker, baker.config, doc_dir) + write_yaml( + config_file, + { + "pages": [{"path": "page1.yaml", "name": "page1"}], + "directories": { + "base": str(doc_dir), + "build": str(doc_dir / "build"), + "dist": str(doc_dir / "dist"), + "documents": str(default_directories.documents), + "pages": str(default_directories.pages), + "templates": str(default_directories.templates), + "images": str(default_directories.images), + }, + "filename": "test_doc", + "variants": [ + {"name": "variant1", "filename": "variant1"}, + {"name": "variant2", "filename": "variant2"}, + ], + }, + ) + + baker = Baker(config_file=baker_config, options=baker_options) + doc_config_path = PathSpec(path=doc_dir, name="test_doc") + doc = Document(config_path=doc_config_path, **baker.config.document_settings) assert doc.config.name == "test_doc" - assert doc.config["pages"] == ["page1.yaml"] - assert len(doc.config["variants"]) == 2 + assert len(doc.config.pages) == 1 + assert len(doc.config.variants) == 2 def test_document_variants_with_different_pages( - tmp_path: Path, baker_config: Path, baker_options: BakerOptions + tmp_path: Path, + baker_config: Path, + baker_options: BakerOptions, + default_directories: Directories, ) -> None: """Test document with variants where each variant has different pages.""" - # Create document config with variants but no pages config_file = tmp_path / "test_doc.yaml" - config_file.write_text(""" - filename: "{{ variant.name }}_doc" - directories: - build: build - dist: dist - variants: - - name: variant1 - filename: variant1 - pages: [page1.yaml] - - name: variant2 - filename: variant2 - pages: [page2.yaml] - """) - - # Create page configs + write_yaml( + config_file, + { + "filename": "{{ variant.name }}_doc", + "directories": default_directories.model_dump(mode="json"), + "variants": [ + { + "name": "variant1", + "filename": "variant1", + "pages": [{"path": "page1.yaml", "name": "page1"}], + }, + { + "name": "variant2", + "filename": "variant2", + "pages": [{"path": "page2.yaml", "name": "page2"}], + }, + ], + }, + ) + pages_dir = tmp_path / "pages" pages_dir.mkdir() + write_yaml( + pages_dir / "page1.yaml", + {"template": "template.svg", "content": "Variant 1 content"}, + ) + write_yaml( + pages_dir / "page2.yaml", + {"template": "template.svg", "content": "Variant 2 content"}, + ) - page_file1 = pages_dir / "page1.yaml" - page_file1.write_text(""" - template: template.svg - content: "Variant 1 content" - """) - - page_file2 = pages_dir / "page2.yaml" - page_file2.write_text(""" - template: template.svg - content: "Variant 2 content" - """) - - # Create template templates_dir = tmp_path / "templates" templates_dir.mkdir() - template_file = templates_dir / "template.svg" - template_file.write_text( + (templates_dir / "template.svg").write_text( '' ) - baker = Baker(baker_config, options=baker_options) - doc = Document(baker, baker.config, config_file) + baker = Baker(config_file=baker_config, options=baker_options) + doc_config_path = PathSpec(path=config_file, name="test_doc") + doc = Document(config_path=doc_config_path, **baker.config.document_settings) - # Check that document initialization works without pages at doc level assert doc.config.name == "test_doc" - assert "pages" not in doc.config - assert len(doc.config["variants"]) == 2 - - # Check that each variant has its own pages definition - assert doc.config["variants"][0]["pages"] == ["page1.yaml"] - assert doc.config["variants"][1]["pages"] == ["page2.yaml"] - - # Test that processing works with per-variant pages - # We don't need to call process for this basic functionality test - # as that would require inkapscape/cairosvg and be more integration testing - - # Instead, test that we can determine pages from variant configs - variant1_config = doc.config.copy() - variant1_config.update(doc.config["variants"][0]) - assert "pages" in variant1_config - doc.config.determine_pages(variant1_config) - assert len(doc.config.pages) > 0 - assert doc.config.pages[0].name == "page1.yaml" - - variant2_config = doc.config.copy() - variant2_config.update(doc.config["variants"][1]) - assert "pages" in variant2_config - doc.config.determine_pages(variant2_config) - assert len(doc.config.pages) > 0 - assert doc.config.pages[0].name == "page2.yaml" - - -def test_document_teardown( - baker_config: Path, - baker_options: BakerOptions, - doc_dir: Path, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test document teardown.""" - # Create build directory and some files - build_dir = doc_dir / "build" / "test_doc" - build_dir.mkdir(parents=True) - (build_dir / "file1.pdf").write_text("test") - (build_dir / "file2.pdf").write_text("test") - - baker = Baker(baker_config, options=baker_options) - doc = Document(baker, baker.config, doc_dir) - assert doc.config.name == "test_doc" - assert doc.config["pages"] == ["page1.yaml"] - - with caplog.at_level(logging.DEBUG): - # Manually reinstall caplog handler to the root logger - logging.getLogger().addHandler(caplog.handler) - doc.teardown() - - assert not build_dir.exists() - assert "Tearing down build directory" in caplog.text - assert "Removing files in build directory" in caplog.text - assert "Removing build directory" in caplog.text + assert not doc.config.pages + assert len(doc.config.variants) == 2 diff --git a/tests/test_pdf.py b/tests/test_pdf.py index 4c829be..7d760d4 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -188,35 +188,26 @@ def test_convert_svg_to_pdf_cairosvg(tmp_path: Path) -> None: assert output_file.exists() -def test_convert_svg_to_pdf_unknown_backend( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: +def test_convert_svg_to_pdf_unknown_backend(tmp_path: Path) -> None: """Test SVG to PDF conversion with unknown backend.""" svg_file = tmp_path / "test.svg" svg_file.write_text( '' '' ) - output_file = tmp_path / "output.pdf" - with caplog.at_level(logging.WARNING): - # Manually reinstall caplog handler to the root logger - logging.getLogger().addHandler(caplog.handler) + with pytest.raises(SVGConversionError) as exc_info: convert_svg_to_pdf(svg_file, output_file, backend="unknown") - assert "Unknown svg2pdf backend: unknown - falling back to cairosvg" in caplog.text + assert "Unknown svg2pdf backend" in str(exc_info.value) def test_convert_svg_to_pdf_invalid_svg(tmp_path: Path) -> None: """Test SVG to PDF conversion with invalid SVG.""" - # Create an invalid SVG file svg_file = tmp_path / "test.svg" svg_file.write_text("Not an SVG file") - output_file = tmp_path / "output.pdf" with pytest.raises(SVGConversionError) as exc_info: convert_svg_to_pdf(svg_file, output_file) - - # Check for the specific error assert "syntax error: line 1, column 0" in str(exc_info.value) From 664549108b18ffea2f24e1b3bd5d4ef682204ddc Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 29 Apr 2025 11:19:35 +1200 Subject: [PATCH 18/19] fix: nested quotes --- src/pdfbaker/document.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pdfbaker/document.py b/src/pdfbaker/document.py index f9846e8..5748c83 100644 --- a/src/pdfbaker/document.py +++ b/src/pdfbaker/document.py @@ -135,9 +135,9 @@ def _process_pages(self, config: DocumentConfig) -> list[Path]: specific_config = getattr(config, page_name, None) if specific_config: + source = "Variant" if config.is_variant else "Document" self.log_debug_subsection( - f'{config.is_variant and 'Variant' or 'Document'} "{config.name}" ' - f'provides settings for page "{page_name}"' + f'{source} "{config.name}" provides settings for page "{page_name}"' ) self.log_trace(specific_config) page.config = page.config.merge(specific_config) From f31954bd22018cf68c02188106efba3dc4eca242 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Tue, 29 Apr 2025 11:36:46 +1200 Subject: [PATCH 19/19] Fix linting/testing - move pylint config to pyproject.toml --- .pre-commit-config.yaml | 2 -- .pylintrc | 12 ------------ pyproject.toml | 14 ++++++++++++++ tests/conftest.py | 21 +++++++++++++++++++++ 4 files changed, 35 insertions(+), 14 deletions(-) delete mode 100644 .pylintrc create mode 100644 tests/conftest.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a54c529..cb7300c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,8 +43,6 @@ repos: rev: v3.3.4 hooks: - id: pylint - args: - - --rcfile=.pylintrc additional_dependencies: - "cairosvg" - "click" diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 8e58e71..0000000 --- a/.pylintrc +++ /dev/null @@ -1,12 +0,0 @@ -[MASTER] -score=n -generated-members=PyPDF2.* -reports=no - -[REPORTS] -msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} -output-format=colorized -reports=no - -[MESSAGES CONTROL] -disable=W0511 # Disable TODO/FIXME warnings diff --git a/pyproject.toml b/pyproject.toml index 60349eb..27e9d00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,17 @@ addopts = "-v --cov=pdfbaker --cov-report=term-missing" [tool.coverage.run] source = ["pdfbaker"] + +[tool.pylint.main] +py-version = "3.11" +ignore-paths = ["tests/"] +init-hook = "import sys; sys.path.insert(0, 'src')" + +[tool.pylint.messages_control] +disable = ["W0511"] # Disable TODO/FIXME warnings + +[tool.pylint.reports] +msg-template = "{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}" +output-format = "colorized" +reports = "no" +score = "no" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..390c368 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Common configuration of tests.""" + +from pathlib import Path + +import pytest + +from pdfbaker.config import Directories + + +@pytest.fixture +def default_directories(tmp_path: Path) -> Directories: + """Fixture providing default Directories for tests.""" + return Directories( + base=tmp_path, + build=tmp_path / "build", + dist=tmp_path / "dist", + documents=tmp_path / "documents", + pages=tmp_path / "pages", + templates=tmp_path / "templates", + images=tmp_path / "images", + )