Skip to content

Commit cdb3810

Browse files
committed
feat: QueryList: Filter lists of dictionaries w/ nested support
1 parent c97aa0d commit cdb3810

File tree

4 files changed

+464
-0
lines changed

4 files changed

+464
-0
lines changed

docs/contributing/internals.md

+5
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,8 @@ stability policy.
1818
.. autoapimodule:: libvcs.types
1919
:members:
2020
```
21+
22+
```{eval-rst}
23+
.. autoapimodule:: libvcs.utils.query_list
24+
:members:
25+
```

libvcs/utils/__init__.py

Whitespace-only changes.

libvcs/utils/query_list.py

+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import re
2+
import traceback
3+
from typing import Any, Callable, Optional, Protocol, Sequence, TypeVar, Union
4+
5+
T = TypeVar("T", Any, Any)
6+
7+
8+
def keygetter(obj, path):
9+
"""obj, "foods__breakfast", obj['foods']['breakfast']
10+
11+
>>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast")
12+
'cereal'
13+
>>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods")
14+
{'breakfast': 'cereal'}
15+
16+
"""
17+
try:
18+
sub_fields = path.split("__")
19+
dct = obj
20+
for sub_field in sub_fields:
21+
dct = dct[sub_field]
22+
return dct
23+
except Exception as e:
24+
traceback.print_exception(e)
25+
return None
26+
27+
28+
def parse_lookup(obj, path, lookup):
29+
"""Check if field lookup key, e.g. "my__path__contains" has comparator, return val.
30+
31+
If comparator not used or value not found, return None.
32+
33+
mykey__endswith("mykey") -> "mykey" else None
34+
35+
>>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith")
36+
'red apple'
37+
"""
38+
try:
39+
if path.endswith(lookup):
40+
if field_name := path.rsplit(lookup)[0]:
41+
return keygetter(obj, field_name)
42+
except Exception as e:
43+
traceback.print_exception(e)
44+
return None
45+
46+
47+
class LookupProtocol(Protocol):
48+
"""Protocol for :class:`QueryList` filtering operators."""
49+
50+
def __call__(self, data: Union[list[str], str], rhs: Union[list[str], str]):
51+
"""Callback for :class:`QueryList` filtering operators."""
52+
53+
54+
def lookup_exact(data, rhs):
55+
return rhs == data
56+
57+
58+
def lookup_iexact(data, rhs):
59+
return rhs.lower() == data.lower()
60+
61+
62+
def lookup_contains(data, rhs):
63+
return rhs in data
64+
65+
66+
def lookup_icontains(data, rhs):
67+
return rhs.lower() in data.lower()
68+
69+
70+
def lookup_startswith(data, rhs):
71+
return data.startswith(rhs)
72+
73+
74+
def lookup_istartswith(data, rhs):
75+
return data.lower().startswith(rhs.lower())
76+
77+
78+
def lookup_endswith(data, rhs):
79+
return data.endswith(rhs)
80+
81+
82+
def lookup_iendswith(data, rhs):
83+
return data.lower().endswith(rhs.lower())
84+
85+
86+
def lookup_in(data, rhs):
87+
if isinstance(rhs, list):
88+
return data in rhs
89+
return rhs in data
90+
91+
92+
def lookup_nin(data, rhs):
93+
if isinstance(rhs, list):
94+
return data not in rhs
95+
return rhs not in data
96+
97+
98+
def lookup_regex(data, rhs):
99+
return re.search(rhs, data)
100+
101+
102+
def lookup_iregex(data, rhs):
103+
return re.search(rhs, data, re.IGNORECASE)
104+
105+
106+
LOOKUP_NAME_MAP: dict[str, LookupProtocol] = {
107+
"eq": lookup_exact,
108+
"exact": lookup_exact,
109+
"iexact": lookup_iexact,
110+
"contains": lookup_contains,
111+
"icontains": lookup_icontains,
112+
"startswith": lookup_startswith,
113+
"istartswith": lookup_istartswith,
114+
"endswith": lookup_endswith,
115+
"iendswith": lookup_iendswith,
116+
"in": lookup_in,
117+
"nin": lookup_nin,
118+
"regex": lookup_regex,
119+
"iregex": lookup_iregex,
120+
}
121+
122+
123+
class QueryList(list[T]):
124+
"""Filter list of object/dicts. For small, local datasets. *Experimental, unstable*.
125+
126+
>>> query = QueryList(
127+
... [
128+
... {
129+
... "place": "Largo",
130+
... "city": "Tampa",
131+
... "state": "Florida",
132+
... "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"},
133+
... },
134+
... {
135+
... "place": "Chicago suburbs",
136+
... "city": "Elmhurst",
137+
... "state": "Illinois",
138+
... "foods": {"fruit": ["apple", "cantelope"], "breakfast": "waffles"},
139+
... },
140+
... ]
141+
... )
142+
>>> query.filter(place="Chicago suburbs")[0]['city']
143+
'Elmhurst'
144+
>>> query.filter(place__icontains="chicago")[0]['city']
145+
'Elmhurst'
146+
>>> query.filter(foods__breakfast="waffles")[0]['city']
147+
'Elmhurst'
148+
>>> query.filter(foods__fruit__in="cantelope")[0]['city']
149+
'Elmhurst'
150+
>>> query.filter(foods__fruit__in="orange")[0]['city']
151+
'Tampa'
152+
"""
153+
154+
data: Sequence[T]
155+
156+
def items(self):
157+
data: Sequence[T]
158+
159+
if self.pk_key is None:
160+
raise Exception("items() require a pk_key exists")
161+
return [(getattr(item, self.pk_key), item) for item in self]
162+
163+
def __eq__(self, other):
164+
data = other
165+
166+
if not isinstance(self, list) or not isinstance(data, list):
167+
return False
168+
169+
if len(self) == len(data):
170+
for (a, b) in zip(self, data):
171+
if isinstance(a, dict):
172+
a_keys = a.keys()
173+
if a.keys == b.keys():
174+
for key in a_keys:
175+
if abs(a[key] - b[key]) > 1:
176+
return False
177+
else:
178+
if a != b:
179+
return False
180+
181+
return True
182+
return False
183+
184+
def filter(self, matcher: Optional[Union[Callable[[T], bool], T]] = None, **kwargs):
185+
def filter_lookup(obj) -> bool:
186+
for path, v in kwargs.items():
187+
try:
188+
lhs, op = path.rsplit("__", 1)
189+
190+
if op not in LOOKUP_NAME_MAP:
191+
raise ValueError(f"{op} not in LOOKUP_NAME_MAP")
192+
except ValueError:
193+
lhs = path
194+
op = "exact"
195+
196+
assert op in LOOKUP_NAME_MAP
197+
path = lhs
198+
data = keygetter(obj, path)
199+
200+
if not LOOKUP_NAME_MAP[op](data, v):
201+
return False
202+
203+
return True
204+
205+
if callable(matcher):
206+
_filter = matcher
207+
elif matcher is not None:
208+
209+
def val_match(obj):
210+
if isinstance(matcher, list):
211+
return obj in matcher
212+
else:
213+
return obj == matcher
214+
215+
_filter = val_match
216+
else:
217+
_filter = filter_lookup
218+
219+
return self.__class__(k for k in self if _filter(k))

0 commit comments

Comments
 (0)