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
equiframe/eda/api.py ADDED
@@ -0,0 +1,535 @@
1
+ """One execution path for Python/CLI, immutable snapshots and independent evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import hashlib
7
+ import importlib
8
+ import importlib.metadata
9
+ import os
10
+ import platform
11
+ import sys
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable
15
+ from contextlib import suppress
16
+ from pathlib import Path
17
+ from typing import Any, Literal, cast
18
+ from uuid import uuid4
19
+
20
+ from equiframe import __version__
21
+ from equiframe._error_reporting import report_failure
22
+ from equiframe._report_io import _open_regular
23
+ from equiframe.errors import CapabilityError
24
+ from equiframe.evidence import canonical_bytes, load_json, parse_json, publish_json
25
+ from equiframe.execution_types import TableArtifact
26
+ from equiframe.local import local_path
27
+
28
+ from ._backend import Backend
29
+ from ._paths import manifest_member, ordinary_path
30
+ from .models import EdaConfig, EdaManifest, EdaResult, Source
31
+
32
+ LIMITS = (
33
+ "Diagnostics only: no equivalence, independent correctness, fraud or DW publication decision.",
34
+ "DuckDB memory controls buffer manager, not hard process RSS; temp cap covers DuckDB "
35
+ "spill only.",
36
+ "Input-byte/row/column/group caps and cancellation callbacks are cooperative; "
37
+ "queries have interrupt timer.",
38
+ "Snapshot copies, typed outputs and HTML consume additional disk "
39
+ "outside DuckDB temporary-disk quota.",
40
+ "Row references are zero-based ordinals of the fixed snapshot; no business uniqueness "
41
+ "inferred.",
42
+ "Raw NULL, NaN, empty and zero are distinct; missingness lost in an export is not "
43
+ "reconstructed.",
44
+ "Floating mean/stddev/histogram/visualization are approximate; "
45
+ "monetary sums and discrete quantiles exact.",
46
+ "Group budgets and unavailable outlier metrics remain explicit, never zero alerts.",
47
+ )
48
+
49
+
50
+ class _Unavailable(Exception):
51
+ pass
52
+
53
+
54
+ class _Cancelled(Exception):
55
+ pass
56
+
57
+
58
+ def _admit_arrow_schema(schema: Any) -> None:
59
+ """Reject scalar transport loss before DuckDB rewrites source types."""
60
+ types = importlib.import_module("pyarrow.types")
61
+ for field in schema:
62
+ dtype = field.type
63
+ preserved = (
64
+ types.is_null(dtype)
65
+ or types.is_boolean(dtype)
66
+ or types.is_integer(dtype)
67
+ or (types.is_floating(dtype) and dtype.bit_width in (32, 64))
68
+ or types.is_string(dtype)
69
+ or types.is_large_string(dtype)
70
+ or types.is_date32(dtype)
71
+ or (types.is_timestamp(dtype) and dtype.unit != "ns" and dtype.tz is None)
72
+ or (
73
+ types.is_decimal(dtype)
74
+ and dtype.precision <= 38
75
+ and 0 <= dtype.scale <= dtype.precision
76
+ )
77
+ )
78
+ if not preserved:
79
+ raise CapabilityError(
80
+ f"MVP raw Arrow type {dtype} in {field.name!r} requires a separate explicit "
81
+ "projection; DuckDB/Python report transport is not faithful"
82
+ )
83
+
84
+
85
+ def _sha(path: Path, checkpoint: Callable[[], None] | None = None) -> str:
86
+ h = hashlib.sha256()
87
+ with _open_regular(path) as stream:
88
+ while block := stream.read(1024 * 1024):
89
+ if checkpoint is not None:
90
+ checkpoint()
91
+ h.update(block)
92
+ return h.hexdigest()
93
+
94
+
95
+ def _manifest(source: Source, path: Path, output: Path) -> dict[str, Any] | None:
96
+ if source.manifest_path is None:
97
+ return None
98
+ manifest = ordinary_path(Path(source.manifest_path))
99
+ if output.is_relative_to(manifest.parent):
100
+ raise ValueError("EDA output must be outside the source delivery")
101
+ if manifest.stat().st_size > 16 * 1024**2:
102
+ raise ValueError("Source delivery manifest exceeds its byte budget")
103
+ with _open_regular(manifest) as stream:
104
+ payload = stream.read(16 * 1024**2 + 1)
105
+ if len(payload) > 16 * 1024**2:
106
+ raise ValueError("Source delivery manifest exceeds its byte budget")
107
+ if hashlib.sha256(payload).hexdigest() != source.manifest_sha256:
108
+ raise ValueError("source manifest SHA-256 mismatch")
109
+ parsed = parse_json(payload)
110
+ if not isinstance(parsed, dict):
111
+ raise ValueError("Source delivery manifest must be an object")
112
+ doc = parsed
113
+ if doc.get("schema") != "equiframe.sercop-public-dataset-delivery.v1":
114
+ raise ValueError("unsupported external delivery manifest version")
115
+ records = doc.get("files")
116
+ if not isinstance(records, list) or len(records) > 2000:
117
+ raise ValueError("Invalid bounded source delivery inventory")
118
+ matches = []
119
+ for record in records:
120
+ if not isinstance(record, dict):
121
+ raise ValueError("Invalid source inventory member")
122
+ if manifest_member(manifest.parent, record.get("file")) == path:
123
+ matches.append(record)
124
+ if len(matches) != 1:
125
+ raise ValueError("selected source is not a unique manifest member")
126
+ return cast("dict[str, Any]", matches[0])
127
+
128
+
129
+ def analyze(
130
+ source: Source,
131
+ *,
132
+ config: EdaConfig | None = None,
133
+ output: Path,
134
+ cancellation: Callable[[], bool] | None = None,
135
+ ) -> EdaResult:
136
+ """Profile one fixed local file; exclusive output directory, optional explicit conversions.
137
+
138
+ Invalid metadata/output collisions fail before execution. Runtime failure,
139
+ exhausted budget and cancellation have distinct persisted diagnostic states.
140
+ """
141
+ return _analyze(source, config=config, output=output, cancellation=cancellation)
142
+
143
+
144
+ def _analyze(
145
+ source: Source,
146
+ *,
147
+ config: EdaConfig | None,
148
+ output: Path,
149
+ cancellation: Callable[[], bool] | None = None,
150
+ evidence_links: tuple[dict[str, str], ...] = (),
151
+ before_finalize: Callable[[], None] | None = None,
152
+ ) -> EdaResult:
153
+ source = Source.model_validate(source)
154
+ config = EdaConfig.model_validate(config) if config is not None else EdaConfig()
155
+ if cancellation is not None and not callable(cancellation):
156
+ raise TypeError("cancellation requires a callable returning bool")
157
+ output = ordinary_path(output, directory=True, allow_missing=True)
158
+ path = ordinary_path(Path(source.path))
159
+ if output.is_relative_to(path.parent):
160
+ raise ValueError("output must be outside the source file directory")
161
+ binding = _manifest(source, path, output)
162
+ output.mkdir(mode=0o700, parents=True, exist_ok=False)
163
+ run_id = "eda_" + uuid4().hex
164
+ started = time.monotonic()
165
+ limits = config.resources
166
+ fingerprint = hashlib.sha256(canonical_bytes(config.model_dump(mode="json"))).hexdigest()
167
+ publish_json(config, output / "config.json")
168
+ publish_json(source, output / "source.json")
169
+ state: Literal["completed", "not_evaluable", "failed", "cancelled"] = "completed"
170
+ reason: str | None = None
171
+ source_hash: str | None = None
172
+ unchanged: bool | None = None
173
+ snapshot_complete = False
174
+ rows: int | None = None
175
+ columns: tuple[Any, ...] = ()
176
+ duplicates: tuple[Any, ...] = ()
177
+ conversions: tuple[dict[str, object], ...] = ()
178
+ numeric: tuple[dict[str, object], ...] = ()
179
+ backend: Backend | None = None
180
+ connection: Any = None
181
+ timer: threading.Timer | None = None
182
+ environment = {"python": sys.version, "platform": platform.platform()}
183
+ cancellation_failure: str | None = None
184
+
185
+ def checkpoint() -> None:
186
+ nonlocal cancellation_failure
187
+ if cancellation_failure is not None:
188
+ raise _Unavailable(cancellation_failure)
189
+ if cancellation is not None:
190
+ try:
191
+ requested = cancellation()
192
+ except Exception as error:
193
+ report_failure(error, "Cancellation callback failed")
194
+ cancellation_failure = "cancellation_callback_failed"
195
+ raise _Unavailable(cancellation_failure) from None
196
+ if type(requested) is not bool:
197
+ cancellation_failure = "invalid_cancellation_result"
198
+ raise _Unavailable(cancellation_failure)
199
+ if requested:
200
+ raise _Cancelled("cancellation_requested")
201
+ if time.monotonic() - started >= limits.timeout_seconds:
202
+ raise _Unavailable("timeout_budget_exhausted")
203
+
204
+ try:
205
+ checkpoint()
206
+ if path.stat().st_size > limits.max_input_bytes:
207
+ raise _Unavailable("input_bytes_budget_exhausted")
208
+ source_hash = _sha(path, checkpoint)
209
+ if source.expected_sha256 is not None and source_hash != source.expected_sha256:
210
+ raise ValueError("source SHA-256 mismatch")
211
+ if source.expected_bytes is not None and path.stat().st_size != source.expected_bytes:
212
+ raise ValueError("source byte size mismatch")
213
+ if binding and (
214
+ source_hash != binding["sha256"] or path.stat().st_size != binding["bytes"]
215
+ ):
216
+ raise ValueError("selected source contradicts trusted manifest")
217
+ snapshot = output / ("snapshot." + source.format)
218
+ with _open_regular(path) as reader, snapshot.open("xb") as writer:
219
+ while block := reader.read(1024 * 1024):
220
+ checkpoint()
221
+ writer.write(block)
222
+ writer.flush()
223
+ os.fsync(writer.fileno())
224
+ if _sha(snapshot, checkpoint) != source_hash or _sha(path, checkpoint) != source_hash:
225
+ raise ValueError("source changed during snapshot capture")
226
+ snapshot_complete = True
227
+ try:
228
+ duckdb = importlib.import_module("duckdb")
229
+ except ImportError as error:
230
+ raise _Unavailable("missing_optional_dependency: install equiframe[eda]") from error
231
+ environment["duckdb"] = duckdb.__version__
232
+ temporary = output / "temporary"
233
+ temporary.mkdir(mode=0o700)
234
+ connection = duckdb.connect(
235
+ ":memory:",
236
+ config={
237
+ "memory_limit": f"{limits.memory_mb}MB",
238
+ "threads": limits.threads,
239
+ "max_temp_directory_size": f"{limits.temp_disk_mb}MB",
240
+ "temp_directory": str(temporary),
241
+ "autoinstall_known_extensions": False,
242
+ "preserve_insertion_order": True,
243
+ },
244
+ )
245
+ # ICU bundled with the wheel is initialized during connect on some hosts.
246
+ # Set UTC afterwards, while implicit extension downloads remain disabled.
247
+ connection.execute("SET TimeZone='UTC'")
248
+ if connection.execute("SELECT current_setting('TimeZone')").fetchone() != ("UTC",):
249
+ raise ValueError("EDA requires an available UTC timezone setting")
250
+
251
+ def interrupt() -> None:
252
+ # Timer race with close is not a diagnostic success.
253
+ with suppress(duckdb.Error):
254
+ connection.interrupt()
255
+
256
+ timer = threading.Timer(
257
+ max(0.001, limits.timeout_seconds - (time.monotonic() - started)), interrupt
258
+ )
259
+ timer.daemon = True
260
+ timer.start()
261
+ if source.format == "parquet":
262
+ parquet = importlib.import_module("pyarrow.parquet")
263
+ original_schema = parquet.read_schema(snapshot)
264
+ original_names = original_schema.names
265
+ if len({n.casefold() for n in original_names}) != len(original_names):
266
+ raise ValueError("Parquet column names collide before DuckDB name rewriting")
267
+ _admit_arrow_schema(original_schema)
268
+ connection.read_parquet(str(snapshot)).create_view("eda_input")
269
+ elif source.format == "csv":
270
+ with snapshot.open("r", encoding="utf-8", newline="") as stream:
271
+ headers = next(csv.reader(stream, delimiter=source.delimiter))
272
+ if not all(headers) or len({n.casefold() for n in headers}) != len(headers):
273
+ raise ValueError("CSV requires nonempty unique case-insensitive column names")
274
+ file_literal = str(snapshot).replace("'", "''")
275
+ delimiter_literal = source.delimiter.replace("'", "''")
276
+ required_strings = (
277
+ "[" + ",".join("'" + h.replace("'", "''") + "'" for h in headers) + "]"
278
+ )
279
+ connection.execute(
280
+ f"CREATE TEMP VIEW eda_input AS SELECT * FROM read_csv('{file_literal}', "
281
+ f"all_varchar=true, header=true, force_not_null={required_strings}, "
282
+ f"delim='{delimiter_literal}')"
283
+ )
284
+ else:
285
+ arrow = importlib.import_module("pyarrow")
286
+ environment["pyarrow"] = arrow.__version__
287
+ reader = arrow.ipc.open_file(arrow.memory_map(str(snapshot), "r"))
288
+ original_schema = reader.schema
289
+ if len({n.casefold() for n in reader.schema.names}) != len(reader.schema.names):
290
+ raise ValueError("Arrow column names collide before DuckDB name rewriting")
291
+ _admit_arrow_schema(original_schema)
292
+ connection.register("eda_input", reader.read_all())
293
+ input_names = [r[0] for r in connection.execute("DESCRIBE eda_input").fetchall()]
294
+ if not set(config.identifiers) <= set(input_names):
295
+ raise ValueError("identifier rule references unknown source column")
296
+ if any(n.casefold().startswith("__ef_") for n in input_names) or len(
297
+ {n.casefold() for n in input_names}
298
+ ) != len(input_names):
299
+ raise ValueError("source column names conflict with reserved diagnostics namespace")
300
+ if len(input_names) > limits.max_columns:
301
+ raise _Unavailable("column_budget_exhausted")
302
+ connection.execute(
303
+ "CREATE TEMP TABLE raw AS SELECT row_number() OVER () - 1 "
304
+ "AS __ef_eda_row, * FROM eda_input"
305
+ )
306
+ backend = Backend(connection, config, checkpoint)
307
+ rows = backend.query("SELECT count(*) FROM raw")[0][0]
308
+ if rows > limits.max_rows:
309
+ raise _Unavailable("row_budget_exhausted")
310
+ columns = backend.columns(rows)
311
+ conversions = backend.convert()
312
+ duplicates = (
313
+ backend.duplicates(tuple(backend.raw_schema)),
314
+ *(backend.duplicates(k.columns, k) for k in config.keys),
315
+ )
316
+ numeric = tuple(backend.numeric(rule) for rule in config.numeric)
317
+ if any(n["state"] == "not_evaluable" for n in numeric) or any(
318
+ f.severity == "not_evaluable" for f in backend.findings
319
+ ):
320
+ state, reason = "not_evaluable", "requested_metrics_unavailable"
321
+ if config.conversions:
322
+ destination = str(output / "typed.parquet").replace("'", "''")
323
+ backend.query(f"COPY (SELECT * FROM typed) TO '{destination}' (FORMAT PARQUET)")
324
+ checkpoint()
325
+ if before_finalize is not None:
326
+ before_finalize()
327
+ except _Cancelled as error:
328
+ state, reason = "cancelled", str(error)
329
+ except _Unavailable as error:
330
+ state, reason = "not_evaluable", str(error)
331
+ except Exception as error:
332
+ resource_error = isinstance(error, CapabilityError) or type(error).__name__ in {
333
+ "OutOfMemoryException",
334
+ "OutOfRangeException",
335
+ "InterruptException",
336
+ }
337
+ state = "not_evaluable" if resource_error else "failed"
338
+ reason = report_failure(
339
+ error,
340
+ "analysis_unavailable" if resource_error else "analysis_failed",
341
+ )
342
+ finally:
343
+ if timer is not None:
344
+ timer.cancel()
345
+ timer.join()
346
+ if connection is not None:
347
+ connection.close()
348
+ try:
349
+ if source_hash is not None:
350
+ unchanged = _sha(path) == source_hash
351
+ if not unchanged:
352
+ state, reason = "failed", "source_changed_during_analysis"
353
+ if (
354
+ source.manifest_path is not None
355
+ and _sha(Path(source.manifest_path)) != source.manifest_sha256
356
+ ):
357
+ state, reason = "failed", "source_manifest_changed_during_analysis"
358
+ except OSError:
359
+ state, reason, unchanged = "failed", "source_readback_unavailable", False
360
+ result = EdaResult(
361
+ run_id=run_id,
362
+ producer_version=__version__,
363
+ snapshot_id=source.snapshot_id,
364
+ state=state,
365
+ reason=reason,
366
+ elapsed_seconds=time.monotonic() - started,
367
+ calculation_elapsed_seconds=time.monotonic() - started,
368
+ source=source,
369
+ source_sha256=source_hash,
370
+ source_unchanged=unchanged,
371
+ snapshot_complete=snapshot_complete,
372
+ config_sha256=fingerprint,
373
+ rows=rows,
374
+ columns=columns,
375
+ duplicates=duplicates,
376
+ conversions=conversions,
377
+ numeric=numeric,
378
+ findings=tuple(backend.findings) if backend is not None else (),
379
+ environment=environment,
380
+ limits=LIMITS,
381
+ evidence_links=evidence_links,
382
+ )
383
+ from ._report import publish_report
384
+
385
+ return publish_report(
386
+ result, config=config, output=output, checkpoint=checkpoint, started=started
387
+ )
388
+
389
+
390
+ def analyze_artifact(
391
+ artifact: TableArtifact, *, config: EdaConfig | None = None, output: Path
392
+ ) -> EdaResult:
393
+ """Use a captured table's original byte identity; no ETL or fictitious execution."""
394
+ artifact = TableArtifact.model_validate(artifact)
395
+ ref = artifact.data
396
+ return analyze(
397
+ Source(
398
+ path=str(local_path(ref)),
399
+ format=cast("Literal['arrow', 'parquet']", ref.format),
400
+ snapshot_id=artifact.name + ":" + ref.sha256,
401
+ expected_sha256=ref.sha256,
402
+ expected_bytes=ref.size_bytes,
403
+ ),
404
+ config=config,
405
+ output=output,
406
+ )
407
+
408
+
409
+ def analyze_migration(
410
+ path: Path,
411
+ *,
412
+ side: Literal["source", "target"] = "source",
413
+ config: EdaConfig | None = None,
414
+ output: Path,
415
+ ) -> EdaResult:
416
+ """Read/verify an existing migration, then diagnose its output independently."""
417
+ from equiframe.bundle import read_bundle
418
+ from equiframe.migration import MigrationResult, read_migration
419
+
420
+ if side not in ("source", "target"):
421
+ raise ValueError("side must be source or target")
422
+ selected = ordinary_path(path, directory=path.is_dir())
423
+ root = selected.parent if selected.is_file() else selected
424
+ destination = ordinary_path(output, directory=True, allow_missing=True)
425
+ if destination.is_relative_to(root):
426
+ raise ValueError("EDA results must be outside the existing migration evidence")
427
+ portable = (root / "bundle.json").is_file()
428
+ if portable:
429
+ bundle = read_bundle(root)
430
+ if bundle.kind != "migration":
431
+ raise ValueError("selected portable bundle must contain migration evidence")
432
+ document = root / bundle.root_document
433
+ # read_bundle has already invoked the original verified reader using its
434
+ # closed public reference inventory. Do not rewrite those historical URIs.
435
+ original = load_json(MigrationResult, document)
436
+ else:
437
+ original = read_migration(path)
438
+ document = root / "migration.json"
439
+ pipeline = original.source if side == "source" else original.target
440
+ if pipeline is None or pipeline.execution is None or pipeline.execution.output is None:
441
+ raise ValueError("migration has no captured output on the selected side")
442
+ artifact = pipeline.execution.output
443
+ ref = artifact.data
444
+ links = (
445
+ {
446
+ "kind": "verified_migration",
447
+ "migration_id": original.migration_id,
448
+ "path": str(document),
449
+ "sha256": _sha(document),
450
+ "side": side,
451
+ "state": original.state,
452
+ "verification": original.verification,
453
+ "index_scope": "index artifact not profiled"
454
+ if artifact.index is not None
455
+ else "no index",
456
+ },
457
+ )
458
+ if portable:
459
+ members = [r for r in bundle.references if r.uri == ref.uri]
460
+ if len(members) != 1:
461
+ raise ValueError("captured output is not a unique verified bundle member")
462
+ input_path = root / members[0].member.path
463
+ else:
464
+ input_path = local_path(ref)
465
+
466
+ def revalidate() -> None:
467
+ if _sha(document) != links[0]["sha256"]:
468
+ raise ValueError("migration document byte identity changed during diagnostics")
469
+ if read_bundle(root) != bundle if portable else read_migration(path) != original:
470
+ raise ValueError("migration evidence changed during diagnostics")
471
+
472
+ result = _analyze(
473
+ Source(
474
+ path=str(input_path),
475
+ format=cast("Literal['arrow', 'parquet']", ref.format),
476
+ snapshot_id=artifact.name + ":" + ref.sha256,
477
+ expected_sha256=ref.sha256,
478
+ expected_bytes=ref.size_bytes,
479
+ ),
480
+ config=config,
481
+ output=output,
482
+ evidence_links=links,
483
+ before_finalize=revalidate,
484
+ )
485
+ return result
486
+
487
+
488
+ def read_result(path: Path) -> EdaResult:
489
+ """Read a closed versioned diagnostic run, checking all recorded byte identities."""
490
+ selected = ordinary_path(path, directory=path.is_dir())
491
+ root = selected if selected.is_dir() else selected.parent
492
+ manifest = load_json(EdaManifest, root / "manifest.json").model_dump(mode="json")
493
+ if manifest.get("schema_version") != "1.0" or manifest.get("kind") != "equiframe.eda":
494
+ raise ValueError("unsupported EDA manifest")
495
+ names = [r["path"] for r in manifest["files"]]
496
+ if len(set(names)) != len(names):
497
+ raise ValueError("duplicate EDA inventory member")
498
+ for entry in manifest["files"]:
499
+ member = manifest_member(root, entry["path"])
500
+ if member.stat().st_size != entry["bytes"] or _sha(member) != entry["sha256"]:
501
+ raise ValueError("EDA result artifact integrity mismatch")
502
+ actual = {
503
+ p.relative_to(root).as_posix()
504
+ for p in root.rglob("*")
505
+ if p.is_file() and p.relative_to(root).as_posix() != "manifest.json"
506
+ }
507
+ if actual != set(names):
508
+ raise ValueError("EDA inventory is not closed")
509
+ result = load_json(EdaResult, root / "summary.json")
510
+ config = load_json(EdaConfig, root / "config.json")
511
+ if (
512
+ result.run_id != manifest["run_id"]
513
+ or result.state != manifest["state"]
514
+ or result.source != load_json(Source, root / "source.json")
515
+ or result.config_sha256
516
+ != hashlib.sha256(canonical_bytes(config.model_dump(mode="json"))).hexdigest()
517
+ ):
518
+ raise ValueError("EDA result identity mismatch")
519
+ with _open_regular(root / "findings.json") as stream:
520
+ findings_raw = stream.read(16 * 1024**2 + 1)
521
+ if len(findings_raw) > 16 * 1024**2:
522
+ raise ValueError("EDA findings exceed their byte budget")
523
+ if parse_json(findings_raw) != {
524
+ "schema_version": "1.0",
525
+ "kind": "equiframe.eda.findings",
526
+ "run_id": result.run_id,
527
+ "items": [f.model_dump(mode="json") for f in result.findings],
528
+ }:
529
+ raise ValueError("EDA findings contradict summary")
530
+ snapshot = root / ("snapshot." + result.source.format)
531
+ if result.snapshot_complete and (
532
+ not snapshot.is_file() or result.source_sha256 != _sha(snapshot)
533
+ ):
534
+ raise ValueError("fixed snapshot contradicts source identity")
535
+ return result
equiframe/eda/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ """Optional diagnostics CLI using the same Python implementation."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from equiframe._error_reporting import report_failure
9
+ from equiframe.evidence import load_json
10
+
11
+ from .api import analyze, analyze_migration, read_result
12
+ from .models import EdaConfig, Source
13
+
14
+ app = typer.Typer(no_args_is_help=True, add_completion=False, pretty_exceptions_enable=False)
15
+
16
+
17
+ @app.command("run")
18
+ def run(
19
+ source: Path,
20
+ output: Path = typer.Option(...),
21
+ config: Path | None = typer.Option(None),
22
+ format: str = typer.Option("parquet"),
23
+ snapshot_id: str = typer.Option("local"),
24
+ expected_sha256: str | None = typer.Option(None),
25
+ manifest: Path | None = typer.Option(None),
26
+ manifest_sha256: str | None = typer.Option(None),
27
+ ) -> None:
28
+ """Profile one immutable file; 0 complete, 2 invalid metadata, 3 unavailable/failure."""
29
+ try:
30
+ spec = Source.model_validate(
31
+ {
32
+ "path": str(source),
33
+ "format": format,
34
+ "snapshot_id": snapshot_id,
35
+ "expected_sha256": expected_sha256,
36
+ "manifest_path": str(manifest) if manifest is not None else None,
37
+ "manifest_sha256": manifest_sha256,
38
+ }
39
+ )
40
+ result = analyze(
41
+ spec, config=load_json(EdaConfig, config) if config is not None else None, output=output
42
+ )
43
+ except Exception as error:
44
+ typer.echo(
45
+ report_failure(error, "EDA request failed. Check the inputs and retry."), err=True
46
+ )
47
+ raise typer.Exit(2) from None
48
+ typer.echo(
49
+ json.dumps(
50
+ {
51
+ "state": result.state,
52
+ "run_id": result.run_id,
53
+ "rows": result.rows,
54
+ "reason": result.reason,
55
+ "report": str(output / "report.html"),
56
+ }
57
+ )
58
+ )
59
+ raise typer.Exit(0 if result.state == "completed" else 3)
60
+
61
+
62
+ @app.command("migration")
63
+ def migration(
64
+ path: Path,
65
+ output: Path = typer.Option(...),
66
+ side: str = typer.Option("source"),
67
+ config: Path | None = typer.Option(None),
68
+ ) -> None:
69
+ """Read verified migration output; no ETL or original evidence edits."""
70
+ from typing import Literal, cast
71
+
72
+ try:
73
+ result = analyze_migration(
74
+ path,
75
+ side=cast("Literal['source', 'target']", side),
76
+ config=load_json(EdaConfig, config) if config is not None else None,
77
+ output=output,
78
+ )
79
+ except Exception as error:
80
+ typer.echo(
81
+ report_failure(error, "EDA request failed. Check the inputs and retry."), err=True
82
+ )
83
+ raise typer.Exit(2) from None
84
+ typer.echo(result.state)
85
+ raise typer.Exit(0 if result.state == "completed" else 3)
86
+
87
+
88
+ @app.command("inspect")
89
+ def inspect(path: Path) -> None:
90
+ """Check byte identity; 0 intact even for unavailable/failed stored results, 3 corrupt."""
91
+ try:
92
+ result = read_result(path)
93
+ except Exception as error:
94
+ typer.echo(report_failure(error, "EDA evidence could not be verified."), err=True)
95
+ raise typer.Exit(3) from None
96
+ typer.echo(
97
+ json.dumps({"state": result.state, "run_id": result.run_id, "integrity": "verified"})
98
+ )
99
+
100
+
101
+ def main() -> None:
102
+ app()