Skip to content

Commit 4f23218

Browse files
committed
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
1 parent e776433 commit 4f23218

18 files changed

Lines changed: 683 additions & 766 deletions

File tree

.pre-commit-config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,4 @@ repos:
5252
- "pydantic"
5353
- "pypdf"
5454
- "pytest"
55-
- "pyyaml" # FIXME: remove once fully migrated to ruamel
5655
- "ruamel.yaml"

examples/custom_locations/other_pages/custom_page.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
title: "Custom Location Example"
22
description: "This page uses custom directory structure"
33
template:
4-
# If you just wrote this directly it would be relative to the templates directory
5-
# We want it to be relative to the config file, so use path:
64
path: "../other_templates/custom_page.svg.j2"
75
detailed_description:
86
"This example demonstrates custom file locations in pdfbaker. The template file is in

examples/custom_processing/bake.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
import urllib.request
66
from datetime import datetime
77

8-
from pdfbaker.document import PDFBakerDocument
8+
from pdfbaker.document import Document
99
from pdfbaker.errors import PDFBakerError
1010
from pdfbaker.processing import wordwrap
1111

1212

13-
def process_document(document: PDFBakerDocument) -> None:
13+
def process_document(document: Document) -> None:
1414
"""Process document with live XKCD comic."""
1515
try:
1616
# Fetch latest XKCD
@@ -29,7 +29,7 @@ def process_document(document: PDFBakerDocument) -> None:
2929
wrapped_alt_text = wordwrap(data["alt"], max_chars=60)
3030

3131
# Update config/template context with XKCD info
32-
document.config["xkcd"] = {
32+
document.config.xkcd = {
3333
"title": data["title"],
3434
"alt_text": data["alt"],
3535
"alt_text_lines": wrapped_alt_text,

examples/examples.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ documents:
22
- minimal
33
- regular
44
- variants
5-
- ./custom_locations/your_directory
5+
- path: ./custom_locations/your_directory
6+
name: custom_locations
67
- custom_processing
78

89
custom_stuff:

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ dependencies = [
1111
"jinja2",
1212
"pydantic",
1313
"pypdf",
14-
"pyyaml", # FIXME: remove once fully migrated to ruamel
1514
"ruamel.yaml",
1615
]
1716
readme = "README.md"

src/pdfbaker/__main__.py

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

99
from pdfbaker import __version__
10-
from pdfbaker.baker import PDFBaker
11-
from pdfbaker.config import BakerOptions
10+
from pdfbaker.baker import Baker, BakerOptions
1211
from pdfbaker.errors import DocumentNotFoundError, PDFBakerError
1312

1413
logger = logging.getLogger(__name__)
@@ -61,14 +60,14 @@ def bake(
6160
trace=trace,
6261
keep_build=keep_build,
6362
)
64-
baker = PDFBaker(config_file, options=options)
63+
baker = Baker(config_file, options=options)
6564
success = baker.bake(document_names=documents if documents else None)
6665
sys.exit(0 if success else 1)
6766
except DocumentNotFoundError as exc:
68-
logger.error(str(exc))
67+
logger.error("❌ %s", str(exc))
6968
sys.exit(2)
7069
except PDFBakerError as exc:
71-
logger.error(str(exc))
70+
logger.error("❌ %s", str(exc))
7271
sys.exit(1)
7372

7473

src/pdfbaker/baker.py

Lines changed: 69 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""PDFBaker class.
1+
"""Baker class.
22
33
Overall orchestration and logging.
44
@@ -7,87 +7,61 @@
77
"""
88

99
from pathlib import Path
10-
from typing import Any
1110

12-
from .config import BakerOptions, PDFBakerConfiguration, deep_merge
13-
from .document import PDFBakerDocument
14-
from .errors import ConfigurationError, DocumentNotFoundError
11+
from pydantic import BaseModel, ValidationError
12+
13+
from .config import (
14+
BakerConfig,
15+
PathSpec,
16+
)
17+
from .document import Document
18+
from .errors import DocumentNotFoundError
1519
from .logging import LoggingMixin, setup_logging
1620

17-
__all__ = ["PDFBaker"]
18-
19-
20-
DEFAULT_BAKER_CONFIG = {
21-
# Default to directories relative to the config file
22-
"directories": {
23-
"documents": ".",
24-
"build": "build",
25-
"dist": "dist",
26-
},
27-
# Highlighting support enabled by default
28-
"template_renderers": ["render_highlight"],
29-
# Make all filters available by default
30-
"template_filters": ["wordwrap"],
31-
}
32-
33-
34-
class PDFBaker(LoggingMixin):
35-
"""Main class for PDF document generation."""
36-
37-
class Configuration(PDFBakerConfiguration):
38-
"""PDFBaker configuration."""
39-
40-
def __init__(
41-
self, baker: "PDFBaker", base_config: dict[str, Any], config_file: Path
42-
) -> None:
43-
"""Initialize baker configuration (needs documents)."""
44-
self.baker = baker
45-
self.name = config_file.name
46-
self.baker.log_debug_section("Loading main configuration: %s", config_file)
47-
super().__init__(base_config, config_file)
48-
self.baker.log_trace(self.pretty())
49-
if "documents" not in self:
50-
raise ConfigurationError(
51-
'Key "documents" missing - is this the main configuration file?'
52-
)
53-
self.build_dir = self["directories"]["build"]
54-
self.documents = []
55-
for doc_spec in self["documents"]:
56-
doc_path = self.resolve_path(
57-
doc_spec, directory=self["directories"]["documents"]
58-
)
59-
self.documents.append({"name": doc_path.name, "path": doc_path})
21+
__all__ = ["Baker", "BakerOptions"]
22+
23+
24+
class BakerOptions(BaseModel):
25+
"""Options for controlling PDFBaker behavior.
26+
27+
Attributes:
28+
quiet: Show errors only
29+
verbose: Show debug information
30+
trace: Show trace information (even more detailed than debug)
31+
keep_build: Keep build artifacts after processing
32+
default_config_overrides: Dictionary of values to override the built-in defaults
33+
before loading the main configuration
34+
"""
35+
36+
quiet: bool = False
37+
verbose: bool = False
38+
trace: bool = False
39+
keep_build: bool = False
40+
41+
42+
class Baker(LoggingMixin):
43+
"""Baker class."""
6044

6145
def __init__(
6246
self,
6347
config_file: Path,
6448
options: BakerOptions | None = None,
49+
**kwargs,
6550
) -> None:
66-
"""Initialize PDFBaker with config file path. Set logging level.
67-
68-
Args:
69-
config_file: Path to config file
70-
options: Optional options for logging and build behavior
71-
"""
72-
super().__init__()
51+
"""Set up logging and load configuration."""
7352
options = options or BakerOptions()
7453
setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose)
75-
self.keep_build = options.keep_build
76-
77-
base_config = DEFAULT_BAKER_CONFIG.copy()
78-
if options and options.default_config_overrides:
79-
base_config = deep_merge(base_config, options.default_config_overrides)
80-
base_config["directories"]["config"] = config_file.parent.resolve()
81-
82-
self.config = self.Configuration(
83-
baker=self,
84-
base_config=base_config,
54+
self.log_debug_section("Loading main configuration: %s", config_file)
55+
self.config = BakerConfig(
8556
config_file=config_file,
57+
keep_build=options and options.keep_build or False,
58+
**kwargs,
8659
)
60+
self.log_trace(self.config.readable())
8761

8862
def _get_documents_to_process(
8963
self, selected_document_names: tuple[str, ...] | None = None
90-
) -> list[Path]:
64+
) -> list[PathSpec]:
9165
"""Get the document paths to process based on optional filtering.
9266
9367
Args:
@@ -99,61 +73,57 @@ def _get_documents_to_process(
9973
if not selected_document_names:
10074
return self.config.documents
10175

102-
available_doc_names = [doc["name"] for doc in self.config.documents]
76+
available_doc_names = [doc.name for doc in self.config.documents]
10377
missing_docs = [
10478
name for name in selected_document_names if name not in available_doc_names
10579
]
10680
if missing_docs:
10781
available_str = ", ".join([f'"{name}"' for name in available_doc_names])
108-
self.log_info(f"Documents in {self.config.name}: {available_str}")
82+
self.log_info(
83+
f"Documents in {self.config.config_file.name}: {available_str}"
84+
)
10985
missing_str = ", ".join([f'"{name}"' for name in missing_docs])
11086
raise DocumentNotFoundError(
11187
f"Document{'s' if len(missing_docs) != 1 else ''} not found "
11288
f"in configuration: {missing_str}."
11389
)
11490

11591
return [
116-
doc
117-
for doc in self.config.documents
118-
if doc["name"] in selected_document_names
92+
doc for doc in self.config.documents if doc.name in selected_document_names
11993
]
12094

121-
def bake(self, document_names: tuple[str, ...] | None = None) -> bool:
122-
"""Create PDFs for all documents or only the specified ones.
123-
124-
Args:
125-
document_names: Optional tuple of document names to process
126-
127-
Returns:
128-
bool: True if all documents were processed successfully, False if any failed
129-
"""
95+
def bake(self, document_names: tuple[str, ...] | None = None) -> None:
96+
"""Bake the documents."""
13097
pdfs_created: list[Path] = []
131-
failed_docs: list[tuple[str, str]] = []
98+
failed_docs: list[tuple[PathSpec, str]] = []
13299

133-
documents = self._get_documents_to_process(document_names)
100+
doc_configs = self._get_documents_to_process(document_names)
134101

135102
self.log_debug_subsection("Documents to process:")
136-
self.log_debug(documents)
137-
for doc_config in documents:
138-
doc = PDFBakerDocument(
139-
baker=self,
140-
base_config=self.config,
141-
config_path=doc_config["path"],
142-
)
143-
pdf_files, error_message = doc.process_document()
103+
self.log_debug(doc_configs)
104+
for doc_config in doc_configs:
105+
try:
106+
document = Document(
107+
config_path=doc_config, **self.config.document_settings
108+
)
109+
except ValidationError as e:
110+
self.log_error(f'Invalid config for document "{doc_config.name}": {e}')
111+
continue
112+
113+
pdf_files, error_message = document.process_document()
144114
if error_message:
145115
self.log_error(
146116
"Failed to process document '%s': %s",
147-
doc.config.name,
117+
document.config.name,
148118
error_message,
149119
)
150-
failed_docs.append((doc.config.name, error_message))
120+
failed_docs.append((document, error_message))
151121
else:
152122
if isinstance(pdf_files, Path):
153123
pdf_files = [pdf_files]
154124
pdfs_created.extend(pdf_files)
155-
if not self.keep_build:
156-
doc.teardown()
125+
if not self.config.keep_build:
126+
document.teardown()
157127

158128
if pdfs_created:
159129
self.log_info("Successfully created PDFs:")
@@ -171,19 +141,20 @@ def bake(self, document_names: tuple[str, ...] | None = None) -> bool:
171141
for doc_name, error in failed_docs:
172142
self.log_error(" %s: %s", doc_name, error)
173143

174-
if not self.keep_build:
144+
if not self.config.keep_build:
175145
self.teardown()
176146

177147
return not failed_docs
178148

179149
def teardown(self) -> None:
180150
"""Clean up (top-level) build directory after processing."""
151+
build_dir = self.config.directories.build
181152
self.log_debug_subsection(
182-
"Tearing down top-level build directory: %s", self.config.build_dir
153+
"Tearing down top-level build directory: %s", build_dir
183154
)
184-
if self.config.build_dir.exists():
155+
if build_dir.exists():
185156
try:
186157
self.log_debug("Removing top-level build directory...")
187-
self.config.build_dir.rmdir()
158+
build_dir.rmdir()
188159
except OSError:
189160
self.log_warning("Top-level build directory not empty - not removing")

0 commit comments

Comments
 (0)