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