reflet 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.
- reflet/__init__.py +36 -0
- reflet/__main__.py +3 -0
- reflet/cli.py +67 -0
- reflet/declarative.py +133 -0
- reflet/errors.py +72 -0
- reflet/lens.py +113 -0
- reflet/lenses.py +432 -0
- reflet/pipeline.py +218 -0
- reflet/provenance.py +30 -0
- reflet/py.typed +0 -0
- reflet/sources/__init__.py +88 -0
- reflet/sources/csv_source.py +55 -0
- reflet/sources/json_path_source.py +93 -0
- reflet/sources/json_source.py +56 -0
- reflet/sources/sqlite_source.py +83 -0
- reflet/view.py +509 -0
- reflet-0.1.0.dist-info/METADATA +656 -0
- reflet-0.1.0.dist-info/RECORD +20 -0
- reflet-0.1.0.dist-info/WHEEL +4 -0
- reflet-0.1.0.dist-info/licenses/LICENSE +28 -0
reflet/lenses.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Concrete lenses: rename, select, filter, map, constant, compute, aggregate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from reflet.errors import ConflictError, UnknownFieldError
|
|
10
|
+
from reflet.lens import Cell, ConflictPolicy, Lens, Record, RowId, RowKey, Table, index_by_rid
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from reflet.pipeline import Pipeline
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RowView:
|
|
17
|
+
"""Read-only attribute access to a record's field values, for predicates/fns."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, record: Record) -> None:
|
|
20
|
+
self._record = record
|
|
21
|
+
|
|
22
|
+
def __getattr__(self, name: str) -> Any:
|
|
23
|
+
try:
|
|
24
|
+
return self._record.fields[name].value
|
|
25
|
+
except KeyError as exc:
|
|
26
|
+
raise UnknownFieldError(name) from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RenameLens(Lens):
|
|
30
|
+
"""Renames fields: `mapping` is {new_name: old_name}."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, mapping: dict[str, str]) -> None:
|
|
33
|
+
self.mapping = mapping
|
|
34
|
+
self.name = f"rename({mapping})"
|
|
35
|
+
|
|
36
|
+
def get(self, s: Table) -> Table:
|
|
37
|
+
out = []
|
|
38
|
+
for r in s:
|
|
39
|
+
fields = dict(r.fields)
|
|
40
|
+
for new_name, old_name in self.mapping.items():
|
|
41
|
+
cell = fields.pop(old_name)
|
|
42
|
+
fields[new_name] = cell.transform(
|
|
43
|
+
lambda v: v, f"rename {old_name}->{new_name}"
|
|
44
|
+
)
|
|
45
|
+
out.append(r.with_fields(fields))
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
49
|
+
s_by_rid = index_by_rid(s)
|
|
50
|
+
out = []
|
|
51
|
+
for a_row in a:
|
|
52
|
+
fields = dict(a_row.fields)
|
|
53
|
+
for new_name, old_name in self.mapping.items():
|
|
54
|
+
cell = fields.pop(new_name)
|
|
55
|
+
fields[old_name] = cell.transform(
|
|
56
|
+
lambda v: v, f"rename {new_name}->{old_name}"
|
|
57
|
+
)
|
|
58
|
+
out.append(s_by_rid[a_row.rid].with_fields(fields))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SelectLens(Lens):
|
|
63
|
+
"""Projects down to a subset of fields; dropped fields pass through untouched."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, fields: Iterable[str]) -> None:
|
|
66
|
+
self.fields = tuple(fields)
|
|
67
|
+
self.name = f"select({self.fields})"
|
|
68
|
+
|
|
69
|
+
def get(self, s: Table) -> Table:
|
|
70
|
+
out = []
|
|
71
|
+
for r in s:
|
|
72
|
+
fields = {k: r.fields[k] for k in self.fields}
|
|
73
|
+
out.append(r.with_fields(fields))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
77
|
+
s_by_rid = index_by_rid(s)
|
|
78
|
+
out = []
|
|
79
|
+
for a_row in a:
|
|
80
|
+
original = s_by_rid[a_row.rid]
|
|
81
|
+
fields = dict(original.fields)
|
|
82
|
+
fields.update(a_row.fields)
|
|
83
|
+
out.append(original.with_fields(fields))
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FilterLens(Lens):
|
|
88
|
+
"""Keeps only rows matching `predicate`; dropped rows pass through unchanged.
|
|
89
|
+
|
|
90
|
+
`defaults`, when given, are the field values that make a freshly
|
|
91
|
+
`View.insert()`-ed row satisfy `predicate` (e.g. `{"country": "FR"}` for a
|
|
92
|
+
`country == "FR"` filter) — they're never checked against `predicate`
|
|
93
|
+
itself, just applied so the new row has a value for every such field.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self, predicate: Callable[[RowView], bool], defaults: dict[str, Any] | None = None
|
|
98
|
+
) -> None:
|
|
99
|
+
self.predicate = predicate
|
|
100
|
+
self.defaults = defaults or {}
|
|
101
|
+
self.name = "filter(...)"
|
|
102
|
+
|
|
103
|
+
def get(self, s: Table) -> Table:
|
|
104
|
+
return [r for r in s if self.predicate(RowView(r))]
|
|
105
|
+
|
|
106
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
107
|
+
a_by_rid = index_by_rid(a)
|
|
108
|
+
return [a_by_rid.get(r.rid, r) for r in s]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SortLens(Lens):
|
|
112
|
+
"""Orders the view by a field; write-back ignores order and merges by rid."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, field: str, reverse: bool = False) -> None:
|
|
115
|
+
self.field = field
|
|
116
|
+
self.reverse = reverse
|
|
117
|
+
self.name = f"sort({field})"
|
|
118
|
+
|
|
119
|
+
def get(self, s: Table) -> Table:
|
|
120
|
+
return sorted(s, key=lambda r: r.fields[self.field].value, reverse=self.reverse)
|
|
121
|
+
|
|
122
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
123
|
+
a_by_rid = index_by_rid(a)
|
|
124
|
+
return [a_by_rid.get(r.rid, r) for r in s]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MapLens(Lens):
|
|
128
|
+
"""Transforms one field with an explicit forward/backward function pair."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, field: str, fwd: Callable[[Any], Any], bwd: Callable[[Any], Any]) -> None:
|
|
131
|
+
self.field = field
|
|
132
|
+
self.fwd = fwd
|
|
133
|
+
self.bwd = bwd
|
|
134
|
+
self.name = f"map({field})"
|
|
135
|
+
|
|
136
|
+
def get(self, s: Table) -> Table:
|
|
137
|
+
out = []
|
|
138
|
+
for r in s:
|
|
139
|
+
fields = dict(r.fields)
|
|
140
|
+
fields[self.field] = fields[self.field].transform(self.fwd, f"map({self.field})")
|
|
141
|
+
out.append(r.with_fields(fields))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
145
|
+
s_by_rid = index_by_rid(s)
|
|
146
|
+
out = []
|
|
147
|
+
for a_row in a:
|
|
148
|
+
fields = dict(a_row.fields)
|
|
149
|
+
fields[self.field] = fields[self.field].transform(
|
|
150
|
+
self.bwd, f"map({self.field}) [inverse]"
|
|
151
|
+
)
|
|
152
|
+
out.append(s_by_rid[a_row.rid].with_fields(fields))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SplitLens(Lens):
|
|
157
|
+
"""Splits one source field into several view fields (e.g. a full name
|
|
158
|
+
into first/last). `fwd` takes the source value, returns a tuple matching
|
|
159
|
+
`into`; `bwd` takes that tuple back, returns the source value."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
field: str,
|
|
164
|
+
into: tuple[str, ...],
|
|
165
|
+
fwd: Callable[[Any], tuple[Any, ...]],
|
|
166
|
+
bwd: Callable[[tuple[Any, ...]], Any],
|
|
167
|
+
) -> None:
|
|
168
|
+
self.field = field
|
|
169
|
+
self.into = into
|
|
170
|
+
self.fwd = fwd
|
|
171
|
+
self.bwd = bwd
|
|
172
|
+
self.name = f"split({field}->{into})"
|
|
173
|
+
|
|
174
|
+
def get(self, s: Table) -> Table:
|
|
175
|
+
out = []
|
|
176
|
+
for r in s:
|
|
177
|
+
fields = dict(r.fields)
|
|
178
|
+
cell = fields.pop(self.field)
|
|
179
|
+
parts = self.fwd(cell.value)
|
|
180
|
+
for name, value in zip(self.into, parts, strict=True):
|
|
181
|
+
fields[name] = Cell(value, cell.prov.step(f"split({self.field}->{name})"))
|
|
182
|
+
out.append(r.with_fields(fields))
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
186
|
+
s_by_rid = index_by_rid(s)
|
|
187
|
+
out = []
|
|
188
|
+
for a_row in a:
|
|
189
|
+
fields = dict(a_row.fields)
|
|
190
|
+
parts = tuple(fields.pop(name) for name in self.into)
|
|
191
|
+
value = self.bwd(tuple(c.value for c in parts))
|
|
192
|
+
fields[self.field] = parts[0].transform(
|
|
193
|
+
lambda _v, value=value: value, f"split({self.field}->{self.into}) [inverse]"
|
|
194
|
+
)
|
|
195
|
+
out.append(s_by_rid[a_row.rid].with_fields(fields))
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class MergeLens(Lens):
|
|
200
|
+
"""Merges several source fields into one view field (e.g. first/last into
|
|
201
|
+
a full name). The dual of `SplitLens`: `fwd` takes a tuple of the source
|
|
202
|
+
values, returns the merged value; `bwd` takes that value back, returns a
|
|
203
|
+
tuple matching `fields`."""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
fields: tuple[str, ...],
|
|
208
|
+
into: str,
|
|
209
|
+
fwd: Callable[[tuple[Any, ...]], Any],
|
|
210
|
+
bwd: Callable[[Any], tuple[Any, ...]],
|
|
211
|
+
) -> None:
|
|
212
|
+
self.fields = fields
|
|
213
|
+
self.into = into
|
|
214
|
+
self.fwd = fwd
|
|
215
|
+
self.bwd = bwd
|
|
216
|
+
self.name = f"merge({fields}->{into})"
|
|
217
|
+
|
|
218
|
+
def get(self, s: Table) -> Table:
|
|
219
|
+
out = []
|
|
220
|
+
for r in s:
|
|
221
|
+
fields = dict(r.fields)
|
|
222
|
+
cells = [fields.pop(name) for name in self.fields]
|
|
223
|
+
value = self.fwd(tuple(c.value for c in cells))
|
|
224
|
+
step = f"merge({self.fields}->{self.into})"
|
|
225
|
+
fields[self.into] = Cell(value, cells[0].prov.step(step))
|
|
226
|
+
out.append(r.with_fields(fields))
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
230
|
+
s_by_rid = index_by_rid(s)
|
|
231
|
+
out = []
|
|
232
|
+
for a_row in a:
|
|
233
|
+
fields = dict(a_row.fields)
|
|
234
|
+
cell = fields.pop(self.into)
|
|
235
|
+
parts = self.bwd(cell.value)
|
|
236
|
+
for name, value in zip(self.fields, parts, strict=True):
|
|
237
|
+
fields[name] = cell.transform(
|
|
238
|
+
lambda _v, value=value: value, f"merge({self.into}) [inverse] ->{name}"
|
|
239
|
+
)
|
|
240
|
+
out.append(s_by_rid[a_row.rid].with_fields(fields))
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ConstantLens(Lens):
|
|
245
|
+
"""Adds a fixed, read-only field."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, field: str, value: Any) -> None:
|
|
248
|
+
self.field = field
|
|
249
|
+
self.value = value
|
|
250
|
+
self.name = f"constant({field}={value!r})"
|
|
251
|
+
|
|
252
|
+
def get(self, s: Table) -> Table:
|
|
253
|
+
from reflet.provenance import Provenance
|
|
254
|
+
|
|
255
|
+
out = []
|
|
256
|
+
for r in s:
|
|
257
|
+
fields = dict(r.fields)
|
|
258
|
+
fields[self.field] = Cell(
|
|
259
|
+
self.value, Provenance("constant", self.field), readonly=True
|
|
260
|
+
)
|
|
261
|
+
out.append(r.with_fields(fields))
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
265
|
+
s_by_rid = index_by_rid(s)
|
|
266
|
+
out = []
|
|
267
|
+
for a_row in a:
|
|
268
|
+
fields = {k: v for k, v in a_row.fields.items() if k != self.field}
|
|
269
|
+
out.append(s_by_rid[a_row.rid].with_fields(fields))
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class ComputeLens(Lens):
|
|
274
|
+
"""Adds a read-only field derived from the other fields of the same row."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, field: str, fn: Callable[[RowView], Any]) -> None:
|
|
277
|
+
self.field = field
|
|
278
|
+
self.fn = fn
|
|
279
|
+
self.name = f"compute({field})"
|
|
280
|
+
|
|
281
|
+
def get(self, s: Table) -> Table:
|
|
282
|
+
from reflet.provenance import Provenance
|
|
283
|
+
|
|
284
|
+
out = []
|
|
285
|
+
for r in s:
|
|
286
|
+
fields = dict(r.fields)
|
|
287
|
+
fields[self.field] = Cell(
|
|
288
|
+
self.fn(RowView(r)), Provenance("compute", self.field), readonly=True
|
|
289
|
+
)
|
|
290
|
+
out.append(r.with_fields(fields))
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
294
|
+
s_by_rid = index_by_rid(s)
|
|
295
|
+
out = []
|
|
296
|
+
for a_row in a:
|
|
297
|
+
fields = {k: v for k, v in a_row.fields.items() if k != self.field}
|
|
298
|
+
out.append(s_by_rid[a_row.rid].with_fields(fields))
|
|
299
|
+
return out
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AggregateLens(Lens):
|
|
303
|
+
"""Groups rows by `group_by` into one output row per group.
|
|
304
|
+
|
|
305
|
+
Each output row carries the (writable, conflict-policed) group key and a
|
|
306
|
+
read-only `agg_field` computed by `agg_fn` over the group's values of
|
|
307
|
+
`source_field`.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def __init__(
|
|
311
|
+
self,
|
|
312
|
+
group_by: str,
|
|
313
|
+
agg_field: str,
|
|
314
|
+
agg_fn: Callable[[list[Any]], Any],
|
|
315
|
+
source_field: str | None = None,
|
|
316
|
+
policy: ConflictPolicy = ConflictPolicy.ERROR,
|
|
317
|
+
) -> None:
|
|
318
|
+
self.group_by = group_by
|
|
319
|
+
self.agg_field = agg_field
|
|
320
|
+
self.agg_fn = agg_fn
|
|
321
|
+
self.source_field = source_field or group_by
|
|
322
|
+
self.policy = policy
|
|
323
|
+
self.name = f"aggregate(by={group_by}, {agg_field})"
|
|
324
|
+
|
|
325
|
+
def get(self, s: Table) -> Table:
|
|
326
|
+
from reflet.provenance import Provenance
|
|
327
|
+
|
|
328
|
+
groups: dict[Any, list[Record]] = defaultdict(list)
|
|
329
|
+
for r in s:
|
|
330
|
+
groups[r.fields[self.group_by].value].append(r)
|
|
331
|
+
|
|
332
|
+
out = []
|
|
333
|
+
for key, rows in groups.items():
|
|
334
|
+
rids: tuple[RowKey, ...] = tuple(
|
|
335
|
+
r.rid[0] if isinstance(r.rid, tuple) else r.rid for r in rows
|
|
336
|
+
)
|
|
337
|
+
values = [r.fields[self.source_field].value for r in rows]
|
|
338
|
+
fields = {
|
|
339
|
+
self.group_by: Cell(
|
|
340
|
+
key,
|
|
341
|
+
Provenance("aggregate", self.group_by),
|
|
342
|
+
conflict = self.policy if len(rows) > 1 else None,
|
|
343
|
+
conflict_size = len(rows),
|
|
344
|
+
),
|
|
345
|
+
self.agg_field: Cell(
|
|
346
|
+
self.agg_fn(values), Provenance("aggregate", self.agg_field), readonly=True
|
|
347
|
+
),
|
|
348
|
+
}
|
|
349
|
+
rid: RowId = rids if len(rids) > 1 else list(rids)[0]
|
|
350
|
+
out.append(Record(rid, fields))
|
|
351
|
+
return out
|
|
352
|
+
|
|
353
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
354
|
+
s_by_rid = index_by_rid(s)
|
|
355
|
+
result = dict(s_by_rid)
|
|
356
|
+
for a_row in a:
|
|
357
|
+
new_key = a_row.fields[self.group_by].value
|
|
358
|
+
rid = a_row.rid
|
|
359
|
+
group_rids: tuple[RowKey, ...] = rid if isinstance(rid, tuple) else (rid,)
|
|
360
|
+
if all(s_by_rid[r].fields[self.group_by].value == new_key for r in group_rids):
|
|
361
|
+
continue # unchanged: nothing to write back
|
|
362
|
+
if len(group_rids) > 1 and self.policy == ConflictPolicy.ERROR:
|
|
363
|
+
raise ConflictError(len(group_rids))
|
|
364
|
+
group_rids_list = list(group_rids)
|
|
365
|
+
target = (
|
|
366
|
+
group_rids_list[0]
|
|
367
|
+
if len(group_rids) == 1 or self.policy == ConflictPolicy.FIRST
|
|
368
|
+
else group_rids_list[-1]
|
|
369
|
+
)
|
|
370
|
+
original = s_by_rid[target]
|
|
371
|
+
fields = dict(original.fields)
|
|
372
|
+
fields[self.group_by] = a_row.fields[self.group_by].transform(
|
|
373
|
+
lambda v: v, "aggregate [inverse]"
|
|
374
|
+
)
|
|
375
|
+
result[target] = original.with_fields(fields)
|
|
376
|
+
return list(result.values())
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class JoinLens(Lens):
|
|
380
|
+
"""Inner-joins each row against another pipeline's rows, by key.
|
|
381
|
+
|
|
382
|
+
Right-hand fields are **read-only** for now: a true two-way join would
|
|
383
|
+
need a View spanning two backends, which reflet doesn't support yet (see
|
|
384
|
+
the README's Join section). Rows with no match on the right are dropped,
|
|
385
|
+
same as `filter`. `other` is re-read on every `get()`, so edits made to
|
|
386
|
+
its file between reads are picked up.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
def __init__(
|
|
390
|
+
self,
|
|
391
|
+
other: Pipeline,
|
|
392
|
+
on: str,
|
|
393
|
+
other_on: str | None = None,
|
|
394
|
+
prefix: str | None = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
self.other = other
|
|
397
|
+
self.on = on
|
|
398
|
+
self.other_on = other_on or on
|
|
399
|
+
self.prefix = prefix
|
|
400
|
+
self.name = f"join(on={on})"
|
|
401
|
+
|
|
402
|
+
def get(self, s: Table) -> Table:
|
|
403
|
+
right_table = self.other.run()._table
|
|
404
|
+
right_by_key: dict[Any, Record] = {}
|
|
405
|
+
for r in right_table:
|
|
406
|
+
right_by_key.setdefault(r.fields[self.other_on].value, r)
|
|
407
|
+
|
|
408
|
+
out = []
|
|
409
|
+
for r in s:
|
|
410
|
+
match = right_by_key.get(r.fields[self.on].value)
|
|
411
|
+
if match is None:
|
|
412
|
+
continue
|
|
413
|
+
fields = dict(r.fields)
|
|
414
|
+
for name, cell in match.fields.items():
|
|
415
|
+
out_name = f"{self.prefix}{name}" if self.prefix else name
|
|
416
|
+
if out_name in fields:
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f"join(): right-hand field {name!r} collides with a left "
|
|
419
|
+
"field of the same name; pass prefix=..."
|
|
420
|
+
)
|
|
421
|
+
fields[out_name] = Cell(cell.value, cell.prov, readonly=True)
|
|
422
|
+
out.append(r.with_fields(fields))
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
def put(self, a: Table, s: Table) -> Table:
|
|
426
|
+
s_by_rid = index_by_rid(s)
|
|
427
|
+
out = []
|
|
428
|
+
for a_row in a:
|
|
429
|
+
original = s_by_rid[a_row.rid]
|
|
430
|
+
fields = {k: v for k, v in a_row.fields.items() if k in original.fields}
|
|
431
|
+
out.append(original.with_fields(fields))
|
|
432
|
+
return out
|
reflet/pipeline.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Pipeline builder: chains lenses in front of a source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from functools import reduce
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from reflet.lens import ConflictPolicy, IdentityLens, Lens
|
|
10
|
+
from reflet.lenses import (
|
|
11
|
+
AggregateLens,
|
|
12
|
+
ComputeLens,
|
|
13
|
+
ConstantLens,
|
|
14
|
+
FilterLens,
|
|
15
|
+
JoinLens,
|
|
16
|
+
MapLens,
|
|
17
|
+
MergeLens,
|
|
18
|
+
RenameLens,
|
|
19
|
+
RowView,
|
|
20
|
+
SelectLens,
|
|
21
|
+
SortLens,
|
|
22
|
+
SplitLens,
|
|
23
|
+
)
|
|
24
|
+
from reflet.sources import SourceBackend
|
|
25
|
+
from reflet.sources.csv_source import CsvSource
|
|
26
|
+
from reflet.sources.json_path_source import JsonPathSource
|
|
27
|
+
from reflet.sources.json_source import JsonSource
|
|
28
|
+
from reflet.sources.sqlite_source import SqliteSource
|
|
29
|
+
from reflet.view import Validator, View
|
|
30
|
+
|
|
31
|
+
_SQLITE_EXTENSIONS = (".sqlite", ".sqlite3", ".db")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _backend_for(
|
|
35
|
+
path: str, key: str | None, table: str | None, rows_at: str | None
|
|
36
|
+
) -> SourceBackend:
|
|
37
|
+
if path.endswith(_SQLITE_EXTENSIONS):
|
|
38
|
+
if table is None:
|
|
39
|
+
raise ValueError(f"{path!r}: a SQLite source requires table=...")
|
|
40
|
+
return SqliteSource(path, table, key)
|
|
41
|
+
if path.endswith(".json"):
|
|
42
|
+
if rows_at is not None:
|
|
43
|
+
return JsonPathSource(path, rows_at, key)
|
|
44
|
+
return JsonSource(path, key)
|
|
45
|
+
if path.endswith(".csv"):
|
|
46
|
+
if rows_at is not None:
|
|
47
|
+
raise ValueError("rows_at= is only supported for JSON sources")
|
|
48
|
+
return CsvSource(path, key)
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"unsupported source file: {path!r} (expected .csv, .json, or a SQLite file)"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Pipeline:
|
|
55
|
+
"""A chain of lenses to apply in front of a source backend."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
backend: SourceBackend,
|
|
60
|
+
lenses: tuple[Lens, ...] = (),
|
|
61
|
+
validators: dict[str, list[Validator]] | None = None,
|
|
62
|
+
tombstone_field: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.backend = backend
|
|
65
|
+
self.lenses = lenses
|
|
66
|
+
self.validators = validators or {}
|
|
67
|
+
self.tombstone_field = tombstone_field
|
|
68
|
+
|
|
69
|
+
def _add(self, lens: Lens) -> Pipeline:
|
|
70
|
+
return Pipeline(
|
|
71
|
+
self.backend, (*self.lenses, lens), self.validators, self.tombstone_field
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def filter(
|
|
75
|
+
self, predicate: Callable[[RowView], bool], defaults: dict[str, Any] | None = None
|
|
76
|
+
) -> Pipeline:
|
|
77
|
+
"""`defaults` are the field values a `View.insert()`-ed row needs to
|
|
78
|
+
satisfy this filter (see `FilterLens`)."""
|
|
79
|
+
return self._add(FilterLens(predicate, defaults))
|
|
80
|
+
|
|
81
|
+
def rename(self, **mapping: str) -> Pipeline:
|
|
82
|
+
"""`rename(customer="name")` renames source field `name` to `customer`."""
|
|
83
|
+
return self._add(RenameLens(mapping))
|
|
84
|
+
|
|
85
|
+
def select(self, *fields: str) -> Pipeline:
|
|
86
|
+
return self._add(SelectLens(fields))
|
|
87
|
+
|
|
88
|
+
def sort(self, field: str, reverse: bool = False) -> Pipeline:
|
|
89
|
+
"""Orders the view by `field`; write-back ignores order, keyed by rid."""
|
|
90
|
+
return self._add(SortLens(field, reverse))
|
|
91
|
+
|
|
92
|
+
def map(
|
|
93
|
+
self, field: str, *, fwd: Callable[[Any], Any], bwd: Callable[[Any], Any]
|
|
94
|
+
) -> Pipeline:
|
|
95
|
+
return self._add(MapLens(field, fwd, bwd))
|
|
96
|
+
|
|
97
|
+
def cast(
|
|
98
|
+
self, field: str, to: Callable[[Any], Any], back: Callable[[Any], Any]
|
|
99
|
+
) -> Pipeline:
|
|
100
|
+
"""Sugar for `.map()` when the transform is just a type conversion.
|
|
101
|
+
|
|
102
|
+
`back` is required (not guessed) — e.g. `.cast("price", to=float, back=str)`
|
|
103
|
+
if the source stores it as text.
|
|
104
|
+
"""
|
|
105
|
+
return self._add(MapLens(field, to, back))
|
|
106
|
+
|
|
107
|
+
def split(
|
|
108
|
+
self,
|
|
109
|
+
field: str,
|
|
110
|
+
into: tuple[str, ...],
|
|
111
|
+
fwd: Callable[[Any], tuple[Any, ...]],
|
|
112
|
+
bwd: Callable[[tuple[Any, ...]], Any],
|
|
113
|
+
) -> Pipeline:
|
|
114
|
+
"""Splits one source field into several view fields (e.g. a full name
|
|
115
|
+
into first/last)."""
|
|
116
|
+
return self._add(SplitLens(field, into, fwd, bwd))
|
|
117
|
+
|
|
118
|
+
def merge(
|
|
119
|
+
self,
|
|
120
|
+
fields: tuple[str, ...],
|
|
121
|
+
into: str,
|
|
122
|
+
fwd: Callable[[tuple[Any, ...]], Any],
|
|
123
|
+
bwd: Callable[[Any], tuple[Any, ...]],
|
|
124
|
+
) -> Pipeline:
|
|
125
|
+
"""Merges several source fields into one view field — the dual of `.split()`."""
|
|
126
|
+
return self._add(MergeLens(fields, into, fwd, bwd))
|
|
127
|
+
|
|
128
|
+
def join(
|
|
129
|
+
self,
|
|
130
|
+
other: Pipeline,
|
|
131
|
+
on: str,
|
|
132
|
+
other_on: str | None = None,
|
|
133
|
+
prefix: str | None = None,
|
|
134
|
+
) -> Pipeline:
|
|
135
|
+
"""Inner-joins against `other`'s rows by key. Right-hand fields are
|
|
136
|
+
read-only — see `JoinLens` and the README's Join section."""
|
|
137
|
+
return self._add(JoinLens(other, on, other_on, prefix))
|
|
138
|
+
|
|
139
|
+
def constant(self, field: str, value: Any) -> Pipeline:
|
|
140
|
+
return self._add(ConstantLens(field, value))
|
|
141
|
+
|
|
142
|
+
def compute(self, field: str, fn: Callable[[RowView], Any]) -> Pipeline:
|
|
143
|
+
return self._add(ComputeLens(field, fn))
|
|
144
|
+
|
|
145
|
+
def aggregate(
|
|
146
|
+
self,
|
|
147
|
+
group_by: str,
|
|
148
|
+
agg_field: str,
|
|
149
|
+
agg_fn: Callable[[list[Any]], Any],
|
|
150
|
+
source_field: str | None = None,
|
|
151
|
+
policy: ConflictPolicy = ConflictPolicy.ERROR,
|
|
152
|
+
) -> Pipeline:
|
|
153
|
+
return self._add(
|
|
154
|
+
AggregateLens(group_by, agg_field, agg_fn, source_field=source_field, policy=policy)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def validate(
|
|
158
|
+
self, field: str, predicate: Callable[[Any], bool], message: str | None = None
|
|
159
|
+
) -> Pipeline:
|
|
160
|
+
"""Reject an edit to `field` before it ever reaches the source.
|
|
161
|
+
|
|
162
|
+
`predicate` is checked against the new value at edit time
|
|
163
|
+
(`row.field = value`), not at `sync()`. On failure, raises
|
|
164
|
+
`ValidationError` with `message` (or a default) plus the field's
|
|
165
|
+
provenance, so the error points at where the bad value came from.
|
|
166
|
+
"""
|
|
167
|
+
validators = {k: list(v) for k, v in self.validators.items()}
|
|
168
|
+
validators.setdefault(field, []).append((predicate, message))
|
|
169
|
+
return Pipeline(self.backend, self.lenses, validators, self.tombstone_field)
|
|
170
|
+
|
|
171
|
+
def combined_lens(self) -> Lens:
|
|
172
|
+
if not self.lenses:
|
|
173
|
+
return IdentityLens()
|
|
174
|
+
return reduce(lambda a, b: a >> b, self.lenses)
|
|
175
|
+
|
|
176
|
+
def run(self) -> View:
|
|
177
|
+
source_table = self.backend.read()
|
|
178
|
+
lens = self.combined_lens()
|
|
179
|
+
view_table = lens.get(source_table)
|
|
180
|
+
return View(
|
|
181
|
+
view_table,
|
|
182
|
+
source_table,
|
|
183
|
+
lens,
|
|
184
|
+
self.backend,
|
|
185
|
+
lenses = self.lenses,
|
|
186
|
+
validators = self.validators,
|
|
187
|
+
tombstone_field = self.tombstone_field,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def Source(
|
|
192
|
+
path: str,
|
|
193
|
+
key: str | None = None,
|
|
194
|
+
tombstone_field: str | None = None,
|
|
195
|
+
table: str | None = None,
|
|
196
|
+
rows_at: str | None = None,
|
|
197
|
+
) -> Pipeline:
|
|
198
|
+
"""Entry point: `rf.Source("clients.csv")` starts a pipeline over that file.
|
|
199
|
+
|
|
200
|
+
`key` names a source field that uniquely identifies each row, so
|
|
201
|
+
write-back keeps tracking the right row even if the file is reordered
|
|
202
|
+
externally between a `run()` and a `sync()`. Without it, a row's identity
|
|
203
|
+
is simply its position in the file.
|
|
204
|
+
|
|
205
|
+
`tombstone_field` names an existing column to set instead of actually
|
|
206
|
+
removing a row, for `View.delete(..., on_delete="tombstone")`.
|
|
207
|
+
|
|
208
|
+
`table` names the table to read for a SQLite source (`.sqlite`/`.sqlite3`/
|
|
209
|
+
`.db`); required for those, ignored otherwise.
|
|
210
|
+
|
|
211
|
+
`rows_at` is a dotted path (e.g. `"servers"`) to the list to treat as the
|
|
212
|
+
table, for a JSON file whose rows are nested inside a larger document
|
|
213
|
+
rather than being the document itself.
|
|
214
|
+
"""
|
|
215
|
+
return Pipeline(_backend_for(path, key, table, rows_at), tombstone_field=tombstone_field)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__: Sequence[str] = ["Pipeline", "Source"]
|
reflet/provenance.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Per-cell lineage tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Provenance:
|
|
10
|
+
"""Lineage of a single cell: where it came from and the steps applied to it.
|
|
11
|
+
|
|
12
|
+
`source` is the origin identifier (e.g. a file path), `source_field` is the
|
|
13
|
+
field name as it appeared in the source, and `steps` is the ordered list of
|
|
14
|
+
transformation descriptions applied since then (oldest first).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
source: str
|
|
18
|
+
source_field: str
|
|
19
|
+
steps: tuple[str, ...] = field(default_factory=tuple)
|
|
20
|
+
|
|
21
|
+
def step(self, description: str) -> Provenance:
|
|
22
|
+
"""Return a new Provenance with `description` appended to the step chain."""
|
|
23
|
+
return Provenance(self.source, self.source_field, (*self.steps, description))
|
|
24
|
+
|
|
25
|
+
def render(self) -> str:
|
|
26
|
+
chain = " -> ".join((f"{self.source}:{self.source_field}", *self.steps))
|
|
27
|
+
return chain
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str: # pragma: no cover - thin wrapper
|
|
30
|
+
return self.render()
|
reflet/py.typed
ADDED
|
File without changes
|