dictselect 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dictselect/__init__.py +1 -0
- dictselect/dictselect.py +405 -0
- dictselect/tests/__init__.py +0 -0
- dictselect/tests/test_dictselect.py +347 -0
- dictselect-0.1.0.dist-info/METADATA +181 -0
- dictselect-0.1.0.dist-info/RECORD +8 -0
- dictselect-0.1.0.dist-info/WHEEL +5 -0
- dictselect-0.1.0.dist-info/top_level.txt +1 -0
dictselect/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .dictselect import Selector
|
dictselect/dictselect.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""dictselect — a tiny lazy selector for nested Python data structures.
|
|
2
|
+
|
|
3
|
+
Build a reusable pipeline of access operations (key lookup, slicing, attribute
|
|
4
|
+
access, method calls) and apply it to any compatible data object.
|
|
5
|
+
|
|
6
|
+
Example::
|
|
7
|
+
|
|
8
|
+
from dictselect import Selector
|
|
9
|
+
|
|
10
|
+
# Build a pipeline once.
|
|
11
|
+
pipe = Selector["annotations"][:]["x_min", "x_max", "y_min", "y_max"]
|
|
12
|
+
|
|
13
|
+
# Apply it to multiple data objects.
|
|
14
|
+
result = pipe(record)
|
|
15
|
+
# → [[x_min, x_max, y_min, y_max], ...] for every "annotation" in record
|
|
16
|
+
|
|
17
|
+
Supported operations
|
|
18
|
+
--------------------
|
|
19
|
+
* Selector["key"] — dict / sequence key lookup
|
|
20
|
+
* Selector[1:3] — slice
|
|
21
|
+
* Selector[:] or Selector[...] — fan-out over a sequence (map)
|
|
22
|
+
* Selector["a", "b"] — pluck multiple str (or int) keys at once
|
|
23
|
+
* Selector.attr — attribute access
|
|
24
|
+
* Selector.method(args) — record a method call (chained after attr access)
|
|
25
|
+
* pipe_a + pipe_b — compose two pipelines
|
|
26
|
+
* pipe(data) or pipe.apply(data) — evaluate a pipeline against data
|
|
27
|
+
* pipe(data, include_keys=True) — wrap leaf result(s) with the last key as a dict
|
|
28
|
+
* pipe(data, include_null=True) — return None for missing keys instead of raising
|
|
29
|
+
|
|
30
|
+
Python ≥ 3.9 is required.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
__all__ = ["Selector"]
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
_MISSING = object() # sentinel for a failed retrieval when include_null=True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _SelectorMeta(type):
|
|
44
|
+
@property
|
|
45
|
+
def steps(cls):
|
|
46
|
+
return ()
|
|
47
|
+
|
|
48
|
+
def __getattr__(cls, name: str):
|
|
49
|
+
if name.startswith("__"):
|
|
50
|
+
raise AttributeError(name)
|
|
51
|
+
return cls((("getattr", name),))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Selector(metaclass=_SelectorMeta):
|
|
55
|
+
"""An immutable, composable pipeline of data-access operations.
|
|
56
|
+
|
|
57
|
+
A Selector accumulates a sequence of steps (key lookups, slices,
|
|
58
|
+
attribute accesses, …) and evaluates them lazily when .apply() is
|
|
59
|
+
called (or equivalently when the instance is called like a function).
|
|
60
|
+
|
|
61
|
+
Build selectors by subscripting the class or an existing instance:
|
|
62
|
+
|
|
63
|
+
pipe = Selector["annotations"][:]["label"]
|
|
64
|
+
labels = pipe(record) # evaluate
|
|
65
|
+
labels = pipe.apply(record) # same thing
|
|
66
|
+
|
|
67
|
+
Evaluation rule for pipe(data)
|
|
68
|
+
|
|
69
|
+
Calling an instance normally evaluates the pipeline. The one exception:
|
|
70
|
+
if the last recorded step is an attribute lookup (e.g. the chain ends with
|
|
71
|
+
.upper), the call is interpreted as a method-call step and returns a
|
|
72
|
+
new Selector—mirroring how Python itself handles obj.method(args):
|
|
73
|
+
|
|
74
|
+
text_pipe = Selector["title"].upper() # records .upper() call
|
|
75
|
+
result = text_pipe({"title": "hello"}) # → "HELLO"
|
|
76
|
+
|
|
77
|
+
To always force a call step regardless of the preceding step, use
|
|
78
|
+
.invoke():
|
|
79
|
+
|
|
80
|
+
fn_pipe = Selector["callback"].invoke(42)
|
|
81
|
+
fn_pipe({"callback": lambda x: x * 2}) # → 84
|
|
82
|
+
|
|
83
|
+
Composition
|
|
84
|
+
|
|
85
|
+
Two selectors can be joined with +; neither operand is mutated:
|
|
86
|
+
|
|
87
|
+
head = Selector["data"][:]
|
|
88
|
+
tail = Selector["value"]
|
|
89
|
+
pipe = head + tail # equivalent to Selector["data"][:]["value"]
|
|
90
|
+
|
|
91
|
+
Immutability
|
|
92
|
+
|
|
93
|
+
Every builder method (__getitem__, __getattr__, __call__, …)
|
|
94
|
+
returns a new Selector; the original is never modified. Steps are
|
|
95
|
+
stored as a plain tuple so they are cheaply shareable.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
__slots__ = ("steps",)
|
|
99
|
+
|
|
100
|
+
def __init__(self, steps: tuple = ()):
|
|
101
|
+
"""Initialize a selector with the given steps.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
steps:
|
|
105
|
+
Sequence of step tuples that form the pipeline. Pass nothing (or
|
|
106
|
+
an empty tuple) to create the identity/root selector.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
empty = Selector() # identity — apply returns data unchanged
|
|
110
|
+
copy = Selector(other.steps) # clone
|
|
111
|
+
"""
|
|
112
|
+
self.steps = tuple(steps)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def __class_getitem__(cls, key):
|
|
116
|
+
"""Allow Selector[key] as a shorthand for Selector()[key].
|
|
117
|
+
|
|
118
|
+
This lets you start a chain without an explicit Selector() call:
|
|
119
|
+
|
|
120
|
+
pipe = Selector["annotations"][:]["id"]
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
key:
|
|
124
|
+
The first access step; forwarded to __getitem__.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Selector: A new selector with the first step recorded.
|
|
128
|
+
"""
|
|
129
|
+
return cls()[key]
|
|
130
|
+
|
|
131
|
+
def __getitem__(self, key):
|
|
132
|
+
"""Record a key-access, slice, fan-out, or multi-key pluck step.
|
|
133
|
+
|
|
134
|
+
Behavior depends on key:
|
|
135
|
+
|
|
136
|
+
* [:] | [...]
|
|
137
|
+
Fan-out (map): apply all remaining steps to every element of the
|
|
138
|
+
current sequence and return a list of results.
|
|
139
|
+
* [a:b] / [a:b:c]
|
|
140
|
+
Arbitrary slice; applied directly to the current data.
|
|
141
|
+
* ["a", "b"] | [["a", "b"]]
|
|
142
|
+
Multi-key pluck: all keys must be the same type (all str or
|
|
143
|
+
all int). Returns a list of the selected values.
|
|
144
|
+
* Any other value
|
|
145
|
+
Plain key / index lookup (data[key]).
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
key:
|
|
149
|
+
The subscript expression.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Selector: A new selector with the step appended.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
TypeError: If key is a tuple or list with mixed str/int types, or
|
|
156
|
+
with fewer than 2 elements.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
Selector["name"].apply({"name": "Alice"}) # → "Alice"
|
|
160
|
+
Selector[0].apply([10, 20, 30]) # → 10
|
|
161
|
+
Selector[1:3].apply([0, 1, 2, 3]) # → [1, 2]
|
|
162
|
+
Selector["x", "y"].apply({"x": 1, "y": 2, "z": 3}) # → [1, 2]
|
|
163
|
+
Selector[:]["x"].apply([{"x": 1}, {"x": 2}]) # → [1, 2]
|
|
164
|
+
"""
|
|
165
|
+
if key is Ellipsis or (isinstance(key, slice) and key == slice(None)):
|
|
166
|
+
return type(self)(self.steps + (("map",),))
|
|
167
|
+
if isinstance(key, slice):
|
|
168
|
+
return type(self)(self.steps + (("slice", key),))
|
|
169
|
+
if isinstance(key, (tuple, list)):
|
|
170
|
+
if len(key) < 2:
|
|
171
|
+
raise TypeError(
|
|
172
|
+
"Multi-key selection requires at least 2 keys; "
|
|
173
|
+
f"got {len(key)}."
|
|
174
|
+
)
|
|
175
|
+
if all(isinstance(k, str) for k in key):
|
|
176
|
+
pass
|
|
177
|
+
elif all(isinstance(k, int) for k in key):
|
|
178
|
+
pass
|
|
179
|
+
else:
|
|
180
|
+
raise TypeError(
|
|
181
|
+
"Multi-key selection requires homogeneous keys "
|
|
182
|
+
"(all str or all int); got mixed types."
|
|
183
|
+
)
|
|
184
|
+
return type(self)(self.steps + (("multi", tuple(key)),))
|
|
185
|
+
return type(self)(self.steps + (("getitem", key),))
|
|
186
|
+
|
|
187
|
+
def __getattr__(self, name: str):
|
|
188
|
+
"""Record an attribute-access step.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
name:
|
|
192
|
+
The attribute name.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Selector: A new selector with the step appended.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
AttributeError: Immediately for any dunder name (__copy__, __reduce__, …)
|
|
199
|
+
so that pickle, copy, and introspection tools behave correctly.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
Selector.upper.apply("hello") # → <method 'upper'>
|
|
203
|
+
Selector.upper().apply("hello") # → "HELLO"
|
|
204
|
+
"""
|
|
205
|
+
if name.startswith("__"):
|
|
206
|
+
raise AttributeError(name)
|
|
207
|
+
return type(self)(self.steps + (("getattr", name),))
|
|
208
|
+
|
|
209
|
+
def __call__(self, *args, **kwargs):
|
|
210
|
+
"""Evaluate the pipeline or record a method-call step.
|
|
211
|
+
|
|
212
|
+
Evaluation
|
|
213
|
+
|
|
214
|
+
When the last step is not an attribute lookup—or when there are no
|
|
215
|
+
steps—calling the selector evaluates it against the single positional
|
|
216
|
+
argument:
|
|
217
|
+
|
|
218
|
+
pipe = Selector["key"]
|
|
219
|
+
pipe({"key": 42}) # → 42
|
|
220
|
+
|
|
221
|
+
Method-call recording (the special case)
|
|
222
|
+
|
|
223
|
+
When the last step *is* an attribute lookup (e.g. the chain ends with
|
|
224
|
+
.upper), the call mirrors Python's own obj.attr(args) pattern
|
|
225
|
+
and records a call step instead of evaluating:
|
|
226
|
+
|
|
227
|
+
pipe = Selector.upper() # records .upper() call
|
|
228
|
+
pipe("hello") # evaluates → "HELLO"
|
|
229
|
+
|
|
230
|
+
To always record a call step (bypassing the heuristic), use
|
|
231
|
+
.invoke().
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
*args, **kwargs: For evaluation: exactly one positional argument (the data).
|
|
235
|
+
For recording: any arguments forwarded to the method call.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Any | Selector: The result of apply(data) when evaluating, or a new
|
|
239
|
+
Selector with the call step appended when recording.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
TypeError: If the heuristic selects *evaluation* but the wrong number of
|
|
243
|
+
arguments are supplied.
|
|
244
|
+
"""
|
|
245
|
+
if self.steps and self.steps[-1][0] == "getattr":
|
|
246
|
+
return type(self)(self.steps + (("call", args, kwargs),))
|
|
247
|
+
extra = set(kwargs) - {"include_keys", "include_null"}
|
|
248
|
+
if len(args) != 1 or extra:
|
|
249
|
+
raise TypeError(
|
|
250
|
+
"Selector evaluation expects exactly one positional argument "
|
|
251
|
+
"(the data) and optional include_keys / include_null keywords. "
|
|
252
|
+
"To record a method call, access the method via attribute first "
|
|
253
|
+
"(e.g. pipe.method(args)), or use "
|
|
254
|
+
"pipe.invoke(args) to record a call step explicitly."
|
|
255
|
+
)
|
|
256
|
+
return self.apply(args[0], **kwargs)
|
|
257
|
+
|
|
258
|
+
def invoke(self, *args, **kwargs):
|
|
259
|
+
"""Record a call step unconditionally, regardless of the previous step.
|
|
260
|
+
|
|
261
|
+
Use this when you need to invoke a callable obtained via key lookup
|
|
262
|
+
(not attribute access), where the default __call__ heuristic would
|
|
263
|
+
interpret the call as an evaluation instead:
|
|
264
|
+
|
|
265
|
+
pipe = Selector["handler"].invoke(event)
|
|
266
|
+
pipe({"handler": lambda e: e.upper()}) # → "EVENT" (if event="event")
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
*args, **kwargs: Arguments that will be forwarded to the callable at evaluation time.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Selector: A new selector with the call step appended.
|
|
273
|
+
"""
|
|
274
|
+
return type(self)(self.steps + (("call", args, kwargs),))
|
|
275
|
+
|
|
276
|
+
def __add__(self, other):
|
|
277
|
+
"""Return a new selector that concatenates the steps of both operands.
|
|
278
|
+
|
|
279
|
+
Neither self nor other is mutated:
|
|
280
|
+
|
|
281
|
+
head = Selector["data"][:]
|
|
282
|
+
tail = Selector["value"]
|
|
283
|
+
pipe = head + tail # Selector["data"][:]["value"]
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
other: Another Selector instance.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Selector: A fresh selector whose steps are ``self.steps + other.steps``.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
TypeError: If other is not a Selector instance (via NotImplemented
|
|
293
|
+
so Python can try other.__radd__).
|
|
294
|
+
"""
|
|
295
|
+
if other is type(self):
|
|
296
|
+
return type(self)(self.steps)
|
|
297
|
+
if not isinstance(other, type(self)):
|
|
298
|
+
return NotImplemented
|
|
299
|
+
return type(self)(self.steps + other.steps)
|
|
300
|
+
|
|
301
|
+
def __repr__(self):
|
|
302
|
+
"""Return a developer-friendly representation listing all steps.
|
|
303
|
+
|
|
304
|
+
Example:
|
|
305
|
+
repr(Selector["a"][:]) # → "Selector([('getitem', 'a'), ('map',)])"
|
|
306
|
+
"""
|
|
307
|
+
return f"Selector({list(self.steps)!r})"
|
|
308
|
+
|
|
309
|
+
def apply(self, data: Any, include_keys: bool = False, include_null: bool = False) -> Any:
|
|
310
|
+
"""Evaluate the recorded pipeline against data.
|
|
311
|
+
|
|
312
|
+
Steps are executed in order:
|
|
313
|
+
|
|
314
|
+
* getitem — data[key]
|
|
315
|
+
* slice — data[slice]
|
|
316
|
+
* multi — [data[k] for k in keys]
|
|
317
|
+
* map — apply remaining steps to every element; returns a list
|
|
318
|
+
* getattr — getattr(data, name)
|
|
319
|
+
* call — data(*args, **kwargs)
|
|
320
|
+
|
|
321
|
+
A selector with no steps is the identity: it returns *data* unchanged.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
data: The root data object to query.
|
|
325
|
+
include_keys: If True, wrap the leaf result with the last key-bearing
|
|
326
|
+
step's key(s) as a dict. Only ``getitem`` and ``multi`` steps
|
|
327
|
+
qualify; all other terminal steps leave the result unchanged.
|
|
328
|
+
|
|
329
|
+
Selector["b"].apply({"b": 7}, include_keys=True) # → {"b": 7}
|
|
330
|
+
Selector["a","b"].apply({"a":1,"b":2}, include_keys=True) # → {"a":1,"b":2}
|
|
331
|
+
|
|
332
|
+
With a fan-out (``[:]``), the wrapping happens per element:
|
|
333
|
+
Selector[:]["v"].apply([{"v":1},{"v":2}], include_keys=True)
|
|
334
|
+
# → [{"v": 1}, {"v": 2}]
|
|
335
|
+
|
|
336
|
+
include_null: If True, return None (instead of raising) when a key,
|
|
337
|
+
index, or attribute is missing. The None propagates through the
|
|
338
|
+
rest of the chain so subsequent steps are skipped.
|
|
339
|
+
|
|
340
|
+
For multi select, each missing key becomes None individually:
|
|
341
|
+
Selector["a","b"].apply({"a": 1}, include_null=True) # → [1, None]
|
|
342
|
+
|
|
343
|
+
With a fan-out, missing elements become None per item:
|
|
344
|
+
Selector[:]["x"].apply([{"x":1},{"y":2}], include_null=True)
|
|
345
|
+
# → [1, None]
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Any: The result after all steps have been applied.
|
|
349
|
+
|
|
350
|
+
Example:
|
|
351
|
+
Selector["a"]["b"].apply({"a": {"b": 7}}) # → 7
|
|
352
|
+
Selector[:]["v"].apply([{"v": 1}, {"v": 2}]) # → [1, 2]
|
|
353
|
+
"""
|
|
354
|
+
for i, step in enumerate(self.steps):
|
|
355
|
+
kind = step[0]
|
|
356
|
+
|
|
357
|
+
if kind == "map":
|
|
358
|
+
rest_steps = self.steps[i + 1:]
|
|
359
|
+
if not rest_steps:
|
|
360
|
+
return list(data)
|
|
361
|
+
rest = type(self)(rest_steps)
|
|
362
|
+
return [
|
|
363
|
+
rest.apply(x, include_keys=include_keys, include_null=include_null)
|
|
364
|
+
for x in data
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
# Short-circuit: a previous step already failed
|
|
368
|
+
if include_null and data is _MISSING:
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
if kind in ("getitem", "slice"):
|
|
373
|
+
data = data[step[1]]
|
|
374
|
+
elif kind == "multi":
|
|
375
|
+
if include_null:
|
|
376
|
+
row = []
|
|
377
|
+
for k in step[1]:
|
|
378
|
+
try:
|
|
379
|
+
row.append(data[k])
|
|
380
|
+
except (KeyError, IndexError, TypeError):
|
|
381
|
+
row.append(None)
|
|
382
|
+
data = row
|
|
383
|
+
else:
|
|
384
|
+
data = [data[k] for k in step[1]]
|
|
385
|
+
elif kind == "getattr":
|
|
386
|
+
data = getattr(data, step[1])
|
|
387
|
+
elif kind == "call":
|
|
388
|
+
_, call_args, call_kwargs = step
|
|
389
|
+
data = data(*call_args, **call_kwargs)
|
|
390
|
+
except (KeyError, IndexError, AttributeError, TypeError):
|
|
391
|
+
if include_null:
|
|
392
|
+
data = _MISSING
|
|
393
|
+
else:
|
|
394
|
+
raise
|
|
395
|
+
|
|
396
|
+
if include_null and data is _MISSING:
|
|
397
|
+
data = None
|
|
398
|
+
|
|
399
|
+
if include_keys and self.steps:
|
|
400
|
+
last = self.steps[-1]
|
|
401
|
+
if last[0] == "getitem":
|
|
402
|
+
return {last[1]: data}
|
|
403
|
+
if last[0] == "multi":
|
|
404
|
+
return dict(zip(last[1], data))
|
|
405
|
+
return data
|
|
File without changes
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Tests for dictselect.Selector."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from dictselect import Selector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RECORD = {
|
|
11
|
+
"annotations": [
|
|
12
|
+
{"id": 0, "x_min": 1, "y_min": 1, "x_max": 2, "y_max": 2, "label": "car"},
|
|
13
|
+
{"id": 0, "x_min": 1, "y_min": 3, "x_max": 2, "y_max": 5, "label": "car"},
|
|
14
|
+
{"id": 0, "x_min": 1, "y_min": 1, "x_max": 3, "y_max": 2, "label": "other"},
|
|
15
|
+
{"id": 0, "x_min": 1, "y_min": 1, "x_max": 2, "y_max": 3, "label": "truck"},
|
|
16
|
+
],
|
|
17
|
+
"image_id": "xa001",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestGetitem:
|
|
22
|
+
def test_dict_str_key(self):
|
|
23
|
+
assert Selector["image_id"].apply(RECORD) == "xa001"
|
|
24
|
+
|
|
25
|
+
def test_list_int_index(self):
|
|
26
|
+
assert Selector[0].apply([10, 20, 30]) == 10
|
|
27
|
+
|
|
28
|
+
def test_negative_index(self):
|
|
29
|
+
assert Selector[-1].apply([10, 20, 30]) == 30
|
|
30
|
+
|
|
31
|
+
def test_chained_dict_access(self):
|
|
32
|
+
data = {"a": {"b": {"c": 42}}}
|
|
33
|
+
assert Selector["a"]["b"]["c"].apply(data) == 42
|
|
34
|
+
|
|
35
|
+
def test_chained_list_and_dict(self):
|
|
36
|
+
data = [{"x": 7}]
|
|
37
|
+
assert Selector[0]["x"].apply(data) == 7
|
|
38
|
+
|
|
39
|
+
def test_class_subscript_entry_point(self):
|
|
40
|
+
assert Selector["image_id"].apply(RECORD) == Selector()["image_id"].apply(RECORD)
|
|
41
|
+
|
|
42
|
+
def test_eval_via_call(self):
|
|
43
|
+
assert Selector["image_id"](RECORD) == "xa001"
|
|
44
|
+
|
|
45
|
+
def test_eval_and_apply_agree(self):
|
|
46
|
+
pipe = Selector["image_id"]
|
|
47
|
+
assert pipe(RECORD) == pipe.apply(RECORD)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestSlice:
|
|
51
|
+
def test_range_slice(self):
|
|
52
|
+
assert Selector[1:3].apply([0, 1, 2, 3, 4]) == [1, 2]
|
|
53
|
+
|
|
54
|
+
def test_head_slice(self):
|
|
55
|
+
assert Selector[:2].apply([10, 20, 30]) == [10, 20]
|
|
56
|
+
|
|
57
|
+
def test_tail_slice(self):
|
|
58
|
+
assert Selector[-2:].apply([10, 20, 30]) == [20, 30]
|
|
59
|
+
|
|
60
|
+
def test_step_slice(self):
|
|
61
|
+
assert Selector[::2].apply([0, 1, 2, 3, 4]) == [0, 2, 4]
|
|
62
|
+
|
|
63
|
+
def test_partial_slice_on_string(self):
|
|
64
|
+
assert Selector[1:4].apply("hello") == "ell"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestMap:
|
|
68
|
+
def test_full_slice_fans_out(self):
|
|
69
|
+
"""S[:] on a list returns a copy of that list (identity fan-out)."""
|
|
70
|
+
assert Selector[:].apply([1, 2, 3]) == [1, 2, 3]
|
|
71
|
+
|
|
72
|
+
def test_ellipsis_fans_out(self):
|
|
73
|
+
assert Selector[...].apply([1, 2, 3]) == [1, 2, 3]
|
|
74
|
+
|
|
75
|
+
def test_colon_and_ellipsis_equivalent(self):
|
|
76
|
+
data = [{"x": 1}, {"x": 2}]
|
|
77
|
+
assert Selector[:]["x"].apply(data) == Selector[...]["x"].apply(data)
|
|
78
|
+
|
|
79
|
+
def test_map_then_getitem(self):
|
|
80
|
+
data = [{"x": 1}, {"x": 2}, {"x": 3}]
|
|
81
|
+
assert Selector[:]["x"].apply(data) == [1, 2, 3]
|
|
82
|
+
|
|
83
|
+
def test_map_empty_sequence(self):
|
|
84
|
+
assert Selector[:]["x"].apply([]) == []
|
|
85
|
+
|
|
86
|
+
# Regression – bug #3: duplicate map steps used to pick the wrong remainder
|
|
87
|
+
def test_nested_map_no_index_collision(self):
|
|
88
|
+
data = [[1, 2], [3, 4]]
|
|
89
|
+
assert Selector[:][:].apply(data) == [[1, 2], [3, 4]]
|
|
90
|
+
|
|
91
|
+
def test_triple_nested_map(self):
|
|
92
|
+
data = [[[1, 2], [3]], [[4]]]
|
|
93
|
+
assert Selector[:][:][:].apply(data) == [[[1, 2], [3]], [[4]]]
|
|
94
|
+
|
|
95
|
+
def test_map_then_attr_and_call(self):
|
|
96
|
+
assert Selector[:].upper().apply(["hello", "world"]) == ["HELLO", "WORLD"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestMultiKey:
|
|
100
|
+
# Regression – bug #2: original code only checked for list, not tuple
|
|
101
|
+
def test_tuple_syntax_str_keys(self):
|
|
102
|
+
data = {"a": 1, "b": 2, "c": 3}
|
|
103
|
+
assert Selector["a", "b"].apply(data) == [1, 2]
|
|
104
|
+
|
|
105
|
+
def test_list_syntax_str_keys(self):
|
|
106
|
+
data = {"a": 1, "b": 2, "c": 3}
|
|
107
|
+
assert Selector[["a", "b"]].apply(data) == [1, 2]
|
|
108
|
+
|
|
109
|
+
def test_tuple_syntax_int_keys(self):
|
|
110
|
+
assert Selector[0, 2].apply([10, 20, 30, 40]) == [10, 30]
|
|
111
|
+
|
|
112
|
+
def test_list_syntax_int_keys(self):
|
|
113
|
+
assert Selector[[0, 2]].apply([10, 20, 30, 40]) == [10, 30]
|
|
114
|
+
|
|
115
|
+
def test_mixed_key_types_raise(self):
|
|
116
|
+
with pytest.raises(TypeError, match="homogeneous"):
|
|
117
|
+
Selector["a", 1]
|
|
118
|
+
|
|
119
|
+
def test_single_element_multi_key_raises(self):
|
|
120
|
+
with pytest.raises(TypeError, match="at least 2"):
|
|
121
|
+
Selector[["only_one"]]
|
|
122
|
+
|
|
123
|
+
def test_map_then_multi_key_str(self):
|
|
124
|
+
"""Primary use-case: pluck multiple fields from each element."""
|
|
125
|
+
result = Selector["annotations"][:]["x_min", "x_max", "y_min", "y_max"].apply(RECORD)
|
|
126
|
+
assert result == [
|
|
127
|
+
[1, 2, 1, 2],
|
|
128
|
+
[1, 2, 3, 5],
|
|
129
|
+
[1, 3, 1, 2],
|
|
130
|
+
[1, 2, 1, 3],
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestGetattr:
|
|
135
|
+
def test_simple_attribute(self):
|
|
136
|
+
class Obj:
|
|
137
|
+
value = 99
|
|
138
|
+
assert Selector.value.apply(Obj()) == 99
|
|
139
|
+
|
|
140
|
+
def test_chained_attributes(self):
|
|
141
|
+
class Inner:
|
|
142
|
+
x = 5
|
|
143
|
+
class Outer:
|
|
144
|
+
inner = Inner()
|
|
145
|
+
assert Selector.inner.x.apply(Outer()) == 5
|
|
146
|
+
|
|
147
|
+
def test_dunder_attr_raises_attribute_error(self):
|
|
148
|
+
with pytest.raises(AttributeError):
|
|
149
|
+
_ = Selector["x"].__copy__
|
|
150
|
+
|
|
151
|
+
def test_hasattr_dunder_is_false(self):
|
|
152
|
+
assert not hasattr(Selector["x"], "__copy__")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestMethodChain:
|
|
156
|
+
def test_call_after_getattr_records_step(self):
|
|
157
|
+
"""Calling a selector whose last step is getattr records a call step."""
|
|
158
|
+
pipe = Selector.upper()
|
|
159
|
+
assert pipe.steps == (("getattr", "upper"), ("call", (), {}))
|
|
160
|
+
|
|
161
|
+
def test_str_upper_method_chain(self):
|
|
162
|
+
assert Selector.upper().apply("hello") == "HELLO"
|
|
163
|
+
|
|
164
|
+
def test_method_with_args(self):
|
|
165
|
+
assert Selector.replace("l", "y").apply("hello") == "heyyo"
|
|
166
|
+
|
|
167
|
+
def test_method_with_positional_args(self):
|
|
168
|
+
# str.center takes fillchar as positional-only in CPython
|
|
169
|
+
result = Selector.center(7, "-").apply("hi")
|
|
170
|
+
assert len(result) == 7 and result.strip("-") == "hi"
|
|
171
|
+
|
|
172
|
+
def test_chained_method_after_getitem(self):
|
|
173
|
+
assert Selector["title"].upper().apply({"title": "hello"}) == "HELLO"
|
|
174
|
+
|
|
175
|
+
def test_annotation_conjugate_pipeline(self):
|
|
176
|
+
pipe = Selector["annotations"][:]["id"].conjugate()
|
|
177
|
+
assert pipe.apply(RECORD) == [0, 0, 0, 0]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestInvoke:
|
|
181
|
+
def test_invoke_after_getitem_records_call(self):
|
|
182
|
+
"""invoke() always records, even when the last step is not getattr."""
|
|
183
|
+
pipe = Selector["fn"].invoke(1, 2)
|
|
184
|
+
assert pipe.steps[-1] == ("call", (1, 2), {})
|
|
185
|
+
|
|
186
|
+
def test_invoke_calls_function_in_data(self):
|
|
187
|
+
data = {"fn": lambda x, y: x + y}
|
|
188
|
+
assert Selector["fn"].invoke(3, 4).apply(data) == 7
|
|
189
|
+
|
|
190
|
+
def test_invoke_with_kwargs(self):
|
|
191
|
+
def greet(name, greeting="Hello"):
|
|
192
|
+
return f"{greeting}, {name}!"
|
|
193
|
+
|
|
194
|
+
data = {"fn": greet}
|
|
195
|
+
assert Selector["fn"].invoke("Alice", greeting="Hi").apply(data) == "Hi, Alice!"
|
|
196
|
+
|
|
197
|
+
def test_invoke_after_getattr_also_records(self):
|
|
198
|
+
"""invoke() always records regardless of context."""
|
|
199
|
+
pipe = Selector.upper.invoke()
|
|
200
|
+
assert pipe.steps[-1] == ("call", (), {})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestCompose:
|
|
204
|
+
def test_add_composes_two_selectors(self):
|
|
205
|
+
head = Selector["a"]
|
|
206
|
+
tail = Selector["b"]
|
|
207
|
+
assert (head + tail).apply({"a": {"b": 42}}) == 42
|
|
208
|
+
|
|
209
|
+
def test_add_does_not_mutate_left_operand(self):
|
|
210
|
+
a = Selector["a"]
|
|
211
|
+
b = Selector["b"]
|
|
212
|
+
steps_before = a.steps
|
|
213
|
+
_ = a + b
|
|
214
|
+
assert a.steps == steps_before
|
|
215
|
+
|
|
216
|
+
def test_add_does_not_mutate_right_operand(self):
|
|
217
|
+
b = Selector["b"]
|
|
218
|
+
a = Selector["a"]
|
|
219
|
+
steps_before = b.steps
|
|
220
|
+
_ = a + b
|
|
221
|
+
assert b.steps == steps_before
|
|
222
|
+
|
|
223
|
+
def test_add_three_selectors(self):
|
|
224
|
+
pipe = Selector["a"] + Selector["b"] + Selector["c"]
|
|
225
|
+
assert pipe.apply({"a": {"b": {"c": 99}}}) == 99
|
|
226
|
+
|
|
227
|
+
def test_add_non_selector_raises_type_error(self):
|
|
228
|
+
with pytest.raises(TypeError):
|
|
229
|
+
_ = Selector["x"] + 1
|
|
230
|
+
|
|
231
|
+
def test_add_with_empty_selector(self):
|
|
232
|
+
pipe = Selector["x"] + Selector
|
|
233
|
+
assert pipe.apply({"x": 7}) == 7
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TestMisc:
|
|
237
|
+
def test_empty_selector_is_identity(self):
|
|
238
|
+
data = {"x": 42}
|
|
239
|
+
# Here is does make a difference when using the uninitializes Selector for an empty object.
|
|
240
|
+
assert Selector().apply(data) is data
|
|
241
|
+
|
|
242
|
+
def test_repr_contains_class_name(self):
|
|
243
|
+
assert repr(Selector["a"][:]).startswith("Selector(")
|
|
244
|
+
|
|
245
|
+
def test_repr_contains_step_kinds(self):
|
|
246
|
+
r = repr(Selector["a"][:])
|
|
247
|
+
assert "getitem" in r
|
|
248
|
+
assert "map" in r
|
|
249
|
+
|
|
250
|
+
def test_pickle_roundtrip(self):
|
|
251
|
+
pipe = Selector["annotations"][:]
|
|
252
|
+
restored = pickle.loads(pickle.dumps(pipe))
|
|
253
|
+
assert restored.steps == pipe.steps
|
|
254
|
+
|
|
255
|
+
def test_pickle_empty_selector(self):
|
|
256
|
+
restored = pickle.loads(pickle.dumps(Selector))
|
|
257
|
+
assert restored.steps == ()
|
|
258
|
+
|
|
259
|
+
def test_wrong_arity_on_evaluation_raises(self):
|
|
260
|
+
with pytest.raises(TypeError):
|
|
261
|
+
Selector["x"](1, 2) # two args; last step is getitem → tries to evaluate
|
|
262
|
+
|
|
263
|
+
def test_call_with_kwargs_on_non_getattr_raises(self):
|
|
264
|
+
with pytest.raises(TypeError):
|
|
265
|
+
Selector["x"](data={"x": 1}) # kwargs not allowed for evaluation
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestIncludeKeys:
|
|
269
|
+
def test_single_getitem(self):
|
|
270
|
+
assert Selector["a"]["b"]({"a": {"b": 12}}, include_keys=True) == {"b": 12}
|
|
271
|
+
|
|
272
|
+
def test_map_then_getitem(self):
|
|
273
|
+
result = Selector[:]["a"]([{"a": 1}, {"a": 2}], include_keys=True)
|
|
274
|
+
assert result == [{"a": 1}, {"a": 2}]
|
|
275
|
+
|
|
276
|
+
def test_map_then_multi(self):
|
|
277
|
+
data = [{"a": 1, "b": 2, "c": 3}, {"a": 4, "c": 6, "b": 5}]
|
|
278
|
+
result = Selector[:]["a", "b"](data, include_keys=True)
|
|
279
|
+
assert result == [{"a": 1, "b": 2}, {"a": 4, "b": 5}]
|
|
280
|
+
|
|
281
|
+
def test_int_key_wrapped(self):
|
|
282
|
+
assert Selector[0]([10, 20, 30], include_keys=True) == {0: 10}
|
|
283
|
+
|
|
284
|
+
def test_flag_false_is_unchanged(self):
|
|
285
|
+
assert Selector["x"]({"x": 7}, include_keys=False) == 7
|
|
286
|
+
|
|
287
|
+
def test_apply_path(self):
|
|
288
|
+
assert Selector["x"].apply({"x": 7}, include_keys=True) == {"x": 7}
|
|
289
|
+
|
|
290
|
+
def test_slice_terminal_ignored(self):
|
|
291
|
+
assert Selector[1:3]([0, 1, 2, 3], include_keys=True) == [1, 2]
|
|
292
|
+
|
|
293
|
+
def test_call_terminal_ignored(self):
|
|
294
|
+
assert Selector["x"].upper().apply({"x": "hi"}, include_keys=True) == "HI"
|
|
295
|
+
|
|
296
|
+
def test_map_terminal_ignored(self):
|
|
297
|
+
assert Selector["a"][:]({"a": [1, 2, 3]}, include_keys=True) == [1, 2, 3]
|
|
298
|
+
|
|
299
|
+
def test_empty_selector_ignored(self):
|
|
300
|
+
data = {"x": 1}
|
|
301
|
+
assert Selector().apply(data, include_keys=True) is data
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class TestIncludeNull:
|
|
305
|
+
def test_missing_key_returns_none(self):
|
|
306
|
+
assert Selector["a"]["missing"]({"a": {}}, include_null=True) is None
|
|
307
|
+
|
|
308
|
+
def test_missing_top_level_key(self):
|
|
309
|
+
assert Selector["nope"]({"a": 1}, include_null=True) is None
|
|
310
|
+
|
|
311
|
+
def test_present_key_unaffected(self):
|
|
312
|
+
assert Selector["a"]({"a": 42}, include_null=True) == 42
|
|
313
|
+
|
|
314
|
+
def test_missing_key_propagates_through_chain(self):
|
|
315
|
+
# Once a step returns None, subsequent steps are skipped
|
|
316
|
+
assert Selector["x"]["y"]["z"]({"x": {}}, include_null=True) is None
|
|
317
|
+
|
|
318
|
+
def test_missing_index_returns_none(self):
|
|
319
|
+
assert Selector[5]([1, 2, 3], include_null=True) is None
|
|
320
|
+
|
|
321
|
+
def test_multi_partial_missing(self):
|
|
322
|
+
result = Selector["a", "b"]({"a": 1}, include_null=True)
|
|
323
|
+
assert result == [1, None]
|
|
324
|
+
|
|
325
|
+
def test_multi_all_missing(self):
|
|
326
|
+
result = Selector["a", "b"]({}, include_null=True)
|
|
327
|
+
assert result == [None, None]
|
|
328
|
+
|
|
329
|
+
def test_map_with_partial_missing(self):
|
|
330
|
+
data = [{"x": 1}, {"y": 2}, {"x": 3}]
|
|
331
|
+
assert Selector[:]["x"](data, include_null=True) == [1, None, 3]
|
|
332
|
+
|
|
333
|
+
def test_apply_path(self):
|
|
334
|
+
assert Selector["missing"].apply({}, include_null=True) is None
|
|
335
|
+
|
|
336
|
+
def test_flag_false_raises(self):
|
|
337
|
+
with pytest.raises(KeyError):
|
|
338
|
+
Selector["missing"]({"a": 1}, include_null=False)
|
|
339
|
+
|
|
340
|
+
def test_combined_include_keys_and_null(self):
|
|
341
|
+
result = Selector["a"]["missing"]({"a": {}}, include_null=True, include_keys=True)
|
|
342
|
+
assert result == {"missing": None}
|
|
343
|
+
|
|
344
|
+
def test_combined_map_keys_and_null(self):
|
|
345
|
+
data = [{"x": 1}, {"y": 2}]
|
|
346
|
+
result = Selector[:]["x"](data, include_null=True, include_keys=True)
|
|
347
|
+
assert result == [{"x": 1}, {"x": None}]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: dictselect
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lazy selector for nested Python data structures.
|
|
5
|
+
Home-page: UNKNOWN
|
|
6
|
+
Author: alphacena
|
|
7
|
+
Author-email: lukas.makswitis@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Platform: UNKNOWN
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# dictselect
|
|
23
|
+
|
|
24
|
+
A Python library for extracting data from nested dicts and lists using reusable pipelines.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from dictselect import Selector
|
|
28
|
+
|
|
29
|
+
pipe = Selector["annotations"][:]["x_min", "x_max"]
|
|
30
|
+
my_data_selection = pipe(data_dict)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install dictselect
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python ≥ 3.9.
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
Build a `Selector` by chaining operations, then call it with your data. The pipeline is built to be reusable.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from dictselect import Selector
|
|
47
|
+
|
|
48
|
+
data_dict = {
|
|
49
|
+
"image_id": "xa001",
|
|
50
|
+
"annotations": [
|
|
51
|
+
{"id": 1, "x_min": 10, "x_max": 20, "label": "cat"},
|
|
52
|
+
{"id": 2, "x_min": 30, "x_max": 50, "label": "dog"},
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Selector["image_id"](data_dict) # → "xa001"
|
|
57
|
+
Selector["annotations"][0]["label"](data_dict) # → "cat"
|
|
58
|
+
Selector["annotations"][:]["label"](data_dict) # → ["cat", "dog"]
|
|
59
|
+
Selector["annotations"][:]["x_min", "x_max"](data_dict) # → [[10, 20], [30, 50]]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Operations
|
|
63
|
+
|
|
64
|
+
| Syntax | What it does |
|
|
65
|
+
|------------------------------------|-------------------------------------------------------------------------|
|
|
66
|
+
| `Selector["key"]` | Dict key or list index lookup |
|
|
67
|
+
| `Selector[0]`, `Selector[-1]` | List index |
|
|
68
|
+
| `Selector[1:3]` | Slice |
|
|
69
|
+
| `Selector[:]` or `Selector[...]` | Fan-out — apply the rest of the chain to **every element** at this step |
|
|
70
|
+
| `Selector["a", "b"]` | Input multiple keys at once, returns a list |
|
|
71
|
+
| `Selector.method()` | Call a method on the current value |
|
|
72
|
+
| `pipe_a + pipe_b` | Compose two pipelines into one |
|
|
73
|
+
| `Selector.invoke(*args, **kwargs)` | Call the function if the current value is a function |
|
|
74
|
+
|
|
75
|
+
### Fan-out `[:]`
|
|
76
|
+
|
|
77
|
+
`[:]` maps the remaining steps over every item in this step. Steps after `[:]` run on each element individually.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
data = [{"v": 1}, {"v": 2}, {"v": 3}]
|
|
81
|
+
|
|
82
|
+
Selector[:]["v"](data) # → [1, 2, 3]
|
|
83
|
+
Selector[:][:][0]([[10, 20], [30, 40]]) # → [[10], [30]] (nested fan-out)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Multi-key input `["a", "b"]`
|
|
87
|
+
|
|
88
|
+
Returns a list of values for each key. All keys must be the same type (all strings or all integers).
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
Selector["x", "y"]({"x": 1, "y": 2, "z": 3}) # → [1, 2]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Method calls
|
|
95
|
+
|
|
96
|
+
Access an attribute, then call it like a regular Python method.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
Selector.upper()("hello") # → "HELLO"
|
|
100
|
+
Selector[:].upper()(["hi", "there"]) # → ["HI", "THERE"]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Composition
|
|
104
|
+
|
|
105
|
+
Join two pipelines with `+`.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
head = Selector["data"][:]
|
|
109
|
+
tail = Selector["value"]
|
|
110
|
+
(head + tail)({"data": [{"value": 1}, {"value": 2}]}) # → [1, 2]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Including keys in the result
|
|
114
|
+
|
|
115
|
+
Pass `include_keys=True` to wrap the result with the last key as a dict. Works for single key lookups and multi-key inputs.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
Selector["a"]["b"]({"a": {"b": 12}}, include_keys=True)
|
|
119
|
+
# → {"b": 12}
|
|
120
|
+
|
|
121
|
+
Selector[:]["a"]([{"a": 1}, {"a": 2}], include_keys=True)
|
|
122
|
+
# → [{"a": 1}, {"a": 2}]
|
|
123
|
+
|
|
124
|
+
Selector[:]["a", "b"]([{"a": 1, "b": 2, "c": 3}, {"a": 4, "c": 6, "b": 5}], include_keys=True)
|
|
125
|
+
# → [{"a": 1, "b": 2}, {"a": 4, "b": 5}]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Also works on `.apply()`:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
Selector["x"].apply({"x": 7}, include_keys=True) # → {"x": 7}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Handling missing values
|
|
135
|
+
|
|
136
|
+
Pass `include_null=True` to get `None` instead of a `KeyError`/`IndexError` when a key or index doesn't exist. Once a step fails, the rest of the chain is skipped and `None` is returned.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
Selector["a"]["missing"]({"a": {}}, include_null=True)
|
|
140
|
+
# → None (instead of KeyError)
|
|
141
|
+
|
|
142
|
+
Selector[:]["x"]([{"x": 1}, {"y": 2}, {"x": 3}], include_null=True)
|
|
143
|
+
# → [1, None, 3]
|
|
144
|
+
|
|
145
|
+
Selector["a", "b"]({"a": 1}, include_null=True)
|
|
146
|
+
# → [1, None] (missing keys in multi-select become None individually)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The two flags can be combined:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
Selector[:]["x"]([{"x": 1}, {"y": 2}], include_null=True, include_keys=True)
|
|
153
|
+
# → [{"x": 1}, {"x": None}]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Calling vs. evaluating
|
|
157
|
+
|
|
158
|
+
Normally, calling a selector evaluates it:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
pipe = Selector["key"]
|
|
162
|
+
pipe({"key": 42}) # → 42
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Exception 1:** if the last step is an attribute name (e.g. `.upper`), calling it *records* a method call instead of evaluating. Use `.apply(data)` to force evaluation in that case.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
pipe = Selector["title"].upper() # records .upper() call
|
|
169
|
+
pipe({"title": "hello"}) # evaluates → "HELLO"
|
|
170
|
+
|
|
171
|
+
Selector.upper.apply("hello") # force evaluation → <method object>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Exception 2:** if the last step is a function as a value, use `.invoke(*args, **kwargs)` to force evaluation in that case.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
pipe = Selector["function"]() # value will be a function. Calling the function, results in evaluating the selector -> ERROR.
|
|
178
|
+
pipe = Selector["function"].invoke() # Calls the function without evaluating the Selector
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dictselect/__init__.py,sha256=1-gxwQDEx8GIddm1qJPJ46TEFludo2orDB2ONS5xiiw,33
|
|
2
|
+
dictselect/dictselect.py,sha256=Ik1HNjURT0SCmcFwu58OFWtZ3K5ukHYCwhSCYjrfaLs,15288
|
|
3
|
+
dictselect/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
dictselect/tests/test_dictselect.py,sha256=TAQKP9Gq9RKtwtdqENs71wQqEO9MxtUxbz2uvby9QCk,11981
|
|
5
|
+
dictselect-0.1.0.dist-info/METADATA,sha256=V6JxNrSnbFK25zdAR6T3pM1rG1Zg5OXUky-ky8YY7s4,5979
|
|
6
|
+
dictselect-0.1.0.dist-info/WHEEL,sha256=51RkbunBAw4BWsgaQWTpPhg4Diwp3c9P5iaLk67Hdtg,92
|
|
7
|
+
dictselect-0.1.0.dist-info/top_level.txt,sha256=v4kDaitwtwHMEKvDdu7V-Pk33vilOpz-cZtr4Yc1Y40,11
|
|
8
|
+
dictselect-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dictselect
|