diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 2b9519a..25953e5 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -176,10 +176,22 @@ def _merge_mapping_validated( for existing_path in tuple(provenance): if existing_path == path or existing_path.startswith(f"{path}."): del provenance[existing_path] - target[key] = dict(value) if isinstance(value, Mapping) else value + target[key] = _copy_config_value(value) provenance.update(_leaf_provenance(value, source, path)) +def _copy_config_value(value: Any) -> Any: + """Copy nested configuration containers without preserving YAML aliases.""" + + if isinstance(value, Mapping): + return {key: _copy_config_value(child) for key, child in value.items()} + if isinstance(value, list): + return [_copy_config_value(child) for child in value] + if isinstance(value, tuple): + return tuple(_copy_config_value(child) for child in value) + return value + + class BatteriesIncludedConfigLoader: """Load conventional user, project, environment, and explicit layers. diff --git a/tests/test_batteries_included_config.py b/tests/test_batteries_included_config.py index f65f03f..7880547 100644 --- a/tests/test_batteries_included_config.py +++ b/tests/test_batteries_included_config.py @@ -61,6 +61,11 @@ def test_shared_mapping_alias_is_valid_but_recursive_alias_is_rejected_with_path provenance: dict[str, str] = {} _merge_mapping(values, provenance, valid, "user") self.assertEqual(values, {"left": shared, "right": shared}) + self.assertIsNot(values["left"], shared) + self.assertIsNot(values["left"], values["right"]) + assert isinstance(values["left"], dict) + values["left"]["answer"] = 7 + self.assertEqual(values["right"], {"answer": 42}) self.assertEqual(provenance, {"left.answer": "user", "right.answer": "user"}) with tempfile.TemporaryDirectory() as tmpdir: @@ -69,6 +74,18 @@ def test_shared_mapping_alias_is_valid_but_recursive_alias_is_rejected_with_path with self.assertRaisesRegex(base_cli.ConfigurationError, "recursive.yaml.*recursive value.*nested.child"): BatteriesIncludedConfigLoader(user_config_dir=Path(tmpdir) / "user").load(None, path) + def test_nested_mapping_aliases_are_isolated_across_layers(self) -> None: + shared = {"limits": {"retries": 2}} + values: dict[str, object] = {} + provenance: dict[str, str] = {} + + _merge_mapping(values, provenance, {"first": shared, "second": shared}, "user") + _merge_mapping(values, provenance, {"first": {"limits": {"timeout": 30}}}, "project") + + self.assertEqual(values["first"], {"limits": {"retries": 2, "timeout": 30}}) + self.assertEqual(values["second"], {"limits": {"retries": 2}}) + self.assertEqual(shared, {"limits": {"retries": 2}}) + def test_mapping_depth_is_bounded_before_recursive_merge_or_provenance(self) -> None: nested: dict[str, object] = {"value": 1} for index in range(65):