Coverage for src/domain/validation/config_merger.py: 22%

1"""Config merger for combining preset and user configurations.

2

3 This module provides the merge_configs function to merge a preset configuration

4 with user overrides. User values take precedence over preset values.

5

6 Merge rules:

7- If no preset, return user config as-is

8- Command fields: user replaces preset if explicitly set; omitted inherits

9- Coverage: user replaces preset if explicitly set; omitted inherits

10- List fields (code_patterns, config_files, setup_files): user replaces if explicitly set

11

12 Field presence is tracked via the `_fields_set` attribute on configs, which

13 records which fields were explicitly provided in the source YAML (even if

14 the value was null/empty). This allows distinguishing "not set" from

15"explicitly set to null/empty".

16

17 For programmatic configs (where _fields_set is empty), non-None values are

18 treated as explicit overrides. This ensures programmatic callers can override

19 preset values without needing to manually populate _fields_set.

20"""

21

22 from __future__ import annotations

23

24 from typing import TYPE_CHECKING

25

26 from src.domain.validation.config import (

27 CommandsConfig,

28 ValidationConfig,

29)

30

31 if TYPE_CHECKING:

32 from src.domain.validation.config import (

33 CommandConfig,

34 YamlCoverageConfig,

35 )

36

37

38 def _is_field_explicitly_set(

39 field_name: str,

40 fields_set: frozenset[str],

41 user_value: object,

42 is_default: bool,

43) -> bool:

44 """Determine if a field should be treated as explicitly set.

45

46 For YAML configs (fields_set is populated), use fields_set membership.

47 For programmatic configs (fields_set is empty), treat non-None values

48 that differ from the default as explicit overrides.

49

50 Args:

51 field_name: Name of the field to check.

52 fields_set: The _fields_set from the config.

53 user_value: The current value of the field.

54 is_default: Whether user_value equals the default value.

55

56 Returns:

57 True if the field should be treated as explicitly set.

58 """

59 # If fields_set is populated, use it (YAML config via from_dict)

60 if fields_set:

61 return field_name in fields_set

62

63 # For programmatic configs (empty fields_set):

64 # Treat non-default values as explicit overrides

65 return not is_default

66

67

68 def merge_configs(

69 preset: ValidationConfig | None,

70 user: ValidationConfig,

71) -> ValidationConfig:

72 """Merge preset configuration with user overrides.

73

74 User values take precedence over preset values. When a user field is not

75 explicitly set (not in _fields_set), the preset value is inherited. When

76 a user field is explicitly set (in _fields_set), the user value is used

77 even if it's None or empty.

78

79 For programmatic configs (created via constructor, where _fields_set is

80 empty), non-default values are treated as explicit overrides. This ensures

81 both YAML and programmatic configs work correctly.

82

83 Args:

84 preset: Base preset configuration to merge with, or None.

85 user: User configuration with overrides.

86

87 Returns:

88 Merged ValidationConfig with user values taking precedence.

89

90 Examples:

91 >>> # Use from_dict to create configs - this populates _fields_set

92 >>> # which is required for overrides to work correctly

93 >>> preset_cfg = ValidationConfig.from_dict({

94 ... "commands": {

95 ... "test": "pytest",

96 ... "lint": "ruff check",

97 ... },

98 ... "code_patterns": ["**/*.py"],

99 ... })

100 >>> user_cfg = ValidationConfig.from_dict({

101 ... "commands": {

102 ... "test": "pytest -v", # override

103 ... },

104 ... })

105 >>> result = merge_configs(preset_cfg, user_cfg)

106 >>> result.commands.test.command

107 'pytest -v'

108 >>> result.commands.lint.command # inherited

109 'ruff check'

110 """

111 # If no preset, return user config as-is

112 if preset is None:

113 return user

114

115 # Merge commands - check if user explicitly set commands

116 user_commands_explicitly_set = _is_field_explicitly_set(

117 "commands",

118 user._fields_set,

119 user.commands,

120 # Default is an empty CommandsConfig with empty _fields_set

121 user.commands == CommandsConfig(),

122 )

123 merged_commands = _merge_commands(

124 preset.commands, user.commands, user_commands_explicitly_set

125 )

126

127 # Merge run-level command overrides

128 user_run_level_commands_explicitly_set = _is_field_explicitly_set(

129 "run_level_commands",

130 user._fields_set,

131 user.run_level_commands,

132 user.run_level_commands == CommandsConfig(),

133 )

134 merged_run_level_commands = _merge_commands(

135 preset.run_level_commands,

136 user.run_level_commands,

137 user_run_level_commands_explicitly_set,

138 clear_on_explicit_empty=True, # Allow users to clear preset run-level overrides

139 )

140

141 # Coverage: user replaces if explicitly set, otherwise inherit

142 user_coverage_explicitly_set = _is_field_explicitly_set(

143 "coverage",

144 user._fields_set,

145 user.coverage,

146 user.coverage is None, # Default is None

147 )

148 merged_coverage = _merge_coverage(

149 preset.coverage, user.coverage, user_coverage_explicitly_set

150 )

151

152 # List fields: user replaces if explicitly set, otherwise inherit

153 code_patterns_explicitly_set = _is_field_explicitly_set(

154 "code_patterns",

155 user._fields_set,

156 user.code_patterns,

157 user.code_patterns == (), # Default is empty tuple

158 )

159 merged_code_patterns = (

160 user.code_patterns if code_patterns_explicitly_set else preset.code_patterns

161 )

162

163 config_files_explicitly_set = _is_field_explicitly_set(

164 "config_files",

165 user._fields_set,

166 user.config_files,

167 user.config_files == (), # Default is empty tuple

168 )

169 merged_config_files = (

170 user.config_files if config_files_explicitly_set else preset.config_files

171 )

172

173 setup_files_explicitly_set = _is_field_explicitly_set(

174 "setup_files",

175 user._fields_set,

176 user.setup_files,

177 user.setup_files == (), # Default is empty tuple

178 )

179 merged_setup_files = (

180 user.setup_files if setup_files_explicitly_set else preset.setup_files

181 )

182

183 return ValidationConfig(

184 preset=user.preset, # Keep user's preset reference

185 commands=merged_commands,

186 run_level_commands=merged_run_level_commands,

187 coverage=merged_coverage,

188 code_patterns=merged_code_patterns,

189 config_files=merged_config_files,

190 setup_files=merged_setup_files,

191 _fields_set=user._fields_set, # Preserve user's fields_set

192 )

193

194

195 def _merge_commands(

196 preset: CommandsConfig,

197 user: CommandsConfig,

198 user_commands_explicitly_set: bool,

199 *,

200 clear_on_explicit_empty: bool = False,

201) -> CommandsConfig:

202 """Merge preset and user command configurations.

203

204 For each command field:

205 - If field is explicitly set by user: use user value (even if None)

206 - If field is not explicitly set: inherit from preset

207

208 Args:

209 preset: Preset CommandsConfig to merge with.

210 user: User CommandsConfig with overrides.

211 user_commands_explicitly_set: Whether the user explicitly set the commands

212 field at the parent level (commands or run_level_commands).

213 clear_on_explicit_empty: If True and user explicitly set the field to null

214 or empty ({}) at the top level, return empty CommandsConfig without

215 inheriting from preset. This is used for run_level_commands to allow

216 users to clear all preset overrides.

217

218 """

219 # If clear_on_explicit_empty is enabled and user explicitly set commands to

220 # null/empty (no individual fields set), short-circuit to return the user's

221 # empty config without inheriting. This allows users to clear all preset

222 # run_level_commands overrides.

223 if (

224 clear_on_explicit_empty

225 and user_commands_explicitly_set

226 and not user._fields_set

227 ):

228 return user

229

230 return CommandsConfig(

231 setup=_merge_command_field(preset.setup, user.setup, "setup", user._fields_set),

232 test=_merge_command_field(preset.test, user.test, "test", user._fields_set),

233 lint=_merge_command_field(preset.lint, user.lint, "lint", user._fields_set),

234 format=_merge_command_field(

235 preset.format, user.format, "format", user._fields_set

236 ),

237 typecheck=_merge_command_field(

238 preset.typecheck, user.typecheck, "typecheck", user._fields_set

239 ),

240 e2e=_merge_command_field(preset.e2e, user.e2e, "e2e", user._fields_set),

241 _fields_set=user._fields_set, # Preserve user's fields_set

242 )

243

244

245 def _merge_command_field(

246 preset_cmd: CommandConfig | None,

247 user_cmd: CommandConfig | None,

248 field_name: str,

249 user_fields_set: frozenset[str],

250) -> CommandConfig | None:

251 """Merge a single command field.

252

253 If the field was explicitly set by the user (in user_fields_set or

254 non-None for programmatic configs), use the user value. Otherwise,

255 inherit from preset.

256 """

257 # Check if field is explicitly set

258 is_explicit = _is_field_explicitly_set(

259 field_name,

260 user_fields_set,

261 user_cmd,

262 user_cmd is None, # Default is None

263 )

264

265 if is_explicit:

266 return user_cmd

267

268 # Otherwise inherit from preset

269 return preset_cmd

270

271

272 def _merge_coverage(

273 preset_cov: YamlCoverageConfig | None,

274 user_cov: YamlCoverageConfig | None,

275 user_explicitly_set: bool,

276) -> YamlCoverageConfig | None:

277 """Merge coverage configurations.

278

279 If user explicitly set coverage (even to null), use user value.

280 Otherwise inherit from preset.

281 """

282 # If user explicitly set coverage, use their value (even if None)

283 if user_explicitly_set:

284 return user_cov

285

286 # Otherwise inherit from preset

287 return preset_cov