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 +144 -0
- scidb/across_variants.py +97 -0
- scidb/artifact_stamp.py +301 -0
- scidb/colname.py +57 -0
- scidb/column_selection.py +166 -0
- scidb/constant.py +295 -0
- scidb/csv_export.py +272 -0
- scidb/database.py +4345 -0
- scidb/discover.py +449 -0
- scidb/each_of.py +26 -0
- scidb/exceptions.py +81 -0
- scidb/exclusions.py +405 -0
- scidb/filters.py +1225 -0
- scidb/fixed.py +55 -0
- scidb/foreach.py +5009 -0
- scidb/foreach_config.py +197 -0
- scidb/hashing.py +9 -0
- scidb/inspect/__init__.py +63 -0
- scidb/inspect/api.py +1088 -0
- scidb/inspect/cli.py +819 -0
- scidb/inspect/graph.py +422 -0
- scidb/inspect/mutate.py +157 -0
- scidb/inspect/pick.py +81 -0
- scidb/inspect/render.py +732 -0
- scidb/inspect/report.py +559 -0
- scidb/lineage_save.py +30 -0
- scidb/log.py +14 -0
- scidb/merge.py +94 -0
- scidb/paths.py +9 -0
- scidb/pipeline.py +1301 -0
- scidb/provenance.py +412 -0
- scidb/provenance_query.py +1102 -0
- scidb/provenance_save.py +737 -0
- scidb/query.py +132 -0
- scidb/state.py +659 -0
- scidb/variable.py +815 -0
- scidb/variables/__init__.py +1 -0
- scidb/variant.py +153 -0
- scistack_db-0.1.9.dev0.dist-info/METADATA +128 -0
- scistack_db-0.1.9.dev0.dist-info/RECORD +42 -0
- scistack_db-0.1.9.dev0.dist-info/WHEEL +4 -0
- scistack_db-0.1.9.dev0.dist-info/entry_points.txt +2 -0
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
|
+
]
|
scidb/across_variants.py
ADDED
|
@@ -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)})"
|
scidb/artifact_stamp.py
ADDED
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _xml_unescape(s: str) -> str:
|
|
108
|
+
return s.replace("<", "<").replace(">", ">").replace("&", "&")
|
|
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
|