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

1"""YAML configuration loader for mala.yaml. 

2 

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. 

6 

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""" 

13 

14from __future__ import annotations 

15 

16from typing import TYPE_CHECKING, Any 

17 

18import yaml 

19 

20from src.domain.validation.config import ConfigError, ValidationConfig 

21 

22if TYPE_CHECKING: 

23 from pathlib import Path 

24 

25 

26class ConfigMissingError(ConfigError): 

27 """Raised when the mala.yaml configuration file is not found. 

28 

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.) 

32 

33 Example: 

34 >>> raise ConfigMissingError(Path("/path/to/repo")) 

35 ConfigMissingError: mala.yaml not found in /path/to/repo. 

36 """ 

37 

38 repo_path: Path # Explicit class-level annotation for type checkers 

39 

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) 

44 

45 

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) 

58 

59 

60def load_config(repo_path: Path) -> ValidationConfig: 

61 """Load and validate mala.yaml from the repository root. 

62 

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. 

66 

67 Args: 

68 repo_path: Path to the repository root directory. 

69 

70 Returns: 

71 ValidationConfig instance with all configuration loaded. 

72 

73 Raises: 

74 ConfigError: If the file is missing, has invalid YAML syntax, 

75 contains unknown fields, has invalid types, or fails validation. 

76 

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" 

83 

84 if not config_file.exists(): 

85 raise ConfigMissingError(repo_path) 

86 

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) 

92 

93 return config 

94 

95 

96def _parse_yaml(content: str) -> dict[str, Any]: 

97 """Parse YAML content into a dictionary. 

98 

99 Args: 

100 content: Raw YAML string content. 

101 

102 Returns: 

103 Parsed dictionary. Returns empty dict for empty/null YAML. 

104 

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 

114 

115 # Handle empty file or file with only comments 

116 if data is None: 

117 return {} 

118 

119 if not isinstance(data, dict): 

120 raise ConfigError( 

121 f"mala.yaml must be a YAML mapping, got {type(data).__name__}" 

122 ) 

123 

124 return data 

125 

126 

127def _validate_schema(data: dict[str, Any]) -> None: 

128 """Validate the parsed YAML against the expected schema. 

129 

130 This function checks for unknown fields at the top level. Field type 

131 validation is handled by the dataclass constructors. 

132 

133 Args: 

134 data: Parsed YAML dictionary. 

135 

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") 

146 

147 

148def _build_config(data: dict[str, Any]) -> ValidationConfig: 

149 """Convert a validated YAML dict to a ValidationConfig dataclass. 

150 

151 This function delegates to ValidationConfig.from_dict which handles 

152 parsing of nested structures (commands, coverage, etc.). 

153 

154 Args: 

155 data: Validated YAML dictionary. 

156 

157 Returns: 

158 ValidationConfig instance. 

159 

160 Raises: 

161 ConfigError: If any field has an invalid type or value. 

162 """ 

163 return ValidationConfig.from_dict(data) 

164 

165 

166def _validate_config(config: ValidationConfig) -> None: 

167 """Perform post-build validation on the configuration. 

168 

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). 

172 

173 Args: 

174 config: Built ValidationConfig instance. 

175 

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 )