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

36 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-04 04:43 +0000

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

2 

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

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

5 

6Merge 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 

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

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

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

15"explicitly set to null/empty". 

16 

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

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

19preset values without needing to manually populate _fields_set. 

20""" 

21 

22from __future__ import annotations 

23 

24from typing import TYPE_CHECKING 

25 

26from src.domain.validation.config import ( 

27 CommandsConfig, 

28 ValidationConfig, 

29) 

30 

31if TYPE_CHECKING: 

32 from src.domain.validation.config import ( 

33 CommandConfig, 

34 YamlCoverageConfig, 

35 ) 

36 

37 

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

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

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

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

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