Skip to content

Commit 0eb8115

Browse files
authored
🐛 fix: merging of two similarly keyed mappings (#11)
1 parent 09460b7 commit 0eb8115

4 files changed

Lines changed: 44 additions & 9 deletions

File tree

config/common/__init__.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@
22
from collections import abc
33
from typing import Any, Dict, List, Mapping, Optional, Type, TypeVar
44

5+
from deepmerge import Merger
6+
57
from config.errors import ConfigurationOverrideError
68

79
T = TypeVar("T")
810

11+
merger = Merger(
12+
type_strategies=[
13+
(list, ["append"]),
14+
(dict, ["merge"]),
15+
(set, ["union"]),
16+
],
17+
fallback_strategies=["override"],
18+
type_conflict_strategies=["override"],
19+
)
20+
921

10-
def apply_key_value(obj, key, value):
22+
def apply_key_value(obj: Mapping[str, Any], key: str, value: Any) -> Mapping[str, Any]:
1123
key = key.strip("_:.") # remove special characters from both ends
1224
for token in (":", "__", "."):
1325
if token in key:
@@ -52,27 +64,33 @@ def apply_key_value(obj, key, value):
5264
)
5365

5466
try:
55-
sub_property[index] = value
67+
sub_property[index] = merger.merge(sub_property[index], value)
5668
except IndexError:
5769
raise ConfigurationOverrideError(
5870
f"Invalid override for mutable sequence {key}; "
5971
f"assignment index out of range"
6072
)
6173
else:
6274
try:
63-
sub_property[last_part] = value
75+
if isinstance(sub_property, abc.Mapping):
76+
sub_property[last_part] = merger.merge(
77+
sub_property.get(last_part),
78+
value,
79+
)
80+
else:
81+
sub_property[last_part] = value
6482
except TypeError as type_error:
6583
raise ConfigurationOverrideError(
6684
f"Invalid assignment {key} -> {value}; {str(type_error)}"
6785
)
6886

6987
return obj
7088

71-
obj[key] = value
89+
obj[key] = merger.merge(obj.get(key), value)
7290
return obj
7391

7492

75-
def merge_values(destination, source):
93+
def merge_values(destination: Mapping[str, Any], source: Mapping[str, Any]) -> None:
7694
for key, value in source.items():
7795
apply_key_value(destination, key, value)
7896

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ classifiers = [
2121
]
2222
keywords = ["configuration", "root", "management", "strategy", "settings"]
2323

24-
dependencies = ["tomli; python_version < '3.11'", "python-dotenv~=1.0.0"]
24+
dependencies = ["deepmerge~=1.1.0", "tomli; python_version < '3.11'", "python-dotenv~=1.0.0"]
2525

2626
[project.optional-dependencies]
2727
yaml = ["PyYAML"]

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ black==22.10.0
44
build==0.10.0
55
click==8.1.3
66
coverage==6.5.0
7+
deepmerge==1.1.0
78
flake8==5.0.4
89
iniconfig==1.1.1
910
isort==5.10.1

tests/test_configuration.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
from typing import Any, Dict
3+
from uuid import uuid4
34

45
import pkg_resources
56
import pytest
@@ -343,11 +344,13 @@ def test_to_dictionary_method_after_applying_env():
343344
# in this test, environmental variables with TEST_ prefix are used
344345
# to override values from a previous source
345346

346-
os.environ["TEST_b__c__d"] = "200"
347-
os.environ["TEST_a__0"] = "3"
347+
prefix = str(uuid4())
348+
349+
os.environ[f"{prefix}_b__c__d"] = "200"
350+
os.environ[f"{prefix}_a__0"] = "3"
348351
builder = ConfigurationBuilder(
349352
MapSource({"a": [1, 2, 3], "b": {"c": {"d": 100}}}),
350-
EnvVars("TEST_"),
353+
EnvVars(f"{prefix}_"),
351354
)
352355

353356
config = builder.build()
@@ -368,3 +371,16 @@ def test_overriding_sub_properties():
368371
assert config.a.b.c == 200
369372
assert config.a.b2 == "foo"
370373
assert config.a2 == "oof"
374+
375+
376+
def test_deep_merges_of_mappings():
377+
builder = ConfigurationBuilder(
378+
MapSource({"a": {"b": {"c": 100}}, "a2": "oof"}),
379+
MapSource({"a": {"b": {"d": 200}}}),
380+
)
381+
382+
config = builder.build()
383+
384+
assert config.a.b.c == 100
385+
assert config.a.b.d == 200
386+
assert config.a2 == "oof"

0 commit comments

Comments
 (0)