Skip to content

Commit 8b5fd8d

Browse files
committed
feat(get): get() function supports 'for-each' containers with '[]'
1 parent 7c8be6f commit 8b5fd8d

File tree

3 files changed

+129
-15
lines changed

3 files changed

+129
-15
lines changed

src/filterpath/_get.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ def get( # noqa: C901, PLR0915
3030
:return:
3131
:rtype: Any | list[Any]
3232
"""
33-
escapable_sequences = frozenset({path_separator, "\\"})
33+
escapable_sequences = frozenset({path_separator, "\\", "["})
3434
sentinel = object()
3535

36-
def _deep_get(_obj: ObjTypes, _path: PathTypes) -> Any | list[Any]:
36+
def _deep_get(_obj: ObjTypes, _path: PathTypes, container: list) -> Any | list[Any]: # noqa: C901
3737
if _obj is sentinel:
3838
# STOP: Run out of objects to traverse
3939
logger.trace("out of objects: raising NoPathExistsError")
@@ -49,15 +49,50 @@ def _deep_get(_obj: ObjTypes, _path: PathTypes) -> Any | list[Any]:
4949
logger.trace("out of iterables: raising NoPathExistsError")
5050
raise NoPathExistsError(obj, path)
5151

52-
key, _path = _parse_path(_path)
52+
key, _path, has_container = _parse_path(_path)
5353
logger.trace(f"current key '{key}' and remaining path '{_path}'")
5454

55+
if has_container:
56+
logger.trace("encountering container")
57+
# Strip brackets for any filtering key or function
58+
filter_key = key[1:-1]
59+
60+
logger.trace(f"filtering container on '{key}'")
61+
try:
62+
filtered_obj = _deep_get(_obj, filter_key, container)
63+
except KeyError:
64+
logger.trace(f"unable to filter '{_obj}' on '{filter_key}', return empty list")
65+
return container
66+
67+
if isinstance(filtered_obj, dict):
68+
filtered_obj = filtered_obj.values()
69+
70+
logger.trace(f"iterating {filtered_obj}")
71+
try:
72+
filtered_obj = iter(filtered_obj)
73+
except TypeError:
74+
logger.trace(f"{filtered_obj} not iterable, returning {filtered_obj}")
75+
container.append(filtered_obj)
76+
return container
77+
78+
for item in filtered_obj:
79+
logger.trace(f"getting path '{_path}' of '{item}'")
80+
try:
81+
deep_obj = _deep_get(item, _path, container)
82+
if deep_obj is not container:
83+
container.append(deep_obj)
84+
except KeyError:
85+
pass
86+
87+
return container
88+
5589
logger.trace(f"access '{key}' in {_obj}")
56-
return _deep_get(_get_any(_obj, key), _path)
90+
return _deep_get(_get_any(_obj, key), _path, container)
5791

58-
def _parse_path(_path: PathTypes) -> tuple[Any, PathTypes]:
92+
def _parse_path(_path: PathTypes) -> tuple[Any, PathTypes, bool]:
5993
if isinstance(_path, str):
6094
is_escaped = False
95+
has_container = _path.startswith("[")
6196
escape_indexes = []
6297
for idx, char in enumerate(_path):
6398
if not is_escaped:
@@ -75,18 +110,18 @@ def _parse_path(_path: PathTypes) -> tuple[Any, PathTypes]:
75110
idx += 1
76111

77112
parsed_path = _remove_char_at_index(_path[:idx], escape_indexes)
78-
return parsed_path, _path[idx + 1 :]
113+
return parsed_path, _path[idx + 1 :], has_container and parsed_path.endswith("]")
79114

80115
# Get next from _path, operating on a list/tuple
81116
curr_path = _path[0]
82117
if isinstance(curr_path, str) and path_separator in curr_path:
83118
# Parse the returned key for any unescaped subpaths
84-
curr_path, remaining_path = _parse_path(curr_path)
119+
curr_path, remaining_path, has_container = _parse_path(curr_path)
85120
if remaining_path:
86121
# Prepend the remaining subpath
87122
remaining_path = [remaining_path, *_path[1:]]
88-
return curr_path, remaining_path
89-
return curr_path, _path[1:]
123+
return curr_path, remaining_path, has_container
124+
return curr_path, _path[1:], False
90125

91126
def _remove_char_at_index(string: str, index: int | list[int]) -> str:
92127
if isinstance(index, int):
@@ -121,12 +156,12 @@ def _get_any(_obj: ObjTypes, key: Any) -> Any:
121156

122157
if isinstance(path, PathTypes):
123158
try:
124-
return _deep_get(obj, path)
125-
except NoPathExistsError as err:
159+
return _deep_get(obj, path, [])
160+
except NoPathExistsError:
126161
if raise_if_unfound:
127162
logger.trace("raise KeyError instead of returning default")
128-
raise KeyError from err
163+
raise
129164
logger.trace(f"return default value: {default}")
130165
return default
131166
else:
132-
raise TypeError from NotPathLikeError(path)
167+
raise NotPathLikeError(path)

tests/__init__.py

Whitespace-only changes.

tests/get_test.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
from collections import defaultdict
12
from typing import Any, NamedTuple
23

34
import pytest
45

56
from filterpath import get
7+
from filterpath._exceptions import NotPathLikeError
68

79

810
class SomeNamedTuple(NamedTuple):
@@ -43,11 +45,9 @@ def __init__(self, **attrs):
4345
({"one": ["two", {"three": [4, 5]}]}, (["one", 1, "three", 1],), 5),
4446
({"one": ["two", {"three": [4, 5]}]}, ("one.1.three.1",), 5),
4547
({"one": ["two", {"three": [4, 5]}]}, ("one.1.three",), [4, 5]),
46-
({"one": ["two", {"three": [4, 5]}]}, ("one.1.three.1",), 5),
4748
(["one", {"two": {"three": [4, 5]}}], ("1.two.three.0",), 4),
4849
(["one", {"two": {"three": [4, [{"four": [5]}]]}}], ("1.two.three.1.0.four.0",), 5),
4950
(["one", {"two": {"three[1]": [4, [{"four": [5]}]]}}], ("1.two.three[1].0",), 4),
50-
(["one", {"two": {"three": [4, [{"four": [5]}]]}}], ("1.two.three.1.0.four.0",), 5),
5151
(["one", {"two": {"three": [4, [{"four": [5]}], 6]}}], ("1.two.three.-2.0.four.0",), 5),
5252
(range(50), ("42",), 42),
5353
(range(50), ("-1",), 49),
@@ -97,3 +97,82 @@ def __init__(self, **attrs):
9797
)
9898
def test_get(obj, args, expected):
9999
assert get(obj, *args) == expected
100+
101+
102+
@pytest.mark.parametrize(
103+
("path", "expected"),
104+
[
105+
("a", [1, 2, {"b": [3, 4]}, {"b": [5, 6]}]),
106+
("0", "c"),
107+
("a.0", 1),
108+
("a\\.0", 11),
109+
("a\\\\\\.0", 12),
110+
("a\\\\.0", 13),
111+
("\\[0]", 9),
112+
("\\\\[0]", 10),
113+
("a.[]", [1, 2, {"b": [3, 4]}, {"b": [5, 6]}]),
114+
("a.b", None),
115+
("a.[b]", []),
116+
("a.[4]", []),
117+
("a.4", None),
118+
("a.[z]", []),
119+
("a.z", None),
120+
("a.b.[]", None),
121+
("[]", [[1, 2, {"b": [3, 4]}, {"b": [5, 6]}], "c", 9, 10, 11, 12, [13], {":0": 99}]),
122+
("[].[]", [1, 2, {"b": [3, 4]}, {"b": [5, 6]}, 13, 99]),
123+
("[].[].[]", [[3, 4], [5, 6]]),
124+
("[].[].[].[]", [3, 4, 5, 6]),
125+
("[].[].[].[].[]", []),
126+
("a.[0]", [1]),
127+
("a.[].0", []),
128+
("a.b.0", None),
129+
("a.2.b.0", 3),
130+
("a.3.b.0", 5),
131+
("a.[].b", [[3, 4], [5, 6]]),
132+
("a.[].b.0", [3, 5]),
133+
("a.[].b.[]", [3, 4, 5, 6]),
134+
],
135+
)
136+
def test_get_enhanced(path, expected):
137+
obj = {
138+
"a": [1, 2, {"b": [3, 4]}, {"b": [5, 6]}],
139+
0: "c",
140+
"[0]": 9,
141+
"\\[0]": 10,
142+
"a.0": 11,
143+
"a\\.0": 12,
144+
"a\\": [13],
145+
"x": {":0": 99},
146+
}
147+
assert get(obj, path) == expected
148+
149+
150+
def test_get__should_not_populate_defaultdict():
151+
data = defaultdict(list)
152+
get(data, "a")
153+
assert data == {}
154+
155+
156+
@pytest.mark.parametrize(
157+
("obj", "path"),
158+
[
159+
(Object(), 1),
160+
(Object(), Object()),
161+
],
162+
)
163+
def test_get__raises_type_error_for_non_pathlike(obj, path):
164+
with pytest.raises(TypeError, match="path argument must be one of 'str | list | tuple', not '.*'"):
165+
get(obj, path)
166+
167+
168+
@pytest.mark.parametrize(
169+
("obj", "path"),
170+
[
171+
({"one": {"two": {"three": 4}}}, "one.four"),
172+
({"one": {"two": {"three": 4}}}, "one.four.three"),
173+
({"one": {"two": {"three": [{"a": 1}]}}}, "one.four.three.0.a"),
174+
],
175+
)
176+
def test_get__raises_key_error_for_unfound(obj, path):
177+
with pytest.raises(KeyError, match=".* does not contain path '.*'"):
178+
get(obj, path, raise_if_unfound=True)

0 commit comments

Comments
 (0)