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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Source backends: read a table from storage, and write edits back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import IO, Any
|
|
12
|
+
|
|
13
|
+
from reflet.lens import RowId, Table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SourceBackend(ABC):
|
|
17
|
+
"""Reads/writes a table to a concrete storage format.
|
|
18
|
+
|
|
19
|
+
`key`, when given, is the source field whose value uniquely identifies a
|
|
20
|
+
row (its `RowId`); rows keep that identity across reads, so write-back
|
|
21
|
+
survives reordering or insertion done outside reflet. With no key, a
|
|
22
|
+
row's identity is its position in the file.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: str, key: str | None = None) -> None:
|
|
26
|
+
self.path = path
|
|
27
|
+
self.key = key
|
|
28
|
+
self.fieldnames: list[str] = []
|
|
29
|
+
self._order: list[RowId] = []
|
|
30
|
+
self._fingerprint: str | None = None
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def read(self) -> Table: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def write(self, table: Table) -> None: ...
|
|
37
|
+
|
|
38
|
+
def changed_since_read(self) -> bool:
|
|
39
|
+
"""Whether the file on disk differs from what was last read/written."""
|
|
40
|
+
if self._fingerprint is None:
|
|
41
|
+
return False
|
|
42
|
+
return self._file_hash() != self._fingerprint
|
|
43
|
+
|
|
44
|
+
def _file_hash(self) -> str:
|
|
45
|
+
with open(self.path, "rb") as fh:
|
|
46
|
+
return hashlib.sha256(fh.read()).hexdigest()
|
|
47
|
+
|
|
48
|
+
def _remember_fingerprint(self) -> None:
|
|
49
|
+
self._fingerprint = self._file_hash()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def atomic_write(path: str, write_fn: Callable[[IO[str]], None]) -> None:
|
|
53
|
+
"""Write via a temp file + os.replace so readers never see a partial file."""
|
|
54
|
+
directory = os.path.dirname(os.path.abspath(path)) or "."
|
|
55
|
+
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".reflet-", suffix=".tmp")
|
|
56
|
+
try:
|
|
57
|
+
with os.fdopen(fd, "w", newline="", encoding="utf-8") as fh:
|
|
58
|
+
write_fn(fh)
|
|
59
|
+
os.replace(tmp_path, path)
|
|
60
|
+
except BaseException:
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
os.remove(tmp_path)
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def infer_scalar(raw: str) -> Any:
|
|
67
|
+
"""Best-effort conversion of a CSV string cell to int/float/bool/str."""
|
|
68
|
+
if raw == "":
|
|
69
|
+
return raw
|
|
70
|
+
if raw in ("True", "False"):
|
|
71
|
+
return raw == "True"
|
|
72
|
+
try:
|
|
73
|
+
return int(raw)
|
|
74
|
+
except ValueError:
|
|
75
|
+
pass
|
|
76
|
+
try:
|
|
77
|
+
return float(raw)
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
return raw
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_scalar(value: object) -> str:
|
|
84
|
+
if isinstance(value, bool):
|
|
85
|
+
return str(value)
|
|
86
|
+
if isinstance(value, float | int):
|
|
87
|
+
return str(value)
|
|
88
|
+
return "" if value is None else str(value)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CSV source backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from typing import IO
|
|
7
|
+
|
|
8
|
+
from reflet.lens import Cell, Record, RowId, Table
|
|
9
|
+
from reflet.provenance import Provenance
|
|
10
|
+
from reflet.sources import SourceBackend, atomic_write, infer_scalar, render_scalar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CsvSource(SourceBackend):
|
|
14
|
+
def read(self) -> Table:
|
|
15
|
+
with open(self.path, newline="", encoding="utf-8") as fh:
|
|
16
|
+
reader = csv.DictReader(fh)
|
|
17
|
+
self.fieldnames = list(reader.fieldnames or [])
|
|
18
|
+
table: Table = []
|
|
19
|
+
seen: set[RowId] = set()
|
|
20
|
+
order: list[RowId] = []
|
|
21
|
+
for i, raw_row in enumerate(reader):
|
|
22
|
+
fields = {
|
|
23
|
+
k: Cell(infer_scalar(v), Provenance(self.path, k)) for k, v in raw_row.items()
|
|
24
|
+
}
|
|
25
|
+
rid: RowId = i
|
|
26
|
+
if self.key is not None:
|
|
27
|
+
if self.key not in fields:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"{self.path}: key field {self.key!r} missing on row {i}"
|
|
30
|
+
)
|
|
31
|
+
rid = fields[self.key].value
|
|
32
|
+
if rid in seen:
|
|
33
|
+
raise ValueError(f"{self.path}: duplicate key {self.key}={rid!r}")
|
|
34
|
+
seen.add(rid)
|
|
35
|
+
order.append(rid)
|
|
36
|
+
table.append(Record(rid, fields))
|
|
37
|
+
self._order = order
|
|
38
|
+
self._remember_fingerprint()
|
|
39
|
+
return table
|
|
40
|
+
|
|
41
|
+
def write(self, table: Table) -> None:
|
|
42
|
+
fieldnames = self.fieldnames or (list(table[0].fields.keys()) if table else [])
|
|
43
|
+
by_rid = {r.rid: r for r in table}
|
|
44
|
+
|
|
45
|
+
def _write(fh: IO[str]) -> None:
|
|
46
|
+
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
|
47
|
+
writer.writeheader()
|
|
48
|
+
for rid in self._order:
|
|
49
|
+
record = by_rid.get(rid)
|
|
50
|
+
if record is None:
|
|
51
|
+
continue
|
|
52
|
+
writer.writerow({k: render_scalar(record.fields[k].value) for k in fieldnames})
|
|
53
|
+
|
|
54
|
+
atomic_write(self.path, _write)
|
|
55
|
+
self._remember_fingerprint()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""JSON source addressing a list nested inside a larger document, by path.
|
|
2
|
+
|
|
3
|
+
`rows_at="servers"` (or the more explicit "servers[*]") treats
|
|
4
|
+
`doc["servers"]` as the table; everything else in the document is kept
|
|
5
|
+
byte-for-byte except for that one list on write-back. Only a single,
|
|
6
|
+
non-wildcard-in-the-middle dotted path is supported (e.g. "a.b.servers");
|
|
7
|
+
a path like "a[*].b" addressing more than one list isn't.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import IO, Any
|
|
14
|
+
|
|
15
|
+
from reflet.lens import Cell, Record, RowId, Table
|
|
16
|
+
from reflet.provenance import Provenance
|
|
17
|
+
from reflet.sources import SourceBackend, atomic_write
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _path_parts(path: str) -> list[str]:
|
|
21
|
+
cleaned = path[:-3] if path.endswith("[*]") else path
|
|
22
|
+
return [p for p in cleaned.split(".") if p]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_path(doc: Any, path: str) -> Any:
|
|
26
|
+
node = doc
|
|
27
|
+
for part in _path_parts(path):
|
|
28
|
+
if not isinstance(node, dict) or part not in node:
|
|
29
|
+
raise ValueError(f"path {path!r}: no key {part!r}")
|
|
30
|
+
node = node[part]
|
|
31
|
+
return node
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _set_path(doc: Any, path: str, value: Any) -> None:
|
|
35
|
+
parts = _path_parts(path)
|
|
36
|
+
node = doc
|
|
37
|
+
for part in parts[:-1]:
|
|
38
|
+
node = node[part]
|
|
39
|
+
node[parts[-1]] = value
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JsonPathSource(SourceBackend):
|
|
43
|
+
def __init__(self, path: str, row_path: str, key: str | None = None) -> None:
|
|
44
|
+
super().__init__(path, key)
|
|
45
|
+
self.row_path = row_path
|
|
46
|
+
self._raw_doc: Any = None
|
|
47
|
+
|
|
48
|
+
def read(self) -> Table:
|
|
49
|
+
with open(self.path, encoding="utf-8") as fh:
|
|
50
|
+
self._raw_doc = json.load(fh)
|
|
51
|
+
rows = _get_path(self._raw_doc, self.row_path)
|
|
52
|
+
if not isinstance(rows, list):
|
|
53
|
+
raise ValueError(f"{self.path}: path {self.row_path!r} doesn't resolve to a list")
|
|
54
|
+
if rows:
|
|
55
|
+
self.fieldnames = list(rows[0].keys())
|
|
56
|
+
|
|
57
|
+
table: Table = []
|
|
58
|
+
seen: set[RowId] = set()
|
|
59
|
+
order: list[RowId] = []
|
|
60
|
+
source_name = f"{self.path}#{self.row_path}"
|
|
61
|
+
for i, raw_row in enumerate(rows):
|
|
62
|
+
fields = {k: Cell(v, Provenance(source_name, k)) for k, v in raw_row.items()}
|
|
63
|
+
rid: RowId = i
|
|
64
|
+
if self.key is not None:
|
|
65
|
+
if self.key not in fields:
|
|
66
|
+
raise ValueError(f"{source_name}: key field {self.key!r} missing on row {i}")
|
|
67
|
+
rid = fields[self.key].value
|
|
68
|
+
if rid in seen:
|
|
69
|
+
raise ValueError(f"{source_name}: duplicate key {self.key}={rid!r}")
|
|
70
|
+
seen.add(rid)
|
|
71
|
+
order.append(rid)
|
|
72
|
+
table.append(Record(rid, fields))
|
|
73
|
+
self._order = order
|
|
74
|
+
self._remember_fingerprint()
|
|
75
|
+
return table
|
|
76
|
+
|
|
77
|
+
def write(self, table: Table) -> None:
|
|
78
|
+
by_rid = {r.rid: r for r in table}
|
|
79
|
+
new_rows = []
|
|
80
|
+
for rid in self._order:
|
|
81
|
+
record = by_rid.get(rid)
|
|
82
|
+
if record is None:
|
|
83
|
+
continue
|
|
84
|
+
keys = self.fieldnames or list(record.fields.keys())
|
|
85
|
+
new_rows.append({k: record.fields[k].value for k in keys})
|
|
86
|
+
_set_path(self._raw_doc, self.row_path, new_rows)
|
|
87
|
+
|
|
88
|
+
def _write(fh: IO[str]) -> None:
|
|
89
|
+
json.dump(self._raw_doc, fh, indent=2)
|
|
90
|
+
fh.write("\n")
|
|
91
|
+
|
|
92
|
+
atomic_write(self.path, _write)
|
|
93
|
+
self._remember_fingerprint()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""JSON source backend: a file containing a list of flat objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import IO
|
|
7
|
+
|
|
8
|
+
from reflet.lens import Cell, Record, RowId, Table
|
|
9
|
+
from reflet.provenance import Provenance
|
|
10
|
+
from reflet.sources import SourceBackend, atomic_write
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonSource(SourceBackend):
|
|
14
|
+
def read(self) -> Table:
|
|
15
|
+
with open(self.path, encoding="utf-8") as fh:
|
|
16
|
+
raw = json.load(fh)
|
|
17
|
+
if not isinstance(raw, list):
|
|
18
|
+
raise ValueError(f"{self.path}: expected a JSON array of objects")
|
|
19
|
+
if raw:
|
|
20
|
+
self.fieldnames = list(raw[0].keys())
|
|
21
|
+
|
|
22
|
+
table: Table = []
|
|
23
|
+
seen: set[RowId] = set()
|
|
24
|
+
order: list[RowId] = []
|
|
25
|
+
for i, raw_row in enumerate(raw):
|
|
26
|
+
fields = {k: Cell(v, Provenance(self.path, k)) for k, v in raw_row.items()}
|
|
27
|
+
rid: RowId = i
|
|
28
|
+
if self.key is not None:
|
|
29
|
+
if self.key not in fields:
|
|
30
|
+
raise ValueError(f"{self.path}: key field {self.key!r} missing on row {i}")
|
|
31
|
+
rid = fields[self.key].value
|
|
32
|
+
if rid in seen:
|
|
33
|
+
raise ValueError(f"{self.path}: duplicate key {self.key}={rid!r}")
|
|
34
|
+
seen.add(rid)
|
|
35
|
+
order.append(rid)
|
|
36
|
+
table.append(Record(rid, fields))
|
|
37
|
+
self._order = order
|
|
38
|
+
self._remember_fingerprint()
|
|
39
|
+
return table
|
|
40
|
+
|
|
41
|
+
def write(self, table: Table) -> None:
|
|
42
|
+
by_rid = {r.rid: r for r in table}
|
|
43
|
+
|
|
44
|
+
def _write(fh: IO[str]) -> None:
|
|
45
|
+
rows = []
|
|
46
|
+
for rid in self._order:
|
|
47
|
+
record = by_rid.get(rid)
|
|
48
|
+
if record is None:
|
|
49
|
+
continue
|
|
50
|
+
keys = self.fieldnames or list(record.fields.keys())
|
|
51
|
+
rows.append({k: record.fields[k].value for k in keys})
|
|
52
|
+
json.dump(rows, fh, indent=2)
|
|
53
|
+
fh.write("\n")
|
|
54
|
+
|
|
55
|
+
atomic_write(self.path, _write)
|
|
56
|
+
self._remember_fingerprint()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""SQLite source backend, via the stdlib `sqlite3` module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from reflet.lens import Cell, Record, RowId, Table
|
|
8
|
+
from reflet.provenance import Provenance
|
|
9
|
+
from reflet.sources import SourceBackend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _quote_identifier(identifier: str) -> str:
|
|
13
|
+
return '"' + identifier.replace('"', '""') + '"'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqliteSource(SourceBackend):
|
|
17
|
+
"""Reads/writes one table of a SQLite database file.
|
|
18
|
+
|
|
19
|
+
Write-back replaces the table's contents wholesale (delete + reinsert,
|
|
20
|
+
in one transaction) rather than diffing individual rows, same as the
|
|
21
|
+
CSV/JSON backends: only the columns seen on read are written back.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: str, table: str, key: str | None = None) -> None:
|
|
25
|
+
super().__init__(path, key)
|
|
26
|
+
self.table = table
|
|
27
|
+
|
|
28
|
+
def read(self) -> Table:
|
|
29
|
+
conn = sqlite3.connect(self.path)
|
|
30
|
+
try:
|
|
31
|
+
conn.row_factory = sqlite3.Row
|
|
32
|
+
cur = conn.execute(f"SELECT * FROM {_quote_identifier(self.table)}")
|
|
33
|
+
rows = cur.fetchall()
|
|
34
|
+
self.fieldnames = [d[0] for d in cur.description]
|
|
35
|
+
finally:
|
|
36
|
+
conn.close()
|
|
37
|
+
|
|
38
|
+
table: Table = []
|
|
39
|
+
seen: set[RowId] = set()
|
|
40
|
+
order: list[RowId] = []
|
|
41
|
+
source_name = f"{self.path}#{self.table}"
|
|
42
|
+
for i, raw_row in enumerate(rows):
|
|
43
|
+
fields = {
|
|
44
|
+
k: Cell(v, Provenance(source_name, k)) for k, v in dict(raw_row).items()
|
|
45
|
+
}
|
|
46
|
+
rid: RowId = i
|
|
47
|
+
if self.key is not None:
|
|
48
|
+
if self.key not in fields:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"{self.path}#{self.table}: key field {self.key!r} missing on row {i}"
|
|
51
|
+
)
|
|
52
|
+
rid = fields[self.key].value
|
|
53
|
+
if rid in seen:
|
|
54
|
+
raise ValueError(f"{self.path}#{self.table}: duplicate key {self.key}={rid!r}")
|
|
55
|
+
seen.add(rid)
|
|
56
|
+
order.append(rid)
|
|
57
|
+
table.append(Record(rid, fields))
|
|
58
|
+
self._order = order
|
|
59
|
+
self._remember_fingerprint()
|
|
60
|
+
return table
|
|
61
|
+
|
|
62
|
+
def write(self, table: Table) -> None:
|
|
63
|
+
by_rid = {r.rid: r for r in table}
|
|
64
|
+
cols = self.fieldnames or (list(table[0].fields.keys()) if table else [])
|
|
65
|
+
qtable = _quote_identifier(self.table)
|
|
66
|
+
qcols = ", ".join(_quote_identifier(c) for c in cols)
|
|
67
|
+
placeholders = ", ".join("?" for _ in cols)
|
|
68
|
+
|
|
69
|
+
conn = sqlite3.connect(self.path)
|
|
70
|
+
try:
|
|
71
|
+
with conn:
|
|
72
|
+
conn.execute(f"DELETE FROM {qtable}")
|
|
73
|
+
for rid in self._order:
|
|
74
|
+
record = by_rid.get(rid)
|
|
75
|
+
if record is None:
|
|
76
|
+
continue
|
|
77
|
+
conn.execute(
|
|
78
|
+
f"INSERT INTO {qtable} ({qcols}) VALUES ({placeholders})",
|
|
79
|
+
[record.fields[c].value for c in cols],
|
|
80
|
+
)
|
|
81
|
+
finally:
|
|
82
|
+
conn.close()
|
|
83
|
+
self._remember_fingerprint()
|