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
« 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.
3This module provides the merge_configs function to merge a preset configuration
4with user overrides. User values take precedence over preset values.
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
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".
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"""
22from __future__ import annotations
24from typing import TYPE_CHECKING
26from src.domain.validation.config import (
27 CommandsConfig,
28 ValidationConfig,
29)
31if TYPE_CHECKING:
32 from src.domain.validation.config import (
33 CommandConfig,
34 YamlCoverageConfig,
35 )
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.
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.
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.
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
63 # For programmatic configs (empty fields_set):
64 # Treat non-default values as explicit overrides
65 return not is_default
68def merge_configs(
69 preset: ValidationConfig | None,
70 user: ValidationConfig,
71) -> ValidationConfig:
72 """Merge preset configuration with user overrides.
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.
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.
83 Args:
84 preset: Base preset configuration to merge with, or None.
85 user: User configuration with overrides.
87 Returns:
88 Merged ValidationConfig with user values taking precedence.
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
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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.
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
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.
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
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 )
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.
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 )
265 if is_explicit:
266 return user_cmd
268 # Otherwise inherit from preset
269 return preset_cmd
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.
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
286 # Otherwise inherit from preset
287 return preset_cov