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
2 changes: 1 addition & 1 deletion .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ containers =
layers =
pyproject_toml | setup_cfg
ini | toml | yaml
dir
dir | merge
exhaustive = true

[importlinter:contract:ui_interface]
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ dynamic = [
dependencies = [
"configupdater>=3.2",
"grimp>=3.14",
"mergedeep>=1.3.4",
"packaging>=20.9",
"pydantic>=2.5.0",
"requests>=2.26.0",
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,6 @@ mergedeep==1.3.4 \
# via
# mkdocs
# mkdocs-get-deps
# usethis
mkdocs==1.6.1 \
--hash=sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2 \
--hash=sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e
Expand Down
24 changes: 24 additions & 0 deletions src/usethis/_file/merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from __future__ import annotations

from collections.abc import MutableMapping
from typing import Any


def _deep_merge(
target: MutableMapping[Any, Any], source: MutableMapping[Any, Any]
) -> MutableMapping[Any, Any]:
"""Recursively merge source into target in place, returning target.

For keys present in both mappings, if both values are mappings the merge is
applied recursively; otherwise the source value replaces the target value.
"""
for key, value in source.items():
if (
key in target
and isinstance(target[key], MutableMapping)
and isinstance(value, MutableMapping)
):
_deep_merge(target[key], value)
else:
target[key] = value
return target
6 changes: 3 additions & 3 deletions src/usethis/_file/toml/io_.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import re
from typing import TYPE_CHECKING, Any

import mergedeep
import tomlkit.api
import tomlkit.items
from pydantic import TypeAdapter, ValidationError
Expand All @@ -13,6 +12,7 @@
from tomlkit.exceptions import TOMLKitError
from typing_extensions import assert_never

from usethis._file.merge import _deep_merge
from usethis._file.toml.errors import (
TOMLDecodeError,
TOMLNotFoundError,
Expand Down Expand Up @@ -381,7 +381,7 @@ def _set_value_in_existing(
contents = value
for key in reversed(keys):
contents = {key: contents}
toml_document = mergedeep.merge(toml_document, contents) # type: ignore[reportAssignmentType]
toml_document = _deep_merge(toml_document, contents) # type: ignore[reportAssignmentType]
assert isinstance(toml_document, TOMLDocument)
else:
# Note that this alternative logic is just to avoid a bug:
Expand All @@ -396,7 +396,7 @@ def _set_value_in_existing(
# https://github.com/usethis-python/usethis-python/issues/558

placeholder = {keys[0]: {keys[1]: {}}}
toml_document = mergedeep.merge(toml_document, placeholder) # type: ignore[reportArgumentType]
toml_document = _deep_merge(toml_document, placeholder) # type: ignore[reportArgumentType]

contents = value
for key in reversed(unshared_keys[1:]):
Expand Down
6 changes: 3 additions & 3 deletions src/usethis/_file/yaml/io_.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from io import StringIO
from typing import TYPE_CHECKING, Any, ClassVar

import mergedeep
import ruamel.yaml
from pydantic import TypeAdapter, ValidationError
from ruamel.yaml.comments import CommentedMap
Expand All @@ -16,6 +15,7 @@
from typing_extensions import assert_never

from usethis._console import info_print
from usethis._file.merge import _deep_merge
from usethis._file.yaml.errors import (
UnexpectedYAMLIOError,
UnexpectedYAMLOpenError,
Expand Down Expand Up @@ -309,7 +309,7 @@ def extend_list(self, *, keys: Sequence[Key], values: list[Any]) -> None:
for key in reversed(keys):
new_content = {key: new_content}
assert isinstance(new_content, dict)
content = mergedeep.merge(content, new_content)
content = _deep_merge(content, new_content)
assert isinstance(content, dict)
else:
TypeAdapter(dict).validate_python(p_parent)
Expand Down Expand Up @@ -385,7 +385,7 @@ def _set_value_in_existing(
contents = value
for key in reversed(keys):
contents = {key: contents}
content = mergedeep.merge(content, contents) # type: ignore[reportAssignmentType]
content = _deep_merge(content, contents) # type: ignore[reportAssignmentType]


def _validate_keys(keys: Sequence[Key]) -> list[str]:
Expand Down
82 changes: 82 additions & 0 deletions tests/usethis/_file/test_merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

from usethis._file.merge import _deep_merge


class TestDeepMerge:
class TestBasicMerge:
def test_top_level_key_added(self) -> None:
target: dict = {"a": 1}
source: dict = {"b": 2}
result = _deep_merge(target, source)
assert result == {"a": 1, "b": 2}

class TestNestedDicts:
def test_nested_merge(self) -> None:
target: dict = {"a": {"x": 1}}
source: dict = {"a": {"y": 2}}
result = _deep_merge(target, source)
assert result == {"a": {"x": 1, "y": 2}}

def test_deeply_nested(self) -> None:
target: dict = {"a": {"b": {"c": 1}}}
source: dict = {"a": {"b": {"d": 2}}}
result = _deep_merge(target, source)
assert result == {"a": {"b": {"c": 1, "d": 2}}}

class TestReplacementOfNonDictValues:
def test_scalar_replaced_by_scalar(self) -> None:
target: dict = {"a": 1}
source: dict = {"a": 2}
result = _deep_merge(target, source)
assert result == {"a": 2}

def test_dict_replaced_by_scalar(self) -> None:
target: dict = {"a": {"x": 1}}
source: dict = {"a": 99}
result = _deep_merge(target, source)
assert result == {"a": 99}

def test_scalar_replaced_by_dict(self) -> None:
target: dict = {"a": 99}
source: dict = {"a": {"x": 1}}
result = _deep_merge(target, source)
assert result == {"a": {"x": 1}}

def test_list_replaced_by_list(self) -> None:
target: dict = {"a": [1, 2]}
source: dict = {"a": [3, 4]}
result = _deep_merge(target, source)
assert result == {"a": [3, 4]}

class TestInPlaceMutation:
def test_returns_target(self) -> None:
target: dict = {"a": 1}
source: dict = {"b": 2}
result = _deep_merge(target, source)
assert result is target

def test_target_is_mutated(self) -> None:
target: dict = {"a": 1}
source: dict = {"b": 2}
_deep_merge(target, source)
assert target == {"a": 1, "b": 2}

class TestDisjointKeys:
def test_disjoint_keys_merged(self) -> None:
target: dict = {"a": 1, "b": 2}
source: dict = {"c": 3, "d": 4}
result = _deep_merge(target, source)
assert result == {"a": 1, "b": 2, "c": 3, "d": 4}

def test_empty_source(self) -> None:
target: dict = {"a": 1}
source: dict = {}
result = _deep_merge(target, source)
assert result == {"a": 1}

def test_empty_target(self) -> None:
target: dict = {}
source: dict = {"a": 1}
result = _deep_merge(target, source)
assert result == {"a": 1}
2 changes: 0 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading