Skip to content

Commit 43f85fa

Browse files
Move _deep_merge to src/usethis/_file/merge.py, add unit tests
Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/f3e236f8-f385-4552-9e5c-cd568c97529c
1 parent c93b479 commit 43f85fa

6 files changed

Lines changed: 111 additions & 26 deletions

File tree

.importlinter

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ containers =
119119
layers =
120120
pyproject_toml | setup_cfg
121121
ini | toml | yaml
122-
dir
122+
dir | merge
123123
exhaustive = true
124124

125125
[importlinter:contract:ui_interface]

src/usethis/_file/merge.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import MutableMapping
4+
from typing import Any
5+
6+
7+
def _deep_merge(
8+
target: MutableMapping[Any, Any], source: MutableMapping[Any, Any]
9+
) -> MutableMapping[Any, Any]:
10+
"""Recursively merge source into target in place, returning target.
11+
12+
For keys present in both mappings, if both values are mappings the merge is
13+
applied recursively; otherwise the source value replaces the target value.
14+
"""
15+
for key, value in source.items():
16+
if (
17+
key in target
18+
and isinstance(target[key], MutableMapping)
19+
and isinstance(value, MutableMapping)
20+
):
21+
_deep_merge(target[key], value)
22+
else:
23+
target[key] = value
24+
return target

src/usethis/_file/toml/io_.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from tomlkit.exceptions import TOMLKitError
1313
from typing_extensions import assert_never
1414

15+
from usethis._file.merge import _deep_merge
1516
from usethis._file.toml.errors import (
1617
TOMLDecodeError,
1718
TOMLNotFoundError,
@@ -26,7 +27,6 @@
2627
KeyValueFileManager,
2728
UnexpectedFileIOError,
2829
UnexpectedFileOpenError,
29-
_deep_merge,
3030
print_keys,
3131
)
3232

src/usethis/_file/yaml/io_.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing_extensions import assert_never
1616

1717
from usethis._console import info_print
18+
from usethis._file.merge import _deep_merge
1819
from usethis._file.yaml.errors import (
1920
UnexpectedYAMLIOError,
2021
UnexpectedYAMLOpenError,
@@ -29,7 +30,6 @@
2930
KeyValueFileManager,
3031
UnexpectedFileIOError,
3132
UnexpectedFileOpenError,
32-
_deep_merge,
3333
print_keys,
3434
)
3535

src/usethis/_io.py

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
import re
44
from abc import abstractmethod
5-
from collections.abc import MutableMapping
6-
from typing import TYPE_CHECKING, Any, Generic, TypeAlias, TypeVar
5+
from typing import TYPE_CHECKING, Generic, TypeAlias, TypeVar
76

87
from typing_extensions import assert_never
98

@@ -14,7 +13,7 @@
1413
from collections.abc import Sequence
1514
from pathlib import Path
1615
from types import TracebackType
17-
from typing import ClassVar
16+
from typing import Any, ClassVar
1817

1918
from typing_extensions import Self
2019

@@ -247,23 +246,3 @@ def print_keys(keys: Sequence[Key]) -> str:
247246
assert_never(key)
248247

249248
return ".".join(components)
250-
251-
252-
def _deep_merge(
253-
target: MutableMapping[Any, Any], source: MutableMapping[Any, Any]
254-
) -> MutableMapping[Any, Any]:
255-
"""Recursively merge source into target in place, returning target.
256-
257-
For keys present in both mappings, if both values are mappings the merge is
258-
applied recursively; otherwise the source value replaces the target value.
259-
"""
260-
for key, value in source.items():
261-
if (
262-
key in target
263-
and isinstance(target[key], MutableMapping)
264-
and isinstance(value, MutableMapping)
265-
):
266-
_deep_merge(target[key], value)
267-
else:
268-
target[key] = value
269-
return target

tests/usethis/_file/test_merge.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
from usethis._file.merge import _deep_merge
4+
5+
6+
class TestDeepMerge:
7+
class TestBasicMerge:
8+
def test_top_level_key_added(self) -> None:
9+
target: dict = {"a": 1}
10+
source: dict = {"b": 2}
11+
result = _deep_merge(target, source)
12+
assert result == {"a": 1, "b": 2}
13+
14+
class TestNestedDicts:
15+
def test_nested_merge(self) -> None:
16+
target: dict = {"a": {"x": 1}}
17+
source: dict = {"a": {"y": 2}}
18+
result = _deep_merge(target, source)
19+
assert result == {"a": {"x": 1, "y": 2}}
20+
21+
def test_deeply_nested(self) -> None:
22+
target: dict = {"a": {"b": {"c": 1}}}
23+
source: dict = {"a": {"b": {"d": 2}}}
24+
result = _deep_merge(target, source)
25+
assert result == {"a": {"b": {"c": 1, "d": 2}}}
26+
27+
class TestReplacementOfNonDictValues:
28+
def test_scalar_replaced_by_scalar(self) -> None:
29+
target: dict = {"a": 1}
30+
source: dict = {"a": 2}
31+
result = _deep_merge(target, source)
32+
assert result == {"a": 2}
33+
34+
def test_dict_replaced_by_scalar(self) -> None:
35+
target: dict = {"a": {"x": 1}}
36+
source: dict = {"a": 99}
37+
result = _deep_merge(target, source)
38+
assert result == {"a": 99}
39+
40+
def test_scalar_replaced_by_dict(self) -> None:
41+
target: dict = {"a": 99}
42+
source: dict = {"a": {"x": 1}}
43+
result = _deep_merge(target, source)
44+
assert result == {"a": {"x": 1}}
45+
46+
def test_list_replaced_by_list(self) -> None:
47+
target: dict = {"a": [1, 2]}
48+
source: dict = {"a": [3, 4]}
49+
result = _deep_merge(target, source)
50+
assert result == {"a": [3, 4]}
51+
52+
class TestInPlaceMutation:
53+
def test_returns_target(self) -> None:
54+
target: dict = {"a": 1}
55+
source: dict = {"b": 2}
56+
result = _deep_merge(target, source)
57+
assert result is target
58+
59+
def test_target_is_mutated(self) -> None:
60+
target: dict = {"a": 1}
61+
source: dict = {"b": 2}
62+
_deep_merge(target, source)
63+
assert target == {"a": 1, "b": 2}
64+
65+
class TestDisjointKeys:
66+
def test_disjoint_keys_merged(self) -> None:
67+
target: dict = {"a": 1, "b": 2}
68+
source: dict = {"c": 3, "d": 4}
69+
result = _deep_merge(target, source)
70+
assert result == {"a": 1, "b": 2, "c": 3, "d": 4}
71+
72+
def test_empty_source(self) -> None:
73+
target: dict = {"a": 1}
74+
source: dict = {}
75+
result = _deep_merge(target, source)
76+
assert result == {"a": 1}
77+
78+
def test_empty_target(self) -> None:
79+
target: dict = {}
80+
source: dict = {"a": 1}
81+
result = _deep_merge(target, source)
82+
assert result == {"a": 1}

0 commit comments

Comments
 (0)