Coverage for src/pdfbaker/config.py: 99%

75 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-20 14:42 +1200

1"""Base configuration for pdfbaker classes.""" 

2 

3import logging 

4import pprint 

5from pathlib import Path 

6from typing import Any 

7 

8import yaml 

9from jinja2 import Template 

10 

11from .errors import ConfigurationError 

12from .logging import truncate_strings 

13from .types import PathSpec 

14 

15__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"] 

16 

17logger = logging.getLogger(__name__) 

18 

19 

20def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: 

21 """Deep merge two dictionaries.""" 

22 result = base.copy() 

23 for key, value in update.items(): 

24 if key in result and isinstance(result[key], dict) and isinstance(value, dict): 

25 result[key] = deep_merge(result[key], value) 

26 else: 

27 result[key] = value 

28 return result 

29 

30 

31class PDFBakerConfiguration(dict): 

32 """Base class for handling config loading/merging/parsing.""" 

33 

34 def __init__( 

35 self, 

36 base_config: dict[str, Any], 

37 config_file: Path, 

38 ) -> None: 

39 """Initialize configuration from a file. 

40 

41 Args: 

42 base_config: Existing base configuration 

43 config: Path to YAML file to merge with base_config 

44 """ 

45 try: 

46 with open(config_file, encoding="utf-8") as f: 

47 config = yaml.safe_load(f) 

48 except yaml.scanner.ScannerError as exc: 

49 raise ConfigurationError( 

50 f"Invalid YAML syntax in config file {config_file}: {exc}" 

51 ) from exc 

52 except Exception as exc: 

53 raise ConfigurationError(f"Failed to load config file: {exc}") from exc 

54 

55 # Determine all relevant directories 

56 self["directories"] = directories = {"config": config_file.parent.resolve()} 

57 for directory in ( 

58 "documents", 

59 "pages", 

60 "templates", 

61 "images", 

62 "build", 

63 "dist", 

64 ): 

65 if directory in config.get("directories", {}): 

66 # Set in this config file, relative to this config file 

67 directories[directory] = self.resolve_path( 

68 config["directories"][directory] 

69 ) 

70 elif directory in base_config.get("directories", {}): 

71 # Inherited (absolute) or default (relative to _this_ config) 

72 directories[directory] = self.resolve_path( 

73 str(base_config["directories"][directory]) 

74 ) 

75 super().__init__(deep_merge(base_config, config)) 

76 self["directories"] = directories 

77 

78 def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path: 

79 """Resolve a possibly relative path specification. 

80 

81 Args: 

82 spec: Path specification (string or dict with path/name) 

83 directory: Optional directory to use for resolving paths 

84 Returns: 

85 Resolved Path object 

86 """ 

87 directory = directory or self["directories"]["config"] 

88 if isinstance(directory, str): 

89 directory = Path(directory) 

90 

91 if isinstance(spec, str): 

92 return directory / spec 

93 

94 if "path" not in spec and "name" not in spec: 

95 raise ConfigurationError("Invalid path specification: needs path or name") 

96 

97 if "path" in spec: 

98 return Path(spec["path"]) 

99 

100 return directory / spec["name"] 

101 

102 def pretty(self, max_chars: int = 60) -> str: 

103 """Return readable presentation (for debugging).""" 

104 truncated = truncate_strings(self, max_chars=max_chars) 

105 return pprint.pformat(truncated, indent=2) 

106 

107 

108def _convert_paths_to_strings(config: dict[str, Any]) -> dict[str, Any]: 

109 """Convert all Path objects in config to strings.""" 

110 result = {} 

111 for key, value in config.items(): 

112 if isinstance(value, Path): 

113 result[key] = str(value) 

114 elif isinstance(value, dict): 

115 result[key] = _convert_paths_to_strings(value) 

116 elif isinstance(value, list): 

117 result[key] = [ 

118 _convert_paths_to_strings(item) 

119 if isinstance(item, dict) 

120 else str(item) 

121 if isinstance(item, Path) 

122 else item 

123 for item in value 

124 ] 

125 else: 

126 result[key] = value 

127 return result 

128 

129 

130def render_config(config: dict[str, Any]) -> dict[str, Any]: 

131 """Resolve all template strings in config using its own values. 

132 

133 This allows the use of "{{ variant }}" in the "filename" etc. 

134 

135 Args: 

136 config: Configuration dictionary to render 

137 

138 Returns: 

139 Resolved configuration dictionary 

140 

141 Raises: 

142 ConfigurationError: If maximum number of iterations is reached 

143 (circular references) 

144 """ 

145 max_iterations = 10 

146 current_config = dict(config) 

147 current_config = _convert_paths_to_strings(current_config) 

148 

149 for _ in range(max_iterations): 

150 config_yaml = Template(yaml.dump(current_config)) 

151 resolved_yaml = config_yaml.render(**current_config) 

152 new_config = yaml.safe_load(resolved_yaml) 

153 

154 # Check for direct self-references 

155 for key, value in new_config.items(): 

156 if isinstance(value, str) and f"{ { {key} } } " in value: 

157 raise ConfigurationError( 

158 f"Circular reference detected: {key} references itself" 

159 ) 

160 

161 if new_config == current_config: # No more changes 

162 return new_config 

163 current_config = new_config 

164 

165 raise ConfigurationError( 

166 "Maximum number of iterations reached. " 

167 "Check for circular references in your configuration." 

168 )