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/adapters.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Three independent native engines; imports and connections are per execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from threading import Event, Thread
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
11
|
+
|
|
12
|
+
from equiframe._duckdb_transport import (
|
|
13
|
+
duckdb_arrow_schema,
|
|
14
|
+
duckdb_parameters,
|
|
15
|
+
duckdb_temporal_capabilities,
|
|
16
|
+
verify_duckdb_table,
|
|
17
|
+
)
|
|
18
|
+
from equiframe.errors import (
|
|
19
|
+
CapabilityError,
|
|
20
|
+
InvalidExecutionInput,
|
|
21
|
+
NativeEngineError,
|
|
22
|
+
ResourceLimitError,
|
|
23
|
+
)
|
|
24
|
+
from equiframe.execution import RunContext
|
|
25
|
+
from equiframe.execution_types import EngineCapabilities
|
|
26
|
+
from equiframe.registry import (
|
|
27
|
+
DuckDBTransformation,
|
|
28
|
+
PandasTransformation,
|
|
29
|
+
PolarsTransformation,
|
|
30
|
+
Transformation,
|
|
31
|
+
)
|
|
32
|
+
from equiframe.tables import (
|
|
33
|
+
LoadedTable,
|
|
34
|
+
check_arrow,
|
|
35
|
+
copy_pandas_frame,
|
|
36
|
+
pandas_capture,
|
|
37
|
+
require_dependency,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
import pyarrow as pa
|
|
42
|
+
|
|
43
|
+
from equiframe.migration.backends._timestamp_transport import _TimestampCodec
|
|
44
|
+
from equiframe.ports import EngineAdapter
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _input_capabilities(loaded: LoadedTable, engine: str, context: RunContext) -> None:
|
|
48
|
+
import pyarrow as pa
|
|
49
|
+
|
|
50
|
+
check_arrow(loaded.table)
|
|
51
|
+
if engine == "duckdb" and len({name.casefold() for name in loaded.table.column_names}) != len(
|
|
52
|
+
loaded.table.column_names
|
|
53
|
+
):
|
|
54
|
+
raise CapabilityError("DuckDB would rename input columns that differ only by case")
|
|
55
|
+
if loaded.meaningful_index and engine != "pandas":
|
|
56
|
+
raise CapabilityError(
|
|
57
|
+
"Pandas index requires index_policy='columns' or 'discard' for other engines"
|
|
58
|
+
)
|
|
59
|
+
if loaded.table.num_columns == 0 and (
|
|
60
|
+
engine == "duckdb" or (engine == "pandas" and loaded.table.num_rows > 0)
|
|
61
|
+
):
|
|
62
|
+
raise CapabilityError(f"{engine} does not support faithful zero-column table input")
|
|
63
|
+
for field in loaded.table.schema:
|
|
64
|
+
dtype = field.type
|
|
65
|
+
if (
|
|
66
|
+
engine != "pandas"
|
|
67
|
+
and pa.types.is_decimal(dtype)
|
|
68
|
+
and (pa.types.is_decimal256(dtype) or dtype.scale < 0)
|
|
69
|
+
):
|
|
70
|
+
raise CapabilityError(f"{engine} requires decimal128 with a nonnegative scale")
|
|
71
|
+
if engine == "polars" and pa.types.is_timestamp(dtype) and dtype.unit == "s":
|
|
72
|
+
import pyarrow.compute as pc
|
|
73
|
+
|
|
74
|
+
bounds = pc.min_max(loaded.table[field.name].cast(pa.int64())).as_py()
|
|
75
|
+
if bounds["min"] is not None and (
|
|
76
|
+
bounds["min"] < -(2**63 // 1000) or bounds["max"] > (2**63 - 1) // 1000
|
|
77
|
+
):
|
|
78
|
+
raise CapabilityError(
|
|
79
|
+
"Polars seconds-to-milliseconds transport would overflow int64"
|
|
80
|
+
)
|
|
81
|
+
if engine == "polars" and pa.types.is_date64(dtype):
|
|
82
|
+
raise CapabilityError("Polars converts date64 to timestamp; use explicit date32 input")
|
|
83
|
+
if engine != "pandas" and pa.types.is_decimal(dtype) and dtype.precision > 38:
|
|
84
|
+
raise CapabilityError(f"{engine} supports decimal precision at most 38")
|
|
85
|
+
if (
|
|
86
|
+
engine == "duckdb"
|
|
87
|
+
and pa.types.is_timestamp(dtype)
|
|
88
|
+
and dtype.tz
|
|
89
|
+
and (dtype.unit == "ns" or dtype.tz != context.plan.options.duckdb_timezone)
|
|
90
|
+
):
|
|
91
|
+
raise CapabilityError(
|
|
92
|
+
"DuckDB rejects zoned ns and zones different from its session timezone"
|
|
93
|
+
)
|
|
94
|
+
if (
|
|
95
|
+
engine == "duckdb"
|
|
96
|
+
and pa.types.is_timestamp(dtype)
|
|
97
|
+
and dtype.tz
|
|
98
|
+
and dtype.unit in ("s", "ms")
|
|
99
|
+
):
|
|
100
|
+
import pyarrow.compute as pc
|
|
101
|
+
|
|
102
|
+
factor = 1_000_000 if dtype.unit == "s" else 1_000
|
|
103
|
+
limit = (2**63 - 2) // factor
|
|
104
|
+
bounds = pc.min_max(loaded.table[field.name].cast(pa.int64())).as_py()
|
|
105
|
+
if bounds["min"] is not None and (bounds["min"] < -limit or bounds["max"] > limit):
|
|
106
|
+
raise CapabilityError(
|
|
107
|
+
"DuckDB zoned timestamp transport requires finite int64 microseconds"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PandasAdapter:
|
|
112
|
+
engine = "pandas"
|
|
113
|
+
|
|
114
|
+
def capabilities(self) -> EngineCapabilities:
|
|
115
|
+
return EngineCapabilities(
|
|
116
|
+
engine=self.engine,
|
|
117
|
+
required_packages=(self.engine, "pyarrow"),
|
|
118
|
+
decimal_precision=76,
|
|
119
|
+
timestamp_units=("s", "ms", "us", "ns"),
|
|
120
|
+
zoned_timestamp_units=("s", "ms", "us", "ns"),
|
|
121
|
+
lazy=False,
|
|
122
|
+
cancellation="cooperative",
|
|
123
|
+
resource_controls=("materialized rows/buffer checkpoints",),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def execute(
|
|
127
|
+
self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
|
|
128
|
+
) -> LoadedTable:
|
|
129
|
+
require_dependency("pandas", "pandas")
|
|
130
|
+
require_dependency("pyarrow", "pandas")
|
|
131
|
+
import pandas as pd
|
|
132
|
+
|
|
133
|
+
if not isinstance(transformation, PandasTransformation):
|
|
134
|
+
raise InvalidExecutionInput("Pandas adapter requires a PandasTransformation")
|
|
135
|
+
with context.measure("input_conversion"):
|
|
136
|
+
tables = {}
|
|
137
|
+
for name, loaded in inputs.items():
|
|
138
|
+
_input_capabilities(loaded, self.engine, context)
|
|
139
|
+
tables[name] = (
|
|
140
|
+
copy_pandas_frame(loaded.native)
|
|
141
|
+
if isinstance(loaded.native, pd.DataFrame)
|
|
142
|
+
else loaded.table.to_pandas(types_mapper=pd.ArrowDtype, ignore_metadata=True)
|
|
143
|
+
)
|
|
144
|
+
context.record_configuration(
|
|
145
|
+
"pandas_timezone_source",
|
|
146
|
+
"input tzinfo provider; Arrow retains zone/unit; engine rules are independent",
|
|
147
|
+
)
|
|
148
|
+
with context.measure("transformation"):
|
|
149
|
+
frame = transformation.callback(tables, context)
|
|
150
|
+
with context.measure("materialization"):
|
|
151
|
+
return pandas_capture(frame)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PolarsAdapter:
|
|
155
|
+
engine = "polars"
|
|
156
|
+
|
|
157
|
+
def capabilities(self) -> EngineCapabilities:
|
|
158
|
+
return EngineCapabilities(
|
|
159
|
+
engine=self.engine,
|
|
160
|
+
required_packages=(self.engine, "pyarrow"),
|
|
161
|
+
decimal_precision=38,
|
|
162
|
+
timestamp_units=("s", "ms", "us", "ns"),
|
|
163
|
+
zoned_timestamp_units=("s", "ms", "us", "ns"),
|
|
164
|
+
lazy=True,
|
|
165
|
+
cancellation="cooperative",
|
|
166
|
+
resource_controls=("materialized rows/buffer checkpoints",),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def execute(
|
|
170
|
+
self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
|
|
171
|
+
) -> LoadedTable:
|
|
172
|
+
require_dependency("polars", "polars")
|
|
173
|
+
require_dependency("pyarrow", "polars")
|
|
174
|
+
import polars as pl
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
return self._execute_native(transformation, inputs, context)
|
|
178
|
+
except pl.exceptions.PanicException as error:
|
|
179
|
+
raise NativeEngineError(
|
|
180
|
+
"Polars native code panicked; execution did not complete"
|
|
181
|
+
) from error
|
|
182
|
+
|
|
183
|
+
def _execute_native(
|
|
184
|
+
self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
|
|
185
|
+
) -> LoadedTable:
|
|
186
|
+
import polars as pl
|
|
187
|
+
|
|
188
|
+
if not isinstance(transformation, PolarsTransformation):
|
|
189
|
+
raise InvalidExecutionInput("Polars adapter requires a PolarsTransformation")
|
|
190
|
+
codec = _timestamp_codec(self, transformation)
|
|
191
|
+
with context.measure("input_conversion"):
|
|
192
|
+
if codec is not None:
|
|
193
|
+
inputs = codec.encode_inputs(inputs)
|
|
194
|
+
context.record_configuration(
|
|
195
|
+
"ir_timestamp_transport", "unchanged int64 ticks with explicit unit/zone codec"
|
|
196
|
+
)
|
|
197
|
+
tables: dict[str, pl.DataFrame] = {}
|
|
198
|
+
for name, loaded in inputs.items():
|
|
199
|
+
_input_capabilities(loaded, self.engine, context)
|
|
200
|
+
tables[name] = (
|
|
201
|
+
loaded.native.clone()
|
|
202
|
+
if isinstance(loaded.native, pl.DataFrame)
|
|
203
|
+
else cast("pl.DataFrame", pl.from_arrow(loaded.table))
|
|
204
|
+
)
|
|
205
|
+
if tables[name].shape != loaded.table.shape:
|
|
206
|
+
raise CapabilityError("Polars input conversion changed table dimensions")
|
|
207
|
+
context.record_configuration(
|
|
208
|
+
"polars_timezone_source",
|
|
209
|
+
"chrono-tz compiled into locked polars runtime; IANA release not exposed by Python API",
|
|
210
|
+
)
|
|
211
|
+
context.record_configuration("polars_threads", str(pl.thread_pool_size()))
|
|
212
|
+
with context.measure("planning"):
|
|
213
|
+
output = transformation.callback(tables, context)
|
|
214
|
+
if not isinstance(output, (pl.DataFrame, pl.LazyFrame)):
|
|
215
|
+
raise InvalidExecutionInput("Polars transformation must return DataFrame/LazyFrame")
|
|
216
|
+
with context.measure("materialization"):
|
|
217
|
+
frame = output.collect() if isinstance(output, pl.LazyFrame) else output
|
|
218
|
+
table = frame.to_arrow()
|
|
219
|
+
if table.shape != frame.shape:
|
|
220
|
+
raise CapabilityError("Polars output conversion changed table dimensions")
|
|
221
|
+
if codec is not None:
|
|
222
|
+
table = codec.decode_output(table)
|
|
223
|
+
check_arrow(table)
|
|
224
|
+
return LoadedTable(table, native_types=tuple((n, str(t)) for n, t in frame.schema.items()))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class DuckDBAdapter:
|
|
228
|
+
engine = "duckdb"
|
|
229
|
+
|
|
230
|
+
def capabilities(self) -> EngineCapabilities:
|
|
231
|
+
return EngineCapabilities(
|
|
232
|
+
engine=self.engine,
|
|
233
|
+
required_packages=(self.engine, "pyarrow"),
|
|
234
|
+
decimal_precision=38,
|
|
235
|
+
timestamp_units=("s", "ms", "us", "ns"),
|
|
236
|
+
zoned_timestamp_units=("s", "ms", "us"),
|
|
237
|
+
lazy=False,
|
|
238
|
+
cancellation="interrupt_query",
|
|
239
|
+
resource_controls=(
|
|
240
|
+
"DuckDB memory_limit (engine-managed memory only)",
|
|
241
|
+
"DuckDB threads",
|
|
242
|
+
"materialized rows/buffer checkpoints",
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def execute(
|
|
247
|
+
self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
|
|
248
|
+
) -> LoadedTable:
|
|
249
|
+
require_dependency("duckdb", "duckdb")
|
|
250
|
+
require_dependency("pyarrow", "duckdb")
|
|
251
|
+
import duckdb
|
|
252
|
+
|
|
253
|
+
if not isinstance(transformation, DuckDBTransformation):
|
|
254
|
+
raise InvalidExecutionInput("DuckDB adapter requires a DuckDBTransformation")
|
|
255
|
+
from equiframe.migration.backends.duckdb import _DuckDBProgram
|
|
256
|
+
|
|
257
|
+
program = transformation if isinstance(transformation, _DuckDBProgram) else None
|
|
258
|
+
codec = _timestamp_codec(self, transformation)
|
|
259
|
+
with context.measure("input_capabilities"):
|
|
260
|
+
if codec is not None:
|
|
261
|
+
inputs = codec.encode_inputs(inputs)
|
|
262
|
+
context.record_configuration(
|
|
263
|
+
"ir_timestamp_transport", "unchanged int64 ticks with explicit unit/zone codec"
|
|
264
|
+
)
|
|
265
|
+
for name, loaded in inputs.items():
|
|
266
|
+
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name) is None:
|
|
267
|
+
raise CapabilityError(
|
|
268
|
+
"DuckDB input identifiers allow ASCII letters, digits and underscores"
|
|
269
|
+
)
|
|
270
|
+
_input_capabilities(loaded, self.engine, context)
|
|
271
|
+
duckdb_temporal_capabilities(loaded.table)
|
|
272
|
+
options = context.plan.options
|
|
273
|
+
stopped = Event()
|
|
274
|
+
connection: duckdb.DuckDBPyConnection | None = None
|
|
275
|
+
|
|
276
|
+
def watch() -> None:
|
|
277
|
+
if not (connection is not None):
|
|
278
|
+
raise AssertionError("Internal invariant check failed.")
|
|
279
|
+
while not stopped.wait(0.01):
|
|
280
|
+
if context.cancellation.cancelled or (
|
|
281
|
+
context.deadline is not None and time.perf_counter() >= context.deadline
|
|
282
|
+
):
|
|
283
|
+
connection.interrupt()
|
|
284
|
+
# Keep interrupting until the active stage finishes, covering start races.
|
|
285
|
+
|
|
286
|
+
watcher = Thread(target=watch, name=context.execution_id + "_interrupt", daemon=False)
|
|
287
|
+
try:
|
|
288
|
+
with context.measure("engine_setup"):
|
|
289
|
+
connection = duckdb.connect(
|
|
290
|
+
":memory:",
|
|
291
|
+
config={
|
|
292
|
+
"memory_limit": f"{options.duckdb_memory_mb}MB",
|
|
293
|
+
"threads": options.duckdb_threads,
|
|
294
|
+
"enable_external_access": False,
|
|
295
|
+
"autoinstall_known_extensions": False,
|
|
296
|
+
"python_enable_replacements": False,
|
|
297
|
+
"max_temp_directory_size": "0B",
|
|
298
|
+
"preserve_insertion_order": True,
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
connection.execute("SET TimeZone = ?", [options.duckdb_timezone])
|
|
302
|
+
connection.execute("SET autoload_known_extensions=false")
|
|
303
|
+
settings = connection.execute(
|
|
304
|
+
"SELECT current_setting('memory_limit'), current_setting('threads'), "
|
|
305
|
+
"current_setting('TimeZone'), current_setting('max_temp_directory_size')"
|
|
306
|
+
).fetchone()
|
|
307
|
+
if not (settings is not None):
|
|
308
|
+
raise AssertionError("Internal invariant check failed.")
|
|
309
|
+
for name, value in zip(
|
|
310
|
+
("memory_limit", "threads", "timezone", "max_spill"), settings, strict=True
|
|
311
|
+
):
|
|
312
|
+
context.record_configuration("duckdb_effective_" + name, str(value))
|
|
313
|
+
queries = (
|
|
314
|
+
(*tuple(step.sql for step in program.checkpoints), program.final_sql)
|
|
315
|
+
if program is not None
|
|
316
|
+
else (transformation.sql,)
|
|
317
|
+
)
|
|
318
|
+
for query in queries:
|
|
319
|
+
statements = connection.extract_statements(query)
|
|
320
|
+
if len(statements) != 1 or statements[0].type != duckdb.StatementType.SELECT:
|
|
321
|
+
raise InvalidExecutionInput(
|
|
322
|
+
"registered DuckDB SQL must be one SELECT/WITH query"
|
|
323
|
+
)
|
|
324
|
+
if set(statements[0].named_parameters) != set(context.parameters):
|
|
325
|
+
raise InvalidExecutionInput(
|
|
326
|
+
"SQL named parameters must match execution plan parameters"
|
|
327
|
+
)
|
|
328
|
+
if len({name.casefold() for name in inputs}) != len(inputs):
|
|
329
|
+
raise InvalidExecutionInput("DuckDB input names must also be unique ignoring case")
|
|
330
|
+
context.record_configuration(
|
|
331
|
+
"duckdb_memory_limit", f"{options.duckdb_memory_mb}MB (not a total Python RSS cap)"
|
|
332
|
+
)
|
|
333
|
+
context.record_configuration("duckdb_threads", str(options.duckdb_threads))
|
|
334
|
+
context.record_configuration("duckdb_timezone", options.duckdb_timezone)
|
|
335
|
+
context.record_configuration(
|
|
336
|
+
"duckdb_timezone_source",
|
|
337
|
+
"bundled ICU extension in installed DuckDB wheel; independent of Python tzdata",
|
|
338
|
+
)
|
|
339
|
+
context.record_configuration(
|
|
340
|
+
"duckdb_iana_release", "not exposed; installed wheel version recorded"
|
|
341
|
+
)
|
|
342
|
+
context.record_configuration(
|
|
343
|
+
"duckdb_external_access",
|
|
344
|
+
"disabled; no shared database, filesystem SQL or extension downloads",
|
|
345
|
+
)
|
|
346
|
+
with context.measure("input_conversion"):
|
|
347
|
+
for name, loaded in inputs.items():
|
|
348
|
+
connection.register(name, loaded.table)
|
|
349
|
+
watcher.start()
|
|
350
|
+
with context.measure("transformation"):
|
|
351
|
+
if program is not None:
|
|
352
|
+
available = {name.casefold() for name in inputs}
|
|
353
|
+
remaining = Counter(
|
|
354
|
+
name for step in program.checkpoints for name in step.dependencies
|
|
355
|
+
)
|
|
356
|
+
remaining[program.output_relation] += 1
|
|
357
|
+
if not program.checkpoints or not program.final_sql:
|
|
358
|
+
raise InvalidExecutionInput("DuckDB IR program requires native checkpoints")
|
|
359
|
+
context.record_configuration(
|
|
360
|
+
"duckdb_ir_checkpoints", str(len(program.checkpoints))
|
|
361
|
+
)
|
|
362
|
+
for step in program.checkpoints:
|
|
363
|
+
context.check()
|
|
364
|
+
if step.name.casefold() in available or any(
|
|
365
|
+
name.casefold() not in available for name in step.dependencies
|
|
366
|
+
):
|
|
367
|
+
raise InvalidExecutionInput("DuckDB IR checkpoint dependency mismatch")
|
|
368
|
+
connection.execute(step.sql, duckdb_parameters(context.parameters))
|
|
369
|
+
observed = tuple(
|
|
370
|
+
(str(item[0]), str(item[1])) for item in connection.description or ()
|
|
371
|
+
)
|
|
372
|
+
duckdb_arrow_schema(observed, options.duckdb_timezone)
|
|
373
|
+
intermediate = cast("pa.Table", connection.to_arrow_table(batch_size=2048))
|
|
374
|
+
verify_duckdb_table(intermediate, observed, options.duckdb_timezone)
|
|
375
|
+
context.check_table(intermediate, output=False, enforce_rows=False)
|
|
376
|
+
connection.register(step.name, intermediate)
|
|
377
|
+
available.add(step.name.casefold())
|
|
378
|
+
for name in step.dependencies:
|
|
379
|
+
remaining[name] -= 1
|
|
380
|
+
if remaining[name] == 0:
|
|
381
|
+
connection.unregister(name)
|
|
382
|
+
available.remove(name.casefold())
|
|
383
|
+
if program.output_relation.casefold() not in available:
|
|
384
|
+
raise InvalidExecutionInput("DuckDB IR output checkpoint is missing")
|
|
385
|
+
query = program.final_sql
|
|
386
|
+
else:
|
|
387
|
+
query = transformation.sql
|
|
388
|
+
connection.execute(query, duckdb_parameters(context.parameters))
|
|
389
|
+
native_types = tuple(
|
|
390
|
+
(str(item[0]), str(item[1])) for item in connection.description or ()
|
|
391
|
+
)
|
|
392
|
+
with context.measure("materialization"):
|
|
393
|
+
duckdb_arrow_schema(native_types, options.duckdb_timezone)
|
|
394
|
+
table = cast("pa.Table", connection.to_arrow_table(batch_size=2048))
|
|
395
|
+
verify_duckdb_table(table, native_types, options.duckdb_timezone)
|
|
396
|
+
if codec is not None:
|
|
397
|
+
table = codec.decode_output(table)
|
|
398
|
+
return LoadedTable(
|
|
399
|
+
table,
|
|
400
|
+
native_types=native_types,
|
|
401
|
+
notes=(
|
|
402
|
+
"Observed DuckDB order retained; SQL without ORDER BY has no order guarantee.",
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
except duckdb.OutOfMemoryException as error:
|
|
406
|
+
raise ResourceLimitError("DuckDB engine memory limit exhausted") from error
|
|
407
|
+
except duckdb.InterruptException:
|
|
408
|
+
context.check()
|
|
409
|
+
raise
|
|
410
|
+
finally:
|
|
411
|
+
stopped.set()
|
|
412
|
+
if watcher.ident is not None:
|
|
413
|
+
watcher.join()
|
|
414
|
+
if connection is not None:
|
|
415
|
+
connection.close()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _timestamp_codec(
|
|
419
|
+
adapter: EngineAdapter, transformation: Transformation
|
|
420
|
+
) -> _TimestampCodec | None:
|
|
421
|
+
"""Only native adapters for the matching compiler understand tick encodings."""
|
|
422
|
+
from equiframe.migration.backends._timestamp_transport import (
|
|
423
|
+
_TimestampDuckDBTransformation,
|
|
424
|
+
_TimestampPolarsTransformation,
|
|
425
|
+
)
|
|
426
|
+
from equiframe.migration.backends.duckdb import _DuckDBProgram
|
|
427
|
+
|
|
428
|
+
if isinstance(transformation, _DuckDBProgram) and not isinstance(adapter, DuckDBAdapter):
|
|
429
|
+
raise CapabilityError("DuckDB IR checkpoints require the native DuckDB adapter")
|
|
430
|
+
|
|
431
|
+
if (
|
|
432
|
+
isinstance(adapter, DuckDBAdapter)
|
|
433
|
+
and isinstance(transformation, _TimestampDuckDBTransformation)
|
|
434
|
+
) or (
|
|
435
|
+
isinstance(adapter, PolarsAdapter)
|
|
436
|
+
and isinstance(transformation, _TimestampPolarsTransformation)
|
|
437
|
+
):
|
|
438
|
+
codec = transformation.timestamp_codec
|
|
439
|
+
if codec is None:
|
|
440
|
+
raise InvalidExecutionInput("IR timestamp transformation requires its explicit codec")
|
|
441
|
+
return codec
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def native_adapter(engine: str) -> EngineAdapter:
|
|
446
|
+
adapters: dict[str, EngineAdapter] = {
|
|
447
|
+
"pandas": PandasAdapter(),
|
|
448
|
+
"polars": PolarsAdapter(),
|
|
449
|
+
"duckdb": DuckDBAdapter(),
|
|
450
|
+
}
|
|
451
|
+
try:
|
|
452
|
+
return adapters[engine]
|
|
453
|
+
except KeyError:
|
|
454
|
+
raise CapabilityError("unknown native engine; inject an explicit EngineAdapter") from None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Acceptance policy and detached decisions over existing comparison results.
|
|
2
|
+
|
|
3
|
+
These models describe integration acceptance, not another comparison algorithm.
|
|
4
|
+
The sole guarantee is complete observed equivalence under the declared output
|
|
5
|
+
contract. A peer or expected reference does not establish independent correctness,
|
|
6
|
+
and evidence integrity does not authenticate the producer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import model_validator
|
|
14
|
+
from typing_extensions import Never, Self
|
|
15
|
+
|
|
16
|
+
from equiframe._base import FrozenModel, PositiveInt
|
|
17
|
+
from equiframe.types import ComparisonState, Sha256
|
|
18
|
+
|
|
19
|
+
EquivalenceCode = Literal[
|
|
20
|
+
"equivalent",
|
|
21
|
+
"different",
|
|
22
|
+
"not_evaluable",
|
|
23
|
+
"guarantee_mismatch",
|
|
24
|
+
"evidence_missing",
|
|
25
|
+
"evidence_corrupt",
|
|
26
|
+
"evidence_path_rejected",
|
|
27
|
+
"evidence_budget_exceeded",
|
|
28
|
+
"evidence_unreadable",
|
|
29
|
+
]
|
|
30
|
+
EvidenceFailureCode = Literal[
|
|
31
|
+
"evidence_missing",
|
|
32
|
+
"evidence_corrupt",
|
|
33
|
+
"evidence_path_rejected",
|
|
34
|
+
"evidence_budget_exceeded",
|
|
35
|
+
"evidence_unreadable",
|
|
36
|
+
]
|
|
37
|
+
EvidenceStatus = Literal[
|
|
38
|
+
"not_requested",
|
|
39
|
+
"verified",
|
|
40
|
+
"missing",
|
|
41
|
+
"corrupt",
|
|
42
|
+
"path_rejected",
|
|
43
|
+
"budget_exceeded",
|
|
44
|
+
"unreadable",
|
|
45
|
+
]
|
|
46
|
+
GuaranteeFailure = Literal["required_contract_sha256", "required_spec_sha256", "required_reference"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EquivalencePolicy(FrozenModel):
|
|
50
|
+
"""Require complete output-schema equivalence, optionally pinning its context.
|
|
51
|
+
|
|
52
|
+
No setting weakens evaluation, changes a comparison policy, permits partial
|
|
53
|
+
results or asserts independent correctness. Persisted inspection requires an
|
|
54
|
+
explicit evidence_root and verifies the current manifest and all differences
|
|
55
|
+
within these budgets; source captures are not opened.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
59
|
+
guarantee: Literal["observed_contract"] = "observed_contract"
|
|
60
|
+
required_contract_sha256: Sha256 | None = None
|
|
61
|
+
required_spec_sha256: Sha256 | None = None
|
|
62
|
+
required_reference: Literal["peer", "expected"] | None = None
|
|
63
|
+
require_persisted_evidence: bool = False
|
|
64
|
+
max_evidence_bytes: PositiveInt = 64 * 1024 * 1024
|
|
65
|
+
max_records: PositiveInt = 100000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EquivalenceDecision(FrozenModel):
|
|
69
|
+
"""Safe integration summary preserving the original comparison verdict.
|
|
70
|
+
|
|
71
|
+
Inspect accepted explicitly. Comparison state failures take precedence over
|
|
72
|
+
guarantee mismatches and evidence failures, which remain separately visible.
|
|
73
|
+
This document is not accepted as input to assert_equivalent.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
77
|
+
accepted: bool
|
|
78
|
+
code: EquivalenceCode
|
|
79
|
+
state: ComparisonState
|
|
80
|
+
complete: bool
|
|
81
|
+
counts_final: bool
|
|
82
|
+
scope: Literal["output_schema"] = "output_schema"
|
|
83
|
+
reference: Literal["peer", "expected"]
|
|
84
|
+
contract_sha256: Sha256
|
|
85
|
+
spec_sha256: Sha256
|
|
86
|
+
guarantee: Literal["observed_contract"] = "observed_contract"
|
|
87
|
+
guarantee_conforms: bool
|
|
88
|
+
guarantee_failures: tuple[GuaranteeFailure, ...]
|
|
89
|
+
evidence_status: EvidenceStatus
|
|
90
|
+
evidence_code: EvidenceFailureCode | None
|
|
91
|
+
safe_reason_code: str | None = None
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def consistent(self) -> Self:
|
|
95
|
+
if self.complete != self.counts_final:
|
|
96
|
+
raise ValueError("decision count completeness must preserve result completeness")
|
|
97
|
+
if self.state in ("equivalent", "different") and not self.complete:
|
|
98
|
+
raise ValueError("a final verdict requires complete evaluation")
|
|
99
|
+
if self.guarantee_conforms != (not self.guarantee_failures):
|
|
100
|
+
raise ValueError("guarantee conformance must describe its unmet requirements")
|
|
101
|
+
if len(set(self.guarantee_failures)) != len(self.guarantee_failures):
|
|
102
|
+
raise ValueError("guarantee failures must be unique")
|
|
103
|
+
expected_evidence = (
|
|
104
|
+
None
|
|
105
|
+
if self.evidence_status in ("not_requested", "verified")
|
|
106
|
+
else "evidence_" + self.evidence_status
|
|
107
|
+
)
|
|
108
|
+
if self.evidence_code != expected_evidence:
|
|
109
|
+
raise ValueError("evidence status and failure code must agree")
|
|
110
|
+
expected_code = (
|
|
111
|
+
self.state
|
|
112
|
+
if self.state != "equivalent"
|
|
113
|
+
else "guarantee_mismatch"
|
|
114
|
+
if not self.guarantee_conforms
|
|
115
|
+
else self.evidence_code or "equivalent"
|
|
116
|
+
)
|
|
117
|
+
if self.code != expected_code or self.accepted != (expected_code == "equivalent"):
|
|
118
|
+
raise ValueError("acceptance must preserve comparison, guarantee and evidence failures")
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __bool__(self) -> Never:
|
|
122
|
+
raise TypeError("EquivalenceDecision has no truth value; inspect .accepted explicitly")
|