diff --git a/dpath/__init__.py b/dpath/__init__.py index 9f56e6b..1a1904e 100644 --- a/dpath/__init__.py +++ b/dpath/__init__.py @@ -18,6 +18,7 @@ "Path", "Hints", "Creator", + "DDict", ] from collections.abc import MutableMapping, MutableSequence @@ -26,6 +27,8 @@ from dpath import segments, options from dpath.exceptions import InvalidKeyName, PathNotFound from dpath.types import MergeType, PathSegment, Creator, Filter, Glob, Path, Hints +from dpath.ddict import DDict + _DEFAULT_SENTINEL = object() diff --git a/dpath/ddict.py b/dpath/ddict.py new file mode 100644 index 0000000..adf16cc --- /dev/null +++ b/dpath/ddict.py @@ -0,0 +1,135 @@ +from copy import deepcopy +from typing import Any, MutableMapping + +from dpath import Creator +from dpath.types import MergeType, Filter, Glob + +_DEFAULT_SENTINEL: Any = object() + + +class DDict(dict): + """ + Glob aware dict + """ + + def __init__(self, data: MutableMapping, __separator="/", __creator: Creator = None, **kwargs): + super().__init__() + super().update(data, **kwargs) + + self.separator = __separator + self.creator = __creator + + self.recursive_items = True + + def __getitem__(self, item): + return self.get(item) + + def __contains__(self, item): + return len(self.search(item)) > 0 + + def __setitem__(self, glob, value): + from dpath import new + + # Prevent infinite recursion and other issues + temp = dict(self) + + new(temp, glob, value, separator=self.separator, creator=self.creator) + + self.clear() + self.update(temp) + + def __delitem__(self, glob: Glob, afilter: Filter = None): + from dpath import delete + + temp = dict(self) + + delete(temp, glob, separator=self.separator, afilter=afilter) + + self.clear() + self.update(temp) + + def __or__(self, other): + from dpath import merge + + copy = deepcopy(self) + return merge(copy, other, self.separator) + + def __ior__(self, other): + return self.merge(other) + + def __len__(self): + return sum(1 for _ in self.keys()) + + def __repr__(self): + return f"{type(self).__name__}({super().__repr__()})" + + def get(self, glob: Glob, default=_DEFAULT_SENTINEL) -> Any: + """ + Same as dict.get but accepts glob aware keys. + """ + from dpath import get + + if default is _DEFAULT_SENTINEL: + # Let util.get handle default value + return get(self, glob, separator=self.separator) + else: + # Default value was passed + return get(self, glob, separator=self.separator, default=default) + + def setdefault(self, glob: Glob, __default=_DEFAULT_SENTINEL): + if glob in self: + return self[glob] + + self[glob] = None if __default == _DEFAULT_SENTINEL else __default + return self[glob] + + def pop(self, glob: Glob): + results = self.search(glob) + del self[glob] + return results + + def search(self, glob: Glob, yielded=False, afilter: Filter = None, dirs=True): + from dpath import search + + return search(self, glob, yielded=yielded, separator=self.separator, afilter=afilter, dirs=dirs) + + def merge(self, src: MutableMapping, afilter: Filter = None, flags=MergeType.ADDITIVE): + """ + Performs in-place merge with another dict. + """ + from dpath import merge + + result = merge(self, src, separator=self.separator, afilter=afilter, flags=flags) + + self.update(result) + + return self + + def keys(self): + from dpath.segments import walk + + if not self.recursive_items: + yield from dict(self).keys() + return + + for path, _ in walk(self): + yield self.separator.join((str(segment) for segment in path)) + + def values(self): + from dpath.segments import walk + + d = self + if not self.recursive_items: + d = dict(self) + + for _, value in walk(d): + yield value + + def walk(self): + """ + Yields all possible key, value pairs. + """ + from dpath.segments import walk + + for path, value in walk(self): + yield self.separator.join((str(segment) for segment in path)), value diff --git a/dpath/version.py b/dpath/version.py index b777579..3c00bb4 100644 --- a/dpath/version.py +++ b/dpath/version.py @@ -1 +1 @@ -VERSION = "2.1.2" +VERSION = "2.2.0" diff --git a/tests/test_oop.py b/tests/test_oop.py new file mode 100644 index 0000000..201666e --- /dev/null +++ b/tests/test_oop.py @@ -0,0 +1,201 @@ +from copy import deepcopy + +from dpath import DDict + + +def test_oop_getitem(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + }) + + assert d["a"] == 1 + assert d["b/0"] == 12 + assert d["b/-1"] == 34 + + +def test_oop_setitem(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + }) + + d["b/5"] = 1 + assert d["b"][5] == 1 + + d["c"] = [54, 43, 32, 21] + assert d["c"] == [54, 43, 32, 21] + + +def test_oop_delitem(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + + del d["a"] + assert "a" not in d + + del d["c/**/1"] + assert 67 not in d["c/d/e"] + + +def test_oop_pop(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + before = deepcopy(d) + + popped = d.pop("a") + assert popped == {"a": 1} + + d = deepcopy(before) + popped = d.pop("b/1") + assert popped == {"b": [None, 23]} + + d = deepcopy(before) + popped = d.pop("c/**/1") + assert popped == { + "c": { + "d": { + "e": [None, 67] + } + } + } + + +def test_oop_setitem_overwrite(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + }) + + d["b"] = "abc" + assert d["b"] == "abc" + + +def test_oop_contains(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + + assert "a" in d + assert "b" in d + assert "b/0" in d + assert "c/d/e/1" in d + + +def test_oop_merge(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + + expected_after = { + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + }, + "f": [54], + } + + before = deepcopy(d) + + assert d | {"f": [54]} == expected_after + + assert d == before + + d |= {"f": [54]} + + assert d != before + assert d == expected_after + + +def test_oop_len(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + + assert len(d) == 10, len(d) + + d.recursive_items = False + assert len(d) == 3, len(d) + + +def test_oop_keys(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + + assert not { + "a", + "b", + "c", + "b/0", + "b/1", + "b/2", + "c/d", + "c/d/e", + "c/d/e/0", + "c/d/e/1", + }.difference(set(d.keys())) + + +def test_oop_setdefault(): + d = DDict({ + "a": 1, + "b": [12, 23, 34], + "c": { + "d": { + "e": [56, 67] + } + } + }) + + res = d.setdefault("a", 345) + assert res == 1 + + res = d.setdefault("c/4", 567) + assert res == 567 + assert d["c"]["4"] == res + + res = d.setdefault("b/6", 89) + assert res == 89 + assert d["b"] == [12, 23, 34, None, None, None, 89]