Coverage for src / tracekit / config / loader.py: 99%
107 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Configuration file loading utilities.
3This module provides unified configuration loading from YAML and JSON files
4with support for schema validation, default injection, and path resolution.
7Example:
8 >>> from tracekit.config.loader import load_config_file
9 >>> config = load_config_file("pipeline.yaml", schema="pipeline")
10"""
12from __future__ import annotations
14import json
15from pathlib import Path
16from typing import Any
18from tracekit.core.exceptions import ConfigurationError
20# Try to import yaml
21try:
22 import yaml
24 YAML_AVAILABLE = True
25except ImportError:
26 YAML_AVAILABLE = False
29def load_config_file(
30 path: str | Path,
31 *,
32 schema: str | None = None,
33 validate: bool = True,
34 inject_defaults: bool = True,
35) -> dict[str, Any]:
36 """Load configuration from YAML or JSON file.
38 Automatically detects file format from extension.
39 Optionally validates against schema and injects defaults.
41 Args:
42 path: Path to configuration file.
43 schema: Schema name to validate against (e.g., "protocol").
44 validate: If True and schema provided, validate configuration.
45 inject_defaults: If True, inject default values from schema.
47 Returns:
48 Configuration dictionary.
50 Raises:
51 ConfigurationError: If file cannot be loaded or parsed.
53 Example:
54 >>> config = load_config_file("uart.yaml", schema="protocol")
55 >>> print(config["name"])
56 'uart'
57 """
58 path = Path(path).expanduser().resolve()
60 if not path.exists():
61 raise ConfigurationError(
62 "Configuration file not found",
63 config_key=str(path),
64 )
66 # Determine format from extension
67 ext = path.suffix.lower()
69 if ext in (".yaml", ".yml"):
70 config = _load_yaml(path)
71 elif ext == ".json":
72 config = _load_json(path)
73 else:
74 # Try YAML first, then JSON
75 try:
76 config = _load_yaml(path)
77 except ConfigurationError:
78 try:
79 config = _load_json(path)
80 except ConfigurationError:
81 raise ConfigurationError( # noqa: B904
82 f"Unsupported configuration format: {ext}",
83 config_key=str(path),
84 fix_hint="Use .yaml, .yml, or .json extension",
85 )
87 # Validate against schema if requested
88 if validate and schema is not None:
89 from tracekit.config.schema import validate_against_schema
91 validate_against_schema(config, schema)
93 # Inject defaults if requested
94 if inject_defaults and schema is not None:
95 from tracekit.config.defaults import inject_defaults as do_inject
97 config = do_inject(config, schema)
99 return config
102def _load_yaml(path: Path) -> dict[str, Any]:
103 """Load YAML configuration file.
105 Args:
106 path: Path to YAML file.
108 Returns:
109 Parsed configuration dictionary.
111 Raises:
112 ConfigurationError: If YAML parsing fails.
113 """
114 if not YAML_AVAILABLE:
115 raise ConfigurationError(
116 "YAML support not available",
117 fix_hint="Install PyYAML: pip install pyyaml",
118 )
120 try:
121 with open(path, encoding="utf-8") as f:
122 content = yaml.safe_load(f)
124 if content is None:
125 return {}
127 if not isinstance(content, dict):
128 raise ConfigurationError(
129 "Configuration must be a dictionary",
130 config_key=str(path),
131 expected_type="object",
132 actual_value=type(content).__name__,
133 )
135 return content
137 except yaml.YAMLError as e:
138 # Extract line number from YAML error
139 line = None
140 if hasattr(e, "problem_mark") and e.problem_mark is not None: 140 ↛ 143line 140 didn't jump to line 143 because the condition on line 140 was always true
141 line = e.problem_mark.line + 1
143 raise ConfigurationError(
144 "Failed to parse YAML configuration",
145 config_key=str(path),
146 details=f"Line {line}: {e}" if line else str(e),
147 ) from e
148 except OSError as e:
149 raise ConfigurationError(
150 "Failed to read configuration file",
151 config_key=str(path),
152 details=str(e),
153 ) from e
156def _load_json(path: Path) -> dict[str, Any]:
157 """Load JSON configuration file.
159 Args:
160 path: Path to JSON file.
162 Returns:
163 Parsed configuration dictionary.
165 Raises:
166 ConfigurationError: If JSON parsing fails.
167 """
168 try:
169 with open(path, encoding="utf-8") as f:
170 content = json.load(f)
172 if content is None:
173 return {}
175 if not isinstance(content, dict):
176 raise ConfigurationError(
177 "Configuration must be a dictionary",
178 config_key=str(path),
179 expected_type="object",
180 actual_value=type(content).__name__,
181 )
183 return content
185 except json.JSONDecodeError as e:
186 raise ConfigurationError(
187 "Failed to parse JSON configuration",
188 config_key=str(path),
189 details=f"Line {e.lineno}, column {e.colno}: {e.msg}",
190 ) from e
191 except OSError as e:
192 raise ConfigurationError(
193 "Failed to read configuration file",
194 config_key=str(path),
195 details=str(e),
196 ) from e
199def load_config(
200 config_path: str | Path | None = None,
201 *,
202 use_defaults: bool = True,
203) -> dict[str, Any]:
204 """Load TraceKit configuration.
206 Searches for configuration in standard locations if no path provided.
208 Args:
209 config_path: Path to configuration file. If None, searches
210 standard locations.
211 use_defaults: If True, merge with default configuration.
213 Returns:
214 Configuration dictionary.
216 Example:
217 >>> config = load_config() # Auto-find config
218 >>> config = load_config("~/.tracekit/config.yaml")
219 """
220 from tracekit.config.defaults import DEFAULT_CONFIG, deep_merge
222 config: dict[str, Any] = {}
224 if use_defaults:
225 import copy
227 config = copy.deepcopy(DEFAULT_CONFIG)
229 if config_path is None:
230 # Search standard locations
231 search_paths = [
232 Path.cwd() / "tracekit.yaml",
233 Path.cwd() / ".tracekit.yaml",
234 Path.cwd() / "tracekit.json",
235 Path.home() / ".tracekit" / "config.yaml",
236 Path.home() / ".config" / "tracekit" / "config.yaml",
237 ]
239 for path in search_paths:
240 if path.exists():
241 config_path = path
242 break
244 if config_path is not None:
245 user_config = load_config_file(
246 config_path,
247 validate=False,
248 inject_defaults=False,
249 )
250 config = deep_merge(config, user_config) if use_defaults else user_config
252 return config
255def save_config(
256 config: dict[str, Any],
257 path: str | Path,
258 *,
259 format: str | None = None,
260) -> None:
261 """Save configuration to file.
263 Args:
264 config: Configuration dictionary to save.
265 path: Output file path.
266 format: Output format ("yaml" or "json"). Auto-detected from
267 extension if not specified.
269 Raises:
270 ConfigurationError: If configuration cannot be saved.
272 Example:
273 >>> save_config(config, "config.yaml")
274 >>> save_config(config, "config.json")
275 """
276 path = Path(path).expanduser().resolve()
278 # Determine format
279 if format is None:
280 ext = path.suffix.lower()
281 if ext in (".yaml", ".yml"):
282 format = "yaml"
283 elif ext == ".json":
284 format = "json"
285 else:
286 format = "yaml" # Default
288 # Create parent directory
289 path.parent.mkdir(parents=True, exist_ok=True)
291 try:
292 if format == "yaml":
293 if not YAML_AVAILABLE:
294 raise ConfigurationError(
295 "YAML support not available",
296 fix_hint="Install PyYAML: pip install pyyaml",
297 )
298 with open(path, "w", encoding="utf-8") as f:
299 yaml.dump(config, f, default_flow_style=False, sort_keys=False)
300 else:
301 with open(path, "w", encoding="utf-8") as f:
302 json.dump(config, f, indent=2)
304 except OSError as e:
305 raise ConfigurationError(
306 "Failed to save configuration",
307 config_key=str(path),
308 details=str(e),
309 ) from e
312def get_config_value(
313 config: dict[str, Any],
314 key_path: str,
315 default: Any = None,
316) -> Any:
317 """Get configuration value by dot-separated path.
319 Args:
320 config: Configuration dictionary.
321 key_path: Dot-separated path (e.g., "defaults.sample_rate").
322 default: Default value if key not found.
324 Returns:
325 Configuration value or default.
327 Example:
328 >>> get_config_value(config, "defaults.sample_rate", 1e6)
329 1000000.0
330 """
331 keys = key_path.split(".")
332 value = config
334 for key in keys:
335 if isinstance(value, dict) and key in value:
336 value = value[key]
337 else:
338 return default
340 return value
343__all__ = [
344 "get_config_value",
345 "load_config",
346 "load_config_file",
347 "save_config",
348]