Skip to content

Commit 4616e94

Browse files
committed
feat: list_query: Filterable list
1 parent 9827748 commit 4616e94

File tree

4 files changed

+442
-0
lines changed

4 files changed

+442
-0
lines changed

docs/contributing/internals.md

Lines changed: 5 additions & 0 deletions
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.list_query
24+
:members:
25+
```

libvcs/utils/__init__.py

Whitespace-only changes.

libvcs/utils/list_query.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import dataclasses
2+
import re
3+
import traceback
4+
from typing import Any, Callable, Generic, Optional, Sequence, TypeVar, Union
5+
6+
T = TypeVar("T", Any, Any)
7+
8+
9+
def keygetter(obj, path):
10+
"""obj, "foods__breakfast", obj['foods']['breakfast']
11+
12+
>>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods__breakfast")
13+
'cereal'
14+
>>> keygetter({ "foods": { "breakfast": "cereal" } }, "foods")
15+
{'breakfast': 'cereal'}
16+
17+
"""
18+
try:
19+
sub_fields = path.split("__")
20+
dct = obj
21+
for sub_field in sub_fields:
22+
dct = dct[sub_field]
23+
return dct
24+
except Exception as e:
25+
traceback.print_exception(e)
26+
return None
27+
28+
29+
def parse_lookup(obj, path, lookup):
30+
"""Check if field lookup key, e.g. "my__path__contains" has comparator, return val.
31+
32+
If comparator not used or value not found, return None.
33+
34+
mykey__endswith("mykey") -> "mykey" else None
35+
36+
>>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith")
37+
'red apple'
38+
"""
39+
try:
40+
if path.endswith(lookup):
41+
if field_name := path.rsplit(lookup)[0]:
42+
return keygetter(obj, field_name)
43+
except Exception as e:
44+
traceback.print_exception(e)
45+
return None
46+
47+
48+
@dataclasses.dataclass(eq=False)
49+
class ListQuery(Generic[T]):
50+
"""Filter a list of dicts. *Experimental and unstable*.
51+
52+
:py:func:`dataclasses.dataclass` is only used for ``__repr__`` and pytest comparison
53+
details.
54+
55+
>>> query = ListQuery(
56+
... [
57+
... {
58+
... "place": "Largo",
59+
... "city": "Tampa",
60+
... "state": "Florida",
61+
... "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"},
62+
... },
63+
... {
64+
... "place": "Chicago suburbs",
65+
... "city": "Elmhurst",
66+
... "state": "Illinois",
67+
... "foods": {"fruit": ["apple", "cantelope"], "breakfast": "waffles"},
68+
... },
69+
... ]
70+
... )
71+
>>> query.filter(place="Chicago suburbs").data[0]['city']
72+
'Elmhurst'
73+
>>> query.filter(place__icontains="chicago").data[0]['city']
74+
'Elmhurst'
75+
>>> query.filter(foods__breakfast="waffles").data[0]['city']
76+
'Elmhurst'
77+
>>> query.filter(foods__fruit__in="cantelope").data[0]['city']
78+
'Elmhurst'
79+
>>> query.filter(foods__fruit__in="orange").data[0]['city']
80+
'Tampa'
81+
"""
82+
83+
__slots__ = ("data", "pk_key")
84+
data: Sequence[T]
85+
86+
# def __init__(self, data, pk_key: Optional[str] = None):
87+
# self.data: Sequence[T] = data
88+
# #: Primary key for objects, optional.
89+
# #: Use for .get(), .items()
90+
# self.pk_key: Optional[Any] = pk_key
91+
92+
def items(self):
93+
data: Sequence[T]
94+
95+
if self.pk_key is None:
96+
raise Exception("items() require a pk_key exists")
97+
return [(getattr(item, self.pk_key), item) for item in self.data]
98+
99+
def __eq__(self, other):
100+
data = other
101+
if hasattr(data, "data"):
102+
data = getattr(data, "data")
103+
104+
if not isinstance(self.data, list) or not isinstance(data, list):
105+
return False
106+
107+
if len(self.data) == len(data):
108+
for (a, b) in zip(self.data, data):
109+
if isinstance(a, dict):
110+
a_keys = a.keys()
111+
if a.keys == b.keys():
112+
for key in a_keys:
113+
if abs(a[key] - b[key]) > 1:
114+
return False
115+
else:
116+
if a != b:
117+
return False
118+
119+
return True
120+
return False
121+
122+
def filter(self, matcher: Optional[Union[Callable[[T], bool], T]] = None, **kwargs):
123+
def filter_lookup(obj) -> bool:
124+
for path, v in kwargs.items():
125+
if (field := parse_lookup(obj, path, "__contains")) is not None:
126+
if v not in field:
127+
return False
128+
elif (field := parse_lookup(obj, path, "__icontains")) is not None:
129+
if v.lower() not in field.lower():
130+
return False
131+
elif (field := parse_lookup(obj, path, "__in")) is not None:
132+
if isinstance(v, list):
133+
if field not in v:
134+
return False
135+
else:
136+
if v not in field:
137+
return False
138+
elif (field := parse_lookup(obj, path, "__nin")) is not None:
139+
if isinstance(v, list):
140+
if field in v:
141+
return False
142+
else:
143+
if v in field:
144+
return False
145+
elif (field := parse_lookup(obj, path, "__startswith")) is not None:
146+
if not field.startswith(v):
147+
return False
148+
elif (field := parse_lookup(obj, path, "__istartswith")) is not None:
149+
if not field.lower().startswith(v.lower()):
150+
return False
151+
elif (field := parse_lookup(obj, path, "__endswith")) is not None:
152+
if not field.endswith(v):
153+
return False
154+
elif (field := parse_lookup(obj, path, "__iendswith")) is not None:
155+
if not field.lower().endswith(v.lower()):
156+
return False
157+
elif (field := parse_lookup(obj, path, "__regex")) is not None:
158+
if not re.search(v, field):
159+
return False
160+
elif (field := parse_lookup(obj, path, "__iregex")) is not None:
161+
if not re.search(v, field, re.IGNORECASE):
162+
return False
163+
elif (field := parse_lookup(obj, path, "__iexact")) is not None:
164+
if field.lower() != v.lower():
165+
return False
166+
elif (
167+
field := parse_lookup(obj, path, "__exact")
168+
) is not None: # same as else
169+
if field != v:
170+
return False
171+
elif (
172+
field := parse_lookup(obj, path, "__eq")
173+
) is not None: # same as else
174+
if field != v:
175+
return False
176+
else:
177+
if (field := keygetter(obj, path)) is not None: # same as else
178+
if field != v:
179+
return False
180+
181+
return True
182+
183+
if callable(matcher):
184+
_filter = matcher
185+
elif matcher is not None:
186+
187+
def val_match(obj):
188+
if isinstance(matcher, list):
189+
return obj in matcher
190+
else:
191+
return obj == matcher
192+
193+
_filter = val_match
194+
else:
195+
_filter = filter_lookup
196+
197+
return self.__class__(data=[k for k in self.data if _filter(k)])

0 commit comments

Comments
 (0)