Coverage for src/pdfbaker/config.py: 99%
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"""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(spec, str):
89 return directory / spec
91 if "path" not in spec and "name" not in spec:
92 raise ConfigurationError("Invalid path specification: needs path or name")
94 if "path" in spec:
95 return Path(spec["path"])
97 return directory / spec["name"]
99 def pretty(self, max_chars: int = 60) -> str:
100 """Return readable presentation (for debugging)."""
101 truncated = truncate_strings(self, max_chars=max_chars)
102 return pprint.pformat(truncated, indent=2)
105def _convert_paths_to_strings(config: dict[str, Any]) -> dict[str, Any]:
106 """Convert all Path objects in config to strings."""
107 result = {}
108 for key, value in config.items():
109 if isinstance(value, Path):
110 result[key] = str(value)
111 elif isinstance(value, dict):
112 result[key] = _convert_paths_to_strings(value)
113 elif isinstance(value, list):
114 result[key] = [
115 _convert_paths_to_strings(item)
116 if isinstance(item, dict)
117 else str(item)
118 if isinstance(item, Path)
119 else item
120 for item in value
121 ]
122 else:
123 result[key] = value
124 return result
127def render_config(config: dict[str, Any]) -> dict[str, Any]:
128 """Resolve all template strings in config using its own values.
130 This allows the use of "{{ variant }}" in the "filename" etc.
132 Args:
133 config: Configuration dictionary to render
135 Returns:
136 Resolved configuration dictionary
138 Raises:
139 ConfigurationError: If maximum number of iterations is reached
140 (circular references)
141 """
142 max_iterations = 10
143 current_config = dict(config)
144 current_config = _convert_paths_to_strings(current_config)
146 for _ in range(max_iterations):
147 config_yaml = Template(yaml.dump(current_config))
148 resolved_yaml = config_yaml.render(**current_config)
149 new_config = yaml.safe_load(resolved_yaml)
151 # Check for direct self-references
152 for key, value in new_config.items():
153 if isinstance(value, str) and f"{ { {key} } } " in value:
154 raise ConfigurationError(
155 f"Circular reference detected: {key} references itself"
156 )
158 if new_config == current_config: # No more changes
159 return new_config
160 current_config = new_config
162 raise ConfigurationError(
163 "Maximum number of iterations reached. "
164 "Check for circular references in your configuration."
165 )