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

1"""PDFBaker class. 

2 

3Overall orchestration and logging. 

4 

5Is given a configuration file and sets up logging. 

6bake() delegates to its documents and reports back the end result. 

7""" 

8 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import Any 

12 

13from .config import PDFBakerConfiguration, deep_merge 

14from .document import PDFBakerDocument 

15from .errors import ConfigurationError 

16from .logging import LoggingMixin, setup_logging 

17 

18__all__ = ["PDFBaker", "PDFBakerOptions"] 

19 

20 

21DEFAULT_BAKER_CONFIG = { 

22 # Default to directories relative to the config file 

23 "directories": { 

24 "documents": ".", 

25 "build": "build", 

26 "dist": "dist", 

27 }, 

28} 

29 

30 

31@dataclass 

32class PDFBakerOptions: 

33 """Options for controlling PDFBaker behavior. 

34 

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 """ 

43 

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 

49 

50 

51class PDFBaker(LoggingMixin): 

52 """Main class for PDF document generation.""" 

53 

54 class Configuration(PDFBakerConfiguration): 

55 """PDFBaker configuration.""" 

56 

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 ] 

74 

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. 

81 

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 

90 

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() 

95 

96 self.config = self.Configuration( 

97 baker=self, 

98 base_config=base_config, 

99 config_file=config_file, 

100 ) 

101 

102 def bake(self) -> None: 

103 """Create PDFs for all documents. 

104 

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]] = [] 

110 

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() 

133 

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.") 

140 

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) 

149 

150 if not self.keep_build: 

151 self.teardown() 

152 

153 return not failed_docs 

154 

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")