Skip to content
Open
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
17 changes: 15 additions & 2 deletions src/dotbot/context.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import copy
import os
from argparse import Namespace
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type

if TYPE_CHECKING:
from dotbot.plugin import Plugin
Expand Down Expand Up @@ -33,7 +33,20 @@ def base_directory(self, canonical_path: bool = True) -> str: # noqa: FBT001, F
return base_directory

def set_defaults(self, defaults: Dict[str, Any]) -> None:
self._defaults = defaults
self._defaults = self._merge_defaults(self._defaults, defaults)

@classmethod
def _merge_defaults(
cls, current: Mapping[str, Any], updates: Mapping[str, Any]
) -> Dict[str, Any]:
"""Merge default mappings recursively, with later values taking precedence."""
merged = copy.deepcopy(dict(current))
for key, value in updates.items():
if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
merged[key] = cls._merge_defaults(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged

def defaults(self) -> Dict[str, Any]:
return copy.deepcopy(self._defaults)
Expand Down
54 changes: 54 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from typing import Any, Dict

from dotbot.context import Context


def test_set_defaults_merges_nested_mappings() -> None:
"""Verify that successive defaults preserve mappings from earlier tasks."""

context = Context(".")

context.set_defaults(
{
"shell": {"quiet": True},
"link": {"create": True, "relink": True},
}
)
context.set_defaults({"plugin": {"interactive": False}})

assert context.defaults() == {
"shell": {"quiet": True},
"link": {"create": True, "relink": True},
"plugin": {"interactive": False},
}


def test_set_defaults_later_values_override_without_dropping_siblings() -> None:
"""Verify that later nested defaults override only the selected value."""

context = Context(".")
context.set_defaults(
{
"link": {"create": True, "relink": False},
"options": ["--safe-links"],
}
)

context.set_defaults({"link": {"relink": True}})

assert context.defaults() == {
"link": {"create": True, "relink": True},
"options": ["--safe-links"],
}


def test_defaults_are_copied_when_merged() -> None:
"""Verify that mutating input defaults does not mutate the context."""

context = Context(".")
defaults: Dict[str, Any] = {"plugin": {"interactive": False}}

context.set_defaults(defaults)
defaults["plugin"]["interactive"] = True

assert context.defaults()["plugin"]["interactive"] is False