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/tables.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Explicit in-memory/local inputs and loss-aware Arrow IPC capture.
|
|
2
|
+
|
|
3
|
+
Arrow is optional and imported only when a table is accessed. Expected contract
|
|
4
|
+
types never drive casts of observed data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from importlib import import_module
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from equiframe.errors import CapabilityError, InvalidExecutionInput, MissingDependencyError
|
|
20
|
+
from equiframe.execution_types import PhysicalColumn, TableArtifact
|
|
21
|
+
from equiframe.types import ArtifactRef
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import pandas as pd
|
|
25
|
+
import polars as pl
|
|
26
|
+
import pyarrow as pa
|
|
27
|
+
|
|
28
|
+
from equiframe.execution import RunContext
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def require_dependency(name: str, extra: str) -> None:
|
|
32
|
+
try:
|
|
33
|
+
import_module(name)
|
|
34
|
+
except ImportError as error:
|
|
35
|
+
raise MissingDependencyError(
|
|
36
|
+
f"optional dependency {name} is unavailable; install 'equiframe[{extra}]' "
|
|
37
|
+
"from the team's wheel/checkout (public PyPI release is pending)"
|
|
38
|
+
) from error
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class LoadedTable:
|
|
43
|
+
table: pa.Table
|
|
44
|
+
native: object | None = None
|
|
45
|
+
native_types: tuple[tuple[str, str], ...] = ()
|
|
46
|
+
index: pa.Table | None = None
|
|
47
|
+
meaningful_index: bool = False
|
|
48
|
+
origin: ArtifactRef | None = None
|
|
49
|
+
notes: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_arrow(table: pa.Table) -> None:
|
|
53
|
+
import pyarrow as pa
|
|
54
|
+
|
|
55
|
+
if not isinstance(table, pa.Table):
|
|
56
|
+
raise InvalidExecutionInput("an Arrow Table is required")
|
|
57
|
+
if len(set(table.column_names)) != table.num_columns or any(not n for n in table.column_names):
|
|
58
|
+
raise CapabilityError("table column names must be nonempty and unique")
|
|
59
|
+
try:
|
|
60
|
+
table.validate(full=True)
|
|
61
|
+
except pa.ArrowException as error:
|
|
62
|
+
raise InvalidExecutionInput(
|
|
63
|
+
"Arrow table violates physical type/value invariants"
|
|
64
|
+
) from error
|
|
65
|
+
for field in table.schema:
|
|
66
|
+
dtype = field.type
|
|
67
|
+
if not (
|
|
68
|
+
pa.types.is_null(dtype)
|
|
69
|
+
or pa.types.is_boolean(dtype)
|
|
70
|
+
or pa.types.is_integer(dtype)
|
|
71
|
+
or pa.types.is_string(dtype)
|
|
72
|
+
or pa.types.is_large_string(dtype)
|
|
73
|
+
or pa.types.is_string_view(dtype)
|
|
74
|
+
or (pa.types.is_floating(dtype) and dtype.bit_width in (32, 64))
|
|
75
|
+
or pa.types.is_decimal128(dtype)
|
|
76
|
+
or pa.types.is_decimal256(dtype)
|
|
77
|
+
or pa.types.is_date32(dtype)
|
|
78
|
+
or pa.types.is_date64(dtype)
|
|
79
|
+
or pa.types.is_timestamp(dtype)
|
|
80
|
+
):
|
|
81
|
+
raise CapabilityError(f"unsupported faithful table transport type: {dtype}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detached_arrow(table: pa.Table) -> pa.Table:
|
|
85
|
+
import pyarrow as pa
|
|
86
|
+
|
|
87
|
+
check_arrow(table)
|
|
88
|
+
sink = pa.BufferOutputStream()
|
|
89
|
+
with pa.ipc.new_file(sink, table.schema) as writer:
|
|
90
|
+
writer.write_table(table)
|
|
91
|
+
with pa.ipc.open_file(sink.getvalue()) as reader:
|
|
92
|
+
return cast("pa.Table", reader.read_all())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def copy_pandas_frame(frame: pd.DataFrame) -> pd.DataFrame:
|
|
96
|
+
"""Isolate both values and index/column buffers, including injected native frames."""
|
|
97
|
+
copied = frame.copy(deep=True)
|
|
98
|
+
copied.index = frame.index.copy(deep=True)
|
|
99
|
+
copied.columns = frame.columns.copy(deep=True)
|
|
100
|
+
return copied
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def pandas_capture(frame: pd.DataFrame) -> LoadedTable:
|
|
104
|
+
import pandas as pd
|
|
105
|
+
import pyarrow as pa
|
|
106
|
+
|
|
107
|
+
if not isinstance(frame, pd.DataFrame):
|
|
108
|
+
raise InvalidExecutionInput("Pandas transformation/input must produce a DataFrame")
|
|
109
|
+
if isinstance(frame.columns, pd.CategoricalIndex):
|
|
110
|
+
raise CapabilityError("categorical Pandas column labels cannot be captured faithfully")
|
|
111
|
+
if (
|
|
112
|
+
not all(isinstance(name, str) and name for name in frame.columns)
|
|
113
|
+
or not frame.columns.is_unique
|
|
114
|
+
):
|
|
115
|
+
raise CapabilityError("Pandas column labels must be unique nonempty strings")
|
|
116
|
+
if frame.shape[1] == 0 and frame.shape[0] != 0:
|
|
117
|
+
raise CapabilityError(
|
|
118
|
+
"Pandas tables with rows but zero columns cannot be captured faithfully"
|
|
119
|
+
)
|
|
120
|
+
notes = [
|
|
121
|
+
"Pandas missing sentinels already conflated by input construction cannot be recovered."
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def array_of(series: pd.Series[Any], name: str) -> pa.Array | pa.ChunkedArray:
|
|
125
|
+
dtype = series.dtype
|
|
126
|
+
if isinstance(dtype, pd.CategoricalDtype):
|
|
127
|
+
raise CapabilityError("Pandas categories/ordering cannot be captured as scalar labels")
|
|
128
|
+
if isinstance(dtype, pd.ArrowDtype):
|
|
129
|
+
if pa.types.is_timestamp(dtype.pyarrow_dtype):
|
|
130
|
+
notes.append(
|
|
131
|
+
f"{name}: Arrow zone metadata; original offset/provider version absent."
|
|
132
|
+
)
|
|
133
|
+
return pa.array(series.array, from_pandas=False)
|
|
134
|
+
if pd.api.types.is_datetime64_any_dtype(dtype):
|
|
135
|
+
tzinfo = getattr(dtype, "tz", None)
|
|
136
|
+
provider = type(tzinfo).__module__ + "." + type(tzinfo).__qualname__
|
|
137
|
+
notes.append(f"{name}: input tzinfo provider={provider}; IANA release not exposed.")
|
|
138
|
+
return pa.array(series, from_pandas=True)
|
|
139
|
+
if (
|
|
140
|
+
pd.api.types.is_integer_dtype(dtype)
|
|
141
|
+
or pd.api.types.is_bool_dtype(dtype)
|
|
142
|
+
or pd.api.types.is_float_dtype(dtype)
|
|
143
|
+
or isinstance(dtype, pd.StringDtype)
|
|
144
|
+
):
|
|
145
|
+
if pd.api.types.is_extension_array_dtype(dtype):
|
|
146
|
+
# Extension arrays expose physical widths and their independent validity mask.
|
|
147
|
+
return pa.array(series.array, from_pandas=False)
|
|
148
|
+
if pd.api.types.is_float_dtype(dtype):
|
|
149
|
+
notes.append(
|
|
150
|
+
f"{name}: NumPy float has no independent NULL mask; observed NaNs retained."
|
|
151
|
+
)
|
|
152
|
+
return pa.array(series.to_numpy(copy=True), from_pandas=False)
|
|
153
|
+
if not pd.api.types.is_object_dtype(dtype):
|
|
154
|
+
raise CapabilityError("unsupported native Pandas dtype for faithful scalar transport")
|
|
155
|
+
values = [None if value is pd.NA or value is pd.NaT else value for value in series]
|
|
156
|
+
if any(isinstance(v, datetime) for v in values if v is not None):
|
|
157
|
+
raise CapabilityError(
|
|
158
|
+
"object timestamps need a temporal/Arrow dtype to preserve precision"
|
|
159
|
+
)
|
|
160
|
+
if any(
|
|
161
|
+
v is not None and not isinstance(v, (str, bool, int, float, Decimal, date))
|
|
162
|
+
for v in values
|
|
163
|
+
):
|
|
164
|
+
raise CapabilityError(
|
|
165
|
+
"Pandas object columns support only immutable scalar transport values"
|
|
166
|
+
)
|
|
167
|
+
# Inferring a common float/decimal dtype would erase per-cell numeric kinds.
|
|
168
|
+
kinds = {type(v) for v in values if v is not None}
|
|
169
|
+
if len(kinds) > 1:
|
|
170
|
+
raise CapabilityError(
|
|
171
|
+
"heterogeneous Pandas object scalars require an explicit faithful dtype"
|
|
172
|
+
)
|
|
173
|
+
decimals = [v for v in values if isinstance(v, Decimal)]
|
|
174
|
+
if decimals:
|
|
175
|
+
if any(not v.is_finite() or (v.is_zero() and v.is_signed()) for v in decimals):
|
|
176
|
+
raise CapabilityError(
|
|
177
|
+
"Arrow decimal cannot retain nonfinite or signed-zero Decimal"
|
|
178
|
+
)
|
|
179
|
+
if len({v.as_tuple().exponent for v in decimals}) != 1:
|
|
180
|
+
raise CapabilityError(
|
|
181
|
+
"Arrow fixed-scale decimal cannot retain per-cell exponent differences"
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
return pa.array(values, from_pandas=False)
|
|
185
|
+
except (pa.ArrowException, TypeError, ValueError, OverflowError) as error:
|
|
186
|
+
raise CapabilityError(
|
|
187
|
+
"Pandas values cannot be captured faithfully as Arrow scalars"
|
|
188
|
+
) from error
|
|
189
|
+
|
|
190
|
+
table = pa.Table.from_arrays(
|
|
191
|
+
[array_of(frame[n], n) for n in frame.columns], names=list(frame.columns)
|
|
192
|
+
)
|
|
193
|
+
check_arrow(table)
|
|
194
|
+
index_frame = frame.index.to_frame(index=False)
|
|
195
|
+
index_frame.columns = [f"level_{i}" for i in range(len(index_frame.columns))]
|
|
196
|
+
index = pa.Table.from_arrays(
|
|
197
|
+
[array_of(index_frame[n], "index." + n) for n in index_frame.columns],
|
|
198
|
+
names=list(index_frame.columns),
|
|
199
|
+
)
|
|
200
|
+
check_arrow(index)
|
|
201
|
+
notes.append("Pandas index stored separately; names=" + repr(tuple(frame.index.names)))
|
|
202
|
+
notes.append("Pandas index dtypes=" + repr(tuple(str(t) for t in index_frame.dtypes)))
|
|
203
|
+
notes.append("Pandas column axis name=" + repr(frame.columns.name))
|
|
204
|
+
meaningful = not (
|
|
205
|
+
isinstance(frame.index, pd.RangeIndex)
|
|
206
|
+
and frame.index.start == 0
|
|
207
|
+
and frame.index.step == 1
|
|
208
|
+
and frame.index.name is None
|
|
209
|
+
)
|
|
210
|
+
return LoadedTable(
|
|
211
|
+
table=table,
|
|
212
|
+
native=frame,
|
|
213
|
+
native_types=tuple((str(n), str(t)) for n, t in frame.dtypes.items()),
|
|
214
|
+
index=index,
|
|
215
|
+
meaningful_index=meaningful,
|
|
216
|
+
notes=tuple(notes),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def capture_artifact(
|
|
221
|
+
name: str, loaded: LoadedTable, context: RunContext, *, output: bool = False
|
|
222
|
+
) -> TableArtifact:
|
|
223
|
+
import pyarrow as pa
|
|
224
|
+
import pyarrow.compute as pc
|
|
225
|
+
|
|
226
|
+
check_arrow(loaded.table)
|
|
227
|
+
context.check_table(loaded.table, output=output, enforce_rows=False)
|
|
228
|
+
if loaded.index is not None:
|
|
229
|
+
check_arrow(loaded.index)
|
|
230
|
+
if loaded.index.num_rows != loaded.table.num_rows:
|
|
231
|
+
raise InvalidExecutionInput("index row count must match table row count")
|
|
232
|
+
context.check_table(loaded.index, output=output, enforce_rows=False)
|
|
233
|
+
|
|
234
|
+
def persist(table: pa.Table) -> ArtifactRef:
|
|
235
|
+
path = context.directory / (uuid4().hex + ".arrow")
|
|
236
|
+
try:
|
|
237
|
+
with pa.OSFile(str(path), "wb") as sink, pa.ipc.new_file(sink, table.schema) as writer:
|
|
238
|
+
writer.write_table(table)
|
|
239
|
+
return context.store.put(path, media_type="application/vnd.apache.arrow.file")
|
|
240
|
+
finally:
|
|
241
|
+
path.unlink(missing_ok=True)
|
|
242
|
+
|
|
243
|
+
def describe(
|
|
244
|
+
table: pa.Table, native: tuple[tuple[str, str], ...]
|
|
245
|
+
) -> tuple[PhysicalColumn, ...]:
|
|
246
|
+
types = dict(native)
|
|
247
|
+
return tuple(
|
|
248
|
+
PhysicalColumn(
|
|
249
|
+
name=field.name,
|
|
250
|
+
engine_type=types.get(field.name, str(field.type)),
|
|
251
|
+
arrow_type=str(field.type),
|
|
252
|
+
null_count=table.column(field.name).null_count,
|
|
253
|
+
nan_count=(
|
|
254
|
+
int(pc.sum(pc.is_nan(table.column(field.name))).as_py() or 0)
|
|
255
|
+
if pa.types.is_floating(field.type)
|
|
256
|
+
else 0
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
for field in table.schema
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
from pydantic import TypeAdapter
|
|
263
|
+
|
|
264
|
+
from equiframe._base import Text
|
|
265
|
+
|
|
266
|
+
# Validate all metadata before publishing bytes. A successful data publication
|
|
267
|
+
# is retained in the failed result even if the separate index publication fails.
|
|
268
|
+
notes = TypeAdapter(tuple[Text, ...]).validate_python(loaded.notes, strict=True)
|
|
269
|
+
origin = ArtifactRef.model_validate(loaded.origin) if loaded.origin is not None else None
|
|
270
|
+
columns = describe(loaded.table, loaded.native_types)
|
|
271
|
+
index_columns = describe(loaded.index, ()) if loaded.index is not None else ()
|
|
272
|
+
artifact = TableArtifact(
|
|
273
|
+
name=name,
|
|
274
|
+
data=persist(loaded.table),
|
|
275
|
+
origin=origin,
|
|
276
|
+
row_count=loaded.table.num_rows,
|
|
277
|
+
columns=columns,
|
|
278
|
+
notes=notes
|
|
279
|
+
+ (
|
|
280
|
+
("Index capture incomplete; see execution failure.",)
|
|
281
|
+
if loaded.index is not None
|
|
282
|
+
else ()
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
if output:
|
|
286
|
+
context.captured_output = artifact
|
|
287
|
+
else:
|
|
288
|
+
context.captured_inputs.append(artifact)
|
|
289
|
+
if loaded.index is not None:
|
|
290
|
+
index_ref = persist(loaded.index)
|
|
291
|
+
artifact = TableArtifact(
|
|
292
|
+
name=name,
|
|
293
|
+
data=artifact.data,
|
|
294
|
+
origin=origin,
|
|
295
|
+
row_count=loaded.table.num_rows,
|
|
296
|
+
columns=columns,
|
|
297
|
+
index=index_ref,
|
|
298
|
+
index_columns=index_columns,
|
|
299
|
+
notes=notes,
|
|
300
|
+
)
|
|
301
|
+
if output:
|
|
302
|
+
context.captured_output = artifact
|
|
303
|
+
else:
|
|
304
|
+
context.captured_inputs[-1] = artifact
|
|
305
|
+
return artifact
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass(frozen=True)
|
|
309
|
+
class ArrowInput:
|
|
310
|
+
table: pa.Table
|
|
311
|
+
|
|
312
|
+
def load(self, context: RunContext) -> LoadedTable:
|
|
313
|
+
require_dependency("pyarrow", "arrow")
|
|
314
|
+
context.check_table(self.table, output=False)
|
|
315
|
+
return LoadedTable(
|
|
316
|
+
detached_arrow(self.table),
|
|
317
|
+
notes=(
|
|
318
|
+
"Arrow retains distinctions already encoded in types, values and validity masks.",
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(frozen=True)
|
|
324
|
+
class PandasInput:
|
|
325
|
+
frame: pd.DataFrame
|
|
326
|
+
index_policy: Literal["preserve", "columns", "discard"] = "preserve"
|
|
327
|
+
|
|
328
|
+
def load(self, context: RunContext) -> LoadedTable:
|
|
329
|
+
require_dependency("pandas", "pandas")
|
|
330
|
+
require_dependency("pyarrow", "pandas")
|
|
331
|
+
import pandas as pd
|
|
332
|
+
|
|
333
|
+
if not isinstance(self.frame, pd.DataFrame):
|
|
334
|
+
raise InvalidExecutionInput("PandasInput requires a DataFrame")
|
|
335
|
+
if self.index_policy not in ("preserve", "columns", "discard"):
|
|
336
|
+
raise InvalidExecutionInput("unknown Pandas index policy")
|
|
337
|
+
# Supported cells are immutable scalars; deep=True then isolates container mutation.
|
|
338
|
+
copied = copy_pandas_frame(self.frame)
|
|
339
|
+
if self.index_policy == "columns":
|
|
340
|
+
copied = copied.reset_index()
|
|
341
|
+
elif self.index_policy == "discard":
|
|
342
|
+
copied = copied.reset_index(drop=True)
|
|
343
|
+
loaded = pandas_capture(copied)
|
|
344
|
+
context.check_table(loaded.table, output=False)
|
|
345
|
+
return loaded
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(frozen=True)
|
|
349
|
+
class PolarsInput:
|
|
350
|
+
frame: pl.DataFrame | pl.LazyFrame
|
|
351
|
+
|
|
352
|
+
def load(self, context: RunContext) -> LoadedTable:
|
|
353
|
+
require_dependency("polars", "polars")
|
|
354
|
+
require_dependency("pyarrow", "polars")
|
|
355
|
+
import polars as pl
|
|
356
|
+
|
|
357
|
+
if not isinstance(self.frame, (pl.DataFrame, pl.LazyFrame)):
|
|
358
|
+
raise InvalidExecutionInput("PolarsInput requires a DataFrame or LazyFrame")
|
|
359
|
+
with context.measure("input_materialization"):
|
|
360
|
+
frame = (
|
|
361
|
+
self.frame.collect() if isinstance(self.frame, pl.LazyFrame) else self.frame.clone()
|
|
362
|
+
)
|
|
363
|
+
table = frame.to_arrow()
|
|
364
|
+
if table.shape != frame.shape:
|
|
365
|
+
raise CapabilityError("Polars input capture changed table dimensions")
|
|
366
|
+
check_arrow(table)
|
|
367
|
+
context.check_table(table, output=False)
|
|
368
|
+
return LoadedTable(
|
|
369
|
+
table, native=frame, native_types=tuple((n, str(t)) for n, t in frame.schema.items())
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass(frozen=True)
|
|
374
|
+
class ArtifactInput:
|
|
375
|
+
ref: ArtifactRef
|
|
376
|
+
|
|
377
|
+
def load(self, context: RunContext) -> LoadedTable:
|
|
378
|
+
# Reject remote URIs even if the optional transport is not installed.
|
|
379
|
+
from equiframe.local import local_path
|
|
380
|
+
|
|
381
|
+
local_path(self.ref)
|
|
382
|
+
if self.ref.format not in ("arrow", "parquet"):
|
|
383
|
+
raise CapabilityError("tabular artifact format must be Arrow IPC file or Parquet")
|
|
384
|
+
require_dependency("pyarrow", "arrow")
|
|
385
|
+
import pyarrow as pa
|
|
386
|
+
import pyarrow.parquet as pq
|
|
387
|
+
|
|
388
|
+
if (
|
|
389
|
+
context.plan.options.max_transport_bytes is not None
|
|
390
|
+
and self.ref.size_bytes > context.plan.options.max_transport_bytes
|
|
391
|
+
):
|
|
392
|
+
from equiframe.errors import ResourceLimitError
|
|
393
|
+
|
|
394
|
+
raise ResourceLimitError("input artifact exceeds transport byte budget")
|
|
395
|
+
path = context.directory / ("input_" + uuid4().hex + "." + self.ref.format)
|
|
396
|
+
try:
|
|
397
|
+
context.store.get(self.ref, path)
|
|
398
|
+
if self.ref.format == "parquet":
|
|
399
|
+
table = pq.read_table(path)
|
|
400
|
+
else:
|
|
401
|
+
with path.open("rb") as source_file, pa.ipc.open_file(source_file) as reader:
|
|
402
|
+
table = reader.read_all()
|
|
403
|
+
check_arrow(table)
|
|
404
|
+
context.check_table(table, output=False)
|
|
405
|
+
return LoadedTable(
|
|
406
|
+
table, origin=self.ref, notes=("Source file SHA-256 and size verified.",)
|
|
407
|
+
)
|
|
408
|
+
finally:
|
|
409
|
+
path.unlink(missing_ok=True)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if TYPE_CHECKING:
|
|
413
|
+
from typing import Protocol
|
|
414
|
+
|
|
415
|
+
class TableInput(Protocol):
|
|
416
|
+
def load(self, context: RunContext) -> LoadedTable: ...
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class LocalDataSource:
|
|
420
|
+
def __init__(self, inputs: Mapping[str, TableInput | ArtifactRef]) -> None:
|
|
421
|
+
self._inputs = dict(inputs)
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def table_names(self) -> tuple[str, ...]:
|
|
425
|
+
return tuple(self._inputs)
|
|
426
|
+
|
|
427
|
+
def resolve(self, name: str, *, context: RunContext) -> LoadedTable:
|
|
428
|
+
try:
|
|
429
|
+
value = self._inputs[name]
|
|
430
|
+
except KeyError:
|
|
431
|
+
raise InvalidExecutionInput("input table is unavailable: " + name) from None
|
|
432
|
+
return (
|
|
433
|
+
ArtifactInput(value).load(context)
|
|
434
|
+
if isinstance(value, ArtifactRef)
|
|
435
|
+
else value.load(context)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def read_table(ref: ArtifactRef) -> pa.Table:
|
|
440
|
+
"""Read a local Arrow IPC/Parquet reference after verifying its bytes and size."""
|
|
441
|
+
from equiframe.local import LocalArtifactStore, local_path
|
|
442
|
+
|
|
443
|
+
local_path(ref)
|
|
444
|
+
if ref.format not in ("arrow", "parquet"):
|
|
445
|
+
raise CapabilityError("tabular artifact format must be Arrow IPC file or Parquet")
|
|
446
|
+
require_dependency("pyarrow", "arrow")
|
|
447
|
+
import pyarrow as pa
|
|
448
|
+
import pyarrow.parquet as pq
|
|
449
|
+
|
|
450
|
+
with tempfile.TemporaryDirectory(prefix="equiframe-read-") as directory:
|
|
451
|
+
path = Path(directory) / ("input." + ref.format)
|
|
452
|
+
LocalArtifactStore(Path(directory)).get(ref, path)
|
|
453
|
+
if ref.format == "parquet":
|
|
454
|
+
table = pq.read_table(path)
|
|
455
|
+
else:
|
|
456
|
+
with path.open("rb") as source_file, pa.ipc.open_file(source_file) as reader:
|
|
457
|
+
table = reader.read_all()
|
|
458
|
+
check_arrow(table)
|
|
459
|
+
return cast("pa.Table", table)
|
equiframe/types.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Public states and minimal immutable artifact metadata (no execution results yet)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, model_validator
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from equiframe._base import FrozenModel, Identifier, NonNegativeInt, PositiveInt, Text, fail
|
|
11
|
+
|
|
12
|
+
ExecutionState = Literal[
|
|
13
|
+
"completed", "invalid_input", "failed", "timeout", "resource_exhausted", "cancelled"
|
|
14
|
+
]
|
|
15
|
+
DecisionState = Literal["alert", "no_alert", "not_evaluable"]
|
|
16
|
+
ComparisonState = Literal["equivalent", "different", "not_evaluable"]
|
|
17
|
+
OracleState = Literal["correct", "divergent", "pending", "not_evaluable"]
|
|
18
|
+
FitState = Literal["ready", "not_estimable", "incompatible"]
|
|
19
|
+
JobState = Literal["queued", "running", "succeeded", "failed", "cancelled"]
|
|
20
|
+
Sha256 = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SchemaRef(FrozenModel):
|
|
24
|
+
schema_id: Identifier
|
|
25
|
+
revision: PositiveInt
|
|
26
|
+
sha256: Sha256
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ArtifactRef(FrozenModel):
|
|
30
|
+
schema_version: Literal["1.0"]
|
|
31
|
+
uri: Text
|
|
32
|
+
sha256: Sha256
|
|
33
|
+
format: Literal["json", "jsonl", "arrow", "parquet"]
|
|
34
|
+
media_type: Text
|
|
35
|
+
size_bytes: NonNegativeInt
|
|
36
|
+
schema_ref: SchemaRef | None = None
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def reference_without_credentials(self) -> Self:
|
|
40
|
+
try:
|
|
41
|
+
parts = urlsplit(self.uri)
|
|
42
|
+
host = parts.hostname
|
|
43
|
+
port = parts.port
|
|
44
|
+
except ValueError:
|
|
45
|
+
fail("artifact_uri", "invalid artifact URI", "uri")
|
|
46
|
+
if parts.scheme not in ("https", "file", "s3", "gs"):
|
|
47
|
+
fail("artifact_uri", "allowed URI schemes: https, file, s3, gs", "uri")
|
|
48
|
+
if (
|
|
49
|
+
parts.username is not None
|
|
50
|
+
or parts.password is not None
|
|
51
|
+
or parts.query
|
|
52
|
+
or parts.fragment
|
|
53
|
+
):
|
|
54
|
+
fail(
|
|
55
|
+
"artifact_uri_secret",
|
|
56
|
+
"artifact URI must not contain credentials, query or fragment",
|
|
57
|
+
"uri",
|
|
58
|
+
)
|
|
59
|
+
if re.search(r"[\x00-\x20\x7f]", self.uri):
|
|
60
|
+
fail(
|
|
61
|
+
"artifact_uri", "artifact URI must not contain whitespace/control characters", "uri"
|
|
62
|
+
)
|
|
63
|
+
if "\\" in self.uri or re.search(r"%(?![0-9a-fA-F]{2})", self.uri):
|
|
64
|
+
fail("artifact_uri", "artifact URI has an invalid separator or percent escape", "uri")
|
|
65
|
+
if parts.scheme != "https" and port is not None:
|
|
66
|
+
fail("artifact_uri", "only HTTPS artifact URIs may declare a port", "uri")
|
|
67
|
+
if parts.scheme != "file" and not host:
|
|
68
|
+
fail("artifact_uri", "remote artifact URI requires a host/bucket", "uri")
|
|
69
|
+
if parts.scheme == "file" and not parts.path.startswith("/"):
|
|
70
|
+
fail("artifact_uri", "file URI requires an absolute path", "uri")
|
|
71
|
+
return self
|