Skip to content

Commit

Permalink
🐛 fix: merging of two similarly keyed mappings (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
StummeJ authored Dec 28, 2023
1 parent 09460b7 commit 0eb8115
Show file tree
Hide file tree
Showing 4 changed files with 44 additions and 9 deletions.
28 changes: 23 additions & 5 deletions config/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@
from collections import abc
from typing import Any, Dict, List, Mapping, Optional, Type, TypeVar

from deepmerge import Merger

from config.errors import ConfigurationOverrideError

T = TypeVar("T")

merger = Merger(
type_strategies=[
(list, ["append"]),
(dict, ["merge"]),
(set, ["union"]),
],
fallback_strategies=["override"],
type_conflict_strategies=["override"],
)


def apply_key_value(obj, key, value):
def apply_key_value(obj: Mapping[str, Any], key: str, value: Any) -> Mapping[str, Any]:
key = key.strip("_:.") # remove special characters from both ends
for token in (":", "__", "."):
if token in key:
Expand Down Expand Up @@ -52,27 +64,33 @@ def apply_key_value(obj, key, value):
)

try:
sub_property[index] = value
sub_property[index] = merger.merge(sub_property[index], value)
except IndexError:
raise ConfigurationOverrideError(
f"Invalid override for mutable sequence {key}; "
f"assignment index out of range"
)
else:
try:
sub_property[last_part] = value
if isinstance(sub_property, abc.Mapping):
sub_property[last_part] = merger.merge(
sub_property.get(last_part),
value,
)
else:
sub_property[last_part] = value
except TypeError as type_error:
raise ConfigurationOverrideError(
f"Invalid assignment {key} -> {value}; {str(type_error)}"
)

return obj

obj[key] = value
obj[key] = merger.merge(obj.get(key), value)
return obj


def merge_values(destination, source):
def merge_values(destination: Mapping[str, Any], source: Mapping[str, Any]) -> None:
for key, value in source.items():
apply_key_value(destination, key, value)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ classifiers = [
]
keywords = ["configuration", "root", "management", "strategy", "settings"]

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

[project.optional-dependencies]
yaml = ["PyYAML"]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ black==22.10.0
build==0.10.0
click==8.1.3
coverage==6.5.0
deepmerge==1.1.0
flake8==5.0.4
iniconfig==1.1.1
isort==5.10.1
Expand Down
22 changes: 19 additions & 3 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from typing import Any, Dict
from uuid import uuid4

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

os.environ["TEST_b__c__d"] = "200"
os.environ["TEST_a__0"] = "3"
prefix = str(uuid4())

os.environ[f"{prefix}_b__c__d"] = "200"
os.environ[f"{prefix}_a__0"] = "3"
builder = ConfigurationBuilder(
MapSource({"a": [1, 2, 3], "b": {"c": {"d": 100}}}),
EnvVars("TEST_"),
EnvVars(f"{prefix}_"),
)

config = builder.build()
Expand All @@ -368,3 +371,16 @@ def test_overriding_sub_properties():
assert config.a.b.c == 200
assert config.a.b2 == "foo"
assert config.a2 == "oof"


def test_deep_merges_of_mappings():
builder = ConfigurationBuilder(
MapSource({"a": {"b": {"c": 100}}, "a2": "oof"}),
MapSource({"a": {"b": {"d": 200}}}),
)

config = builder.build()

assert config.a.b.c == 100
assert config.a.b.d == 200
assert config.a2 == "oof"

0 comments on commit 0eb8115

Please sign in to comment.