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 ADDED
@@ -0,0 +1,36 @@
1
+ """reflet: a bidirectional data transformation engine with built-in provenance."""
2
+
3
+ from reflet.errors import (
4
+ ConflictError,
5
+ InsertError,
6
+ ReadOnlyFieldError,
7
+ RefletError,
8
+ SourceConflictError,
9
+ UnknownFieldError,
10
+ UnsupportedOperationError,
11
+ ValidationError,
12
+ )
13
+ from reflet.lens import ConflictPolicy
14
+ from reflet.pipeline import Pipeline, Source
15
+ from reflet.view import Change, FieldProxy, JournalEntry, Row, View
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Source",
21
+ "Pipeline",
22
+ "View",
23
+ "Row",
24
+ "FieldProxy",
25
+ "Change",
26
+ "JournalEntry",
27
+ "ConflictPolicy",
28
+ "RefletError",
29
+ "ReadOnlyFieldError",
30
+ "ConflictError",
31
+ "UnknownFieldError",
32
+ "SourceConflictError",
33
+ "ValidationError",
34
+ "InsertError",
35
+ "UnsupportedOperationError",
36
+ ]
reflet/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from reflet.cli import main
2
+
3
+ main()
reflet/cli.py ADDED
@@ -0,0 +1,67 @@
1
+ """Command-line interface: `python -m reflet show|why ...`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import runpy
7
+ import sys
8
+
9
+ from reflet.declarative import pipeline_from_json
10
+ from reflet.pipeline import Pipeline
11
+ from reflet.view import View
12
+
13
+
14
+ def _load_pipeline(script: str) -> Pipeline:
15
+ if script.endswith(".json"):
16
+ return pipeline_from_json(script)
17
+ namespace = runpy.run_path(script)
18
+ pipeline = namespace.get("pipeline")
19
+ if pipeline is None or not isinstance(pipeline, Pipeline):
20
+ raise SystemExit(f"{script}: no module-level `pipeline = rf.Source(...)...` found")
21
+ return pipeline
22
+
23
+
24
+ def _run(script: str) -> View:
25
+ return _load_pipeline(script).run()
26
+
27
+
28
+ def cmd_show(args: argparse.Namespace) -> None:
29
+ view = _run(args.script)
30
+ if len(view) == 0:
31
+ print("(empty view)")
32
+ return
33
+ fields = list(view._table[0].fields.keys())
34
+ print("\t".join(fields))
35
+ for row in view:
36
+ print("\t".join(str(getattr(row, f)) for f in fields))
37
+
38
+
39
+ def cmd_why(args: argparse.Namespace) -> None:
40
+ view = _run(args.script)
41
+ row = view[args.row]
42
+ field = getattr(row, args.field)
43
+ print(field.why())
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> None:
47
+ parser = argparse.ArgumentParser(prog="python -m reflet")
48
+ subparsers = parser.add_subparsers(dest="command", required=True)
49
+
50
+ script_help = "path to a .py file defining `pipeline`, or a declarative .json spec"
51
+
52
+ show_parser = subparsers.add_parser("show", help="run a pipeline and print its view")
53
+ show_parser.add_argument("script", help=script_help)
54
+ show_parser.set_defaults(func=cmd_show)
55
+
56
+ why_parser = subparsers.add_parser("why", help="print a field's provenance")
57
+ why_parser.add_argument("script", help=script_help)
58
+ why_parser.add_argument("row", type=int, help="row index in the view")
59
+ why_parser.add_argument("field", help="field name")
60
+ why_parser.set_defaults(func=cmd_why)
61
+
62
+ args = parser.parse_args(argv)
63
+ args.func(args)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main(sys.argv[1:])
reflet/declarative.py ADDED
@@ -0,0 +1,133 @@
1
+ """Build a Pipeline from a JSON spec, without writing Python.
2
+
3
+ Only the lenses expressible without an arbitrary callable are supported:
4
+ `rename`, `select`, `sort`, `constant`, `aggregate` (with a named agg
5
+ function), `filter` (a single field/comparison/value), and `map` (a named
6
+ numeric transform: scale or offset). `compute`/`validate`, and a `map` with
7
+ custom logic, need real Python — build those with `reflet.Source(...)`
8
+ directly and treat the declarative spec as a starting point, not the whole
9
+ pipeline.
10
+
11
+ There's no reverse (Pipeline -> spec): a Pipeline built the normal way holds
12
+ arbitrary Python closures for filter/map/compute, which have no general
13
+ serialization. This module only builds pipelines from specs, not the other
14
+ way around.
15
+
16
+ Spec shape:
17
+
18
+ {
19
+ "source": {"path": "clients.csv", "key": "id"},
20
+ "steps": [
21
+ {"op": "filter", "field": "country", "cmp": "==", "value": "FR"},
22
+ {"op": "rename", "mapping": {"customer": "name"}},
23
+ {"op": "map", "field": "price", "transform": "scale", "factor": 1.2},
24
+ {"op": "sort", "field": "customer"}
25
+ ]
26
+ }
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import operator
33
+ from collections.abc import Callable
34
+ from typing import Any
35
+
36
+ from reflet.lens import ConflictPolicy
37
+ from reflet.lenses import RowView
38
+ from reflet.pipeline import Pipeline, Source
39
+
40
+ _CMP_OPS: dict[str, Callable[[Any, Any], bool]] = {
41
+ "==": operator.eq,
42
+ "!=": operator.ne,
43
+ "<": operator.lt,
44
+ "<=": operator.le,
45
+ ">": operator.gt,
46
+ ">=": operator.ge,
47
+ "in": lambda a, b: a in b,
48
+ "not in": lambda a, b: a not in b,
49
+ }
50
+
51
+ _AGG_FNS: dict[str, Callable[[list[Any]], Any]] = {
52
+ "sum": sum,
53
+ "min": min,
54
+ "max": max,
55
+ "count": len,
56
+ "avg": lambda xs: sum(xs) / len(xs),
57
+ }
58
+
59
+
60
+ def _compile_predicate(step: dict[str, Any]) -> Callable[[RowView], bool]:
61
+ field = step["field"]
62
+ try:
63
+ cmp = _CMP_OPS[step["cmp"]]
64
+ except KeyError as exc:
65
+ raise ValueError(f"declarative filter: unknown cmp {step['cmp']!r}") from exc
66
+ value = step["value"]
67
+
68
+ def predicate(row: RowView) -> bool:
69
+ return cmp(getattr(row, field), value)
70
+
71
+ return predicate
72
+
73
+
74
+ def _compile_map(step: dict[str, Any]) -> tuple[Callable[[Any], Any], Callable[[Any], Any]]:
75
+ transform = step["transform"]
76
+ if transform == "scale":
77
+ factor = step["factor"]
78
+ return (lambda x, factor=factor: x * factor, lambda x, factor=factor: x / factor)
79
+ if transform == "offset":
80
+ amount = step["amount"]
81
+ return (lambda x, amount=amount: x + amount, lambda x, amount=amount: x - amount)
82
+ raise ValueError(f"declarative map: unknown transform {transform!r}")
83
+
84
+
85
+ def pipeline_from_dict(spec: dict[str, Any]) -> Pipeline:
86
+ """Build a `Pipeline` from an already-parsed declarative spec dict."""
87
+ src = spec["source"]
88
+ pipeline = Source(
89
+ src["path"],
90
+ key = src.get("key"),
91
+ tombstone_field = src.get("tombstone_field"),
92
+ table = src.get("table"),
93
+ rows_at = src.get("rows_at"),
94
+ )
95
+
96
+ for step in spec.get("steps", []):
97
+ op = step["op"]
98
+ if op == "filter":
99
+ pipeline = pipeline.filter(_compile_predicate(step), defaults=step.get("defaults"))
100
+ elif op == "rename":
101
+ pipeline = pipeline.rename(**step["mapping"])
102
+ elif op == "select":
103
+ pipeline = pipeline.select(*step["fields"])
104
+ elif op == "sort":
105
+ pipeline = pipeline.sort(step["field"], reverse=step.get("reverse", False))
106
+ elif op == "constant":
107
+ pipeline = pipeline.constant(step["field"], step["value"])
108
+ elif op == "map":
109
+ fwd, bwd = _compile_map(step)
110
+ pipeline = pipeline.map(step["field"], fwd=fwd, bwd=bwd)
111
+ elif op == "aggregate":
112
+ try:
113
+ agg_fn = _AGG_FNS[step["agg_fn"]]
114
+ except KeyError as exc:
115
+ name = step["agg_fn"]
116
+ raise ValueError(f"declarative aggregate: unknown agg_fn {name!r}") from exc
117
+ pipeline = pipeline.aggregate(
118
+ step["group_by"],
119
+ step["agg_field"],
120
+ agg_fn,
121
+ source_field = step.get("source_field"),
122
+ policy = ConflictPolicy(step.get("policy", "error")),
123
+ )
124
+ else:
125
+ raise ValueError(f"unknown declarative step: {op!r}")
126
+
127
+ return pipeline
128
+
129
+
130
+ def pipeline_from_json(path: str) -> Pipeline:
131
+ """Build a `Pipeline` from a declarative spec file."""
132
+ with open(path, encoding="utf-8") as fh:
133
+ return pipeline_from_dict(json.load(fh))
reflet/errors.py ADDED
@@ -0,0 +1,72 @@
1
+ """Exceptions raised by reflet."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RefletError(Exception):
7
+ """Base class for all reflet errors."""
8
+
9
+
10
+ class ReadOnlyFieldError(RefletError):
11
+ """Raised when attempting to write to a field produced by a non-invertible lens."""
12
+
13
+ def __init__(self, field: str) -> None:
14
+ super().__init__(
15
+ f"field {field!r} is read-only: it was produced by a non-invertible "
16
+ "operation (e.g. compute/constant/aggregate/join) and has no inverse "
17
+ "to propagate through"
18
+ )
19
+ self.field = field
20
+
21
+
22
+ class ConflictError(RefletError):
23
+ """Raised when an output row maps back to several source rows under the 'error' policy."""
24
+
25
+ def __init__(self, count: int) -> None:
26
+ super().__init__(
27
+ f"ambiguous write-back: output row maps to {count} source rows; "
28
+ "pass a conflict policy ('first', 'last') to resolve this explicitly"
29
+ )
30
+ self.count = count
31
+
32
+
33
+ class UnknownFieldError(RefletError):
34
+ """Raised when referencing a field that does not exist on a row."""
35
+
36
+ def __init__(self, field: str) -> None:
37
+ super().__init__(f"no such field: {field!r}")
38
+ self.field = field
39
+
40
+
41
+ class SourceConflictError(RefletError):
42
+ """Raised when the source file changed on disk since it was read."""
43
+
44
+ def __init__(self, path: str) -> None:
45
+ super().__init__(
46
+ f"{path!r} changed on disk since it was read; pass force=True to "
47
+ "sync() to overwrite it anyway, or re-run the pipeline to pick up "
48
+ "the external changes first"
49
+ )
50
+ self.path = path
51
+
52
+
53
+ class ValidationError(RefletError):
54
+ """Raised when an edit fails a `.validate()` constraint, before it touches the source."""
55
+
56
+ def __init__(
57
+ self, field: str, value: object, message: str | None = None, why: str | None = None
58
+ ) -> None:
59
+ text = message or f"{field}={value!r} failed validation"
60
+ if why:
61
+ text = f"{text} ({why})"
62
+ super().__init__(text)
63
+ self.field = field
64
+ self.value = value
65
+
66
+
67
+ class InsertError(RefletError):
68
+ """Raised when `View.insert()` can't be resolved back to a valid source row."""
69
+
70
+
71
+ class UnsupportedOperationError(RefletError):
72
+ """Raised when an operation isn't supported for the current lens chain."""
reflet/lens.py ADDED
@@ -0,0 +1,113 @@
1
+ """Core bidirectional lens abstraction, operating on tables of records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Hashable
7
+ from dataclasses import dataclass, replace
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+ from reflet.errors import ConflictError, ReadOnlyFieldError
12
+ from reflet.provenance import Provenance
13
+
14
+ # A row's stable identity: its position (no key given) or a key field's value.
15
+ RowKey = Hashable
16
+ # A row identifier: a single row key, or a tuple of several when a lens
17
+ # (e.g. aggregate) merges multiple source rows into one output row.
18
+ RowId = RowKey | tuple[RowKey, ...]
19
+
20
+
21
+ class ConflictPolicy(str, Enum):
22
+ """How to resolve a write to a field shared by several source rows."""
23
+
24
+ FIRST = "first"
25
+ LAST = "last"
26
+ ERROR = "error"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Cell:
31
+ """A single field value together with its provenance."""
32
+
33
+ value: Any
34
+ prov: Provenance
35
+ readonly: bool = False
36
+ conflict: ConflictPolicy | None = None
37
+ conflict_size: int = 1
38
+
39
+ def transform(self, fn: Any, step: str, *, readonly: bool = False) -> Cell:
40
+ return Cell(fn(self.value), self.prov.step(step), readonly=readonly)
41
+
42
+ def require_writable(self, field: str) -> None:
43
+ if self.readonly:
44
+ raise ReadOnlyFieldError(field)
45
+ if self.conflict == ConflictPolicy.ERROR:
46
+ raise ConflictError(self.conflict_size)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Record:
51
+ """A single row: its (possibly composite) identity plus its field cells."""
52
+
53
+ rid: RowId
54
+ fields: dict[str, Cell]
55
+
56
+ def with_fields(self, fields: dict[str, Cell]) -> Record:
57
+ return replace(self, fields=fields)
58
+
59
+
60
+ Table = list[Record]
61
+
62
+
63
+ class Lens(ABC):
64
+ """A bidirectional transformation from one table shape to another.
65
+
66
+ `get` produces the forward view; `put` takes an edited view (`a`, built by
67
+ editing the result of `get(s)`) together with the original table `s` and
68
+ reconstructs the updated `s`.
69
+ """
70
+
71
+ name: str = "lens"
72
+
73
+ @abstractmethod
74
+ def get(self, s: Table) -> Table: ...
75
+
76
+ @abstractmethod
77
+ def put(self, a: Table, s: Table) -> Table: ...
78
+
79
+ def __rshift__(self, other: Lens) -> Lens:
80
+ return ComposedLens(self, other)
81
+
82
+
83
+ class ComposedLens(Lens):
84
+ """Sequential composition of two lenses: self first, then other."""
85
+
86
+ def __init__(self, first: Lens, second: Lens) -> None:
87
+ self.first = first
88
+ self.second = second
89
+ self.name = f"{first.name}>>{second.name}"
90
+
91
+ def get(self, s: Table) -> Table:
92
+ return self.second.get(self.first.get(s))
93
+
94
+ def put(self, a: Table, s: Table) -> Table:
95
+ mid = self.first.get(s)
96
+ new_mid = self.second.put(a, mid)
97
+ return self.first.put(new_mid, s)
98
+
99
+
100
+ class IdentityLens(Lens):
101
+ """The no-op lens; used when a pipeline has no transformation steps yet."""
102
+
103
+ name = "identity"
104
+
105
+ def get(self, s: Table) -> Table:
106
+ return list(s)
107
+
108
+ def put(self, a: Table, s: Table) -> Table:
109
+ return list(a)
110
+
111
+
112
+ def index_by_rid(table: Table) -> dict[RowId, Record]:
113
+ return {r.rid: r for r in table}