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/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