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.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. 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()