Coverage for src / domain / validation / config_loader.py: 33%
43 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-01-04 04:43 +0000
1"""YAML configuration loader for mala.yaml.
3This module provides functionality to load, parse, and validate the mala.yaml
4configuration file. It enforces strict schema validation and provides clear
5error messages for common misconfigurations.
7Key functions:
8- load_config: Load and validate mala.yaml from a repository path
9- parse_yaml: Parse YAML content with error handling
10- validate_schema: Validate against expected schema
11- build_config: Convert parsed dict to ValidationConfig dataclass
12"""
14from __future__ import annotations
16from typing import TYPE_CHECKING, Any
18import yaml
20from src.domain.validation.config import ConfigError, ValidationConfig
22if TYPE_CHECKING:
23 from pathlib import Path
26class ConfigMissingError(ConfigError):
27 """Raised when the mala.yaml configuration file is not found.
29 This is a subclass of ConfigError to allow callers to catch either:
30 - ConfigMissingError: Only handle missing file case
31 - ConfigError: Handle all config errors (missing, invalid syntax, etc.)
33 Example:
34 >>> raise ConfigMissingError(Path("/path/to/repo"))
35 ConfigMissingError: mala.yaml not found in /path/to/repo.
36 """
38 repo_path: Path # Explicit class-level annotation for type checkers
40 def __init__(self, repo_path: Path) -> None:
41 self.repo_path = repo_path
42 message = f"mala.yaml not found in {repo_path}. Mala requires a configuration file to run."
43 super().__init__(message)
46# Fields allowed at the top level of mala.yaml
47_ALLOWED_TOP_LEVEL_FIELDS = frozenset(
48 {
49 "preset",
50 "commands",
51 "run_level_commands",
52 "coverage",
53 "code_patterns",
54 "config_files",
55 "setup_files",
56 }
57)
60def load_config(repo_path: Path) -> ValidationConfig:
61 """Load and validate mala.yaml from the repository root.
63 This is the main entry point for loading configuration. It reads the file,
64 parses YAML, validates the schema, builds the config dataclass, and runs
65 post-build validation.
67 Args:
68 repo_path: Path to the repository root directory.
70 Returns:
71 ValidationConfig instance with all configuration loaded.
73 Raises:
74 ConfigError: If the file is missing, has invalid YAML syntax,
75 contains unknown fields, has invalid types, or fails validation.
77 Example:
78 >>> config = load_config(Path("/path/to/repo"))
79 >>> print(config.preset)
80 'python-uv'
81 """
82 config_file = repo_path / "mala.yaml"
84 if not config_file.exists():
85 raise ConfigMissingError(repo_path)
87 content = config_file.read_text(encoding="utf-8")
88 data = _parse_yaml(content)
89 _validate_schema(data)
90 config = _build_config(data)
91 _validate_config(config)
93 return config
96def _parse_yaml(content: str) -> dict[str, Any]:
97 """Parse YAML content into a dictionary.
99 Args:
100 content: Raw YAML string content.
102 Returns:
103 Parsed dictionary. Returns empty dict for empty/null YAML.
105 Raises:
106 ConfigError: If YAML syntax is invalid.
107 """
108 try:
109 data = yaml.safe_load(content)
110 except yaml.YAMLError as e:
111 # Extract useful error details from the exception
112 details = str(e)
113 raise ConfigError(f"Invalid YAML syntax in mala.yaml: {details}") from e
115 # Handle empty file or file with only comments
116 if data is None:
117 return {}
119 if not isinstance(data, dict):
120 raise ConfigError(
121 f"mala.yaml must be a YAML mapping, got {type(data).__name__}"
122 )
124 return data
127def _validate_schema(data: dict[str, Any]) -> None:
128 """Validate the parsed YAML against the expected schema.
130 This function checks for unknown fields at the top level. Field type
131 validation is handled by the dataclass constructors.
133 Args:
134 data: Parsed YAML dictionary.
136 Raises:
137 ConfigError: If unknown fields are present.
138 """
139 unknown_fields = set(data.keys()) - _ALLOWED_TOP_LEVEL_FIELDS
140 if unknown_fields:
141 # Sort for consistent error messages; convert to str to handle
142 # non-string YAML keys (e.g., null, integers) without TypeError
143 unknown_as_strs = sorted(str(k) for k in unknown_fields)
144 first_unknown = unknown_as_strs[0]
145 raise ConfigError(f"Unknown field '{first_unknown}' in mala.yaml")
148def _build_config(data: dict[str, Any]) -> ValidationConfig:
149 """Convert a validated YAML dict to a ValidationConfig dataclass.
151 This function delegates to ValidationConfig.from_dict which handles
152 parsing of nested structures (commands, coverage, etc.).
154 Args:
155 data: Validated YAML dictionary.
157 Returns:
158 ValidationConfig instance.
160 Raises:
161 ConfigError: If any field has an invalid type or value.
162 """
163 return ValidationConfig.from_dict(data)
166def _validate_config(config: ValidationConfig) -> None:
167 """Perform post-build validation on the configuration.
169 This validates semantic constraints that can't be checked during
170 parsing, such as ensuring at least one command is defined (when
171 no preset is specified).
173 Args:
174 config: Built ValidationConfig instance.
176 Raises:
177 ConfigError: If configuration is semantically invalid.
178 """
179 # If no preset is specified, at least one command must be defined
180 if config.preset is None and not config.has_any_command():
181 raise ConfigError(
182 "At least one command must be defined. "
183 "Specify a preset or define commands directly."
184 )