Coverage for src/pdfbaker/baker.py: 85%
73 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-20 04:55 +1200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-20 04:55 +1200
1"""PDFBaker class.
3Overall orchestration and logging.
5Is given a configuration file and sets up logging.
6bake() delegates to its documents and reports back the end result.
7"""
9from dataclasses import dataclass
10from pathlib import Path
11from typing import Any
13from .config import PDFBakerConfiguration, deep_merge
14from .document import PDFBakerDocument
15from .errors import ConfigurationError
16from .logging import LoggingMixin, setup_logging
18__all__ = ["PDFBaker", "PDFBakerOptions"]
21DEFAULT_BAKER_CONFIG = {
22 # Default to directories relative to the config file
23 "directories": {
24 "documents": ".",
25 "build": "build",
26 "dist": "dist",
27 },
28}
31@dataclass
32class PDFBakerOptions:
33 """Options for controlling PDFBaker behavior.
35 Attributes:
36 quiet: Show errors only
37 verbose: Show debug information
38 trace: Show trace information (even more detailed than debug)
39 keep_build: Keep build artifacts after processing
40 default_config_overrides: Dictionary of values to override the built-in defaults
41 before loading the main configuration
42 """
44 quiet: bool = False
45 verbose: bool = False
46 trace: bool = False
47 keep_build: bool = False
48 default_config_overrides: dict[str, Any] | None = None
51class PDFBaker(LoggingMixin):
52 """Main class for PDF document generation."""
54 class Configuration(PDFBakerConfiguration):
55 """PDFBaker configuration."""
57 def __init__(
58 self, baker: "PDFBaker", base_config: dict[str, Any], config_file: Path
59 ) -> None:
60 """Initialize baker configuration (needs documents)."""
61 self.baker = baker
62 self.baker.log_debug_section("Loading main configuration: %s", config_file)
63 super().__init__(base_config, config_file)
64 self.baker.log_trace(self.pretty())
65 if "documents" not in self:
66 raise ConfigurationError(
67 'Key "documents" missing - is this the main configuration file?'
68 )
69 self.build_dir = self["directories"]["build"]
70 self.documents = [
71 self.resolve_path(doc_spec, directory=self["directories"]["documents"])
72 for doc_spec in self["documents"]
73 ]
75 def __init__(
76 self,
77 config_file: Path,
78 options: PDFBakerOptions | None = None,
79 ) -> None:
80 """Initialize PDFBaker with config file path. Set logging level.
82 Args:
83 config_file: Path to config file
84 options: Optional options for logging and build behavior
85 """
86 super().__init__()
87 options = options or PDFBakerOptions()
88 setup_logging(quiet=options.quiet, trace=options.trace, verbose=options.verbose)
89 self.keep_build = options.keep_build
91 base_config = DEFAULT_BAKER_CONFIG.copy()
92 if options and options.default_config_overrides:
93 base_config = deep_merge(base_config, options.default_config_overrides)
94 base_config["directories"]["config"] = config_file.parent.resolve()
96 self.config = self.Configuration(
97 baker=self,
98 base_config=base_config,
99 config_file=config_file,
100 )
102 def bake(self) -> None:
103 """Create PDFs for all documents.
105 Returns:
106 bool: True if all documents were processed successfully, False if any failed
107 """
108 pdfs_created: list[Path] = []
109 failed_docs: list[tuple[str, str]] = []
111 self.log_debug_subsection("Documents to process:")
112 self.log_debug(self.config.documents)
113 for doc_config in self.config.documents:
114 doc = PDFBakerDocument(
115 baker=self,
116 base_config=self.config,
117 config_path=doc_config,
118 )
119 pdf_files, error_message = doc.process_document()
120 if error_message:
121 self.log_error(
122 "Failed to process document '%s': %s",
123 doc.config.name,
124 error_message,
125 )
126 failed_docs.append((doc.config.name, error_message))
127 else:
128 if isinstance(pdf_files, Path):
129 pdf_files = [pdf_files]
130 pdfs_created.extend(pdf_files)
131 if not self.keep_build:
132 doc.teardown()
134 if pdfs_created:
135 self.log_info("Successfully created PDFs:")
136 for pdf in pdfs_created:
137 self.log_info(" %s", pdf)
138 else:
139 self.log_warning("No PDFs were created.")
141 if failed_docs:
142 self.log_warning(
143 "Failed to process %d document%s:",
144 len(failed_docs),
145 "" if len(failed_docs) == 1 else "s",
146 )
147 for doc_name, error in failed_docs:
148 self.log_error(" %s: %s", doc_name, error)
150 if not self.keep_build:
151 self.teardown()
153 return not failed_docs
155 def teardown(self) -> None:
156 """Clean up (top-level) build directory after processing."""
157 self.log_debug_subsection(
158 "Tearing down top-level build directory: %s", self.config.build_dir
159 )
160 if self.config.build_dir.exists():
161 try:
162 self.log_debug("Removing top-level build directory...")
163 self.config.build_dir.rmdir()
164 except OSError:
165 self.log_warning("Top-level build directory not empty - not removing")