Skip to content
Closed
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- `ConfDict`: `ConfDict.ops.REPLACE` now resolves when applied onto a missing, `null` or empty target instead of staying in the config as data. Materializing a patch (`ConfDict(...)`, `from_yaml`, list items) still leaves its ops pending. [#322]

## 0.5.29 - 26-07-28

- `ConfDict`: added `ConfDict` operations `ConfDict.ops.DELETE`, `ConfDict.ops.BEFORE`, and `ConfDict.ops.AFTER`; `ConfDict.ops.REPLACE` replaces `ConfDict.OVERRIDE`. [#310]
Expand Down
47 changes: 30 additions & 17 deletions exca/confdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ def _is_seq(val: tp.Any) -> tp.TypeGuard[tp.Sequence[tp.Any]]:
return isinstance(val, abc.Sequence) and not isinstance(val, str)


def _as_patch(mapping: Mapping | None, kwargs: dict[str, tp.Any]) -> dict[str, tp.Any]:
"""Fold a positional mapping and keyword arguments into one patch"""
if mapping is not None:
if not isinstance(mapping, abc.Mapping):
mapping = dict(mapping)
kwargs.update(mapping)
return kwargs


def _apply_move(obj: dict[str, tp.Any], key: str) -> None:
sub = obj[key]
present = [op for op in (ConfDict.ops.BEFORE, ConfDict.ops.AFTER) if op in sub]
Expand Down Expand Up @@ -112,7 +121,7 @@ def _propagate_confdict(obj: tp.Any, replace_dicts: bool = False) -> tp.Any:
return obj


def _set_item(obj: tp.Any, key: str, val: tp.Any) -> None:
def _set_item(obj: tp.Any, key: str, val: tp.Any, consume_ops: bool = True) -> None:
"""Internal recursive setitem on ConfDict/list"""
p, *rest = key.split(".", maxsplit=1)
if not rest:
Expand Down Expand Up @@ -153,15 +162,15 @@ def _set_item(obj: tp.Any, key: str, val: tp.Any) -> None:
if isinstance(val, dict) and not isinstance(val, OrderedDict):
if isinstance(sub, OrderedDict):
patched = ConfDict(sub)
patched.update(val) # degrades to dict
patched._update(val, consume_ops) # degrades to dict
sub.clear()
sub.update(OrderedDict(patched.items()))
elif not isinstance(sub, ConfDict):
sub = ConfDict(sub) if isinstance(sub, dict) else ConfDict()
dict.__setitem__(obj, p, sub)
sub.update(val)
sub._update(val, consume_ops)
else:
sub.update(val)
sub._update(val, consume_ops)
_apply_move(obj, p)
else:
dict.__setitem__(obj, p, val)
Expand Down Expand Up @@ -192,7 +201,7 @@ class ConfDict(dict[str, tp.Any], metaclass=_ConfDictMeta):

def __init__(self, mapping: Mapping | None = None, **kwargs: tp.Any) -> None:
super().__init__()
self.update(mapping, **kwargs)
self._update(_as_patch(mapping, kwargs), consume_ops=False)

@classmethod
def from_model(
Expand Down Expand Up @@ -281,7 +290,8 @@ def update( # type: ignore
-----------
- :code:`ConfDict.ops.DELETE` as value to a key deletes the key altogether.
- :code:`ConfDict.ops.REPLACE` as key with True value replaces the whole
existing content with the new one instead of merging recursively.
existing content with the new one instead of merging recursively. It
always resolves, including onto a missing, null or empty target.
- :code:`ConfDict.ops.BEFORE` and :code:`ConfDict.ops.AFTER` reorders a key
relative to an existing sibling.

Expand All @@ -297,18 +307,23 @@ def update( # type: ignore
>>> cfg
{'b': {'x': 2, 'y': 12}, 'a': {'y': 4}}
"""
if mapping is not None:
if not isinstance(mapping, abc.Mapping):
mapping = dict(mapping)
kwargs.update(mapping)
if not kwargs:
self._update(_as_patch(mapping, kwargs), consume_ops=True)

def _update(self, patch: Mapping, consume_ops: bool) -> None:
"""Merge *patch* into self, resolving its ops only when applying it.

Materializing a patch (:code:`__init__`, :code:`from_yaml`, list items)
leaves its ops pending for whichever config it later lands on.
"""
patch = dict(patch)
if not patch:
return
if self and kwargs.pop(ConfDict.ops.REPLACE, False):
if consume_ops and patch.pop(ConfDict.ops.REPLACE, False):
self.clear()
for key, val in kwargs.items():
for key, val in patch.items():
if not isinstance(key, str):
raise TypeError(f"ConfDict only supports str keys, got {key!r}")
_set_item(self, key, val)
_set_item(self, key, val, consume_ops)

def flat(self) -> dict[str, tp.Any]:
"""Returns a flat dictionary such as
Expand Down Expand Up @@ -339,9 +354,7 @@ def from_yaml(
out = _yaml.safe_load(yaml)
if not isinstance(out, dict):
raise TypeError(f"Cannot convert non-dict yaml:\n{out}\n(from {input_})")
conf = ConfDict()
conf.update(out)
return conf
return ConfDict(out)

def to_yaml(self, filepath: Path | str | None = None) -> str:
"""Exports the ConfDict to yaml string
Expand Down
15 changes: 15 additions & 0 deletions exca/test_confdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ def test_update() -> None:
assert data == {"a": {"b": {"e": 15}, "c": 1}}


@pytest.mark.parametrize("base", [{}, {"a": None}, {"a": {}}, {"a": {"x": 1}}])
def test_update_replace_resolves_on_any_target(base: dict[str, tp.Any]) -> None:
data = ConfDict(base)
data.update({"a": {ConfDict.ops.REPLACE: True, "y": 2}})
assert data["a"] == {"y": 2}


def test_replace_stays_pending_until_the_patch_is_applied() -> None:
patch = ConfDict({"a": {ConfDict.ops.REPLACE: True, "y": 2}})
assert patch["a"] == {ConfDict.ops.REPLACE: True, "y": 2}
data = ConfDict({"a": {"x": 1}})
data.update(patch)
assert data["a"] == {"y": 2}


def test_update_docstring_examples() -> None:
parser = doctest.DocTestParser()
runner = doctest.DocTestRunner()
Expand Down