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,433 @@
|
|
|
1
|
+
"""Bounded native snapshots for ``diff``, including caller-supplied DuckDB relations.
|
|
2
|
+
|
|
3
|
+
Native size estimates and Arrow buffer checks are cooperative limits, not hard
|
|
4
|
+
RSS or latency guarantees. No expected schema participates in capture.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from datetime import date
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
16
|
+
from typing import TYPE_CHECKING, Literal, cast
|
|
17
|
+
|
|
18
|
+
from equiframe._base import FrozenModel, PositiveInt
|
|
19
|
+
from equiframe.errors import (
|
|
20
|
+
CapabilityError,
|
|
21
|
+
ExecutionCancelled,
|
|
22
|
+
InvalidExecutionInput,
|
|
23
|
+
ResourceLimitError,
|
|
24
|
+
)
|
|
25
|
+
from equiframe.tables import (
|
|
26
|
+
LoadedTable,
|
|
27
|
+
check_arrow,
|
|
28
|
+
copy_pandas_frame,
|
|
29
|
+
detached_arrow,
|
|
30
|
+
pandas_capture,
|
|
31
|
+
require_dependency,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
import duckdb
|
|
36
|
+
import pandas as pd
|
|
37
|
+
import polars as pl
|
|
38
|
+
import pyarrow as pa
|
|
39
|
+
|
|
40
|
+
from equiframe.execution import CancellationToken
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DiffCaptureOptions(FrozenModel):
|
|
44
|
+
"""Per-input eager capture limits, separate from comparison budgets.
|
|
45
|
+
|
|
46
|
+
``max_bytes`` bounds native estimated size before copying/conversion and
|
|
47
|
+
captured Arrow data plus meaningful index buffers afterward. Intermediate
|
|
48
|
+
allocations, Python overhead and serialization buffers are not hard-capped.
|
|
49
|
+
Passing an actual DuckDB relation explicitly authorizes consuming that
|
|
50
|
+
relation. Capture limits rows with max_rows+1 and checks Arrow batches before
|
|
51
|
+
detachment. Native planning/first-batch allocations are cooperative, not hard
|
|
52
|
+
memory/time limits. Polars lazy frames, SQL strings and connections are not
|
|
53
|
+
accepted as native tables.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
max_rows: PositiveInt = 1_000_000
|
|
57
|
+
max_bytes: PositiveInt = 256 * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class _NativeInput:
|
|
62
|
+
value: object
|
|
63
|
+
options: DiffCaptureOptions
|
|
64
|
+
cancellation: CancellationToken | None = None
|
|
65
|
+
|
|
66
|
+
def load(self) -> LoadedTable:
|
|
67
|
+
return capture_native(self.value, self.options, cancellation=self.cancellation)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class _CapturedInput:
|
|
72
|
+
loaded: LoadedTable
|
|
73
|
+
|
|
74
|
+
def load(self) -> LoadedTable:
|
|
75
|
+
return self.loaded
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_loaded_type(value: object, module_name: str, type_name: str) -> bool:
|
|
79
|
+
"""Check actual engine classes without importing an unrelated extra."""
|
|
80
|
+
module = sys.modules.get(module_name)
|
|
81
|
+
native_type = getattr(module, type_name, None) if module is not None else None
|
|
82
|
+
return isinstance(native_type, type) and isinstance(value, native_type)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _require_base_class(value: object, module_name: str, type_name: str) -> None:
|
|
86
|
+
# A subclass may attach units/index semantics or replace copy/conversion.
|
|
87
|
+
# Merely being isinstance-compatible does not authorize discarding those.
|
|
88
|
+
if type(value) is not getattr(sys.modules[module_name], type_name):
|
|
89
|
+
raise CapabilityError(
|
|
90
|
+
f"{module_name}.{type_name} subclasses require an explicit metadata/capture policy"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _capture_versions(engine: Literal["arrow", "pandas", "polars", "duckdb"]) -> tuple[str, ...]:
|
|
95
|
+
"""Record installed capture distributions, not a tested compatibility matrix.
|
|
96
|
+
|
|
97
|
+
Only fixed distribution names and bounded version tokens enter evidence.
|
|
98
|
+
Loaded Polars runtime modules identify the variant without importing another
|
|
99
|
+
engine or consulting paths, session settings, or module version internals.
|
|
100
|
+
"""
|
|
101
|
+
names = ["pyarrow"]
|
|
102
|
+
if engine != "arrow":
|
|
103
|
+
names.append(engine)
|
|
104
|
+
if engine == "polars":
|
|
105
|
+
for variant in ("32", "64", "compat"):
|
|
106
|
+
if f"_polars_runtime_{variant}" in sys.modules:
|
|
107
|
+
names.append(f"polars-runtime-{variant}")
|
|
108
|
+
notes = []
|
|
109
|
+
for name in names:
|
|
110
|
+
try:
|
|
111
|
+
installed = version(name)
|
|
112
|
+
except PackageNotFoundError:
|
|
113
|
+
installed = "not-installed"
|
|
114
|
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.!+_-]{0,127}", installed) is None:
|
|
115
|
+
installed = "invalid-version-metadata"
|
|
116
|
+
notes.append(f"Native capture installed distribution: {name}={installed}")
|
|
117
|
+
return tuple(notes)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_size(rows: int, size: int, options: DiffCaptureOptions) -> None:
|
|
121
|
+
if rows > options.max_rows:
|
|
122
|
+
raise ResourceLimitError("native diff input exceeds capture row budget")
|
|
123
|
+
if size > options.max_bytes:
|
|
124
|
+
raise ResourceLimitError("native diff input exceeds capture byte budget")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_metadata(table: pa.Table) -> None:
|
|
128
|
+
# Schema metadata may contain units, extension types or an embedded index.
|
|
129
|
+
# Keeping its bytes is insufficient when the comparator cannot interpret it.
|
|
130
|
+
if table.schema.metadata or any(field.metadata for field in table.schema):
|
|
131
|
+
raise CapabilityError(
|
|
132
|
+
"native Arrow schema/field metadata has no supported comparison policy; "
|
|
133
|
+
"provide an explicitly prepared faithful capture"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _finish(loaded: LoadedTable, options: DiffCaptureOptions) -> LoadedTable:
|
|
138
|
+
check_arrow(loaded.table)
|
|
139
|
+
index = loaded.index if loaded.meaningful_index else None
|
|
140
|
+
if index is not None:
|
|
141
|
+
check_arrow(index)
|
|
142
|
+
if index.num_rows != loaded.table.num_rows:
|
|
143
|
+
raise InvalidExecutionInput("captured index row count must match the data")
|
|
144
|
+
_check_size(
|
|
145
|
+
loaded.table.num_rows,
|
|
146
|
+
loaded.table.nbytes + (index.nbytes if index is not None else 0),
|
|
147
|
+
options,
|
|
148
|
+
)
|
|
149
|
+
# Arrow can alias writable NumPy buffers, including in pandas extension
|
|
150
|
+
# arrays and Polars frames. IPC detachment isolates every accepted snapshot.
|
|
151
|
+
table = detached_arrow(loaded.table)
|
|
152
|
+
index = detached_arrow(index) if index is not None else None
|
|
153
|
+
_check_size(table.num_rows, table.nbytes + (index.nbytes if index is not None else 0), options)
|
|
154
|
+
return replace(loaded, table=table, native=None, index=index)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_pandas_metadata(frame: pd.DataFrame) -> None:
|
|
158
|
+
import pandas as pd
|
|
159
|
+
|
|
160
|
+
if type(frame.columns) not in (pd.Index, pd.RangeIndex):
|
|
161
|
+
raise CapabilityError("unsupported Pandas column index type/metadata for diff capture")
|
|
162
|
+
if any(type(name) is not str for name in frame.columns):
|
|
163
|
+
raise CapabilityError("Pandas column labels must be plain strings without extra metadata")
|
|
164
|
+
if any(name is not None for name in frame.columns.names):
|
|
165
|
+
raise CapabilityError("Pandas column axis names require an explicit metadata policy")
|
|
166
|
+
if frame.attrs:
|
|
167
|
+
raise CapabilityError("Pandas attrs require an explicit metadata policy")
|
|
168
|
+
if type(frame.index) not in (pd.Index, pd.RangeIndex, pd.MultiIndex, pd.DatetimeIndex):
|
|
169
|
+
raise CapabilityError("unsupported Pandas index type/metadata for faithful diff capture")
|
|
170
|
+
# pandas_capture understands only these native extension representations.
|
|
171
|
+
# Generic is_integer_dtype/is_float_dtype alone would also accept arbitrary
|
|
172
|
+
# user extensions whose conversion might erase additional semantics.
|
|
173
|
+
supported_extensions = (
|
|
174
|
+
pd.ArrowDtype,
|
|
175
|
+
pd.BooleanDtype,
|
|
176
|
+
pd.Int8Dtype,
|
|
177
|
+
pd.Int16Dtype,
|
|
178
|
+
pd.Int32Dtype,
|
|
179
|
+
pd.Int64Dtype,
|
|
180
|
+
pd.UInt8Dtype,
|
|
181
|
+
pd.UInt16Dtype,
|
|
182
|
+
pd.UInt32Dtype,
|
|
183
|
+
pd.UInt64Dtype,
|
|
184
|
+
pd.Float32Dtype,
|
|
185
|
+
pd.Float64Dtype,
|
|
186
|
+
pd.StringDtype,
|
|
187
|
+
pd.DatetimeTZDtype,
|
|
188
|
+
)
|
|
189
|
+
index_dtypes = (
|
|
190
|
+
tuple(level.dtype for level in frame.index.levels)
|
|
191
|
+
if isinstance(frame.index, pd.MultiIndex)
|
|
192
|
+
else (frame.index.dtype,)
|
|
193
|
+
)
|
|
194
|
+
for dtype in (*frame.dtypes, *index_dtypes):
|
|
195
|
+
if isinstance(dtype, pd.CategoricalDtype):
|
|
196
|
+
raise CapabilityError("Pandas categories/ordering cannot be captured as scalar labels")
|
|
197
|
+
if (
|
|
198
|
+
isinstance(dtype, pd.api.extensions.ExtensionDtype)
|
|
199
|
+
and type(dtype) not in supported_extensions
|
|
200
|
+
):
|
|
201
|
+
raise CapabilityError("unsupported Pandas extension dtype for faithful diff capture")
|
|
202
|
+
if getattr(dtype, "metadata", None):
|
|
203
|
+
raise CapabilityError("Pandas dtype metadata requires an explicit comparison policy")
|
|
204
|
+
if any(name is not None and type(name) not in (str, int) for name in frame.index.names):
|
|
205
|
+
raise CapabilityError("unsupported Pandas index name metadata")
|
|
206
|
+
if isinstance(frame.index, pd.MultiIndex):
|
|
207
|
+
names = tuple(name for name in frame.index.names if name is not None)
|
|
208
|
+
if len(names) != len(set(names)):
|
|
209
|
+
raise CapabilityError("duplicate Pandas index names cannot be captured faithfully")
|
|
210
|
+
|
|
211
|
+
def check_scalars(values: Iterable[object]) -> None:
|
|
212
|
+
for value in values:
|
|
213
|
+
if value is None or value is pd.NA or value is pd.NaT:
|
|
214
|
+
continue
|
|
215
|
+
if type(value) not in (str, bool, int, float, Decimal, date):
|
|
216
|
+
raise CapabilityError(
|
|
217
|
+
"Pandas object values must be plain immutable scalars; "
|
|
218
|
+
"subclasses and their metadata require an explicit capture policy"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for _, series in frame.items():
|
|
222
|
+
if pd.api.types.is_object_dtype(series.dtype):
|
|
223
|
+
check_scalars(series)
|
|
224
|
+
levels = frame.index.levels if isinstance(frame.index, pd.MultiIndex) else (frame.index,)
|
|
225
|
+
for level in levels:
|
|
226
|
+
if pd.api.types.is_object_dtype(level.dtype):
|
|
227
|
+
check_scalars(level)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _check_polars_schema(frame: pl.DataFrame) -> None:
|
|
231
|
+
import polars as pl
|
|
232
|
+
|
|
233
|
+
supported = (
|
|
234
|
+
pl.Null,
|
|
235
|
+
pl.Boolean,
|
|
236
|
+
pl.Int8,
|
|
237
|
+
pl.Int16,
|
|
238
|
+
pl.Int32,
|
|
239
|
+
pl.Int64,
|
|
240
|
+
pl.UInt8,
|
|
241
|
+
pl.UInt16,
|
|
242
|
+
pl.UInt32,
|
|
243
|
+
pl.UInt64,
|
|
244
|
+
pl.Float32,
|
|
245
|
+
pl.Float64,
|
|
246
|
+
pl.String,
|
|
247
|
+
pl.Decimal,
|
|
248
|
+
pl.Date,
|
|
249
|
+
pl.Datetime,
|
|
250
|
+
)
|
|
251
|
+
if any(dtype.base_type() not in supported for dtype in frame.schema.values()):
|
|
252
|
+
raise CapabilityError("unsupported Polars dtype for faithful scalar diff capture")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _capture_duckdb_relation(
|
|
256
|
+
relation: duckdb.DuckDBPyRelation,
|
|
257
|
+
options: DiffCaptureOptions,
|
|
258
|
+
cancellation: CancellationToken | None,
|
|
259
|
+
) -> LoadedTable:
|
|
260
|
+
"""Consume only the real relation explicitly supplied by the caller.
|
|
261
|
+
|
|
262
|
+
No SQL text is accepted, reconstructed or loaded from evidence. The caller
|
|
263
|
+
owns the relation's connection/settings and must trust its native plan.
|
|
264
|
+
Cancellation is checked before execution and between batches; a blocking
|
|
265
|
+
native first fetch is not an interruptible or sandboxed operation.
|
|
266
|
+
"""
|
|
267
|
+
require_dependency("pyarrow", "duckdb")
|
|
268
|
+
import duckdb
|
|
269
|
+
import pyarrow as pa
|
|
270
|
+
|
|
271
|
+
from equiframe._duckdb_transport import duckdb_arrow_schema, verify_duckdb_table
|
|
272
|
+
|
|
273
|
+
def checkpoint() -> None:
|
|
274
|
+
if cancellation is not None and cancellation.cancelled:
|
|
275
|
+
raise ExecutionCancelled("cancelled during native DuckDB relation capture")
|
|
276
|
+
|
|
277
|
+
checkpoint()
|
|
278
|
+
reader = None
|
|
279
|
+
try:
|
|
280
|
+
native_types = tuple(zip(relation.columns, map(str, relation.types), strict=True))
|
|
281
|
+
duckdb_arrow_schema(native_types, "UTC")
|
|
282
|
+
# No count query, fetch-all or second consumption: this one result has
|
|
283
|
+
# at most one excess row, which lets capture reject rather than truncate.
|
|
284
|
+
reader = relation.limit(options.max_rows + 1).to_arrow_reader(
|
|
285
|
+
min(4096, options.max_rows + 1)
|
|
286
|
+
)
|
|
287
|
+
zones = {
|
|
288
|
+
field.type.tz
|
|
289
|
+
for field in reader.schema
|
|
290
|
+
if pa.types.is_timestamp(field.type) and field.type.tz is not None
|
|
291
|
+
}
|
|
292
|
+
if len(zones) > 1:
|
|
293
|
+
raise CapabilityError("DuckDB relation exposes inconsistent timestamp timezones")
|
|
294
|
+
timezone = next(iter(zones), "UTC")
|
|
295
|
+
expected = duckdb_arrow_schema(native_types, timezone)
|
|
296
|
+
if not reader.schema.equals(expected, check_metadata=True):
|
|
297
|
+
raise CapabilityError("DuckDB relation Arrow schema differs from its native types")
|
|
298
|
+
batches = []
|
|
299
|
+
rows = size = 0
|
|
300
|
+
while True:
|
|
301
|
+
checkpoint()
|
|
302
|
+
try:
|
|
303
|
+
batch = reader.read_next_batch()
|
|
304
|
+
except StopIteration:
|
|
305
|
+
break
|
|
306
|
+
rows += batch.num_rows
|
|
307
|
+
size += batch.nbytes
|
|
308
|
+
_check_size(rows, size, options)
|
|
309
|
+
batches.append(batch)
|
|
310
|
+
checkpoint()
|
|
311
|
+
table = pa.Table.from_batches(batches, schema=expected)
|
|
312
|
+
verify_duckdb_table(table, native_types, timezone)
|
|
313
|
+
captured = _finish(
|
|
314
|
+
LoadedTable(
|
|
315
|
+
table,
|
|
316
|
+
native_types=native_types,
|
|
317
|
+
notes=(
|
|
318
|
+
"Caller-supplied DuckDB relation explicitly consumed once for this input.",
|
|
319
|
+
"Native row order, Decimal precision, NULL and IEEE NaN are retained; "
|
|
320
|
+
"no index.",
|
|
321
|
+
"The caller owns relation/session settings; no SQL text or connection "
|
|
322
|
+
"is accepted.",
|
|
323
|
+
*_capture_versions("duckdb"),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
options,
|
|
327
|
+
)
|
|
328
|
+
checkpoint()
|
|
329
|
+
return captured
|
|
330
|
+
except (duckdb.Error, pa.ArrowException) as error:
|
|
331
|
+
raise InvalidExecutionInput(
|
|
332
|
+
"DuckDB relation cannot be captured as a native table"
|
|
333
|
+
) from error
|
|
334
|
+
finally:
|
|
335
|
+
if reader is not None:
|
|
336
|
+
reader.close()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def capture_native(
|
|
340
|
+
value: object, options: DiffCaptureOptions, *, cancellation: CancellationToken | None = None
|
|
341
|
+
) -> LoadedTable:
|
|
342
|
+
"""Capture a real eager table or explicitly supplied DuckDB relation.
|
|
343
|
+
|
|
344
|
+
Unsupported inputs raise a typed input/capability error. Native row/size
|
|
345
|
+
preflight runs before the first copy or conversion. Repeated calls create
|
|
346
|
+
separate snapshots; reuse is represented internally by ``_CapturedInput``.
|
|
347
|
+
"""
|
|
348
|
+
options = DiffCaptureOptions.model_validate(options)
|
|
349
|
+
if cancellation is not None and cancellation.cancelled:
|
|
350
|
+
raise ExecutionCancelled("cancelled before native input capture")
|
|
351
|
+
if _is_loaded_type(value, "polars", "LazyFrame"):
|
|
352
|
+
raise CapabilityError(
|
|
353
|
+
"diff does not execute a Polars LazyFrame; materialize it through "
|
|
354
|
+
"explicit native execution/capture first"
|
|
355
|
+
)
|
|
356
|
+
if _is_loaded_type(value, "duckdb", "DuckDBPyRelation") or _is_loaded_type(
|
|
357
|
+
value, "_duckdb", "DuckDBPyRelation"
|
|
358
|
+
):
|
|
359
|
+
module_name = (
|
|
360
|
+
"duckdb" if _is_loaded_type(value, "duckdb", "DuckDBPyRelation") else "_duckdb"
|
|
361
|
+
)
|
|
362
|
+
_require_base_class(value, module_name, "DuckDBPyRelation")
|
|
363
|
+
return _capture_duckdb_relation(
|
|
364
|
+
cast("duckdb.DuckDBPyRelation", value), options, cancellation
|
|
365
|
+
)
|
|
366
|
+
if _is_loaded_type(value, "pyarrow", "Table"):
|
|
367
|
+
_require_base_class(value, "pyarrow", "Table")
|
|
368
|
+
table = cast("pa.Table", value)
|
|
369
|
+
_check_size(table.num_rows, table.nbytes, options)
|
|
370
|
+
_check_metadata(table)
|
|
371
|
+
return _finish(
|
|
372
|
+
LoadedTable(
|
|
373
|
+
table,
|
|
374
|
+
native_types=tuple((field.name, str(field.type)) for field in table.schema),
|
|
375
|
+
notes=(
|
|
376
|
+
"Arrow retains distinctions already encoded in types, values "
|
|
377
|
+
"and validity masks.",
|
|
378
|
+
"Direct eager diff input captured once; no native execution performed.",
|
|
379
|
+
*_capture_versions("arrow"),
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
options,
|
|
383
|
+
)
|
|
384
|
+
if _is_loaded_type(value, "pandas", "DataFrame"):
|
|
385
|
+
_require_base_class(value, "pandas", "DataFrame")
|
|
386
|
+
frame = cast("pd.DataFrame", value)
|
|
387
|
+
_check_size(len(frame), 0, options)
|
|
388
|
+
_check_pandas_metadata(frame)
|
|
389
|
+
_check_size(len(frame), int(frame.memory_usage(index=True, deep=True).sum()), options)
|
|
390
|
+
require_dependency("pyarrow", "pandas")
|
|
391
|
+
import pyarrow as pa
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
captured = pandas_capture(copy_pandas_frame(frame))
|
|
395
|
+
except (pa.ArrowException, TypeError, ValueError, OverflowError) as error:
|
|
396
|
+
raise CapabilityError("Pandas input cannot be captured faithfully as Arrow") from error
|
|
397
|
+
return _finish(
|
|
398
|
+
replace(
|
|
399
|
+
captured,
|
|
400
|
+
notes=(
|
|
401
|
+
*captured.notes,
|
|
402
|
+
"Direct eager diff input captured once; no native execution performed.",
|
|
403
|
+
*_capture_versions("pandas"),
|
|
404
|
+
),
|
|
405
|
+
),
|
|
406
|
+
options,
|
|
407
|
+
)
|
|
408
|
+
if _is_loaded_type(value, "polars", "DataFrame"):
|
|
409
|
+
_require_base_class(value, "polars", "DataFrame")
|
|
410
|
+
frame_pl = cast("pl.DataFrame", value)
|
|
411
|
+
_check_size(frame_pl.height, int(frame_pl.estimated_size()), options)
|
|
412
|
+
_check_polars_schema(frame_pl)
|
|
413
|
+
require_dependency("pyarrow", "polars")
|
|
414
|
+
copied = frame_pl.clone()
|
|
415
|
+
table = copied.to_arrow()
|
|
416
|
+
if table.shape != copied.shape:
|
|
417
|
+
raise CapabilityError("Polars direct capture changed table dimensions")
|
|
418
|
+
_check_metadata(table)
|
|
419
|
+
return _finish(
|
|
420
|
+
LoadedTable(
|
|
421
|
+
table,
|
|
422
|
+
native_types=tuple((name, str(dtype)) for name, dtype in copied.schema.items()),
|
|
423
|
+
notes=(
|
|
424
|
+
"Direct eager diff input captured once; no native execution performed.",
|
|
425
|
+
*_capture_versions("polars"),
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
options,
|
|
429
|
+
)
|
|
430
|
+
raise InvalidExecutionInput(
|
|
431
|
+
"diff native input must be an eager pyarrow.Table, pandas.DataFrame, polars.DataFrame "
|
|
432
|
+
"or an explicitly supplied DuckDBPyRelation"
|
|
433
|
+
)
|
equiframe/_doctor.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Local installation metadata checks; no optional imports or network access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from importlib.util import find_spec
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Extra(str, Enum):
|
|
13
|
+
base = "base"
|
|
14
|
+
arrow = "arrow"
|
|
15
|
+
pandas = "pandas"
|
|
16
|
+
polars = "polars"
|
|
17
|
+
duckdb = "duckdb"
|
|
18
|
+
engines = "engines"
|
|
19
|
+
files = "files"
|
|
20
|
+
ml = "ml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_BASE = (
|
|
24
|
+
("typing-extensions", "typing_extensions"),
|
|
25
|
+
("pydantic", "pydantic"),
|
|
26
|
+
("typer", "typer"),
|
|
27
|
+
("tzdata", "tzdata"),
|
|
28
|
+
)
|
|
29
|
+
_ARROW = (("pyarrow", "pyarrow"),)
|
|
30
|
+
_EXTRAS = {
|
|
31
|
+
Extra.base: (),
|
|
32
|
+
Extra.arrow: _ARROW,
|
|
33
|
+
Extra.pandas: (*_ARROW, ("pandas", "pandas")),
|
|
34
|
+
Extra.polars: (*_ARROW, ("polars", "polars")),
|
|
35
|
+
Extra.duckdb: (*_ARROW, ("duckdb", "duckdb")),
|
|
36
|
+
Extra.engines: (*_ARROW, ("pandas", "pandas"), ("polars", "polars"), ("duckdb", "duckdb")),
|
|
37
|
+
Extra.files: (
|
|
38
|
+
*_ARROW,
|
|
39
|
+
("pandas", "pandas"),
|
|
40
|
+
("openpyxl", "openpyxl"),
|
|
41
|
+
("xlrd", "xlrd"),
|
|
42
|
+
("defusedxml", "defusedxml"),
|
|
43
|
+
),
|
|
44
|
+
Extra.ml: (("numpy", "numpy"), ("scikit-learn", "sklearn")),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def diagnose(extra: Extra) -> dict[str, Any]:
|
|
49
|
+
"""Report availability only; pip check and a native operation remain necessary."""
|
|
50
|
+
from equiframe import __version__
|
|
51
|
+
|
|
52
|
+
dependencies: list[dict[str, str | None]] = []
|
|
53
|
+
for distribution, module in _BASE + _EXTRAS[extra]:
|
|
54
|
+
try:
|
|
55
|
+
version = metadata.version(distribution)
|
|
56
|
+
except metadata.PackageNotFoundError:
|
|
57
|
+
version = None
|
|
58
|
+
available = find_spec(module) is not None
|
|
59
|
+
dependencies.append(
|
|
60
|
+
{
|
|
61
|
+
"distribution": distribution,
|
|
62
|
+
"version": version,
|
|
63
|
+
"status": "available" if version is not None and available else "missing",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
installed_version = metadata.version("equiframe")
|
|
68
|
+
except metadata.PackageNotFoundError:
|
|
69
|
+
installed_version = None
|
|
70
|
+
identity = installed_version == __version__
|
|
71
|
+
python_supported = (3, 10) <= sys.version_info[:2] < (3, 14)
|
|
72
|
+
ready = (
|
|
73
|
+
identity
|
|
74
|
+
and python_supported
|
|
75
|
+
and all(item["status"] == "available" for item in dependencies)
|
|
76
|
+
)
|
|
77
|
+
if not identity:
|
|
78
|
+
next_action = (
|
|
79
|
+
"Reinstall the identified private candidate in this interpreter; "
|
|
80
|
+
"code and distribution metadata must have the same version."
|
|
81
|
+
)
|
|
82
|
+
elif not python_supported:
|
|
83
|
+
next_action = "Use a supported Python minor (3.10-3.13) with its matching artifact receipt."
|
|
84
|
+
elif not ready:
|
|
85
|
+
requirement = "equiframe" + (f"[{extra.value}]" if extra != Extra.base else "")
|
|
86
|
+
next_action = (
|
|
87
|
+
f"Install {requirement}=={__version__} from the reviewed private wheelhouse "
|
|
88
|
+
"in this interpreter; do not install an unrelated public homonym."
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
next_action = (
|
|
92
|
+
"Run python -m pip check in this interpreter, then the documented native "
|
|
93
|
+
"example for your task. Metadata availability does not test binary compatibility."
|
|
94
|
+
)
|
|
95
|
+
return {
|
|
96
|
+
"schema_version": "1.0",
|
|
97
|
+
"extra": extra.value,
|
|
98
|
+
"code_version": __version__,
|
|
99
|
+
"installed_version": installed_version,
|
|
100
|
+
"identity_matches": identity,
|
|
101
|
+
"python": ".".join(map(str, sys.version_info[:3])),
|
|
102
|
+
"python_admitted": python_supported,
|
|
103
|
+
"dependencies": dependencies,
|
|
104
|
+
"ready_metadata": ready,
|
|
105
|
+
"native_execution_tested": False,
|
|
106
|
+
"dependency_ranges_tested": False,
|
|
107
|
+
"next_action": next_action,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def diagnostic_text(result: dict[str, Any]) -> str:
|
|
112
|
+
"""A short human view of the same machine-readable diagnosis."""
|
|
113
|
+
lines = [
|
|
114
|
+
f"EquiFrame {result['code_version']} | Python {result['python']} | extra {result['extra']}",
|
|
115
|
+
f"Distribution version: {result['installed_version'] or 'missing'}",
|
|
116
|
+
f"Metadata ready: {result['ready_metadata']}",
|
|
117
|
+
]
|
|
118
|
+
lines.extend(
|
|
119
|
+
f" {item['distribution']}: {item['status']} ({item['version'] or 'missing'})"
|
|
120
|
+
for item in result["dependencies"]
|
|
121
|
+
)
|
|
122
|
+
lines.append("Next action: " + result["next_action"])
|
|
123
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared local document I/O with unchanged historical byte and error contracts.
|
|
2
|
+
|
|
3
|
+
This module does not serialize models or canonicalize JSON. Each caller keeps
|
|
4
|
+
its existing encoding, newline policy, fingerprint and format version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from equiframe._regular_io import local_input_path, open_regular_file, regular_directory
|
|
14
|
+
from equiframe.errors import ArtifactIOError, ResourceLimitError
|
|
15
|
+
from equiframe.serialization import _constant, _JsonObject, _unique_objects
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_document(path: Path, payload: bytes) -> None:
|
|
19
|
+
"""Publish exclusively. A previous profile or bundle is never overwritten."""
|
|
20
|
+
write_document_chunks(path, (payload,))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_document_chunks(path: Path, payloads: Iterable[bytes]) -> None:
|
|
24
|
+
"""Publish byte chunks exclusively; iterator failures never publish a prefix."""
|
|
25
|
+
path = local_input_path(path)
|
|
26
|
+
try:
|
|
27
|
+
regular_directory(path.parent)
|
|
28
|
+
except ArtifactIOError as error:
|
|
29
|
+
cause = error.__cause__
|
|
30
|
+
if isinstance(cause, OSError):
|
|
31
|
+
raise type(cause)(cause.errno, "document directory cannot be used") from None
|
|
32
|
+
raise
|
|
33
|
+
temporary = path.with_name(path.name + "." + uuid4().hex + ".part")
|
|
34
|
+
try:
|
|
35
|
+
with temporary.open("xb") as output:
|
|
36
|
+
for payload in payloads:
|
|
37
|
+
output.write(payload)
|
|
38
|
+
output.flush()
|
|
39
|
+
os.fsync(output.fileno())
|
|
40
|
+
os.link(temporary, path)
|
|
41
|
+
finally:
|
|
42
|
+
temporary.unlink(missing_ok=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_document(path: Path) -> bytes:
|
|
46
|
+
try:
|
|
47
|
+
with open_regular_file(path) as stream:
|
|
48
|
+
raw = stream.read(64 * 1024 * 1024 + 1)
|
|
49
|
+
except ArtifactIOError as error:
|
|
50
|
+
# Keep the original OS-error contract used by profile/detector and
|
|
51
|
+
# benchmark readers, while omitting filenames and external messages.
|
|
52
|
+
cause = error.__cause__
|
|
53
|
+
if isinstance(cause, OSError):
|
|
54
|
+
raise type(cause)(cause.errno, "document cannot be read") from None
|
|
55
|
+
raise
|
|
56
|
+
if len(raw) > 64 * 1024 * 1024:
|
|
57
|
+
raise ResourceLimitError("document exceeds the 64 MiB read budget")
|
|
58
|
+
_unique_objects(
|
|
59
|
+
json.loads(raw.decode("utf-8"), object_pairs_hook=_JsonObject, parse_constant=_constant)
|
|
60
|
+
)
|
|
61
|
+
return raw
|
equiframe/_documents.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Private strict document primitives shared with the experimental package.
|
|
2
|
+
|
|
3
|
+
This preserves the historical ASCII-escaped JSON v1 encoding exactly. It is
|
|
4
|
+
intentionally distinct from contract canonicalization; do not silently switch
|
|
5
|
+
encodings or normalize existing fingerprints when refactoring consumers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Literal
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, StringConstraints
|
|
16
|
+
|
|
17
|
+
from equiframe.types import ArtifactRef
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def canonical_text(value: str) -> str:
|
|
21
|
+
if not value.strip() or value != value.strip():
|
|
22
|
+
raise ValueError("text and identities must be canonical, without surrounding whitespace")
|
|
23
|
+
if any(0xD800 <= ord(c) <= 0xDFFF for c in value):
|
|
24
|
+
raise ValueError("invalid Unicode scalar")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Text = Annotated[str, StringConstraints(min_length=1), AfterValidator(canonical_text)]
|
|
29
|
+
Digest = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Document(BaseModel):
|
|
33
|
+
model_config = ConfigDict(
|
|
34
|
+
strict=True,
|
|
35
|
+
frozen=True,
|
|
36
|
+
extra="forbid",
|
|
37
|
+
allow_inf_nan=False,
|
|
38
|
+
revalidate_instances="always",
|
|
39
|
+
validate_default=True,
|
|
40
|
+
)
|
|
41
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def canonical_document_bytes(document: BaseModel) -> bytes:
|
|
45
|
+
"""Preserve the historical document encoding; callers revalidate their model."""
|
|
46
|
+
return json.dumps(
|
|
47
|
+
document.model_dump(mode="json"),
|
|
48
|
+
sort_keys=True,
|
|
49
|
+
separators=(",", ":"),
|
|
50
|
+
ensure_ascii=True,
|
|
51
|
+
allow_nan=False,
|
|
52
|
+
).encode()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def digest(document: BaseModel) -> str:
|
|
56
|
+
return hashlib.sha256(canonical_document_bytes(document)).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def checked_artifact(ref: ArtifactRef) -> ArtifactRef:
|
|
60
|
+
if not ref.sha256 or ref.size_bytes is None or ref.format not in ("arrow", "parquet"):
|
|
61
|
+
raise ValueError("tabular evidence requires SHA-256, size and Arrow/Parquet format")
|
|
62
|
+
return ref
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Evidence = Annotated[ArtifactRef, AfterValidator(checked_artifact)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_document(document: BaseModel, path: Path) -> None:
|
|
69
|
+
document = type(document).model_validate(document)
|
|
70
|
+
data = canonical_document_bytes(document)
|
|
71
|
+
temporary = path.with_name(path.name + "." + uuid4().hex + ".part")
|
|
72
|
+
try:
|
|
73
|
+
with temporary.open("xb") as stream:
|
|
74
|
+
stream.write(data + b"\n")
|
|
75
|
+
stream.flush()
|
|
76
|
+
os.fsync(stream.fileno())
|
|
77
|
+
os.link(temporary, path)
|
|
78
|
+
finally:
|
|
79
|
+
temporary.unlink(missing_ok=True)
|