Skip to content

Commit

Permalink
Merge pull request #89 from Jasha10/recursive-merge
Browse files Browse the repository at this point in the history
deeply merge_configs
  • Loading branch information
dbatten5 authored Feb 3, 2022
2 parents a0c9eae + 2ad6b3e commit 351f787
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 1 deletion.
3 changes: 2 additions & 1 deletion src/maison/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from maison.errors import NoSchemaError
from maison.schema import ConfigSchema
from maison.utils import _collect_configs
from maison.utils import deep_merge


class ProjectConfig:
Expand Down Expand Up @@ -189,4 +190,4 @@ def _generate_config_dict(self) -> Dict[Any, Any]:
return self._sources[0].to_dict()

source_dicts = [source.to_dict() for source in self._sources]
return reduce(lambda a, b: {**a, **b}, source_dicts)
return reduce(lambda a, b: deep_merge(a, b), source_dicts)
42 changes: 42 additions & 0 deletions src/maison/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,45 @@ def _collect_configs(
sources.append(IniSource(**source_kwargs))

return sources


def deep_merge(destination: Dict[Any, Any], source: Dict[Any, Any]) -> Dict[Any, Any]:
"""Recursively updates the destination dictionary.
Usage example:
>>> a = { 'first' : { 'all_rows' : { 'pass' : 'dog', 'number' : '1' } } }
>>> b = { 'first' : { 'all_rows' : { 'fail' : 'cat', 'number' : '5' } } }
>>> deep_merge(a, b) == {
... "first": {"all_rows": {"pass": "dog", "fail": "cat", "number": "5"}}
... }
True
Note that the arguments may be modified!
Based on https://stackoverflow.com/a/20666342
Args:
destination: A dictionary to be merged into. This will be updated in place.
source: The dictionary supplying data
Returns:
The updated destination dictionary.
Raises:
RuntimeError: A dict cannot be merged on top of a non-dict.
For example, the following would fail:
`deep_merge({"foo": "bar"}, {"foo": {"baz": "qux"}})`
"""
for key, src_value in source.items():
if isinstance(src_value, dict):
# get node or create one
dest_node = destination.setdefault(key, {})
if not isinstance(dest_node, dict):
raise RuntimeError(
f"Cannot merge dict '{src_value}' into type '{type(dest_node)}'"
)
deep_merge(dest_node, src_value)
else:
destination[key] = src_value

return destination
31 changes: 31 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,3 +503,34 @@ def test_overwrites(
assert config.to_dict() == {
"option": "config_3",
}

def test_nested(
self,
create_toml: Callable[..., Path],
create_pyproject_toml: Callable[..., Path],
) -> None:
"""
Given multiple existing config sources with overlapping options,
When the `merge_configs` boolean is set to `True`,
Then the configs are merged from right to left
"""
config_1_path = create_toml(
filename="config_1.toml", content={"option": {"nested_1": "config_1"}}
)
config_2_path = create_toml(
filename="config_2.toml", content={"option": {"nested_2": "config_2"}}
)
pyproject_path = create_pyproject_toml(
content={"option": {"nested_2": "config_3"}}
)

config = ProjectConfig(
project_name="foo",
source_files=[str(config_1_path), str(config_2_path), "pyproject.toml"],
starting_path=pyproject_path,
merge_configs=True,
)

assert config.to_dict() == {
"option": {"nested_1": "config_1", "nested_2": "config_3"},
}
50 changes: 50 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
"""Tests for the `utils` module."""
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Dict
from unittest.mock import MagicMock
from unittest.mock import patch

from pytest import mark
from pytest import param
from pytest import raises

from maison.utils import deep_merge
from maison.utils import get_file_path
from maison.utils import path_contains_file

Expand Down Expand Up @@ -113,3 +120,46 @@ def test_absolute_path_not_exist(self) -> None:
result = get_file_path(filename="~/xxxx/yyyy/doesnotexist.xyz")

assert result is None


@mark.parametrize(
"a,b,expected",
[
param(
{1: 2, 3: 4},
{3: 5, 6: 7},
{1: 2, 3: 5, 6: 7},
id="simple",
),
param(
{1: 2, 3: {4: 5, 6: 7}},
{3: {6: 8, 9: 10}, 11: 12},
{1: 2, 3: {4: 5, 6: 8, 9: 10}, 11: 12},
id="nested",
),
],
)
def test_deep_merge(
a: Dict[Any, Any],
b: Dict[Any, Any],
expected: Dict[Any, Any],
) -> None:
"""
Given two dictionaries `a` and `b`,
when the `deep_merge` function is invoked with `a` and `b` as arguments,
Test that the returned value is as expected.
"""
assert deep_merge(a, b) == expected
assert a == expected


def test_deep_merge_dict_into_scalar() -> None:
"""
Given two incompatible dictionaries `a` and `b`,
when the `deep_merge` function is invoked with `a` and `b` as arguments,
Test that a RuntimeError is raised.
"""
a = {1: 2, 2: 5}
b = {1: {3: 4}}
with raises(RuntimeError):
deep_merge(a, b)

0 comments on commit 351f787

Please sign in to comment.