equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Closed native-to-Arrow admission shared by DuckDB execution and capture.
|
|
2
|
+
|
|
3
|
+
Arrow validation cannot prove native fidelity: DuckDB can export a UHUGEINT as
|
|
4
|
+
a valid decimal128 with a different value. Admit native types before export,
|
|
5
|
+
then require their exact Arrow representation and reject temporal sentinels.
|
|
6
|
+
Optional engines are imported only when a transport check actually runs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from equiframe.errors import CapabilityError
|
|
17
|
+
from equiframe.tables import check_arrow
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
import pyarrow as pa
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def duckdb_parameters(parameters: Mapping[str, object]) -> dict[str, object]:
|
|
24
|
+
"""Bind admitted scalars without DuckDB's lossy Python Decimal inference.
|
|
25
|
+
|
|
26
|
+
Decimal is rendered exactly (no context arithmetic), then explicitly typed
|
|
27
|
+
before native execution. In particular, leading fractional zeros can make
|
|
28
|
+
DuckDB's automatic Decimal inference exceed precision 38 and use DOUBLE.
|
|
29
|
+
"""
|
|
30
|
+
import duckdb
|
|
31
|
+
|
|
32
|
+
bound = {}
|
|
33
|
+
for name, value in parameters.items():
|
|
34
|
+
if value is None or type(value) in (bool, str, float):
|
|
35
|
+
bound[name] = value
|
|
36
|
+
continue
|
|
37
|
+
if type(value) is int and -(2**63) <= value < 2**64:
|
|
38
|
+
bound[name] = value
|
|
39
|
+
continue
|
|
40
|
+
if type(value) is Decimal:
|
|
41
|
+
if not value.is_finite() or (value.is_zero() and value.is_signed()):
|
|
42
|
+
raise CapabilityError("DuckDB parameters cannot retain this Decimal representation")
|
|
43
|
+
digits = value.as_tuple()
|
|
44
|
+
exponent = int(digits.exponent)
|
|
45
|
+
precision = max(len(digits.digits), -exponent)
|
|
46
|
+
if exponent <= 0 and precision <= 38:
|
|
47
|
+
bound[name] = duckdb.Value(
|
|
48
|
+
format(value, "f"), duckdb.decimal_type(precision, -exponent)
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
raise CapabilityError(
|
|
52
|
+
"DuckDB parameters admit plain NULL/bool/text/float, 64-bit integers and "
|
|
53
|
+
"finite nonnegative-scale Decimal up to precision 38; use text with explicit "
|
|
54
|
+
"SQL casts for temporal or other intentionally converted literals"
|
|
55
|
+
)
|
|
56
|
+
return bound
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def duckdb_arrow_schema(native_types: tuple[tuple[str, str], ...], timezone: str) -> pa.Schema:
|
|
60
|
+
"""Return the exact admitted schema, without casting or erasing native types.
|
|
61
|
+
|
|
62
|
+
The names are observed result names and types are DuckDB's canonical native
|
|
63
|
+
descriptions, not user SQL aliases. DuckDB result fields are always nullable.
|
|
64
|
+
HUGEINT, UHUGEINT and unknown/extended types deliberately have no mapping.
|
|
65
|
+
"""
|
|
66
|
+
import pyarrow as pa
|
|
67
|
+
|
|
68
|
+
if not isinstance(timezone, str) or not timezone:
|
|
69
|
+
raise CapabilityError("DuckDB result transport requires its effective session timezone")
|
|
70
|
+
if not native_types:
|
|
71
|
+
raise CapabilityError("DuckDB result transport requires a nonempty native schema")
|
|
72
|
+
scalar_types = {
|
|
73
|
+
"BOOLEAN": pa.bool_(),
|
|
74
|
+
"TINYINT": pa.int8(),
|
|
75
|
+
"SMALLINT": pa.int16(),
|
|
76
|
+
"INTEGER": pa.int32(),
|
|
77
|
+
"BIGINT": pa.int64(),
|
|
78
|
+
"UTINYINT": pa.uint8(),
|
|
79
|
+
"USMALLINT": pa.uint16(),
|
|
80
|
+
"UINTEGER": pa.uint32(),
|
|
81
|
+
"UBIGINT": pa.uint64(),
|
|
82
|
+
"FLOAT": pa.float32(),
|
|
83
|
+
"DOUBLE": pa.float64(),
|
|
84
|
+
"VARCHAR": pa.string(),
|
|
85
|
+
"DATE": pa.date32(),
|
|
86
|
+
"TIMESTAMP_S": pa.timestamp("s"),
|
|
87
|
+
"TIMESTAMP_MS": pa.timestamp("ms"),
|
|
88
|
+
"TIMESTAMP": pa.timestamp("us"),
|
|
89
|
+
"TIMESTAMP_NS": pa.timestamp("ns"),
|
|
90
|
+
"TIMESTAMP WITH TIME ZONE": pa.timestamp("us", tz=timezone),
|
|
91
|
+
}
|
|
92
|
+
fields = []
|
|
93
|
+
names = set()
|
|
94
|
+
for name, native_type in native_types:
|
|
95
|
+
if not isinstance(name, str) or not name or name in names:
|
|
96
|
+
raise CapabilityError("DuckDB result column names must be nonempty and unique")
|
|
97
|
+
names.add(name)
|
|
98
|
+
dtype = scalar_types.get(native_type)
|
|
99
|
+
if dtype is None:
|
|
100
|
+
decimal = re.fullmatch(r"DECIMAL\(([0-9]{1,2}),([0-9]{1,2})\)", native_type)
|
|
101
|
+
if decimal is not None:
|
|
102
|
+
precision, scale = (int(value) for value in decimal.groups())
|
|
103
|
+
if 1 <= precision <= 38 and 0 <= scale <= precision:
|
|
104
|
+
dtype = pa.decimal128(precision, scale)
|
|
105
|
+
if dtype is None:
|
|
106
|
+
if native_type in {"HUGEINT", "UHUGEINT"}:
|
|
107
|
+
raise CapabilityError(
|
|
108
|
+
"DuckDB HUGEINT/UHUGEINT results have no faithful admitted Arrow transport; "
|
|
109
|
+
"use an explicit checked BIGINT or DECIMAL cast in the caller's SQL "
|
|
110
|
+
"when its range and semantics permit it"
|
|
111
|
+
)
|
|
112
|
+
raise CapabilityError("unsupported faithful DuckDB native result type")
|
|
113
|
+
fields.append(pa.field(name, dtype, nullable=True))
|
|
114
|
+
return pa.schema(fields)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def duckdb_temporal_capabilities(table: pa.Table) -> None:
|
|
118
|
+
"""Reject native infinity markers that Arrow exposes as ordinary ticks."""
|
|
119
|
+
import pyarrow as pa
|
|
120
|
+
import pyarrow.compute as pc
|
|
121
|
+
|
|
122
|
+
for field in table.schema:
|
|
123
|
+
if pa.types.is_timestamp(field.type):
|
|
124
|
+
bounds = pc.min_max(table[field.name].cast(pa.int64())).as_py()
|
|
125
|
+
if bounds["min"] is not None and (
|
|
126
|
+
bounds["min"] <= -(2**63 - 1) or bounds["max"] >= 2**63 - 1
|
|
127
|
+
):
|
|
128
|
+
raise CapabilityError(
|
|
129
|
+
"DuckDB timestamp infinity/sentinel values cannot be represented "
|
|
130
|
+
"faithfully in Arrow"
|
|
131
|
+
)
|
|
132
|
+
elif pa.types.is_date32(field.type):
|
|
133
|
+
values = table[field.name].cast(pa.int32())
|
|
134
|
+
sentinels = pa.array([-(2**31 - 1), 2**31 - 1], type=pa.int32())
|
|
135
|
+
if pc.any(pc.is_in(values, value_set=sentinels)).as_py():
|
|
136
|
+
raise CapabilityError(
|
|
137
|
+
"DuckDB date infinity values cannot be represented faithfully in Arrow"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def verify_duckdb_table(
|
|
142
|
+
table: pa.Table, native_types: tuple[tuple[str, str], ...], timezone: str
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Validate physical data, exact native mapping and finite temporal values."""
|
|
145
|
+
expected = duckdb_arrow_schema(native_types, timezone)
|
|
146
|
+
check_arrow(table)
|
|
147
|
+
if not table.schema.equals(expected, check_metadata=True):
|
|
148
|
+
raise CapabilityError("DuckDB Arrow result schema differs from its admitted native schema")
|
|
149
|
+
duckdb_temporal_capabilities(table)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Safe runtime failure summaries and bounded, value-free debug frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_LOG = logging.getLogger("equiframe.diagnostics")
|
|
10
|
+
_LOG.addHandler(logging.NullHandler())
|
|
11
|
+
_NAME = re.compile(r"[A-Za-z_][A-Za-z_0-9.]{0,127}\Z")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def report_failure(error: BaseException, message: str) -> str:
|
|
15
|
+
"""Return an authored public message; never stringify the caught exception.
|
|
16
|
+
|
|
17
|
+
Debug output contains owned module/function/line metadata only. Exception
|
|
18
|
+
values, source lines, filenames, locals and external callback names are
|
|
19
|
+
excluded, including when an exception's __str__ itself is unsafe.
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
enabled = _LOG.isEnabledFor(logging.DEBUG)
|
|
23
|
+
except Exception:
|
|
24
|
+
return message
|
|
25
|
+
if enabled:
|
|
26
|
+
try:
|
|
27
|
+
_debug_frames(error)
|
|
28
|
+
except Exception:
|
|
29
|
+
# Logging is best effort; a caller's failing handler must not
|
|
30
|
+
# expose values or replace the original operational outcome.
|
|
31
|
+
return message
|
|
32
|
+
return message
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _debug_frames(error: BaseException) -> None:
|
|
36
|
+
frames: list[tuple[str, str, int]] = []
|
|
37
|
+
cursor = error.__traceback__
|
|
38
|
+
while cursor is not None and len(frames) < 32:
|
|
39
|
+
frame = cursor.tb_frame
|
|
40
|
+
module = frame.f_globals.get("__name__")
|
|
41
|
+
function = frame.f_code.co_name
|
|
42
|
+
if (
|
|
43
|
+
type(module) is str
|
|
44
|
+
and module.startswith("equiframe.")
|
|
45
|
+
and _NAME.fullmatch(module)
|
|
46
|
+
and _NAME.fullmatch(function)
|
|
47
|
+
):
|
|
48
|
+
frames.append((module, function, cursor.tb_lineno))
|
|
49
|
+
else:
|
|
50
|
+
frames.append(("external", "callback", 0))
|
|
51
|
+
cursor = cursor.tb_next
|
|
52
|
+
_LOG.debug("Operation failed; safe frames=%s", json.dumps(frames, ensure_ascii=True))
|
equiframe/_first_use.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Small synthetic role exercises using the existing public comparison APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Role(str, Enum):
|
|
12
|
+
architect = "architect"
|
|
13
|
+
scientist = "scientist"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Case(str, Enum):
|
|
17
|
+
good = "good"
|
|
18
|
+
different = "different"
|
|
19
|
+
not_evaluable = "not-evaluable"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_demo(role: Role, case: Case, output: Path) -> dict[str, Any]:
|
|
23
|
+
"""Save both report views before deciding; never replace existing evidence."""
|
|
24
|
+
import equiframe as ef
|
|
25
|
+
from equiframe.tables import require_dependency
|
|
26
|
+
|
|
27
|
+
require_dependency("pyarrow", "arrow")
|
|
28
|
+
import pyarrow as pa
|
|
29
|
+
|
|
30
|
+
# Reserve the caller's new destination before performing any native work.
|
|
31
|
+
output.mkdir(parents=True, exist_ok=False)
|
|
32
|
+
if role == Role.architect:
|
|
33
|
+
expected = pa.table({"id": ["A", "B"], "quantity": [10, 20]})
|
|
34
|
+
candidate = ef.capture_duckdb(
|
|
35
|
+
"SELECT 'A' AS id, 10::BIGINT AS quantity UNION ALL SELECT 'B', $quantity::BIGINT",
|
|
36
|
+
parameters={"quantity": 21 if case == Case.different else 20},
|
|
37
|
+
work_dir=output / "capture",
|
|
38
|
+
)
|
|
39
|
+
declaration = ef.infer_diff_contract(expected.schema, keys=["id"])
|
|
40
|
+
else:
|
|
41
|
+
expected = pa.table(
|
|
42
|
+
{
|
|
43
|
+
"sample_id": ["0001", "0002", "0003"],
|
|
44
|
+
"duration_s": pa.array(
|
|
45
|
+
[Decimal("1.250"), None, Decimal("1000000.001")], type=pa.decimal128(16, 3)
|
|
46
|
+
),
|
|
47
|
+
"observed_at": pa.array([0, 1000000, None], type=pa.timestamp("us", tz="UTC")),
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
# Candidate calculation is separate from the literal expected durations.
|
|
51
|
+
durations = [Decimal("1250") / Decimal("1000"), None, Decimal("1000000001") / 1000]
|
|
52
|
+
if case == Case.different:
|
|
53
|
+
durations[0] = Decimal("1.251")
|
|
54
|
+
candidate = pa.table(
|
|
55
|
+
{
|
|
56
|
+
"sample_id": ["0001", "0002", "0003"],
|
|
57
|
+
"duration_s": pa.array(durations, type=pa.decimal128(16, 3)),
|
|
58
|
+
"observed_at": pa.array([0, 1000000, None], type=pa.timestamp("us", tz="UTC")),
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
duration = ef.ColumnSpec(
|
|
62
|
+
name="duration_s",
|
|
63
|
+
logical_type=ef.DecimalType(precision=16, scale=3),
|
|
64
|
+
nullable=True,
|
|
65
|
+
null_policy=ef.NullPolicy(representations=("null",)),
|
|
66
|
+
numeric_policy=ef.NumericPolicy(comparison="exact", unit="seconds"),
|
|
67
|
+
)
|
|
68
|
+
declaration = ef.infer_diff_contract(
|
|
69
|
+
expected.schema, keys=["sample_id"], column_overrides=(duration,)
|
|
70
|
+
)
|
|
71
|
+
result = ef.diff(
|
|
72
|
+
candidate,
|
|
73
|
+
ef.ArrowInput(expected),
|
|
74
|
+
contract=declaration,
|
|
75
|
+
work_dir=output / "private",
|
|
76
|
+
options=ef.ComparisonOptions(max_rows=1 if case == Case.not_evaluable else 100),
|
|
77
|
+
)
|
|
78
|
+
report = ef.write_comparison_report(result, output / "report")
|
|
79
|
+
detail = ef.write_comparison_report(
|
|
80
|
+
result,
|
|
81
|
+
output / "synthetic-detail",
|
|
82
|
+
options=ef.ReportOptions(include_values=True, include_metadata=True),
|
|
83
|
+
)
|
|
84
|
+
decision = ef.equivalence_decision(
|
|
85
|
+
result,
|
|
86
|
+
policy=ef.EquivalencePolicy(require_persisted_evidence=True),
|
|
87
|
+
evidence_root=output / "private",
|
|
88
|
+
)
|
|
89
|
+
return {
|
|
90
|
+
"role": role.value,
|
|
91
|
+
"case": case.value,
|
|
92
|
+
"synthetic": True,
|
|
93
|
+
"state": decision.state,
|
|
94
|
+
"complete": decision.complete,
|
|
95
|
+
"accepted": decision.accepted,
|
|
96
|
+
"code": decision.code,
|
|
97
|
+
"next_action": report.next_action,
|
|
98
|
+
"report": report.to_text(),
|
|
99
|
+
"synthetic_detail": detail.to_text(),
|
|
100
|
+
"report_html": str(output / "report" / "report.html"),
|
|
101
|
+
"synthetic_detail_html": str(output / "synthetic-detail" / "report.html"),
|
|
102
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Filesystem compatibility for the supported CPython versions.
|
|
2
|
+
|
|
3
|
+
Windows junctions are mount-point reparse points. The lstat tag has existed
|
|
4
|
+
since Python 3.8, so older Python must inspect it rather than assume no junction.
|
|
5
|
+
This is not a race-free sandbox and does not extend the supported filesystems.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_junction(path: Path) -> bool:
|
|
14
|
+
"""Identify a Windows junction without following it, including on 3.10/3.11.
|
|
15
|
+
|
|
16
|
+
POSIX has no Windows junctions. Missing paths are not junctions; other I/O
|
|
17
|
+
failures propagate so inaccessible paths cannot be silently accepted.
|
|
18
|
+
Symlinks are checked separately by the caller, preserving its error contract.
|
|
19
|
+
"""
|
|
20
|
+
if sys.platform != "win32":
|
|
21
|
+
return False
|
|
22
|
+
try:
|
|
23
|
+
result = path.lstat()
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
return False
|
|
26
|
+
return result.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
|
equiframe/_physical.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Verify captured physical metadata without executing or normalizing a transformation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from equiframe.errors import ArtifactIntegrityError, CapabilityError
|
|
11
|
+
from equiframe.local import local_path
|
|
12
|
+
from equiframe.tables import require_dependency
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from equiframe.execution_types import PhysicalColumn, TableArtifact
|
|
16
|
+
from equiframe.types import ArtifactRef
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class _Observation:
|
|
21
|
+
schema: tuple[tuple[str, str], ...]
|
|
22
|
+
rows: int
|
|
23
|
+
buffer_bytes: int
|
|
24
|
+
nulls: tuple[int, ...]
|
|
25
|
+
nans: tuple[int, ...]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _observe(ref: ArtifactRef, *, check: Callable[[], None] | None = None) -> _Observation:
|
|
29
|
+
"""Hash first, then decode batches; buffer bytes are distinct from file bytes."""
|
|
30
|
+
require_dependency("pyarrow", "arrow")
|
|
31
|
+
import pyarrow as pa
|
|
32
|
+
import pyarrow.compute as pc
|
|
33
|
+
|
|
34
|
+
if ref.format not in ("arrow", "parquet"):
|
|
35
|
+
raise ArtifactIntegrityError("physical table requires Arrow IPC or Parquet")
|
|
36
|
+
try:
|
|
37
|
+
with local_path(ref).open("rb") as source:
|
|
38
|
+
if check:
|
|
39
|
+
check()
|
|
40
|
+
digest = hashlib.sha256()
|
|
41
|
+
file_bytes = 0
|
|
42
|
+
while block := source.read(1024 * 1024):
|
|
43
|
+
if check:
|
|
44
|
+
check()
|
|
45
|
+
digest.update(block)
|
|
46
|
+
file_bytes += len(block)
|
|
47
|
+
if check:
|
|
48
|
+
check()
|
|
49
|
+
if (digest.hexdigest(), file_bytes) != (ref.sha256, ref.size_bytes):
|
|
50
|
+
raise ArtifactIntegrityError("physical table SHA-256 or size mismatch")
|
|
51
|
+
source.seek(0)
|
|
52
|
+
if ref.format == "arrow":
|
|
53
|
+
reader = pa.ipc.open_file(source)
|
|
54
|
+
schema = reader.schema
|
|
55
|
+
batches = (reader.get_batch(i) for i in range(reader.num_record_batches))
|
|
56
|
+
else:
|
|
57
|
+
import pyarrow.parquet as pq
|
|
58
|
+
|
|
59
|
+
parquet = pq.ParquetFile(source)
|
|
60
|
+
schema = parquet.schema_arrow
|
|
61
|
+
# Preserve row-group boundaries. A 65536-row subdivision is byte
|
|
62
|
+
# aligned for validity bitmaps and does not change materialized
|
|
63
|
+
# Arrow nbytes through additional partial bitmap boundaries.
|
|
64
|
+
batches = (
|
|
65
|
+
batch
|
|
66
|
+
for group in range(parquet.num_row_groups)
|
|
67
|
+
for batch in parquet.iter_batches(
|
|
68
|
+
batch_size=65536, row_groups=[group], use_threads=False
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
nulls = [0] * len(schema)
|
|
72
|
+
nans = [0] * len(schema)
|
|
73
|
+
rows = buffer_bytes = 0
|
|
74
|
+
batch_iterator = iter(batches)
|
|
75
|
+
while True:
|
|
76
|
+
if check:
|
|
77
|
+
check()
|
|
78
|
+
try:
|
|
79
|
+
batch = next(batch_iterator)
|
|
80
|
+
except StopIteration:
|
|
81
|
+
break
|
|
82
|
+
batch.validate(full=True)
|
|
83
|
+
if batch.schema != schema:
|
|
84
|
+
raise ArtifactIntegrityError("physical batch schema changed")
|
|
85
|
+
rows += batch.num_rows
|
|
86
|
+
buffer_bytes += batch.nbytes
|
|
87
|
+
for i, array in enumerate(batch.columns):
|
|
88
|
+
nulls[i] += array.null_count
|
|
89
|
+
if pa.types.is_floating(array.type):
|
|
90
|
+
nans[i] += int(pc.sum(pc.is_nan(array)).as_py() or 0)
|
|
91
|
+
if check:
|
|
92
|
+
check()
|
|
93
|
+
return _Observation(
|
|
94
|
+
schema=tuple((field.name, str(field.type)) for field in schema),
|
|
95
|
+
rows=rows,
|
|
96
|
+
buffer_bytes=buffer_bytes,
|
|
97
|
+
nulls=tuple(nulls),
|
|
98
|
+
nans=tuple(nans),
|
|
99
|
+
)
|
|
100
|
+
except (OSError, ValueError, CapabilityError, pa.ArrowException) as error:
|
|
101
|
+
raise ArtifactIntegrityError("cannot verify captured physical table") from error
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def physical_size(ref: ArtifactRef, *, check: Callable[[], None] | None = None) -> tuple[int, int]:
|
|
105
|
+
"""Return rows and materialized Arrow buffer bytes, with verified source bytes.
|
|
106
|
+
|
|
107
|
+
Decoding is by record batch. An IPC record batch can itself be large; this
|
|
108
|
+
function does not impose a process-memory quota or execute native engines.
|
|
109
|
+
"""
|
|
110
|
+
observed = _observe(ref, check=check)
|
|
111
|
+
return observed.rows, observed.buffer_bytes
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _verify_columns(observed: _Observation, columns: tuple[PhysicalColumn, ...], rows: int) -> None:
|
|
115
|
+
if observed.rows != rows or observed.schema != tuple(
|
|
116
|
+
(column.name, column.arrow_type) for column in columns
|
|
117
|
+
):
|
|
118
|
+
raise ArtifactIntegrityError("captured physical rows or schema contradict metadata")
|
|
119
|
+
if observed.nulls != tuple(column.null_count for column in columns) or (
|
|
120
|
+
observed.nans != tuple(column.nan_count for column in columns)
|
|
121
|
+
):
|
|
122
|
+
raise ArtifactIntegrityError("captured physical NULL or NaN counts contradict metadata")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def verify_table_metadata(table: TableArtifact) -> None:
|
|
126
|
+
"""Verify schema, counts and a separate index against captured table bytes.
|
|
127
|
+
|
|
128
|
+
Native ``engine_type`` is provenance and cannot be inferred from IPC/Parquet.
|
|
129
|
+
No casts, contract normalization, ETL, plugins or engine imports occur here.
|
|
130
|
+
"""
|
|
131
|
+
_verify_columns(_observe(table.data), table.columns, table.row_count)
|
|
132
|
+
if table.index is not None:
|
|
133
|
+
_verify_columns(_observe(table.index), table.index_columns, table.row_count)
|