scistack-db 0.1.9.dev0__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.
scidb/__init__.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ SciStack: Scientific Data Versioning Framework
3
+
4
+ A lightweight database framework for scientific computing that provides:
5
+ - Type-safe serialization of numpy arrays, DataFrames, and custom types
6
+ - Automatic content-based versioning
7
+ - Flexible metadata-based addressing
8
+ - DuckDB storage for data and lineage (via SciDuck)
9
+
10
+ Example:
11
+ from scidb import configure_database, BaseVariable
12
+ import numpy as np
13
+
14
+ # Setup (single DuckDB file for data + lineage, auto-registers types)
15
+ db = configure_database("experiment.duckdb", ["subject", "session"])
16
+
17
+ class RawSignal(BaseVariable):
18
+ schema_version = 1
19
+
20
+ # Save/load
21
+ RawSignal.save(np.array([1, 2, 3]), subject=1, session="A")
22
+ raw = RawSignal.load(subject=1, session="A")
23
+ """
24
+
25
+ # From scifor (Layer 1)
26
+ from scifor import Col, PathInput, PathOutput, get_schema, set_schema
27
+
28
+ from .across_variants import AcrossVariants
29
+ from .artifact_stamp import read_artifact_stamp, stamp_artifact
30
+ from .colname import ColName
31
+ from .column_selection import ColumnSelection
32
+ from .constant import Constant, constant
33
+ from .database import configure_database, get_database, get_user_id
34
+ from .discover import (
35
+ DiscoveryResult,
36
+ ModuleError,
37
+ ModuleExports,
38
+ PackageResult,
39
+ discover_module,
40
+ scan_package,
41
+ scan_project,
42
+ )
43
+ from .each_of import EachOf
44
+ from .exceptions import (
45
+ AmbiguousParamError,
46
+ AmbiguousVersionError,
47
+ DatabaseLockedError,
48
+ DatabaseNotConfiguredError,
49
+ NotFoundError,
50
+ NotRegisteredError,
51
+ PipelineCycleError,
52
+ ReservedMetadataKeyError,
53
+ SciStackError,
54
+ )
55
+ from .exclusions import exclude_schema, include_schema, list_exclusions
56
+ from .filters import raw_sql, schema_key
57
+ from .fixed import Fixed
58
+
59
+ # Batch execution (Layer 2 — DB-backed, no lineage)
60
+ from .foreach import for_each
61
+ from .foreach_config import ForEachConfig
62
+ from .lineage_save import save
63
+ from .log import Log
64
+ from .merge import Merge
65
+ from .pipeline import Pipeline, PipelineBinding, Step, active_pipeline, scistack
66
+ from .state import (
67
+ check_combo_state,
68
+ check_multiple_nodes_state,
69
+ check_node_state,
70
+ check_pathinput_node_state,
71
+ )
72
+ from .variable import BaseVariable
73
+ from .variant import Variant, branch_param
74
+
75
+ __version__ = "0.1.0"
76
+
77
+ __all__ = [
78
+ # Core classes
79
+ "BaseVariable",
80
+ "Constant",
81
+ "constant",
82
+ # Discovery
83
+ "scan_project",
84
+ "scan_package",
85
+ "discover_module",
86
+ "DiscoveryResult",
87
+ "PackageResult",
88
+ "ModuleExports",
89
+ "ModuleError",
90
+ # Configuration
91
+ "configure_database",
92
+ "get_database",
93
+ "get_user_id",
94
+ # Save
95
+ "save",
96
+ # Pipeline node staleness
97
+ "check_combo_state",
98
+ "check_node_state",
99
+ "check_pathinput_node_state",
100
+ "check_multiple_nodes_state",
101
+ # Logging
102
+ "Log",
103
+ # Batch execution
104
+ "for_each",
105
+ "scistack",
106
+ "Pipeline",
107
+ "PipelineBinding",
108
+ "Step",
109
+ "active_pipeline",
110
+ "Fixed",
111
+ "Variant",
112
+ "AcrossVariants",
113
+ "stamp_artifact",
114
+ "read_artifact_stamp",
115
+ "branch_param",
116
+ "Merge",
117
+ "ColumnSelection",
118
+ "ColName",
119
+ "EachOf",
120
+ "ForEachConfig",
121
+ "PathInput",
122
+ "PathOutput",
123
+ # Standalone / DataFrame support
124
+ "Col",
125
+ "set_schema",
126
+ "get_schema",
127
+ # Filter utilities
128
+ "raw_sql",
129
+ "schema_key",
130
+ # Schema exclusions
131
+ "exclude_schema",
132
+ "include_schema",
133
+ "list_exclusions",
134
+ # Exceptions
135
+ "SciStackError",
136
+ "NotRegisteredError",
137
+ "NotFoundError",
138
+ "DatabaseNotConfiguredError",
139
+ "ReservedMetadataKeyError",
140
+ "AmbiguousVersionError",
141
+ "AmbiguousParamError",
142
+ "DatabaseLockedError",
143
+ "PipelineCycleError",
144
+ ]
@@ -0,0 +1,97 @@
1
+ """AcrossVariants pooling wrapper: opt IN to cross-variant aggregation."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class AcrossVariants:
7
+ """
8
+ Wrapper to deliberately pool ALL branch_param variants of an input into one
9
+ aggregated table, with each row's upstream branch_params attached as columns.
10
+
11
+ Aggregation-mode ``for_each`` (iterating a strict subset of the schema keys)
12
+ **auto-splits** by upstream branch_param signature by default: one call per
13
+ variant group, as if the user had written
14
+ ``EachOf(Variant(X, ...), Variant(X, ...))``. That default is right for
15
+ ordinary aggregation and for endpoint (``plot_``/``stat_``) functions — a
16
+ pooled t-test double-counts, and a figure mixing two filter settings is
17
+ wrong.
18
+
19
+ ``AcrossVariants`` is the explicit opt-out for **multiverse /
20
+ specification-curve analysis**: the one legitimate cross-variant use case,
21
+ where the analysis deliberately spans pipeline decisions to quantify
22
+ robustness. The pooled rows keep their variant identity — each namespaced
23
+ branch_param key (e.g. ``bandpass.low_hz``) becomes an ordinary DataFrame
24
+ column so the function can group by specification::
25
+
26
+ def robustness(df):
27
+ return df.groupby("bandpass.low_hz")["value"].mean()
28
+
29
+ for_each(robustness, {"df": AcrossVariants(Filtered)}, [Spec],
30
+ subject=["S01", "S02"])
31
+
32
+ Composition:
33
+
34
+ - May wrap a variable type, a ``Fixed``, or a ``Variant``
35
+ (``AcrossVariants(Variant(X, low_hz=20))`` pins some params and pools
36
+ whatever variants remain).
37
+ - ``AcrossVariants(ColumnSelection)`` is an ERROR: the column selection
38
+ would drop the attached branch_param columns, silently defeating the
39
+ pooling contract.
40
+ - ``AcrossVariants(Merge(...))`` is an ERROR (mirrors ``Variant`` /
41
+ ``Fixed``): pool per constituent instead.
42
+ - ``AcrossVariants(EachOf(...))`` is an ERROR: ``EachOf`` must stay the
43
+ outermost wrapper.
44
+
45
+ In full-iteration mode (all schema keys iterated) each combo already sees
46
+ exactly one variant, so pooling is meaningless: the input behaves as if
47
+ unwrapped and a warning is logged.
48
+ """
49
+
50
+ def __init__(self, var_type: Any):
51
+ from .column_selection import ColumnSelection
52
+ from .each_of import EachOf
53
+ from .merge import Merge
54
+
55
+ if isinstance(var_type, Merge):
56
+ raise TypeError(
57
+ "AcrossVariants cannot wrap a Merge. branch_params are namespaced "
58
+ "per producing function, so pooling must happen per constituent: "
59
+ "Merge(AcrossVariants(A), B)."
60
+ )
61
+ if isinstance(var_type, EachOf):
62
+ raise TypeError(
63
+ "AcrossVariants cannot wrap an EachOf. EachOf must stay the "
64
+ "outermost wrapper."
65
+ )
66
+ if isinstance(var_type, ColumnSelection):
67
+ raise TypeError(
68
+ "AcrossVariants cannot wrap a ColumnSelection: the column "
69
+ "selection would drop the attached branch_param columns. "
70
+ "Select columns inside your function instead."
71
+ )
72
+ if isinstance(var_type, AcrossVariants):
73
+ var_type = var_type.var_type # idempotent
74
+
75
+ self.var_type = var_type
76
+
77
+ def to_key(self) -> str:
78
+ """Return a canonical string for use as a version key.
79
+
80
+ A pooled run and an auto-split run of the same function must not
81
+ collide: this key lands in the ``__inputs`` config key (same mechanism
82
+ as ``Variant.to_key()``).
83
+ """
84
+ if hasattr(self.var_type, "to_key"):
85
+ inner_key = self.var_type.to_key()
86
+ elif isinstance(self.var_type, type):
87
+ inner_key = self.var_type.__name__
88
+ else:
89
+ inner_key = repr(self.var_type)
90
+ return f"AcrossVariants({inner_key})"
91
+
92
+ @property
93
+ def __name__(self) -> str:
94
+ """Display name for format_inputs and error messages."""
95
+ from .foreach import _input_type_name
96
+
97
+ return f"AcrossVariants({_input_type_name(self.var_type)})"
@@ -0,0 +1,301 @@
1
+ """Artifact provenance stamping (D4, endpoints-viz-and-stats-design.md).
2
+
3
+ Embeds a JSON provenance blob INSIDE endpoint artifacts — ``plot_`` figures
4
+ and ``stat_`` PDF reports — so a file found in a paper draft years later
5
+ traces back to its exact DB record. Embedded metadata travels with the file
6
+ (unlike a sidecar, which a copy/move/email can leave behind); the sidecar
7
+ ``<artifact>.provenance.json`` is strictly the fallback for formats that
8
+ cannot be embedded safely.
9
+
10
+ All stamping is dependency-free (stdlib only) and operates on the FILE, not
11
+ the renderer (``savefig(metadata=)`` cannot include the record_id — it only
12
+ exists after the save — and file-level stamping works identically for
13
+ MATLAB-rendered figures whose paths cross the bridge).
14
+
15
+ Formats:
16
+
17
+ - **PNG** — a ``tEXt`` chunk (keyword ``scidb:provenance``) inserted after
18
+ ``IHDR``; CRC via ``zlib.crc32``.
19
+ - **SVG** — a ``<metadata id="scidb-provenance">`` element inserted right
20
+ after the opening ``<svg …>`` tag.
21
+ - **PDF** — an *incremental update*: a new object carrying
22
+ ``/scidb_provenance <hex-encoded JSON>`` is appended together with a new
23
+ xref subsection and a trailer chaining to the previous one via ``/Prev``.
24
+ Every original byte stays untouched. Works for classic-xref producers
25
+ (reportlab — csv-stats' writer — and matplotlib both qualify); PDFs whose
26
+ trailer cannot be parsed (e.g. xref-stream producers) fall back to the
27
+ sidecar. The READER never depends on the update being well-formed: it is a
28
+ backwards byte-scan for the marker.
29
+
30
+ Failures never raise out of ``stamp_artifact`` — an artifact that renders but
31
+ cannot be stamped must not fail a pipeline. Every stamp, fallback, and
32
+ failure is logged.
33
+ """
34
+
35
+ import json
36
+ import re
37
+ import zlib
38
+ from pathlib import Path
39
+
40
+ from .log import Log
41
+
42
+ STAMP_VERSION = 1
43
+ _PNG_SIG = b"\x89PNG\r\n\x1a\n"
44
+ _PNG_KEYWORD = b"scidb:provenance"
45
+ _SVG_MARKER = '<metadata id="scidb-provenance">'
46
+ _PDF_KEY = b"/scidb_provenance"
47
+ _SIDECAR_SUFFIX = ".provenance.json"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # PNG
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def _png_chunks(data: bytes):
56
+ """Yield (offset, length, type, data_bytes) for each chunk."""
57
+ pos = len(_PNG_SIG)
58
+ while pos + 8 <= len(data):
59
+ length = int.from_bytes(data[pos : pos + 4], "big")
60
+ ctype = data[pos + 4 : pos + 8]
61
+ yield pos, length, ctype, data[pos + 8 : pos + 8 + length]
62
+ pos += 12 + length
63
+
64
+
65
+ def _strip_png_stamp(data: bytes) -> bytes:
66
+ """Remove any existing scidb tEXt chunk (idempotent re-stamping)."""
67
+ for pos, length, ctype, cdata in _png_chunks(data):
68
+ if ctype == b"tEXt" and cdata.startswith(_PNG_KEYWORD + b"\x00"):
69
+ return data[:pos] + data[pos + 12 + length :]
70
+ return data
71
+
72
+
73
+ def _stamp_png(data: bytes, payload: bytes) -> "bytes | None":
74
+ if not data.startswith(_PNG_SIG) or data[12:16] != b"IHDR":
75
+ return None
76
+ data = _strip_png_stamp(data)
77
+ ihdr_len = int.from_bytes(data[8:12], "big")
78
+ insert_at = len(_PNG_SIG) + 12 + ihdr_len # after IHDR incl. its CRC
79
+ chunk_data = _PNG_KEYWORD + b"\x00" + payload
80
+ chunk = (
81
+ len(chunk_data).to_bytes(4, "big")
82
+ + b"tEXt"
83
+ + chunk_data
84
+ + zlib.crc32(b"tEXt" + chunk_data).to_bytes(4, "big")
85
+ )
86
+ return data[:insert_at] + chunk + data[insert_at:]
87
+
88
+
89
+ def _read_png(data: bytes) -> "str | None":
90
+ if not data.startswith(_PNG_SIG):
91
+ return None
92
+ for _pos, _length, ctype, cdata in _png_chunks(data):
93
+ if ctype == b"tEXt" and cdata.startswith(_PNG_KEYWORD + b"\x00"):
94
+ return cdata[len(_PNG_KEYWORD) + 1 :].decode("latin-1")
95
+ return None
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # SVG
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def _xml_escape(s: str) -> str:
104
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
105
+
106
+
107
+ def _xml_unescape(s: str) -> str:
108
+ return s.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
109
+
110
+
111
+ def _stamp_svg(data: bytes, payload: bytes) -> "bytes | None":
112
+ try:
113
+ text = data.decode("utf-8")
114
+ except UnicodeDecodeError:
115
+ return None
116
+ # Strip an existing stamp element (idempotent re-stamping).
117
+ start = text.find(_SVG_MARKER)
118
+ if start != -1:
119
+ end = text.find("</metadata>", start)
120
+ if end != -1:
121
+ text = text[:start] + text[end + len("</metadata>") :]
122
+ svg_open = text.find("<svg")
123
+ if svg_open == -1:
124
+ return None
125
+ tag_end = text.find(">", svg_open)
126
+ if tag_end == -1 or text[tag_end - 1] == "/": # self-closing root: no content
127
+ return None
128
+ element = _SVG_MARKER + _xml_escape(payload.decode("ascii")) + "</metadata>"
129
+ return (text[: tag_end + 1] + element + text[tag_end + 1 :]).encode("utf-8")
130
+
131
+
132
+ def _read_svg(data: bytes) -> "str | None":
133
+ try:
134
+ text = data.decode("utf-8")
135
+ except UnicodeDecodeError:
136
+ return None
137
+ start = text.find(_SVG_MARKER)
138
+ if start == -1:
139
+ return None
140
+ start += len(_SVG_MARKER)
141
+ end = text.find("</metadata>", start)
142
+ if end == -1:
143
+ return None
144
+ return _xml_unescape(text[start:end])
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # PDF (incremental update; classic-xref producers)
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ def _stamp_pdf(data: bytes, payload: bytes) -> "bytes | None":
153
+ if not data.startswith(b"%PDF"):
154
+ return None
155
+ sx = data.rfind(b"startxref")
156
+ if sx == -1:
157
+ return None
158
+ m = re.match(rb"\s*(\d+)", data[sx + len(b"startxref") :])
159
+ if m is None:
160
+ return None
161
+ prev_xref = int(m.group(1))
162
+ # Classic trailer dict (reportlab / matplotlib). No trailer keyword →
163
+ # xref-stream PDF → caller falls back to the sidecar.
164
+ tidx = data.rfind(b"trailer", 0, sx)
165
+ if tidx == -1:
166
+ return None
167
+ tdict = data[tidx:sx]
168
+ root_m = re.search(rb"/Root\s+(\d+)\s+(\d+)\s+R", tdict)
169
+ size_m = re.search(rb"/Size\s+(\d+)", tdict)
170
+ if root_m is None or size_m is None:
171
+ return None
172
+ new_obj = int(size_m.group(1)) # /Size == next free object number
173
+
174
+ out = data if data.endswith(b"\n") else data + b"\n"
175
+ obj_offset = len(out)
176
+ out += b"%d 0 obj\n<< %s <%s> >>\nendobj\n" % (
177
+ new_obj,
178
+ _PDF_KEY,
179
+ payload.hex().encode("ascii"),
180
+ )
181
+ xref_offset = len(out)
182
+ out += b"xref\n%d 1\n%010d 00000 n \n" % (new_obj, obj_offset)
183
+ trailer = b"trailer\n<< /Size %d /Root %s %s R /Prev %d /ScidbProv %d 0 R >>\n" % (
184
+ new_obj + 1,
185
+ root_m.group(1),
186
+ root_m.group(2),
187
+ prev_xref,
188
+ new_obj,
189
+ )
190
+ out += trailer
191
+ out += b"startxref\n%d\n%%%%EOF\n" % xref_offset
192
+ return out
193
+
194
+
195
+ def _read_pdf(data: bytes) -> "str | None":
196
+ # Backwards byte-scan: independent of xref flavor and update validity.
197
+ idx = data.rfind(_PDF_KEY + b" <")
198
+ if idx == -1:
199
+ return None
200
+ start = idx + len(_PDF_KEY) + 2
201
+ end = data.find(b">", start)
202
+ if end == -1:
203
+ return None
204
+ try:
205
+ return bytes.fromhex(data[start:end].decode("ascii")).decode("ascii")
206
+ except ValueError:
207
+ return None
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Public API
212
+ # ---------------------------------------------------------------------------
213
+
214
+ _STAMPERS = {".png": _stamp_png, ".svg": _stamp_svg, ".pdf": _stamp_pdf}
215
+ _READERS = {".png": _read_png, ".svg": _read_svg, ".pdf": _read_pdf}
216
+
217
+
218
+ def _sidecar_path(path: Path) -> Path:
219
+ return path.with_name(path.name + _SIDECAR_SUFFIX)
220
+
221
+
222
+ def stamp_artifact(path: "str | Path", blob: dict) -> bool:
223
+ """Embed ``blob`` (JSON) into the artifact at ``path``.
224
+
225
+ Returns True when embedded IN the file; False when the sidecar fallback
226
+ was used or the file is missing. Never raises: a figure that renders but
227
+ cannot be stamped must not fail the pipeline.
228
+ """
229
+ p = Path(path)
230
+ payload = json.dumps(blob, sort_keys=True, ensure_ascii=True).encode("ascii")
231
+ if not p.is_file():
232
+ Log.warn(f"[artifact-stamp] {p}: file not found — nothing stamped")
233
+ return False
234
+
235
+ suffix = p.suffix.lower()
236
+ stamper = _STAMPERS.get(suffix)
237
+ reason = f"unsupported format {suffix!r}"
238
+ if stamper is not None:
239
+ try:
240
+ data = p.read_bytes()
241
+ new = stamper(data, payload)
242
+ if new is not None:
243
+ p.write_bytes(new)
244
+ Log.info(
245
+ f"[artifact-stamp] {p.name}: embedded provenance "
246
+ f"({suffix}, {len(payload)} bytes)"
247
+ )
248
+ return True
249
+ reason = f"{suffix} structure not recognized (e.g. xref-stream PDF)"
250
+ except Exception as exc: # never fail the pipeline over a stamp
251
+ reason = f"{type(exc).__name__}: {exc}"
252
+
253
+ try:
254
+ _sidecar_path(p).write_text(payload.decode("ascii"))
255
+ Log.warn(
256
+ f"[artifact-stamp] {p.name}: could not embed ({reason}) — "
257
+ f"wrote sidecar {_sidecar_path(p).name}"
258
+ )
259
+ except Exception as exc:
260
+ Log.warn(
261
+ f"[artifact-stamp] {p.name}: could not embed ({reason}) and "
262
+ f"sidecar write failed ({type(exc).__name__}: {exc})"
263
+ )
264
+ return False
265
+
266
+
267
+ def read_artifact_stamp(path: "str | Path") -> "dict | None":
268
+ """Read the provenance blob from an artifact (embedded or sidecar).
269
+
270
+ Returns the parsed dict, or None when no stamp is found.
271
+ """
272
+ p = Path(path)
273
+ raw: str | None = None
274
+ if p.is_file():
275
+ reader = _READERS.get(p.suffix.lower())
276
+ try:
277
+ data = p.read_bytes()
278
+ if reader is not None:
279
+ raw = reader(data)
280
+ else:
281
+ # Unknown extension: probe all formats by content.
282
+ for probe in (_read_png, _read_svg, _read_pdf):
283
+ raw = probe(data)
284
+ if raw is not None:
285
+ break
286
+ except Exception:
287
+ raw = None
288
+ if raw is None:
289
+ sidecar = _sidecar_path(p)
290
+ if sidecar.is_file():
291
+ try:
292
+ raw = sidecar.read_text()
293
+ except Exception:
294
+ return None
295
+ if raw is None:
296
+ return None
297
+ try:
298
+ parsed = json.loads(raw)
299
+ except (ValueError, TypeError):
300
+ return None
301
+ return parsed if isinstance(parsed, dict) else None
scidb/colname.py ADDED
@@ -0,0 +1,57 @@
1
+ """ColName wrapper — resolves to the single data column name of a Variable type."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ColName:
7
+ """
8
+ Marker that resolves to the single data column name of a DB-backed variable.
9
+
10
+ Use this when a function needs to know the name of the data column
11
+ but the function itself should stay framework-agnostic.
12
+
13
+ Two forms:
14
+
15
+ 1. ``ColName(MyVar)`` — *static*. At for_each time it is replaced by the
16
+ string name of the single data column for that variable type. Raises
17
+ ValueError if the variable has 0 or 2+ data columns.
18
+
19
+ 2. ``ColName()`` — *deferred*. Resolves per-column inside a ``for_columns``
20
+ iteration to the name of the column currently being fed to the function.
21
+ Requires at least one iterate input (``MyVar.for_columns()``); using it
22
+ without one is an error.
23
+
24
+ Example (static):
25
+ for_each(
26
+ analyze,
27
+ inputs={"table": MyVar, "col_name": ColName(MyVar)},
28
+ outputs=[Result],
29
+ subject=[1, 2, 3],
30
+ )
31
+
32
+ # The function is pure — no framework imports:
33
+ def analyze(table, col_name):
34
+ return table[col_name].mean()
35
+
36
+ Example (deferred, current for_columns column):
37
+ for_each(
38
+ analyze,
39
+ inputs={"df": MyVar.for_columns(), "col_name": ColName()},
40
+ outputs=[Result],
41
+ subject=[],
42
+ )
43
+ """
44
+
45
+ def __init__(self, var_type: Any = None):
46
+ """
47
+ Args:
48
+ var_type: The variable type (class) whose data column name will be
49
+ resolved (static form). Omit it for the deferred form, which
50
+ resolves to the current ``for_columns`` column.
51
+ """
52
+ self.var_type = var_type
53
+
54
+ @property
55
+ def is_deferred(self) -> bool:
56
+ """True for the no-arg form (resolves to the current for_columns column)."""
57
+ return self.var_type is None