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
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-20 14:42 +1200
1"""Base configuration for pdfbaker classes."""
3import logging
4import pprint
5from pathlib import Path
6from typing import Any
8import yaml
9from jinja2 import Template
11from .errors import ConfigurationError
12from .logging import truncate_strings
13from .types import PathSpec
15__all__ = ["PDFBakerConfiguration", "deep_merge", "render_config"]
17logger = logging.getLogger(__name__)
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
31class PDFBakerConfiguration(dict):
32 """Base class for handling config loading/merging/parsing."""
34 def __init__(
35 self,
36 base_config: dict[str, Any],
37 config_file: Path,
38 ) -> None:
39 """Initialize configuration from a file.
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
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
78 def resolve_path(self, spec: PathSpec, directory: Path | None = None) -> Path:
79 """Resolve a possibly relative path specification.
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)
91 if isinstance(spec, str):
92 return directory / spec
94 if "path" not in spec and "name" not in spec:
95 raise ConfigurationError("Invalid path specification: needs path or name")
97 if "path" in spec:
98 return Path(spec["path"])
100 return directory / spec["name"]
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)
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
130def render_config(config: dict[str, Any]) -> dict[str, Any]:
131 """Resolve all template strings in config using its own values.
133 This allows the use of "{{ variant }}" in the "filename" etc.
135 Args:
136 config: Configuration dictionary to render
138 Returns:
139 Resolved configuration dictionary
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)
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)
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 )
161 if new_config == current_config: # No more changes
162 return new_config
163 current_config = new_config
165 raise ConfigurationError(
166 "Maximum number of iterations reached. "
167 "Check for circular references in your configuration."
168 )