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/view.py ADDED
@@ -0,0 +1,509 @@
1
+ """View: the editable, provenance-carrying result of running a pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from reflet.errors import (
11
+ InsertError,
12
+ ReadOnlyFieldError,
13
+ SourceConflictError,
14
+ UnknownFieldError,
15
+ UnsupportedOperationError,
16
+ ValidationError,
17
+ )
18
+ from reflet.lens import Cell, Lens, Record, RowId, Table
19
+ from reflet.lenses import (
20
+ AggregateLens,
21
+ ComputeLens,
22
+ ConstantLens,
23
+ FilterLens,
24
+ MapLens,
25
+ RenameLens,
26
+ SelectLens,
27
+ )
28
+ from reflet.provenance import Provenance
29
+ from reflet.sources import SourceBackend
30
+
31
+ Validator = tuple[Callable[[Any], bool], "str | None"]
32
+
33
+
34
+ class FieldProxy:
35
+ """A field's current value plus access to its provenance via `.why()`."""
36
+
37
+ __slots__ = ("_cell",)
38
+
39
+ def __init__(self, cell: Cell) -> None:
40
+ self._cell = cell
41
+
42
+ @property
43
+ def value(self) -> object:
44
+ return self._cell.value
45
+
46
+ def why(self) -> str:
47
+ """Return a human-readable rendering of this field's lineage."""
48
+ return self._cell.prov.render()
49
+
50
+ def __eq__(self, other: object) -> bool:
51
+ if isinstance(other, FieldProxy):
52
+ return bool(self._cell.value == other._cell.value)
53
+ return bool(self._cell.value == other)
54
+
55
+ def __hash__(self) -> int:
56
+ return hash(self._cell.value)
57
+
58
+ def __repr__(self) -> str:
59
+ return repr(self._cell.value)
60
+
61
+ def __str__(self) -> str:
62
+ return str(self._cell.value)
63
+
64
+ def __format__(self, spec: str) -> str:
65
+ return format(self._cell.value, spec)
66
+
67
+
68
+ class Row:
69
+ """A single editable row of a View; attribute access reads/writes fields."""
70
+
71
+ _view: View
72
+ _index: int
73
+
74
+ def __init__(self, view: View, index: int) -> None:
75
+ object.__setattr__(self, "_view", view)
76
+ object.__setattr__(self, "_index", index)
77
+
78
+ def __getattr__(self, name: str) -> FieldProxy:
79
+ record = self._view._table[self._index]
80
+ if name not in record.fields:
81
+ raise UnknownFieldError(name)
82
+ return FieldProxy(record.fields[name])
83
+
84
+ def __setattr__(self, name: str, value: object) -> None:
85
+ self._view._edit(self._index, name, value)
86
+
87
+ def __repr__(self) -> str:
88
+ record = self._view._table[self._index]
89
+ values = {k: c.value for k, c in record.fields.items()}
90
+ return f"Row({values!r})"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Change:
95
+ """One field write that a sync (real or dry-run) would make to the source."""
96
+
97
+ source: str
98
+ rid: RowId
99
+ field: str
100
+ before: object
101
+ after: object
102
+
103
+ def __repr__(self) -> str:
104
+ return (
105
+ f"Change({self.source}[{self.rid!r}].{self.field}: "
106
+ f"{self.before!r} -> {self.after!r})"
107
+ )
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class JournalEntry:
112
+ """One field write that was actually committed to the source, for audit."""
113
+
114
+ timestamp: datetime
115
+ rid: RowId
116
+ field: str
117
+ before: object
118
+ after: object
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _EditUndo:
123
+ index: int
124
+ field: str
125
+ cell: Cell
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class _SyncUndo:
130
+ source_table: Table
131
+
132
+
133
+ _UndoAction = _EditUndo | _SyncUndo
134
+
135
+ # Lenses through which View.insert() has no well-defined inverse: a select()
136
+ # hides source fields an insert would need a value for, and an aggregate()'s
137
+ # output rows don't correspond to a single source row to invent.
138
+ _INSERT_UNSUPPORTED = (SelectLens, AggregateLens)
139
+
140
+
141
+ class View:
142
+ """The result of `Pipeline.run()`: an editable, synchronizable table."""
143
+
144
+ def __init__(
145
+ self,
146
+ view_table: Table,
147
+ source_table: Table,
148
+ lens: Lens,
149
+ backend: SourceBackend,
150
+ *,
151
+ lenses: tuple[Lens, ...] = (),
152
+ validators: dict[str, list[Validator]] | None = None,
153
+ tombstone_field: str | None = None,
154
+ ) -> None:
155
+ self._table = view_table
156
+ self._source_table = source_table
157
+ self._lens = lens
158
+ self._backend = backend
159
+ self._lenses = lenses
160
+ self._validators = validators or {}
161
+ self._tombstone_field = tombstone_field
162
+ self._dirty: set[RowId] = set()
163
+ self._pending_inserts: dict[RowId, Record] = {}
164
+ self._pending_deletes: set[RowId] = set()
165
+ self._pending_tombstones: set[RowId] = set()
166
+ self._undo_stack: list[_UndoAction] = []
167
+ self._redo_stack: list[_UndoAction] = []
168
+ self._journal: list[JournalEntry] = []
169
+
170
+ def __len__(self) -> int:
171
+ return len(self._table)
172
+
173
+ def __getitem__(self, index: int) -> Row:
174
+ return Row(self, index)
175
+
176
+ def __iter__(self) -> Iterator[Row]:
177
+ return (Row(self, i) for i in range(len(self._table)))
178
+
179
+ @property
180
+ def journal(self) -> tuple[JournalEntry, ...]:
181
+ """The append-only history of every field write actually committed."""
182
+ return tuple(self._journal)
183
+
184
+ def _edit(self, index: int, field: str, value: object) -> None:
185
+ record = self._table[index]
186
+ if field not in record.fields:
187
+ raise UnknownFieldError(field)
188
+ cell = record.fields[field]
189
+ cell.require_writable(field)
190
+ for predicate, message in self._validators.get(field, []):
191
+ if not predicate(value):
192
+ raise ValidationError(field, value, message, cell.prov.render())
193
+ new_cell = Cell(
194
+ value,
195
+ cell.prov.step("edit"),
196
+ readonly = cell.readonly,
197
+ conflict = cell.conflict,
198
+ conflict_size = cell.conflict_size,
199
+ )
200
+ new_fields = dict(record.fields)
201
+ new_fields[field] = new_cell
202
+ self._undo_stack.append(_EditUndo(index, field, cell))
203
+ self._redo_stack.clear()
204
+ self._table[index] = record.with_fields(new_fields)
205
+ self._dirty.add(record.rid)
206
+
207
+ def _has_pending(self) -> bool:
208
+ return bool(
209
+ self._dirty or self._pending_inserts or self._pending_deletes
210
+ or self._pending_tombstones
211
+ )
212
+
213
+ def _pending(self) -> tuple[Table, list[Change]]:
214
+ edit_rids = self._dirty - set(self._pending_inserts)
215
+ edited = [r for r in self._table if r.rid in edit_rids]
216
+ updated = self._lens.put(edited, self._source_table) if edited else []
217
+
218
+ by_rid_old = {r.rid: r for r in self._source_table}
219
+ changes: list[Change] = []
220
+ for record in updated:
221
+ old = by_rid_old.get(record.rid)
222
+ for field, cell in record.fields.items():
223
+ old_cell = old.fields.get(field) if old is not None else None
224
+ if old_cell is None or old_cell.value != cell.value:
225
+ before = old_cell.value if old_cell is not None else None
226
+ changes.append(
227
+ Change(self._backend.path, record.rid, field, before, cell.value)
228
+ )
229
+
230
+ for rid, record in self._pending_inserts.items():
231
+ for field, cell in record.fields.items():
232
+ changes.append(Change(self._backend.path, rid, field, None, cell.value))
233
+
234
+ for rid in self._pending_deletes:
235
+ changes.append(Change(self._backend.path, rid, "*", "<row>", None))
236
+
237
+ for rid in self._pending_tombstones:
238
+ old_record = by_rid_old.get(rid)
239
+ tombstone_field = self._tombstone_field
240
+ assert tombstone_field is not None
241
+ old_value = (
242
+ old_record.fields[tombstone_field].value if old_record is not None else None
243
+ )
244
+ changes.append(Change(self._backend.path, rid, tombstone_field, old_value, True))
245
+
246
+ return updated, changes
247
+
248
+ def diff(self) -> list[Change]:
249
+ """Preview what `sync()` would write to the source, without touching it."""
250
+ if not self._has_pending():
251
+ return []
252
+ _, changes = self._pending()
253
+ return changes
254
+
255
+ def sync(self, *, dry_run: bool = False, force: bool = False) -> list[Change]:
256
+ """Propagate pending edits/inserts/deletes back through the lens chain
257
+ and write the source.
258
+
259
+ Only rows touched by an edit are replayed through `put`; untouched rows
260
+ are left exactly as they were read. Returns the changes written (or,
261
+ with `dry_run=True`, the changes that *would* be written).
262
+
263
+ Raises `SourceConflictError` if the source file changed on disk since
264
+ it was read, unless `force=True`.
265
+ """
266
+ if not self._has_pending():
267
+ return []
268
+ if dry_run:
269
+ return self.diff()
270
+ if not force and self._backend.changed_since_read():
271
+ raise SourceConflictError(self._backend.path)
272
+
273
+ updated, changes = self._pending()
274
+
275
+ self._undo_stack.append(_SyncUndo(list(self._source_table)))
276
+ self._redo_stack.clear()
277
+
278
+ by_rid = {r.rid: r for r in self._source_table}
279
+ for record in updated:
280
+ by_rid[record.rid] = record
281
+ for rid, record in self._pending_inserts.items():
282
+ by_rid[rid] = record
283
+ self._backend._order.append(rid)
284
+ for rid in self._pending_tombstones:
285
+ tombstone_field = self._tombstone_field
286
+ assert tombstone_field is not None
287
+ target = by_rid[rid]
288
+ new_fields = dict(target.fields)
289
+ new_fields[tombstone_field] = Cell(
290
+ True, Provenance(self._backend.path, tombstone_field).step("delete")
291
+ )
292
+ by_rid[rid] = target.with_fields(new_fields)
293
+ for rid in self._pending_deletes:
294
+ by_rid.pop(rid, None)
295
+
296
+ self._source_table = list(by_rid.values())
297
+ self._backend.write(self._source_table)
298
+
299
+ self._dirty.clear()
300
+ self._pending_inserts.clear()
301
+ self._pending_deletes.clear()
302
+ self._pending_tombstones.clear()
303
+
304
+ now = datetime.now()
305
+ self._journal.extend(
306
+ JournalEntry(now, c.rid, c.field, c.before, c.after) for c in changes
307
+ )
308
+ return changes
309
+
310
+ def undo(self) -> bool:
311
+ """Undo the most recent edit or sync. Returns False if there's nothing to undo.
312
+
313
+ Doesn't (yet) cover `insert()`/`delete()` made since the last sync."""
314
+ if not self._undo_stack:
315
+ return False
316
+ action = self._undo_stack.pop()
317
+ if isinstance(action, _EditUndo):
318
+ record = self._table[action.index]
319
+ prev = record.fields[action.field]
320
+ self._redo_stack.append(_EditUndo(action.index, action.field, prev))
321
+ new_fields = dict(record.fields)
322
+ new_fields[action.field] = action.cell
323
+ self._table[action.index] = record.with_fields(new_fields)
324
+ else:
325
+ self._redo_stack.append(_SyncUndo(list(self._source_table)))
326
+ self._source_table = action.source_table
327
+ self._table = self._lens.get(self._source_table)
328
+ self._backend.write(self._source_table)
329
+ return True
330
+
331
+ def redo(self) -> bool:
332
+ """Reapply the most recently undone edit or sync. Returns False if none."""
333
+ if not self._redo_stack:
334
+ return False
335
+ action = self._redo_stack.pop()
336
+ if isinstance(action, _EditUndo):
337
+ record = self._table[action.index]
338
+ prev = record.fields[action.field]
339
+ self._undo_stack.append(_EditUndo(action.index, action.field, prev))
340
+ new_fields = dict(record.fields)
341
+ new_fields[action.field] = action.cell
342
+ self._table[action.index] = record.with_fields(new_fields)
343
+ self._dirty.add(record.rid)
344
+ else:
345
+ self._undo_stack.append(_SyncUndo(list(self._source_table)))
346
+ self._source_table = action.source_table
347
+ self._table = self._lens.get(self._source_table)
348
+ self._backend.write(self._source_table)
349
+ return True
350
+
351
+ def why_not(self, rid: RowId) -> str:
352
+ """Explain why the source row identified by `rid` is absent from the view."""
353
+ matches = [r for r in self._source_table if r.rid == rid]
354
+ if not matches:
355
+ return f"no source row with id {rid!r}"
356
+ table = matches
357
+ for lens in self._lenses:
358
+ table = lens.get(table)
359
+ if not table:
360
+ return f"excluded by {lens.name}"
361
+ return f"row {rid!r} is present in the view (it wasn't filtered out)"
362
+
363
+ def impact(self, rid: RowId, field: str) -> list[tuple[RowId, str]]:
364
+ """Which (row, view field) pairs were derived from source[rid][field]."""
365
+ result: list[tuple[RowId, str]] = []
366
+ for r in self._table:
367
+ contributing = r.rid if isinstance(r.rid, tuple) else (r.rid,)
368
+ if rid not in contributing:
369
+ continue
370
+ for name, cell in r.fields.items():
371
+ if cell.prov.source_field == field:
372
+ result.append((r.rid, name))
373
+ return result
374
+
375
+ def insert(self, **values: object) -> Row:
376
+ """Insert a new row, given its view-level field values.
377
+
378
+ Fields introduced by `rename`/`map` are un-renamed/un-mapped back to
379
+ their source form; `constant`/`compute` fields can't be supplied (they
380
+ have no inverse); `filter(..., defaults=...)` fills in anything that
381
+ filter requires but wasn't given. Not supported through `select()` or
382
+ `aggregate()` — see `_INSERT_UNSUPPORTED`.
383
+ """
384
+ if any(isinstance(lens, _INSERT_UNSUPPORTED) for lens in self._lenses):
385
+ raise UnsupportedOperationError(
386
+ "insert() isn't supported through select()/aggregate() in this pipeline"
387
+ )
388
+
389
+ source_fields: dict[str, object] = dict(values)
390
+ for lens in reversed(self._lenses):
391
+ if isinstance(lens, RenameLens):
392
+ for new_name, old_name in lens.mapping.items():
393
+ if new_name in source_fields:
394
+ source_fields[old_name] = source_fields.pop(new_name)
395
+ elif isinstance(lens, MapLens):
396
+ if lens.field in source_fields:
397
+ source_fields[lens.field] = lens.bwd(source_fields[lens.field])
398
+ elif isinstance(lens, ConstantLens | ComputeLens):
399
+ if lens.field in source_fields:
400
+ raise ReadOnlyFieldError(lens.field)
401
+ elif isinstance(lens, FilterLens):
402
+ for k, v in lens.defaults.items():
403
+ source_fields.setdefault(k, v)
404
+
405
+ fieldnames = self._backend.fieldnames
406
+ missing = [f for f in fieldnames if f not in source_fields]
407
+ if missing:
408
+ raise InsertError(f"insert() missing required field(s): {', '.join(missing)}")
409
+ extra = [f for f in source_fields if f not in fieldnames]
410
+ if extra:
411
+ raise InsertError(f"insert() got unknown field(s): {', '.join(extra)}")
412
+
413
+ if self._backend.key is not None:
414
+ rid: RowId = source_fields[self._backend.key]
415
+ else:
416
+ existing = [r.rid for r in self._source_table if isinstance(r.rid, int)]
417
+ rid = (max(existing) + 1) if existing else 0
418
+ if rid in {r.rid for r in self._source_table} or rid in self._pending_inserts:
419
+ raise InsertError(f"insert(): a row with id {rid!r} already exists")
420
+
421
+ new_record = Record(
422
+ rid,
423
+ {
424
+ k: Cell(v, Provenance(self._backend.path, k).step("insert"))
425
+ for k, v in source_fields.items()
426
+ },
427
+ )
428
+ view_rows = self._lens.get([new_record])
429
+ if not view_rows:
430
+ raise InsertError(
431
+ "insert(): the new row doesn't satisfy the pipeline's filter(s); "
432
+ "check filter(..., defaults=...)"
433
+ )
434
+
435
+ self._pending_inserts[rid] = new_record
436
+ self._dirty.add(rid)
437
+ self._table.append(view_rows[0])
438
+ return Row(self, len(self._table) - 1)
439
+
440
+ def delete(self, index: int, *, on_delete: str = "delete") -> None:
441
+ """Remove the row at `index` from the view, and on the next `sync()`:
442
+
443
+ - `"delete"` (default): remove the matching source row(s) entirely.
444
+ - `"tombstone"`: set `tombstone_field` (from `Source(..., tombstone_field=...)`)
445
+ instead of removing the row; the field must already be a source column.
446
+ - `"ignore"`: remove it from this view only; the source is untouched.
447
+ """
448
+ record = self._table[index]
449
+ contributing = record.rid if isinstance(record.rid, tuple) else (record.rid,)
450
+
451
+ if on_delete == "ignore":
452
+ del self._table[index]
453
+ return
454
+ if on_delete == "delete":
455
+ self._pending_deletes.update(contributing)
456
+ del self._table[index]
457
+ return
458
+ if on_delete == "tombstone":
459
+ if self._tombstone_field is None:
460
+ raise ValueError("on_delete='tombstone' requires Source(..., tombstone_field=...)")
461
+ if self._tombstone_field not in self._backend.fieldnames:
462
+ raise ValueError(
463
+ f"tombstone field {self._tombstone_field!r} isn't a column in "
464
+ f"{self._backend.path!r}; add it to the source first"
465
+ )
466
+ self._pending_tombstones.update(contributing)
467
+ del self._table[index]
468
+ return
469
+ raise ValueError(
470
+ f"unknown on_delete policy: {on_delete!r} (expected 'delete'/'tombstone'/'ignore')"
471
+ )
472
+
473
+ def watch(
474
+ self,
475
+ *,
476
+ interval: float = 1.0,
477
+ max_iterations: int | None = None,
478
+ on_change: Callable[[], None] | None = None,
479
+ ) -> None:
480
+ """Poll for pending edits and external source changes, syncing both ways.
481
+
482
+ This is a polling loop (reflet has no OS-level file-watch — zero
483
+ dependencies, and no stdlib cross-platform API for it), so `interval`
484
+ trades responsiveness for how often the source file gets re-hashed.
485
+ Blocks the calling thread; with `max_iterations=None` (the default) it
486
+ runs until interrupted (e.g. Ctrl-C/KeyboardInterrupt).
487
+
488
+ Each tick: pending edits are flushed via `sync()`. If that raises
489
+ `SourceConflictError` (or the source simply changed with nothing
490
+ pending), `on_change()` is called instead of silently overwriting or
491
+ ignoring the external change — by default that's a no-op, so pass one
492
+ if you want to reload (e.g. re-run the pipeline and swap this view
493
+ out) or otherwise react.
494
+ """
495
+ import time
496
+
497
+ i = 0
498
+ while max_iterations is None or i < max_iterations:
499
+ if self._has_pending():
500
+ try:
501
+ self.sync()
502
+ except SourceConflictError:
503
+ if on_change is not None:
504
+ on_change()
505
+ elif self._backend.changed_since_read() and on_change is not None:
506
+ on_change()
507
+ i += 1
508
+ if max_iterations is None or i < max_iterations:
509
+ time.sleep(interval)