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.
@@ -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()