Skip to content

Commit 47d6903

Browse files
committed
Various improvements
* Allow override of defaults (for library use, incl. our tests) * Improved logging for all log levels (incl. template rendering preview for --trace) * Teardown of top-level build directory * Minor tidy-ups/clarifications
1 parent 415b72f commit 47d6903

6 files changed

Lines changed: 101 additions & 50 deletions

File tree

src/pdfbaker/__main__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,10 @@ def cli() -> None:
3030
"-t",
3131
"--trace",
3232
is_flag=True,
33-
help="Show trace information (even more detailed than debug)",
33+
help="Show trace information (even more detailed than --verbose)",
3434
)
3535
@click.option("--keep-build", is_flag=True, help="Keep build artifacts")
36-
@click.option(
37-
"--debug", is_flag=True, help="Debug mode (implies --verbose and --keep-build)"
38-
)
36+
@click.option("--debug", is_flag=True, help="Debug mode (--verbose and --keep-build)")
3937
# pylint: disable=too-many-arguments,too-many-positional-arguments
4038
def bake(
4139
config_file: Path,

src/pdfbaker/baker.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pathlib import Path
1212
from typing import Any
1313

14-
from .config import PDFBakerConfiguration
14+
from .config import PDFBakerConfiguration, deep_merge
1515
from .document import PDFBakerDocument
1616
from .errors import ConfigurationError
1717
from .logging import TRACE, LoggingMixin
@@ -38,12 +38,15 @@ class PDFBakerOptions:
3838
verbose: Show debug information
3939
trace: Show trace information (even more detailed than debug)
4040
keep_build: Keep build artifacts after processing
41+
default_config_overrides: Dictionary of values to override the built-in defaults
42+
before loading the main configuration
4143
"""
4244

4345
quiet: bool = False
4446
verbose: bool = False
4547
trace: bool = False
4648
keep_build: bool = False
49+
default_config_overrides: dict[str, Any] | None = None
4750

4851

4952
class PDFBaker(LoggingMixin):
@@ -57,13 +60,14 @@ def __init__(
5760
) -> None:
5861
"""Initialize baker configuration (needs documents)."""
5962
self.baker = baker
63+
self.baker.log_debug_section("Loading main configuration: %s", config_file)
6064
super().__init__(base_config, config_file)
61-
self.baker.log_trace_section("Main configuration: %s", config_file)
6265
self.baker.log_trace(self.pretty())
6366
if "documents" not in self:
6467
raise ConfigurationError(
6568
'Key "documents" missing - is this the main configuration file?'
6669
)
70+
self.build_dir = self["directories"]["build"]
6771
self.documents = [
6872
self.resolve_path(doc_spec, directory=self["directories"]["documents"])
6973
for doc_spec in self["documents"]
@@ -93,7 +97,13 @@ def __init__(
9397
else:
9498
logging.getLogger().setLevel(logging.INFO)
9599
self.keep_build = options.keep_build
100+
101+
# Start with defaults and apply any overrides
96102
base_config = DEFAULT_BAKER_CONFIG.copy()
103+
if options and options.default_config_overrides:
104+
base_config = deep_merge(base_config, options.default_config_overrides)
105+
106+
# Set config directory and initialize
97107
base_config["directories"]["config"] = config_file.parent.resolve()
98108
self.config = self.Configuration(
99109
baker=self,
@@ -102,7 +112,7 @@ def __init__(
102112
)
103113

104114
def bake(self) -> None:
105-
"""Generate PDFs from documents."""
115+
"""Create PDFs for all documents."""
106116
pdfs_created: list[Path] = []
107117
failed_docs: list[tuple[str, str]] = []
108118

@@ -115,7 +125,7 @@ def bake(self) -> None:
115125
config_path=doc_config,
116126
)
117127
pdf_files, error_message = doc.process_document()
118-
if pdf_files is None:
128+
if error_message:
119129
self.log_error(
120130
"Failed to process document '%s': %s",
121131
doc.config.name,
@@ -130,7 +140,7 @@ def bake(self) -> None:
130140
doc.teardown()
131141

132142
if pdfs_created:
133-
self.log_info("Created PDFs:")
143+
self.log_info("Successfully created PDFs:")
134144
for pdf in pdfs_created:
135145
self.log_info(" %s", pdf)
136146
else:
@@ -144,3 +154,18 @@ def bake(self) -> None:
144154
)
145155
for doc_name, error in failed_docs:
146156
self.log_error(" %s: %s", doc_name, error)
157+
158+
if not self.keep_build:
159+
self.teardown()
160+
161+
def teardown(self) -> None:
162+
"""Clean up (top-level) build directory after processing."""
163+
self.log_debug_subsection(
164+
"Tearing down top-level build directory: %s", self.config.build_dir
165+
)
166+
if self.config.build_dir.exists():
167+
try:
168+
self.log_debug("Removing top-level build directory...")
169+
self.config.build_dir.rmdir()
170+
except OSError:
171+
self.log_warning("Top-level build directory not empty - not removing")

src/pdfbaker/config.py

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from jinja2 import Template
1010

1111
from .errors import ConfigurationError
12+
from .logging import truncate_strings
1213
from .types import PathSpec
1314

1415
__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"]
@@ -27,24 +28,6 @@ def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
2728
return result
2829

2930

30-
def _truncate_strings(obj, max_length: int) -> Any:
31-
"""Recursively truncate strings in nested structures."""
32-
if isinstance(obj, str):
33-
return obj if len(obj) <= max_length else obj[:max_length] + "…"
34-
if isinstance(obj, dict):
35-
return {
36-
_truncate_strings(k, max_length): _truncate_strings(v, max_length)
37-
for k, v in obj.items()
38-
}
39-
if isinstance(obj, list):
40-
return [_truncate_strings(item, max_length) for item in obj]
41-
if isinstance(obj, tuple):
42-
return tuple(_truncate_strings(item, max_length) for item in obj)
43-
if isinstance(obj, set):
44-
return {_truncate_strings(item, max_length) for item in obj}
45-
return obj
46-
47-
4831
class PDFBakerConfiguration(dict):
4932
"""Base class for handling config loading/merging/parsing."""
5033

@@ -110,9 +93,9 @@ def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path:
11093

11194
return directory / spec["name"]
11295

113-
def pretty(self, max_string=60) -> str:
96+
def pretty(self, max_chars: int = 60) -> str:
11497
"""Return readable presentation (for debugging)."""
115-
truncated = _truncate_strings(self, max_string)
98+
truncated = truncate_strings(self, max_chars=max_chars)
11699
return pprint.pformat(truncated, indent=2)
117100

118101

@@ -162,6 +145,13 @@ def render_config(config: dict[str, Any]) -> dict[str, Any]:
162145
resolved_yaml = config_yaml.render(**current_config)
163146
new_config = yaml.safe_load(resolved_yaml)
164147

148+
# Check for direct self-references
149+
for key, value in new_config.items():
150+
if isinstance(value, str) and f"{{{{ {key} }}}}" in value:
151+
raise ConfigurationError(
152+
f"Circular reference detected: {key} references itself"
153+
)
154+
165155
if new_config == current_config: # No more changes
166156
return new_config
167157
current_config = new_config

src/pdfbaker/document.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ def __init__(
7171
base_config = deep_merge(base_config, DEFAULT_DOCUMENT_CONFIG)
7272
base_config["directories"]["config"] = config_path.parent.resolve()
7373

74+
self.document.log_trace_section(
75+
"Loading document configuration: %s", config_path
76+
)
7477
super().__init__(base_config, config_path)
75-
self.document.log_trace_section("Document configuration: %s", config_path)
7678
self.document.log_trace(self.pretty())
7779

7880
self.bake_path = self["directories"]["config"] / "bake.py"
@@ -168,15 +170,13 @@ def process(self) -> Path | list[Path]:
168170
variant_config["variant"] = variant
169171
variant_config = render_config(variant_config)
170172
page_pdfs = self._process_pages(variant_config)
171-
pdf_files.append(
172-
self._combine_and_compress(page_pdfs, variant_config)
173-
)
173+
pdf_files.append(self._finalize(page_pdfs, variant_config))
174174
return pdf_files
175175

176176
# Single PDF document
177177
doc_config = render_config(self.config)
178178
page_pdfs = self._process_pages(doc_config)
179-
return self._combine_and_compress(page_pdfs, doc_config)
179+
return self._finalize(page_pdfs, doc_config)
180180
except Exception:
181181
# Ensure build directory is cleaned up if processing fails
182182
if not self.baker.keep_build:
@@ -189,7 +189,6 @@ def _process_pages(self, config: dict[str, Any]) -> list[Path]:
189189
self.log_debug_subsection("Pages to process:")
190190
self.log_debug(self.config.pages)
191191
for page_num, page_config in enumerate(self.config.pages, start=1):
192-
# FIXME: just call with config - already merged
193192
page = PDFBakerPage(
194193
document=self,
195194
page_number=page_num,
@@ -200,10 +199,10 @@ def _process_pages(self, config: dict[str, Any]) -> list[Path]:
200199

201200
return pdf_files
202201

203-
def _combine_and_compress(
204-
self, pdf_files: list[Path], doc_config: dict[str, Any]
205-
) -> Path:
202+
def _finalize(self, pdf_files: list[Path], doc_config: dict[str, Any]) -> Path:
206203
"""Combine PDF pages and optionally compress."""
204+
self.log_debug_subsection("Finalizing document...")
205+
self.log_debug("Combining PDF pages...")
207206
try:
208207
combined_pdf = combine_pdfs(
209208
pdf_files,
@@ -215,6 +214,7 @@ def _combine_and_compress(
215214
output_path = self.config.dist_dir / f"{doc_config['filename']}.pdf"
216215

217216
if doc_config.get("compress_pdf", False):
217+
self.log_debug("Compressing PDF document...")
218218
try:
219219
compress_pdf(combined_pdf, output_path)
220220
self.log_info("PDF compressed successfully")
@@ -227,23 +227,22 @@ def _combine_and_compress(
227227
else:
228228
os.rename(combined_pdf, output_path)
229229

230-
self.log_info("Created PDF: %s", output_path)
230+
self.log_info("Created %s", output_path.name)
231231
return output_path
232232

233233
def teardown(self) -> None:
234-
"""Clean up build directory after successful processing."""
234+
"""Clean up build directory after processing."""
235235
self.log_debug_subsection(
236236
"Tearing down build directory: %s", self.config.build_dir
237237
)
238238
if self.config.build_dir.exists():
239-
# Remove all files in the build directory
239+
self.log_debug("Removing files in build directory...")
240240
for file_path in self.config.build_dir.iterdir():
241241
if file_path.is_file():
242242
file_path.unlink()
243243

244-
# Try to remove the build directory
245244
try:
245+
self.log_debug("Removing build directory...")
246246
self.config.build_dir.rmdir()
247247
except OSError:
248-
# Directory not empty - this is expected if we have subdirectories
249-
pass
248+
self.log_warning("Build directory not empty - not removing")

src/pdfbaker/logging.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ def log_trace(self, msg: str, *args: Any, **kwargs: Any) -> None:
1818
"""Log a trace message (more detailed than debug)."""
1919
self.logger.log(TRACE, msg, *args, **kwargs)
2020

21+
def log_trace_preview(
22+
self, msg: str, *args: Any, max_chars: int = 500, **kwargs: Any
23+
) -> None:
24+
"""Log a trace preview of a potentially large message, truncating if needed."""
25+
self.logger.log(
26+
TRACE, truncate_strings(msg, max_chars=max_chars), *args, **kwargs
27+
)
28+
2129
def log_trace_section(self, msg: str, *args: Any, **kwargs: Any) -> None:
2230
"""Log a trace message as a main section header."""
2331
self.logger.log(TRACE, f"──── {msg} ────", *args, **kwargs)
@@ -61,3 +69,21 @@ def log_error(self, msg: str, *args: Any, **kwargs: Any) -> None:
6169
def log_critical(self, msg: str, *args: Any, **kwargs: Any) -> None:
6270
"""Log a critical message."""
6371
self.logger.critical(msg, *args, **kwargs)
72+
73+
74+
def truncate_strings(obj, max_chars: int) -> Any:
75+
"""Recursively truncate strings in nested structures."""
76+
if isinstance(obj, str):
77+
return obj if len(obj) <= max_chars else obj[:max_chars] + "…"
78+
if isinstance(obj, dict):
79+
return {
80+
truncate_strings(k, max_chars): truncate_strings(v, max_chars)
81+
for k, v in obj.items()
82+
}
83+
if isinstance(obj, list):
84+
return [truncate_strings(item, max_chars) for item in obj]
85+
if isinstance(obj, tuple):
86+
return tuple(truncate_strings(item, max_chars) for item in obj)
87+
if isinstance(obj, set):
88+
return {truncate_strings(item, max_chars) for item in obj}
89+
return obj

src/pdfbaker/page.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from .config import PDFBakerConfiguration
1515
from .errors import ConfigurationError, SVGConversionError, SVGTemplateError
16-
from .logging import LoggingMixin
16+
from .logging import TRACE, LoggingMixin
1717
from .pdf import convert_svg_to_pdf
1818
from .render import create_env, prepare_template_context
1919

@@ -39,8 +39,8 @@ def __init__(
3939
self.name = config_path.stem
4040
base_config["directories"]["config"] = config_path.parent.resolve()
4141

42+
self.page.log_trace_section("Loading page configuration: %s", config_path)
4243
super().__init__(base_config, config_path)
43-
self.page.log_trace_section("Page configuration: %s", config_path)
4444
self.page.log_trace(self.pretty())
4545

4646
self.templates_dir = self["directories"]["templates"]
@@ -83,9 +83,14 @@ def __init__(
8383

8484
def process(self) -> Path:
8585
"""Render SVG template and convert to PDF."""
86-
self.config.build_dir.mkdir(parents=True, exist_ok=True)
87-
output_svg = self.config.build_dir / f"{self.config.name}_{self.number:03}.svg"
88-
output_pdf = self.config.build_dir / f"{self.config.name}_{self.number:03}.pdf"
86+
self.log_debug_subsection(
87+
"Processing page %d: %s", self.number, self.config.name
88+
)
89+
90+
self.log_debug("Loading template: %s", self.config.template)
91+
if self.logger.isEnabledFor(TRACE):
92+
with open(self.config.template, encoding="utf-8") as f:
93+
self.log_trace_preview(f.read())
8994

9095
try:
9196
jinja_env = create_env(self.config.template.parent)
@@ -101,14 +106,22 @@ def process(self) -> Path:
101106
self.config.images_dir,
102107
)
103108

109+
self.config.build_dir.mkdir(parents=True, exist_ok=True)
110+
output_svg = self.config.build_dir / f"{self.config.name}_{self.number:03}.svg"
111+
output_pdf = self.config.build_dir / f"{self.config.name}_{self.number:03}.pdf"
112+
113+
self.log_debug("Rendering template...")
104114
try:
115+
rendered_template = template.render(**template_context)
105116
with open(output_svg, "w", encoding="utf-8") as f:
106-
f.write(template.render(**template_context))
117+
f.write(rendered_template)
107118
except TemplateError as exc:
108119
raise SVGTemplateError(
109120
f"Failed to render page {self.number} ({self.config.name}): {exc}"
110121
) from exc
122+
self.log_trace_preview(rendered_template)
111123

124+
self.log_debug("Converting SVG to PDF: %s", output_svg)
112125
svg2pdf_backend = self.config.get("svg2pdf_backend", "cairosvg")
113126
try:
114127
return convert_svg_to_pdf(

0 commit comments

Comments
 (0)