equiframe 0.12.4__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.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Reproducible synthetic EDA; standalone, no migration or detector training."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from equiframe.eda import Conversion, EdaConfig, NumericRule, Source, analyze, read_result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
13
|
+
parser.add_argument("--work-dir", type=Path, required=True)
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
args.work_dir.mkdir(parents=True, exist_ok=False)
|
|
16
|
+
inputs = args.work_dir / "input"
|
|
17
|
+
inputs.mkdir()
|
|
18
|
+
path = inputs / "sales.csv"
|
|
19
|
+
with path.open("x", encoding="utf8", newline="") as stream:
|
|
20
|
+
writer = csv.writer(stream)
|
|
21
|
+
writer.writerow(["id", "amount", "currency"])
|
|
22
|
+
writer.writerows(
|
|
23
|
+
(f"{i:03}", f"{v}.00", "USD") for i, v in enumerate([1, 2, 3, 4, 5, 6, 7, 100], 1)
|
|
24
|
+
)
|
|
25
|
+
fingerprint = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
26
|
+
result = analyze(
|
|
27
|
+
Source(
|
|
28
|
+
path=str(path),
|
|
29
|
+
format="csv",
|
|
30
|
+
snapshot_id="synthetic-sales-v1",
|
|
31
|
+
expected_sha256=fingerprint,
|
|
32
|
+
),
|
|
33
|
+
config=EdaConfig(
|
|
34
|
+
charts=True,
|
|
35
|
+
identifiers=("id",),
|
|
36
|
+
conversions=(Conversion(column="amount", target="money", kind="decimal", scale=2),),
|
|
37
|
+
numeric=(
|
|
38
|
+
NumericRule(
|
|
39
|
+
column="money", groups=("currency",), monetary=True, currency_column="currency"
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
output=args.work_dir / "eda",
|
|
44
|
+
)
|
|
45
|
+
if (
|
|
46
|
+
result.state != "completed"
|
|
47
|
+
or result.rows != 8
|
|
48
|
+
or read_result(args.work_dir / "eda") != result
|
|
49
|
+
):
|
|
50
|
+
raise RuntimeError("synthetic EDA incomplete or inconsistent")
|
|
51
|
+
if result.numeric[0]["groups"][0]["outliers"]["count"] != 1:
|
|
52
|
+
raise RuntimeError("independent expected outlier count differs")
|
|
53
|
+
if hashlib.sha256(path.read_bytes()).hexdigest() != fingerprint:
|
|
54
|
+
raise RuntimeError("input changed")
|
|
55
|
+
print("PASS: eight synthetic rows, exact Decimal, one investigation candidate; input unchanged")
|
|
56
|
+
print(args.work_dir / "eda/report.html")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Synthetic installed-package smoke: reports and empty outputs never grant approval.
|
|
2
|
+
|
|
3
|
+
Requires the identified private wheel with the arrow extra. Run this copied file
|
|
4
|
+
outside the repository. Every case uses a new child directory; no prior evidence
|
|
5
|
+
is overwritten. Exit zero means the expected gate behavior was observed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pyarrow as pa
|
|
16
|
+
|
|
17
|
+
import equiframe as ef
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def reject_truth_value(value: object) -> None:
|
|
21
|
+
"""Treat failure to reject implicit approval as a broken example, even with -O."""
|
|
22
|
+
try:
|
|
23
|
+
bool(value)
|
|
24
|
+
except TypeError as error:
|
|
25
|
+
if "has no truth value" not in str(error):
|
|
26
|
+
raise
|
|
27
|
+
else:
|
|
28
|
+
raise RuntimeError(f"{type(value).__name__} incorrectly accepted a truth test")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(work_dir: Path) -> list[dict[str, str | bool | int]]:
|
|
32
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
expected = pa.table({"id": ["one", "two"], "quantity": [10, 20]})
|
|
34
|
+
observations: list[dict[str, str | bool | int]] = []
|
|
35
|
+
for case in ("equivalent", "different", "not_evaluable", "empty"):
|
|
36
|
+
reference = expected.slice(0, 0) if case == "empty" else expected
|
|
37
|
+
candidate = (
|
|
38
|
+
pa.table({"id": ["one", "two"], "quantity": [10, 21]})
|
|
39
|
+
if case == "different"
|
|
40
|
+
else reference
|
|
41
|
+
)
|
|
42
|
+
directory = Path(tempfile.mkdtemp(prefix=case + "-", dir=work_dir))
|
|
43
|
+
result = ef.diff(
|
|
44
|
+
candidate,
|
|
45
|
+
reference,
|
|
46
|
+
keys=["id"],
|
|
47
|
+
options=ef.ComparisonOptions(max_work=1) if case == "not_evaluable" else None,
|
|
48
|
+
work_dir=directory / "comparison",
|
|
49
|
+
)
|
|
50
|
+
report = ef.write_comparison_report(result, directory / "report")
|
|
51
|
+
reject_truth_value(result)
|
|
52
|
+
reject_truth_value(report)
|
|
53
|
+
wanted_state = "equivalent" if case == "empty" else case
|
|
54
|
+
if result.state != wanted_state or report.state != result.state:
|
|
55
|
+
raise RuntimeError("comparison/report state differs from the synthetic expectation")
|
|
56
|
+
try:
|
|
57
|
+
ef.assert_equivalent(result)
|
|
58
|
+
except ef.EquivalenceDifferentError:
|
|
59
|
+
gate = "different"
|
|
60
|
+
except ef.EquivalenceNotEvaluableError:
|
|
61
|
+
gate = "not_evaluable"
|
|
62
|
+
else:
|
|
63
|
+
gate = "equivalent"
|
|
64
|
+
if gate != wanted_state:
|
|
65
|
+
raise RuntimeError("the explicit assertion did not preserve the comparison verdict")
|
|
66
|
+
# Emptiness is legitimate only for this captured-output contract. It does
|
|
67
|
+
# not establish a business requirement that any records must exist.
|
|
68
|
+
observations.append(
|
|
69
|
+
{
|
|
70
|
+
"case": case,
|
|
71
|
+
"state": result.state,
|
|
72
|
+
"complete": result.complete,
|
|
73
|
+
"candidate_rows": candidate.num_rows,
|
|
74
|
+
"explicit_gate": gate,
|
|
75
|
+
"report_generated": True,
|
|
76
|
+
"implicit_approval_rejected": True,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
for state in ("passed", "failed", "not_evaluable", "not_requested", "pending_review"):
|
|
80
|
+
reject_truth_value(ef.VerificationCheck(state=state, reason="Synthetic gate example"))
|
|
81
|
+
reject_truth_value(ef.CompilationCheck(engine="synthetic", supported=True))
|
|
82
|
+
reject_truth_value(ef.DetectorScores(state="not_estimable", reason="No input support"))
|
|
83
|
+
return observations
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
88
|
+
parser.add_argument("--work-dir", type=Path, required=True)
|
|
89
|
+
args = parser.parse_args()
|
|
90
|
+
print(json.dumps(run(args.work_dir), sort_keys=True, indent=2))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Small trusted SQLite producer for EquiFrame's existing SnapshotClient port.
|
|
2
|
+
|
|
3
|
+
This external example is deliberately not an equiframe module, general DB-API
|
|
4
|
+
adapter, remote data service or SQL sandbox. See docs/sqlite-snapshots.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from decimal import Decimal
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
import pyarrow as pa
|
|
22
|
+
|
|
23
|
+
import equiframe as ef
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SQLiteQuery:
|
|
28
|
+
"""Trusted SELECT/WITH and its independently declared output Arrow schema.
|
|
29
|
+
|
|
30
|
+
Positional parameters are bound by SQLite, never interpolated. Only int64,
|
|
31
|
+
string and decimal128 with nonnegative scale are supported by this example.
|
|
32
|
+
Decimal input must be exact decimal TEXT, never SQLite REAL or NUMERIC.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
sql: str
|
|
37
|
+
schema: pa.Schema
|
|
38
|
+
parameters: tuple[str | int | float | bytes | None, ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class SQLiteSnapshotClient:
|
|
43
|
+
"""Detached immutable bytes; no live connection or database re-query on fetch."""
|
|
44
|
+
|
|
45
|
+
manifest: ef.SnapshotManifest
|
|
46
|
+
_payloads: Mapping[str, bytes]
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
object.__setattr__(self, "_payloads", MappingProxyType(dict(self._payloads)))
|
|
50
|
+
|
|
51
|
+
def get_snapshot(self, snapshot_id: str) -> ef.SnapshotManifest:
|
|
52
|
+
if snapshot_id != self.manifest.snapshot_id:
|
|
53
|
+
raise LookupError("unknown snapshot identity; request the captured ID")
|
|
54
|
+
return self.manifest
|
|
55
|
+
|
|
56
|
+
def download_artifact(self, ref: ef.ArtifactRef, destination: Path) -> None:
|
|
57
|
+
if not any(table.artifact == ref for table in self.manifest.tables):
|
|
58
|
+
raise LookupError("artifact is not part of this captured snapshot")
|
|
59
|
+
# The URI is an identifier, not a network location this client visits.
|
|
60
|
+
with destination.open("xb") as stream:
|
|
61
|
+
stream.write(self._payloads[ref.uri])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _logical_type(data_type: pa.DataType) -> ef.LogicalType:
|
|
65
|
+
if pa.types.is_int64(data_type):
|
|
66
|
+
return ef.IntegerType(bits=64, signed=True)
|
|
67
|
+
if pa.types.is_string(data_type):
|
|
68
|
+
return ef.StringType()
|
|
69
|
+
if pa.types.is_decimal128(data_type) and 0 <= data_type.scale <= data_type.precision:
|
|
70
|
+
return ef.DecimalType(precision=data_type.precision, scale=data_type.scale)
|
|
71
|
+
raise ValueError("SQLite example supports only int64, string and decimal128(p, 0..p)")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _decode(value: object, field: pa.Field) -> object:
|
|
75
|
+
if value is None:
|
|
76
|
+
if not field.nullable:
|
|
77
|
+
raise ValueError("SQLite returned NULL for a nonnullable declared field")
|
|
78
|
+
return None
|
|
79
|
+
if pa.types.is_int64(field.type):
|
|
80
|
+
if type(value) is not int:
|
|
81
|
+
raise ValueError("declared int64 requires SQLite INTEGER; no implicit cast")
|
|
82
|
+
return value
|
|
83
|
+
if pa.types.is_string(field.type):
|
|
84
|
+
if type(value) is not str:
|
|
85
|
+
raise ValueError("declared string requires SQLite TEXT; no implicit cast")
|
|
86
|
+
return value
|
|
87
|
+
if type(value) is not str or re.fullmatch(r"-?[0-9]+(?:\.[0-9]+)?", value) is None:
|
|
88
|
+
raise ValueError("declared decimal requires plain exact decimal TEXT, never REAL")
|
|
89
|
+
result = Decimal(value)
|
|
90
|
+
if result.is_zero() and result.is_signed():
|
|
91
|
+
raise ValueError("Arrow decimal cannot preserve negative zero; revise the source schema")
|
|
92
|
+
# pa.array(..., safe=True) below rejects overflow and any nonzero digits
|
|
93
|
+
# beyond the declared scale. No float intermediary or rounding is used.
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_query(
|
|
98
|
+
connection: sqlite3.Connection,
|
|
99
|
+
query: SQLiteQuery,
|
|
100
|
+
*,
|
|
101
|
+
max_rows_per_table: int,
|
|
102
|
+
batch_rows: int,
|
|
103
|
+
) -> pa.Table:
|
|
104
|
+
cursor = connection.cursor()
|
|
105
|
+
try:
|
|
106
|
+
cursor.execute(query.sql, query.parameters)
|
|
107
|
+
if cursor.description is None or tuple(item[0] for item in cursor.description) != tuple(
|
|
108
|
+
query.schema.names
|
|
109
|
+
):
|
|
110
|
+
raise ValueError("query names/order differ from the declared Arrow schema")
|
|
111
|
+
batches = []
|
|
112
|
+
count = 0
|
|
113
|
+
while rows := cursor.fetchmany(batch_rows):
|
|
114
|
+
count += len(rows)
|
|
115
|
+
if count > max_rows_per_table:
|
|
116
|
+
raise ef.ResourceLimitError("SQLite capture exceeds its per-table row budget")
|
|
117
|
+
arrays = [
|
|
118
|
+
pa.array([_decode(row[index], field) for row in rows], type=field.type, safe=True)
|
|
119
|
+
for index, field in enumerate(query.schema)
|
|
120
|
+
]
|
|
121
|
+
batches.append(pa.RecordBatch.from_arrays(arrays, schema=query.schema))
|
|
122
|
+
if not connection.in_transaction:
|
|
123
|
+
raise RuntimeError("the native SQLite read transaction ended before capture finished")
|
|
124
|
+
return pa.Table.from_batches(batches, schema=query.schema)
|
|
125
|
+
finally:
|
|
126
|
+
cursor.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def capture_sqlite_snapshot(
|
|
130
|
+
database: Path,
|
|
131
|
+
*,
|
|
132
|
+
snapshot_id: str,
|
|
133
|
+
queries: tuple[SQLiteQuery, ...],
|
|
134
|
+
domain_schema_revision: str,
|
|
135
|
+
extraction_revision: str,
|
|
136
|
+
provenance: tuple[str, ...],
|
|
137
|
+
max_rows_per_table: int = 10_000,
|
|
138
|
+
max_payload_bytes: int = 16 * 1024 * 1024,
|
|
139
|
+
batch_rows: int = 256,
|
|
140
|
+
lock_timeout_seconds: float = 5.0,
|
|
141
|
+
) -> SQLiteSnapshotClient:
|
|
142
|
+
"""Capture every query in one owned read transaction; publish only on success.
|
|
143
|
+
|
|
144
|
+
The database must already exist. No caller connection is borrowed, committed
|
|
145
|
+
or closed. The snapshot is anchored by the first native read, shared across
|
|
146
|
+
queries, then detached. All cursors and the connection close on every path.
|
|
147
|
+
Budget checks are cooperative, not hard memory, query-time or process limits.
|
|
148
|
+
"""
|
|
149
|
+
for value in (max_rows_per_table, max_payload_bytes, batch_rows):
|
|
150
|
+
if type(value) is not int or value <= 0:
|
|
151
|
+
raise ValueError("capture budgets and batch_rows must be positive integers")
|
|
152
|
+
if (
|
|
153
|
+
type(lock_timeout_seconds) not in (int, float)
|
|
154
|
+
or not math.isfinite(lock_timeout_seconds)
|
|
155
|
+
or lock_timeout_seconds < 0
|
|
156
|
+
):
|
|
157
|
+
raise ValueError("lock_timeout_seconds must be finite and nonnegative")
|
|
158
|
+
if not queries or len({query.name for query in queries}) != len(queries):
|
|
159
|
+
raise ValueError("capture requires nonempty queries with unique table names")
|
|
160
|
+
declarations = []
|
|
161
|
+
# Validate the complete declaration before opening a connection or doing SQL.
|
|
162
|
+
for query in queries:
|
|
163
|
+
if re.match(r"\s*(?:SELECT|WITH)\b", query.sql, re.IGNORECASE) is None:
|
|
164
|
+
raise ValueError("provide one trusted SELECT/WITH query per table")
|
|
165
|
+
if not query.schema.names or len(set(query.schema.names)) != len(query.schema.names):
|
|
166
|
+
raise ValueError("each Arrow schema requires unique nonempty column names")
|
|
167
|
+
if query.schema.metadata is not None or any(field.metadata for field in query.schema):
|
|
168
|
+
raise ValueError("this example does not interpret Arrow schema/field metadata")
|
|
169
|
+
declarations.append(
|
|
170
|
+
tuple(
|
|
171
|
+
ef.SnapshotColumn(
|
|
172
|
+
name=field.name,
|
|
173
|
+
physical_type=str(field.type),
|
|
174
|
+
logical_type=_logical_type(field.type),
|
|
175
|
+
nullable=field.nullable,
|
|
176
|
+
)
|
|
177
|
+
for field in query.schema
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
# An opaque per-capture namespace avoids identifying two captures as the same
|
|
181
|
+
# artifact when their human labels are reused. It is not authentication.
|
|
182
|
+
namespace = uuid4().hex
|
|
183
|
+
refs = tuple(
|
|
184
|
+
ef.ArtifactRef(
|
|
185
|
+
schema_version="1.0",
|
|
186
|
+
uri=f"https://example.invalid/sqlite/{namespace}/{quote(query.name, safe='')}.arrow",
|
|
187
|
+
sha256="0" * 64,
|
|
188
|
+
format="arrow",
|
|
189
|
+
media_type="application/vnd.apache.arrow.file",
|
|
190
|
+
size_bytes=0,
|
|
191
|
+
)
|
|
192
|
+
for query in queries
|
|
193
|
+
)
|
|
194
|
+
declaration = ef.SnapshotManifest(
|
|
195
|
+
snapshot_id=snapshot_id,
|
|
196
|
+
domain_schema_revision=domain_schema_revision,
|
|
197
|
+
extraction_revision=extraction_revision,
|
|
198
|
+
provenance=provenance,
|
|
199
|
+
tables=tuple(
|
|
200
|
+
ef.SnapshotTable(name=query.name, artifact=ref, row_count=0, columns=columns)
|
|
201
|
+
for query, ref, columns in zip(queries, refs, declarations, strict=True)
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
payloads: dict[str, bytes] = {}
|
|
205
|
+
tables = []
|
|
206
|
+
total_bytes = 0
|
|
207
|
+
# URI mode=ro refuses a missing database rather than creating an empty one.
|
|
208
|
+
connection = sqlite3.connect(
|
|
209
|
+
database.resolve().as_uri() + "?mode=ro",
|
|
210
|
+
uri=True,
|
|
211
|
+
isolation_level=None,
|
|
212
|
+
timeout=lock_timeout_seconds,
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
connection.execute("BEGIN").close()
|
|
216
|
+
for query, table_declaration in zip(queries, declaration.tables, strict=True):
|
|
217
|
+
table = _read_query(
|
|
218
|
+
connection, query, max_rows_per_table=max_rows_per_table, batch_rows=batch_rows
|
|
219
|
+
)
|
|
220
|
+
sink = pa.BufferOutputStream()
|
|
221
|
+
with pa.ipc.new_file(sink, table.schema) as writer:
|
|
222
|
+
writer.write_table(table)
|
|
223
|
+
payload = sink.getvalue().to_pybytes()
|
|
224
|
+
total_bytes += len(payload)
|
|
225
|
+
if total_bytes > max_payload_bytes:
|
|
226
|
+
raise ef.ResourceLimitError(
|
|
227
|
+
"SQLite capture exceeds its total serialized-byte budget"
|
|
228
|
+
)
|
|
229
|
+
ref = table_declaration.artifact.model_copy(
|
|
230
|
+
update={"sha256": hashlib.sha256(payload).hexdigest(), "size_bytes": len(payload)}
|
|
231
|
+
)
|
|
232
|
+
tables.append(
|
|
233
|
+
table_declaration.model_copy(update={"artifact": ref, "row_count": len(table)})
|
|
234
|
+
)
|
|
235
|
+
payloads[ref.uri] = payload
|
|
236
|
+
finally:
|
|
237
|
+
try:
|
|
238
|
+
if connection.in_transaction:
|
|
239
|
+
connection.rollback()
|
|
240
|
+
finally:
|
|
241
|
+
connection.close()
|
|
242
|
+
# No manifest/client is exposed until ALL tables succeeded and SQLite closed.
|
|
243
|
+
manifest = ef.SnapshotManifest.model_validate(
|
|
244
|
+
{**declaration.model_dump(), "tables": tuple(tables)}
|
|
245
|
+
)
|
|
246
|
+
return SQLiteSnapshotClient(manifest, payloads)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Run a synthetic SQLite -> pinned snapshot -> native DuckDB -> comparison gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import pyarrow as pa
|
|
13
|
+
from sqlite_snapshot import SQLiteQuery, SQLiteSnapshotClient, capture_sqlite_snapshot
|
|
14
|
+
|
|
15
|
+
import equiframe as ef
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def declarations() -> tuple[SQLiteQuery, ...]:
|
|
19
|
+
return (
|
|
20
|
+
SQLiteQuery(
|
|
21
|
+
"accounts",
|
|
22
|
+
"SELECT account_id, label FROM accounts ORDER BY account_id",
|
|
23
|
+
pa.schema(
|
|
24
|
+
[pa.field("account_id", pa.int64(), nullable=False), pa.field("label", pa.string())]
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
SQLiteQuery(
|
|
28
|
+
"entries",
|
|
29
|
+
"SELECT id, account_id, amount, currency FROM entries WHERE cohort = ? ORDER BY id",
|
|
30
|
+
pa.schema(
|
|
31
|
+
[
|
|
32
|
+
pa.field("id", pa.string(), nullable=False),
|
|
33
|
+
pa.field("account_id", pa.int64(), nullable=False),
|
|
34
|
+
pa.field("amount", pa.decimal128(10, 2)),
|
|
35
|
+
pa.field("currency", pa.string(), nullable=False),
|
|
36
|
+
]
|
|
37
|
+
),
|
|
38
|
+
parameters=("synthetic",),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_synthetic_database(database: Path, *, discrepant: bool) -> None:
|
|
44
|
+
"""Write only this demo's new synthetic database; never reuse an existing one."""
|
|
45
|
+
with database.open("xb"):
|
|
46
|
+
pass
|
|
47
|
+
connection = sqlite3.connect(database)
|
|
48
|
+
try:
|
|
49
|
+
with connection:
|
|
50
|
+
connection.execute("CREATE TABLE accounts (account_id INTEGER, label TEXT)")
|
|
51
|
+
# TEXT affinity is intentional: SQLite REAL would lose decimal meaning.
|
|
52
|
+
connection.execute(
|
|
53
|
+
"CREATE TABLE entries "
|
|
54
|
+
"(id TEXT, account_id INTEGER, amount TEXT, currency TEXT, cohort TEXT)"
|
|
55
|
+
)
|
|
56
|
+
connection.executemany("INSERT INTO accounts VALUES (?, ?)", [(1, "Café"), (2, None)])
|
|
57
|
+
connection.executemany(
|
|
58
|
+
"INSERT INTO entries VALUES (?, ?, ?, ?, ?)",
|
|
59
|
+
[
|
|
60
|
+
("A", 1, "1.11" if discrepant else "1.10", "USD", "synthetic"),
|
|
61
|
+
("B", 1, None, "USD", "synthetic"),
|
|
62
|
+
("C", 2, "-0.01", "EUR", "synthetic"),
|
|
63
|
+
("excluded", 2, "99.99", "EUR", "other"),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
finally:
|
|
67
|
+
connection.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def expected_output() -> pa.Table:
|
|
71
|
+
"""Literal expectation authored separately; no query/source output is its oracle."""
|
|
72
|
+
return pa.table(
|
|
73
|
+
{
|
|
74
|
+
"id": pa.array(["A", "B", "C"], pa.string()),
|
|
75
|
+
"label": pa.array(["Café", "Café", None], pa.string()),
|
|
76
|
+
"amount": pa.array([Decimal("1.10"), None, Decimal("-0.01")], pa.decimal128(10, 2)),
|
|
77
|
+
"currency": pa.array(["USD", "USD", "EUR"], pa.string()),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def contract_for(manifest: ef.SnapshotManifest) -> ef.Contract:
|
|
83
|
+
def schema(columns: tuple[ef.SnapshotColumn, ...]) -> ef.TableSchema:
|
|
84
|
+
declarations = tuple(
|
|
85
|
+
ef.ColumnSpec(
|
|
86
|
+
name=column.name,
|
|
87
|
+
logical_type=column.logical_type,
|
|
88
|
+
nullable=column.nullable,
|
|
89
|
+
null_policy=ef.NullPolicy(representations=("null",)),
|
|
90
|
+
numeric_policy=(
|
|
91
|
+
ef.NumericPolicy(
|
|
92
|
+
comparison="exact",
|
|
93
|
+
unit=(
|
|
94
|
+
"currency_units"
|
|
95
|
+
if isinstance(column.logical_type, ef.DecimalType)
|
|
96
|
+
else "identifier"
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
if isinstance(column.logical_type, (ef.DecimalType, ef.IntegerType))
|
|
100
|
+
else None
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
for column in columns
|
|
104
|
+
)
|
|
105
|
+
return ef.bag_contract(declarations).spec.contract.output.schema_spec
|
|
106
|
+
|
|
107
|
+
output_columns = (
|
|
108
|
+
manifest.tables[1].columns[0],
|
|
109
|
+
manifest.tables[0].columns[1],
|
|
110
|
+
*manifest.tables[1].columns[2:],
|
|
111
|
+
)
|
|
112
|
+
return ef.Contract(
|
|
113
|
+
schema_version="1.0",
|
|
114
|
+
contract_id="sqlite.synthetic.join",
|
|
115
|
+
revision=1,
|
|
116
|
+
family="joins",
|
|
117
|
+
inputs=tuple(
|
|
118
|
+
ef.TableSpec(name=table.name, schema_spec=schema(table.columns))
|
|
119
|
+
for table in manifest.tables
|
|
120
|
+
),
|
|
121
|
+
output=ef.TableSpec(name="result", schema_spec=schema(output_columns)),
|
|
122
|
+
preconditions=(),
|
|
123
|
+
rules=(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class CorruptingTransport:
|
|
128
|
+
"""Synthetic functional failure: change one byte without changing its manifest."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, client: SQLiteSnapshotClient) -> None:
|
|
131
|
+
self.client = client
|
|
132
|
+
|
|
133
|
+
def get_snapshot(self, snapshot_id: str) -> ef.SnapshotManifest:
|
|
134
|
+
return self.client.get_snapshot(snapshot_id)
|
|
135
|
+
|
|
136
|
+
def download_artifact(self, ref: ef.ArtifactRef, destination: Path) -> None:
|
|
137
|
+
self.client.download_artifact(ref, destination)
|
|
138
|
+
with destination.open("r+b") as stream:
|
|
139
|
+
first = stream.read(1)
|
|
140
|
+
stream.seek(0)
|
|
141
|
+
stream.write(bytes([first[0] ^ 1]))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_demo(output: Path, case: Literal["good", "different", "corrupt"]) -> int:
|
|
145
|
+
"""Exit 0 only for an equivalent capture; 1 difference, 3 failed/incomplete."""
|
|
146
|
+
if case not in ("good", "different", "corrupt"):
|
|
147
|
+
raise ValueError("unknown demonstration case")
|
|
148
|
+
output.mkdir(parents=True, exist_ok=False)
|
|
149
|
+
database = output / "synthetic.sqlite"
|
|
150
|
+
create_synthetic_database(database, discrepant=case == "different")
|
|
151
|
+
client = capture_sqlite_snapshot(
|
|
152
|
+
database,
|
|
153
|
+
snapshot_id="synthetic.sqlite.001",
|
|
154
|
+
queries=declarations(),
|
|
155
|
+
domain_schema_revision="synthetic.accounts.1",
|
|
156
|
+
extraction_revision="sqlite.exact-text-decimal.1",
|
|
157
|
+
provenance=(
|
|
158
|
+
"Synthetic accounts and entries; no real service or DW acceptance",
|
|
159
|
+
f"SQLite {sqlite3.sqlite_version}; one owned native read transaction",
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
source = ef.SnapshotDataSource(
|
|
163
|
+
client.manifest.snapshot_id,
|
|
164
|
+
client=CorruptingTransport(client) if case == "corrupt" else client,
|
|
165
|
+
expected_domain_schema_revision="synthetic.accounts.1",
|
|
166
|
+
)
|
|
167
|
+
registry = ef.TransformationRegistry()
|
|
168
|
+
registry.register(
|
|
169
|
+
ef.DuckDBTransformation(
|
|
170
|
+
"join",
|
|
171
|
+
"SELECT e.id, a.label, e.amount, e.currency "
|
|
172
|
+
"FROM entries e LEFT JOIN accounts a USING (account_id)",
|
|
173
|
+
"sqlite-demo.join.1",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
contract = contract_for(source.manifest)
|
|
177
|
+
execution = ef.execute(
|
|
178
|
+
ef.ExecutionPlan(contract=contract, engine="duckdb", transformation_id="join"),
|
|
179
|
+
source=source,
|
|
180
|
+
registry=registry,
|
|
181
|
+
work_dir=output / "execution",
|
|
182
|
+
)
|
|
183
|
+
receipt = {
|
|
184
|
+
"case": case,
|
|
185
|
+
"snapshot_id": source.snapshot_id,
|
|
186
|
+
"snapshot_manifest_sha256": ef.snapshot_fingerprint(source.manifest),
|
|
187
|
+
"execution_state": execution.state,
|
|
188
|
+
"comparison_state": "not_evaluable",
|
|
189
|
+
}
|
|
190
|
+
code = 3
|
|
191
|
+
if execution.state == "completed":
|
|
192
|
+
comparison = ef.compare(
|
|
193
|
+
execution,
|
|
194
|
+
ef.ArrowInput(expected_output()),
|
|
195
|
+
contract=contract,
|
|
196
|
+
reference="expected",
|
|
197
|
+
work_dir=output / "comparison",
|
|
198
|
+
)
|
|
199
|
+
# Persist a redacted report BEFORE enforcing the gate.
|
|
200
|
+
ef.write_comparison_report(comparison, output / "report")
|
|
201
|
+
receipt["comparison_state"] = comparison.state
|
|
202
|
+
try:
|
|
203
|
+
ef.assert_equivalent(comparison)
|
|
204
|
+
except ef.EquivalenceDifferentError:
|
|
205
|
+
code = 1
|
|
206
|
+
except ef.EquivalenceNotEvaluableError:
|
|
207
|
+
code = 3
|
|
208
|
+
else:
|
|
209
|
+
code = 0
|
|
210
|
+
with (output / "receipt.json").open("x", encoding="utf-8") as stream:
|
|
211
|
+
json.dump({**receipt, "exit_code": code}, stream, indent=2)
|
|
212
|
+
stream.write("\n")
|
|
213
|
+
print(f"execution={execution.state}; comparison={receipt['comparison_state']}; exit={code}")
|
|
214
|
+
return code
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main() -> None:
|
|
218
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
219
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
220
|
+
parser.add_argument("--case", choices=("good", "different", "corrupt"), default="good")
|
|
221
|
+
args = parser.parse_args()
|
|
222
|
+
raise SystemExit(run_demo(args.output, args.case))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
main()
|