dictselect 0.1.0__tar.gz

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.
@@ -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,158 @@
1
+ # dictselect
2
+
3
+ A Python library for extracting data from nested dicts and lists using reusable pipelines.
4
+
5
+ ```python
6
+ from dictselect import Selector
7
+
8
+ pipe = Selector["annotations"][:]["x_min", "x_max"]
9
+ my_data_selection = pipe(data_dict)
10
+ ```
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install dictselect
16
+ ```
17
+
18
+ Requires Python ≥ 3.9.
19
+
20
+ ## How it works
21
+
22
+ Build a `Selector` by chaining operations, then call it with your data. The pipeline is built to be reusable.
23
+
24
+ ```python
25
+ from dictselect import Selector
26
+
27
+ data_dict = {
28
+ "image_id": "xa001",
29
+ "annotations": [
30
+ {"id": 1, "x_min": 10, "x_max": 20, "label": "cat"},
31
+ {"id": 2, "x_min": 30, "x_max": 50, "label": "dog"},
32
+ ],
33
+ }
34
+
35
+ Selector["image_id"](data_dict) # → "xa001"
36
+ Selector["annotations"][0]["label"](data_dict) # → "cat"
37
+ Selector["annotations"][:]["label"](data_dict) # → ["cat", "dog"]
38
+ Selector["annotations"][:]["x_min", "x_max"](data_dict) # → [[10, 20], [30, 50]]
39
+ ```
40
+
41
+ ## Operations
42
+
43
+ | Syntax | What it does |
44
+ |------------------------------------|-------------------------------------------------------------------------|
45
+ | `Selector["key"]` | Dict key or list index lookup |
46
+ | `Selector[0]`, `Selector[-1]` | List index |
47
+ | `Selector[1:3]` | Slice |
48
+ | `Selector[:]` or `Selector[...]` | Fan-out — apply the rest of the chain to **every element** at this step |
49
+ | `Selector["a", "b"]` | Input multiple keys at once, returns a list |
50
+ | `Selector.method()` | Call a method on the current value |
51
+ | `pipe_a + pipe_b` | Compose two pipelines into one |
52
+ | `Selector.invoke(*args, **kwargs)` | Call the function if the current value is a function |
53
+
54
+ ### Fan-out `[:]`
55
+
56
+ `[:]` maps the remaining steps over every item in this step. Steps after `[:]` run on each element individually.
57
+
58
+ ```python
59
+ data = [{"v": 1}, {"v": 2}, {"v": 3}]
60
+
61
+ Selector[:]["v"](data) # → [1, 2, 3]
62
+ Selector[:][:][0]([[10, 20], [30, 40]]) # → [[10], [30]] (nested fan-out)
63
+ ```
64
+
65
+ ### Multi-key input `["a", "b"]`
66
+
67
+ Returns a list of values for each key. All keys must be the same type (all strings or all integers).
68
+
69
+ ```python
70
+ Selector["x", "y"]({"x": 1, "y": 2, "z": 3}) # → [1, 2]
71
+ ```
72
+
73
+ ### Method calls
74
+
75
+ Access an attribute, then call it like a regular Python method.
76
+
77
+ ```python
78
+ Selector.upper()("hello") # → "HELLO"
79
+ Selector[:].upper()(["hi", "there"]) # → ["HI", "THERE"]
80
+ ```
81
+
82
+ ### Composition
83
+
84
+ Join two pipelines with `+`.
85
+
86
+ ```python
87
+ head = Selector["data"][:]
88
+ tail = Selector["value"]
89
+ (head + tail)({"data": [{"value": 1}, {"value": 2}]}) # → [1, 2]
90
+ ```
91
+
92
+ ## Including keys in the result
93
+
94
+ Pass `include_keys=True` to wrap the result with the last key as a dict. Works for single key lookups and multi-key inputs.
95
+
96
+ ```python
97
+ Selector["a"]["b"]({"a": {"b": 12}}, include_keys=True)
98
+ # → {"b": 12}
99
+
100
+ Selector[:]["a"]([{"a": 1}, {"a": 2}], include_keys=True)
101
+ # → [{"a": 1}, {"a": 2}]
102
+
103
+ Selector[:]["a", "b"]([{"a": 1, "b": 2, "c": 3}, {"a": 4, "c": 6, "b": 5}], include_keys=True)
104
+ # → [{"a": 1, "b": 2}, {"a": 4, "b": 5}]
105
+ ```
106
+
107
+ Also works on `.apply()`:
108
+
109
+ ```python
110
+ Selector["x"].apply({"x": 7}, include_keys=True) # → {"x": 7}
111
+ ```
112
+
113
+ ## Handling missing values
114
+
115
+ 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.
116
+
117
+ ```python
118
+ Selector["a"]["missing"]({"a": {}}, include_null=True)
119
+ # → None (instead of KeyError)
120
+
121
+ Selector[:]["x"]([{"x": 1}, {"y": 2}, {"x": 3}], include_null=True)
122
+ # → [1, None, 3]
123
+
124
+ Selector["a", "b"]({"a": 1}, include_null=True)
125
+ # → [1, None] (missing keys in multi-select become None individually)
126
+ ```
127
+
128
+ The two flags can be combined:
129
+
130
+ ```python
131
+ Selector[:]["x"]([{"x": 1}, {"y": 2}], include_null=True, include_keys=True)
132
+ # → [{"x": 1}, {"x": None}]
133
+ ```
134
+
135
+ ## Calling vs. evaluating
136
+
137
+ Normally, calling a selector evaluates it:
138
+
139
+ ```python
140
+ pipe = Selector["key"]
141
+ pipe({"key": 42}) # → 42
142
+ ```
143
+
144
+ **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.
145
+
146
+ ```python
147
+ pipe = Selector["title"].upper() # records .upper() call
148
+ pipe({"title": "hello"}) # evaluates → "HELLO"
149
+
150
+ Selector.upper.apply("hello") # force evaluation → <method object>
151
+ ```
152
+
153
+ **Exception 2:** if the last step is a function as a value, use `.invoke(*args, **kwargs)` to force evaluation in that case.
154
+
155
+ ```python
156
+ pipe = Selector["function"]() # value will be a function. Calling the function, results in evaluating the selector -> ERROR.
157
+ pipe = Selector["function"].invoke() # Calls the function without evaluating the Selector
158
+ ```
@@ -0,0 +1 @@
1
+ from .dictselect import Selector
@@ -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,10 @@
1
+ README.md
2
+ setup.py
3
+ dictselect/__init__.py
4
+ dictselect/dictselect.py
5
+ dictselect.egg-info/PKG-INFO
6
+ dictselect.egg-info/SOURCES.txt
7
+ dictselect.egg-info/dependency_links.txt
8
+ dictselect.egg-info/top_level.txt
9
+ dictselect/tests/__init__.py
10
+ dictselect/tests/test_dictselect.py
@@ -0,0 +1 @@
1
+ dictselect
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="dictselect",
5
+ version="0.1.0",
6
+ description="A lazy selector for nested Python data structures.",
7
+ long_description=open("README.md").read(),
8
+ long_description_content_type="text/markdown",
9
+ author="alphacena",
10
+ author_email="lukas.makswitis@gmail.com",
11
+ license="MIT",
12
+ python_requires=">=3.9",
13
+ packages=find_packages(exclude=["tests*"]),
14
+ classifiers=[
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Intended Audience :: Developers",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ],
25
+ )