diff --git a/README.md b/README.md index b19286f3..9d7b2062 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,27 @@ A hierarchical config using yaml in Python. Latest version is: **0.20.0** +### Unreleased: breaking merge behavior (deepmerge 4) + +The upgrade from deepmerge 2.1.0 to 4.0.0 changes the default `append_unique` +strategy. When a list is merged with an inherited list, repeated incoming values +are now appended only once. For example, parent `[a]` plus child `[b, b]` produces +`[a, b]`, previously `[a, b, b]`. This also applies to empty inherited lists, +nested lists, lists of dictionaries, and precomputed-parent processing. +Duplicates already in the inherited list remain; lists introduced without an +inherited value are not deduplicated. + +Review generated configuration diffs before upgrading. If repeated incoming +entries are intentional, use `--list-merge-strategy append` (Python API: +`type_strategies=[(list, ["append"]), (dict, ["merge"])]`). This retains all +incoming entries, including those already present in the parent; it does not +reproduce the old `append_unique` behavior exactly. The `append`, `prepend`, and +`override` strategies are unchanged. + +For Python callers explicitly using `type_conflict_strategies=["override_if_not_empty"]`, +incoming `0`, `False`, and `0.0` now override inherited values instead of being +ignored. HIML's default `override` strategy is unaffected. + > **⚠️ Breaking Changes in v0.18.0** > This version includes breaking changes: > - Default list merge strategy changed from `append` to `append_unique` diff --git a/pyproject.toml b/pyproject.toml index 23a3bffe..28e8c0ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ requires-python = ">=3.10" dependencies = [ "PyYAML==6.0.3", - "deepmerge==2.1.0", + "deepmerge==4.0.0", "pathlib2==2.3.7.post1", ] diff --git a/tests/test_config_generator.py b/tests/test_config_generator.py index 8a359487..8580ecba 100644 --- a/tests/test_config_generator.py +++ b/tests/test_config_generator.py @@ -89,6 +89,88 @@ def test_hierarchical_config_merging(self): assert 'feature2' in result['features'] assert 'feature3' in result['features'] + @pytest.mark.parametrize("precompute", [False, True], ids=["hierarchy", "precomputed-parent"]) + @pytest.mark.parametrize( + "parent, child, expected", + [ + pytest.param( + {'features': ['a', 'a']}, + {'features': ['a', 'b', 'b', 'c', 'b']}, + {'features': ['a', 'a', 'b', 'c']}, + id="incoming-duplicates", + ), + pytest.param( + {'features': []}, + {'features': ['b', 'b']}, + {'features': ['b']}, + id="empty-inherited-list", + ), + pytest.param( + {'config': {'features': [{'name': 'a'}]}}, + {'config': {'features': [{'name': 'b'}, {'name': 'b'}]}}, + {'config': {'features': [{'name': 'a'}, {'name': 'b'}]}}, + id="nested-dictionaries", + ), + pytest.param( + {'env': 'default'}, + {'features': ['b', 'b']}, + {'env': 'default', 'features': ['b', 'b']}, + id="new-list-preserves-duplicates", + ), + pytest.param( + {'features': [{1: 'integer', 'name': 'string'}]}, + {'features': [{'name': 'string', 1: 'integer'}]}, + {'features': [{1: 'integer', 'name': 'string'}]}, + id="mixed-dictionary-keys", + ), + ], + ) + def test_default_list_merge_behavior(self, parent, child, expected, precompute): + self.create_test_yaml('default.yaml', parent) + self.create_test_yaml('production/env.yaml', child) + + result = self.config_processor.process( + cwd=self.temp_dir, + path='production', + precomputed_state=parent if precompute else None, + ) + + assert result == expected + + @pytest.mark.parametrize( + "strategy, expected", + [ + ('append', ['a', 'b', 'b']), + ('prepend', ['b', 'b', 'a']), + ('override', ['b', 'b']), + ], + ) + def test_alternative_list_merge_strategies_preserve_incoming_duplicates(self, strategy, expected): + self.create_test_yaml('default.yaml', {'features': ['a']}) + self.create_test_yaml('production/env.yaml', {'features': ['b', 'b']}) + + result = self.config_processor.process( + cwd=self.temp_dir, + path='production', + type_strategies=[(list, [strategy]), (dict, ["merge"])], + ) + + assert result['features'] == expected + + @pytest.mark.parametrize("value", [0, False, 0.0], ids=["zero-int", "false", "zero-float"]) + def test_override_if_not_empty_accepts_falsy_primitives(self, value): + self.create_test_yaml('default.yaml', {'config': {'value': 'inherited'}}) + self.create_test_yaml('production/env.yaml', {'config': {'value': value}}) + + result = self.config_processor.process( + cwd=self.temp_dir, + path='production', + type_conflict_strategies=["override_if_not_empty"], + ) + + assert result['config']['value'] == value + assert type(result['config']['value']) is type(value) + def test_config_filtering(self): """Test config filtering functionality""" config_data = {