Skip to content

Commit 563b726

Browse files
committed
Make a start at new Pydantic config models
I intend to bring this in parallel to functioning previous code.
1 parent 6c6f69b commit 563b726

10 files changed

Lines changed: 452 additions & 51 deletions

File tree

.pre-commit-config.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ repos:
4949
- "cairosvg"
5050
- "click"
5151
- "jinja2"
52+
- "pydantic"
5253
- "pypdf"
5354
- "pytest"
54-
- "pyyaml"
55+
- "pyyaml" # FIXME: remove once fully migrated to ruamel
56+
- "ruamel.yaml"

examples/examples.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ documents:
44
- variants
55
- ./custom_locations/your_directory
66
- custom_processing
7+
8+
custom_stuff:
9+
- year: 2025
10+
- nested:
11+
- anything: really

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ dependencies = [
99
"cairosvg",
1010
"click",
1111
"jinja2",
12+
"pydantic",
1213
"pypdf",
13-
"pyyaml",
14+
"pyyaml", # FIXME: remove once fully migrated to ruamel
15+
"ruamel.yaml",
1416
]
1517
readme = "README.md"
1618
requires-python = ">= 3.11"

src/pdfbaker/__main__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import click
88

99
from pdfbaker import __version__
10-
from pdfbaker.baker import PDFBaker, PDFBakerOptions
10+
from pdfbaker.baker import PDFBaker
11+
from pdfbaker.config import BakerOptions
1112
from pdfbaker.errors import DocumentNotFoundError, PDFBakerError
1213

1314
logger = logging.getLogger(__name__)
@@ -54,7 +55,7 @@ def bake(
5455
keep_build = True
5556

5657
try:
57-
options = PDFBakerOptions(
58+
options = BakerOptions(
5859
quiet=quiet,
5960
verbose=verbose,
6061
trace=trace,

src/pdfbaker/baker.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@
66
bake() delegates to its documents and reports back the end result.
77
"""
88

9-
from dataclasses import dataclass
109
from pathlib import Path
1110
from typing import Any
1211

13-
from .config import PDFBakerConfiguration, deep_merge
12+
from .config import BakerOptions, PDFBakerConfiguration, deep_merge
1413
from .document import PDFBakerDocument
1514
from .errors import ConfigurationError, DocumentNotFoundError
1615
from .logging import LoggingMixin, setup_logging
1716

18-
__all__ = ["PDFBaker", "PDFBakerOptions"]
17+
__all__ = ["PDFBaker"]
1918

2019

2120
DEFAULT_BAKER_CONFIG = {
@@ -32,26 +31,6 @@
3231
}
3332

3433

35-
@dataclass
36-
class PDFBakerOptions:
37-
"""Options for controlling PDFBaker behavior.
38-
39-
Attributes:
40-
quiet: Show errors only
41-
verbose: Show debug information
42-
trace: Show trace information (even more detailed than debug)
43-
keep_build: Keep build artifacts after processing
44-
default_config_overrides: Dictionary of values to override the built-in defaults
45-
before loading the main configuration
46-
"""
47-
48-
quiet: bool = False
49-
verbose: bool = False
50-
trace: bool = False
51-
keep_build: bool = False
52-
default_config_overrides: dict[str, Any] | None = None
53-
54-
5534
class PDFBaker(LoggingMixin):
5635
"""Main class for PDF document generation."""
5736

@@ -82,7 +61,7 @@ def __init__(
8261
def __init__(
8362
self,
8463
config_file: Path,
85-
options: PDFBakerOptions | None = None,
64+
options: BakerOptions | None = None,
8665
) -> None:
8766
"""Initialize PDFBaker with config file path. Set logging level.
8867
@@ -91,7 +70,7 @@ def __init__(
9170
options: Optional options for logging and build behavior
9271
"""
9372
super().__init__()
94-
options = options or PDFBakerOptions()
73+
options = options or BakerOptions()
9574
setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose)
9675
self.keep_build = options.keep_build
9776

src/pdfbaker/config.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,212 @@
22

33
import logging
44
import pprint
5+
from enum import Enum
56
from pathlib import Path
67
from typing import Any
78

89
import yaml
910
from jinja2 import Template
11+
from pydantic import (
12+
BaseModel,
13+
ConfigDict,
14+
Field,
15+
model_validator,
16+
)
17+
from ruamel.yaml import YAML
1018

1119
from .errors import ConfigurationError
12-
from .logging import truncate_strings
20+
from .logging import LoggingMixin, truncate_strings
1321
from .types import PathSpec
1422

1523
__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"]
1624

1725
logger = logging.getLogger(__name__)
1826

1927

28+
# #####################################################################
29+
# New Pydantic models
30+
# #####################################################################
31+
32+
# TODO: show names instead of index numbers for error locations
33+
# https://docs.pydantic.dev/latest/errors/errors/#customize-error-messages
34+
35+
36+
class NewPathSpec(BaseModel):
37+
"""File/Directory location in YAML config."""
38+
39+
# Relative paths may not exist until resolved against root,
40+
# so we have to check existence later
41+
# path: FilePath | DirectoryPath
42+
path: Path
43+
name: str = Field(default_factory=lambda data: data["path"].stem)
44+
45+
@model_validator(mode="before")
46+
@classmethod
47+
def ensure_pathspec(cls, data: Any) -> Any:
48+
"""Coerce what was given"""
49+
if isinstance(data, str):
50+
data = {"name": data}
51+
if isinstance(data, dict) and "path" not in data:
52+
data["path"] = Path(data["name"])
53+
return data
54+
55+
56+
class ImageSpec(NewPathSpec):
57+
"""Image specification."""
58+
59+
type: str | None = None
60+
data: str | None = None
61+
62+
63+
class StyleDict(BaseModel):
64+
"""Style configuration."""
65+
66+
highlight_color: str | None = None
67+
68+
69+
class DirectoriesConfig(BaseModel):
70+
"""Directories configuration."""
71+
72+
root: NewPathSpec
73+
build: NewPathSpec
74+
dist: NewPathSpec
75+
documents: NewPathSpec
76+
pages: NewPathSpec
77+
templates: NewPathSpec
78+
images: NewPathSpec
79+
80+
@model_validator(mode="after")
81+
def resolve_paths(self) -> Any:
82+
"""Resolve all paths relative to the root directory."""
83+
self.root.path = self.root.path.resolve()
84+
for field_name, value in self.__dict__.items():
85+
if field_name != "root" and isinstance(value, NewPathSpec):
86+
value.path = (self.root.path / value.path).resolve()
87+
return self
88+
89+
90+
class PageConfig(BaseModel, LoggingMixin):
91+
"""Page configuration."""
92+
93+
directories: DirectoriesConfig
94+
template: NewPathSpec
95+
model_config = ConfigDict(
96+
strict=True, # don't try to coerce values
97+
extra="allow", # will go in __pydantic_extra__ dict
98+
)
99+
100+
101+
class DocumentConfig(BaseModel, LoggingMixin):
102+
"""Document configuration.
103+
104+
Lazy-loads page configs.
105+
"""
106+
107+
directories: DirectoriesConfig
108+
pages: list[Path | PageConfig]
109+
model_config = ConfigDict(
110+
strict=True, # don't try to coerce values
111+
extra="allow", # will go in __pydantic_extra__ dict
112+
)
113+
114+
115+
class DocumentVariantConfig(DocumentConfig):
116+
"""Document variant configuration."""
117+
118+
119+
class TemplateRenderer(Enum):
120+
"""Possible values for template_renderers."""
121+
122+
RENDER_HIGHLIGHT = "render_highlight"
123+
124+
125+
class TemplateFilter(Enum):
126+
"""Possible values for template_filters."""
127+
128+
WORDWRAP = "wordwrap"
129+
130+
131+
class SVG2PDFBackend(Enum):
132+
"""Possible values for svg2pdf_backend."""
133+
134+
CAIROSVG = "cairosvg"
135+
INKSCAPE = "inkscape"
136+
137+
138+
class BakerConfig(BaseModel, LoggingMixin):
139+
"""Baker configuration.
140+
141+
Lazy-loads document configs.
142+
"""
143+
144+
directories: DirectoriesConfig
145+
# TODO: lazy/forgiving documents parsing
146+
# documents: list[Path | DocumentConfig]
147+
documents: list[str]
148+
template_renderers: list[TemplateRenderer] = [TemplateRenderer.RENDER_HIGHLIGHT]
149+
template_filters: list[TemplateFilter] = [TemplateFilter.WORDWRAP]
150+
svg2pdf_backend: SVG2PDFBackend | None = SVG2PDFBackend.CAIROSVG
151+
compress_pdf: bool = False
152+
model_config = ConfigDict(
153+
strict=True, # don't try to coerce values
154+
extra="allow", # will go in __pydantic_extra__ dict
155+
)
156+
157+
@model_validator(mode="before")
158+
@classmethod
159+
def load_config(cls, data: Any) -> Any:
160+
"""Load documents from YAML file."""
161+
if isinstance(data, dict) and "config_file" in data:
162+
config_file = data.pop("config_file")
163+
config_data = YAML().load(config_file.read_text())
164+
config_data.update(data) # let kwargs override values from YAML
165+
return config_data
166+
return data
167+
168+
@model_validator(mode="before")
169+
@classmethod
170+
def set_default_directories(cls, data: Any) -> Any:
171+
"""Set default directories."""
172+
if isinstance(data, dict):
173+
directories = data.setdefault("directories", {})
174+
directories.setdefault("root", ".")
175+
directories.setdefault("build", "build")
176+
directories.setdefault("dist", "dist")
177+
directories.setdefault("documents", ".")
178+
directories.setdefault("pages", "pages")
179+
directories.setdefault("templates", "templates")
180+
directories.setdefault("images", "images")
181+
return data
182+
183+
@property
184+
def custom_config(self) -> dict[str, Any]:
185+
"""Dictionary of all custom user-defined configuration."""
186+
return self.__pydantic_extra__
187+
188+
189+
class BakerOptions(BaseModel):
190+
"""Options for controlling PDFBaker behavior.
191+
192+
Attributes:
193+
quiet: Show errors only
194+
verbose: Show debug information
195+
trace: Show trace information (even more detailed than debug)
196+
keep_build: Keep build artifacts after processing
197+
default_config_overrides: Dictionary of values to override the built-in defaults
198+
before loading the main configuration
199+
"""
200+
201+
quiet: bool = False
202+
verbose: bool = False
203+
trace: bool = False
204+
keep_build: bool = False
205+
default_config_overrides: dict[str, Any] | None = None
206+
207+
208+
# #####################################################################
209+
210+
20211
def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
21212
"""Deep merge two dictionaries."""
22213
result = base.copy()

src/test_config.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Test the new Pydantic config models."""
2+
3+
import sys
4+
from pathlib import Path
5+
6+
import ruamel.yaml
7+
from pydantic import ValidationError
8+
9+
from pdfbaker import config
10+
11+
CONFIG_FILE = Path("/home/danny/src/pdfbaker/examples/examples.yaml")
12+
13+
14+
def simple_representer(tag):
15+
"""Represent object as a string."""
16+
return lambda representer, data: representer.represent_scalar(tag, str(data))
17+
18+
19+
def register_representers(yaml_instance, class_tag_map, use_multi_for=()):
20+
"""Register representer..
21+
22+
If a class is in use_multi_for, subclasses will also be covered.
23+
(like PosixPath is a subclass of Path)
24+
"""
25+
for cls, tag in class_tag_map.items():
26+
func = simple_representer(tag)
27+
if cls in use_multi_for:
28+
# Add a representer for the class and all subclasses.
29+
yaml_instance.representer.add_multi_representer(cls, func)
30+
else:
31+
# Add a representer for this exact class only.
32+
yaml_instance.representer.add_representer(cls, func)
33+
34+
35+
yaml = ruamel.yaml.YAML()
36+
yaml.indent(offset=4)
37+
yaml.default_flow_style = False
38+
register_representers(
39+
yaml,
40+
{
41+
Path: "!path",
42+
config.SVG2PDFBackend: "!svg2pdf_backend",
43+
config.TemplateRenderer: "!template_renderer",
44+
config.TemplateFilter: "!template_filter",
45+
},
46+
use_multi_for=(Path,),
47+
)
48+
49+
try:
50+
baker_config = config.BakerConfig(config_file=CONFIG_FILE)
51+
baker_config_dict = baker_config.model_dump()
52+
print("*** Full config after parsing: ***")
53+
yaml.dump(baker_config_dict, sys.stdout)
54+
print()
55+
print("*** Custom config values only: ***")
56+
yaml.dump(baker_config.custom_config, sys.stdout)
57+
except ValidationError as e:
58+
print(e)

0 commit comments

Comments
 (0)