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
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()
|