Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
82 changes: 82 additions & 0 deletions tests/test_config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading