Coverage for src/domain/validation/config_loader.py: 33%

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

2

3 This module provides functionality to load, parse, and validate the mala.yaml

4 configuration file. It enforces strict schema validation and provides clear

5 error messages for common misconfigurations.

6

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

14 from __future__ import annotations

15

16 from typing import TYPE_CHECKING, Any

17

18 import yaml

19

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

21

22 if TYPE_CHECKING:

23 from pathlib import Path

24

25

26 class 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

60 def 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

96 def _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

127 def _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

148 def _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

166 def _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 )