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/execution.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Local execution only: no semantic validators, comparator or detector orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator, Mapping
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from datetime import timezone as _timezone
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from threading import Event
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from pydantic import TypeAdapter, ValidationError
|
|
20
|
+
|
|
21
|
+
from equiframe._base import Identifier, Text
|
|
22
|
+
from equiframe._error_reporting import report_failure
|
|
23
|
+
from equiframe._regular_io import local_output_directory
|
|
24
|
+
from equiframe.contracts import DecimalType, TimestampType
|
|
25
|
+
from equiframe.errors import (
|
|
26
|
+
CapabilityError,
|
|
27
|
+
EquiFrameError,
|
|
28
|
+
ExecutionCancelled,
|
|
29
|
+
ExecutionTimeout,
|
|
30
|
+
InvalidExecutionInput,
|
|
31
|
+
ResourceLimitError,
|
|
32
|
+
)
|
|
33
|
+
from equiframe.execution_types import (
|
|
34
|
+
EngineCapabilities,
|
|
35
|
+
ExecutionPlan,
|
|
36
|
+
ExecutionProblem,
|
|
37
|
+
ExecutionResult,
|
|
38
|
+
Parameter,
|
|
39
|
+
StageTiming,
|
|
40
|
+
TableArtifact,
|
|
41
|
+
VersionInfo,
|
|
42
|
+
)
|
|
43
|
+
from equiframe.local import LocalArtifactStore
|
|
44
|
+
from equiframe.registry import TransformationRegistry
|
|
45
|
+
from equiframe.serialization import contract_fingerprint
|
|
46
|
+
from equiframe.tables import LoadedTable, capture_artifact, require_dependency
|
|
47
|
+
from equiframe.types import ExecutionState
|
|
48
|
+
from equiframe.values import InfinityValue, NaNValue, NullValue, StringValue
|
|
49
|
+
|
|
50
|
+
UTC = _timezone.utc
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
import pyarrow as pa
|
|
54
|
+
|
|
55
|
+
from equiframe.migration.backends._timestamp_transport import _TimestampCodec
|
|
56
|
+
from equiframe.ports import ArtifactStore, DataSource, EngineAdapter
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CancellationToken:
|
|
60
|
+
"""A caller-owned cooperative cancellation signal; no background callback threads."""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
self._event = Event()
|
|
64
|
+
|
|
65
|
+
def cancel(self) -> None:
|
|
66
|
+
self._event.set()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cancelled(self) -> bool:
|
|
70
|
+
return self._event.is_set()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RunContext:
|
|
75
|
+
run_id: str
|
|
76
|
+
execution_id: str
|
|
77
|
+
plan: ExecutionPlan
|
|
78
|
+
directory: Path
|
|
79
|
+
store: ArtifactStore
|
|
80
|
+
cancellation: CancellationToken
|
|
81
|
+
deadline: float | None
|
|
82
|
+
timings: list[StageTiming] = field(default_factory=list)
|
|
83
|
+
effective_configuration: list[Parameter] = field(default_factory=list)
|
|
84
|
+
stage: str = "preflight"
|
|
85
|
+
captured_inputs: list[TableArtifact] = field(default_factory=list)
|
|
86
|
+
captured_output: TableArtifact | None = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def parameters(self) -> Mapping[str, object]:
|
|
90
|
+
# Temporal literals stay ISO text; trusted SQL/callbacks must choose explicit casts.
|
|
91
|
+
def scalar(item: Parameter) -> object:
|
|
92
|
+
if isinstance(item.value, NullValue):
|
|
93
|
+
return None
|
|
94
|
+
if isinstance(item.value, NaNValue):
|
|
95
|
+
return float("nan")
|
|
96
|
+
if isinstance(item.value, InfinityValue):
|
|
97
|
+
return float("inf") if item.value.sign == "positive" else -float("inf")
|
|
98
|
+
return item.value.value
|
|
99
|
+
|
|
100
|
+
return {item.name: scalar(item) for item in self.plan.parameters}
|
|
101
|
+
|
|
102
|
+
def check(self) -> None:
|
|
103
|
+
if self.cancellation.cancelled:
|
|
104
|
+
raise ExecutionCancelled("cancellation requested; execution stopped at a checkpoint")
|
|
105
|
+
if self.deadline is not None and time.perf_counter() >= self.deadline:
|
|
106
|
+
raise ExecutionTimeout(
|
|
107
|
+
"time budget exceeded; execution returned/stopped at a checkpoint"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def check_table(self, table: pa.Table, *, output: bool, enforce_rows: bool = True) -> None:
|
|
111
|
+
if type(output) is not bool or type(enforce_rows) is not bool:
|
|
112
|
+
raise InvalidExecutionInput("table budget flags require explicit booleans")
|
|
113
|
+
self.check()
|
|
114
|
+
import pyarrow as pa
|
|
115
|
+
|
|
116
|
+
if not isinstance(table, pa.Table):
|
|
117
|
+
raise InvalidExecutionInput("data source must provide an Arrow Table")
|
|
118
|
+
options = self.plan.options
|
|
119
|
+
rows = options.max_output_rows if output else options.max_input_rows
|
|
120
|
+
if enforce_rows and rows is not None and table.num_rows > rows:
|
|
121
|
+
raise ResourceLimitError("materialized table exceeds row budget")
|
|
122
|
+
if options.max_transport_bytes is not None and table.nbytes > options.max_transport_bytes:
|
|
123
|
+
raise ResourceLimitError("materialized table exceeds Arrow buffer byte budget")
|
|
124
|
+
|
|
125
|
+
def record_configuration(self, name: str, value: str) -> None:
|
|
126
|
+
self.effective_configuration.append(Parameter(name=name, value=StringValue(value=value)))
|
|
127
|
+
|
|
128
|
+
@contextmanager
|
|
129
|
+
def measure(self, stage: str) -> Iterator[None]:
|
|
130
|
+
TypeAdapter(Text).validate_python(stage, strict=True)
|
|
131
|
+
previous = self.stage
|
|
132
|
+
self.stage = stage
|
|
133
|
+
self.check()
|
|
134
|
+
start = time.perf_counter()
|
|
135
|
+
succeeded = False
|
|
136
|
+
try:
|
|
137
|
+
yield
|
|
138
|
+
self.check()
|
|
139
|
+
succeeded = True
|
|
140
|
+
finally:
|
|
141
|
+
self.timings.append(StageTiming(stage=stage, seconds=time.perf_counter() - start))
|
|
142
|
+
if succeeded:
|
|
143
|
+
self.stage = previous
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _versions(engine: str) -> tuple[VersionInfo, ...]:
|
|
147
|
+
result = [VersionInfo(name="python", version=platform.python_version())]
|
|
148
|
+
for name in ("equiframe", "pydantic", "tzdata", "pyarrow", engine, "polars-runtime-32"):
|
|
149
|
+
if name == "polars-runtime-32" and engine != "polars":
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
installed = version(name)
|
|
153
|
+
except PackageNotFoundError:
|
|
154
|
+
installed = "not-installed"
|
|
155
|
+
result.append(VersionInfo(name=name, version=installed))
|
|
156
|
+
return tuple(result)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _contract_capabilities(
|
|
160
|
+
plan: ExecutionPlan,
|
|
161
|
+
capabilities: EngineCapabilities,
|
|
162
|
+
*,
|
|
163
|
+
timestamp_codec: _TimestampCodec | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
if timestamp_codec is not None and timestamp_codec != timestamp_codec.for_contract(
|
|
166
|
+
plan.contract
|
|
167
|
+
):
|
|
168
|
+
raise CapabilityError("IR timestamp codec differs from the execution contract")
|
|
169
|
+
for table in (*plan.contract.inputs, plan.contract.output):
|
|
170
|
+
for column in table.schema_spec.columns:
|
|
171
|
+
dtype = column.logical_type
|
|
172
|
+
if isinstance(dtype, DecimalType) and dtype.precision > capabilities.decimal_precision:
|
|
173
|
+
raise CapabilityError(
|
|
174
|
+
f"{plan.engine} cannot execute declared decimal precision {dtype.precision}"
|
|
175
|
+
)
|
|
176
|
+
if isinstance(dtype, TimestampType):
|
|
177
|
+
if timestamp_codec is not None:
|
|
178
|
+
continue
|
|
179
|
+
supported = (
|
|
180
|
+
capabilities.zoned_timestamp_units
|
|
181
|
+
if dtype.timezone
|
|
182
|
+
else capabilities.timestamp_units
|
|
183
|
+
)
|
|
184
|
+
if dtype.unit not in supported:
|
|
185
|
+
raise CapabilityError(
|
|
186
|
+
f"{plan.engine} cannot preserve declared timestamp unit/zone {dtype.unit}"
|
|
187
|
+
)
|
|
188
|
+
if (
|
|
189
|
+
plan.engine == "duckdb"
|
|
190
|
+
and dtype.timezone
|
|
191
|
+
and dtype.timezone != plan.options.duckdb_timezone
|
|
192
|
+
):
|
|
193
|
+
raise CapabilityError(
|
|
194
|
+
"DuckDB zoned columns require the explicitly configured session timezone"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def execute(
|
|
199
|
+
plan: ExecutionPlan,
|
|
200
|
+
*,
|
|
201
|
+
source: DataSource,
|
|
202
|
+
registry: TransformationRegistry,
|
|
203
|
+
adapter: EngineAdapter | None = None,
|
|
204
|
+
artifact_store: ArtifactStore | None = None,
|
|
205
|
+
work_dir: Path | None = None,
|
|
206
|
+
cancellation: CancellationToken | None = None,
|
|
207
|
+
) -> ExecutionResult:
|
|
208
|
+
"""Execute trusted native code synchronously and persist run.json in a fresh directory.
|
|
209
|
+
|
|
210
|
+
Invalid plan objects and failure to create/publish the run directory raise;
|
|
211
|
+
operational failures inside a run become typed results. completed is not a
|
|
212
|
+
statement of semantic correctness. Callback timeouts are cooperative.
|
|
213
|
+
"""
|
|
214
|
+
plan = ExecutionPlan.model_validate(plan)
|
|
215
|
+
if adapter is None:
|
|
216
|
+
from equiframe.adapters import native_adapter
|
|
217
|
+
|
|
218
|
+
adapter = native_adapter(plan.engine)
|
|
219
|
+
capabilities = EngineCapabilities.model_validate(adapter.capabilities())
|
|
220
|
+
run_id, execution_id = "run_" + uuid4().hex, "exec_" + uuid4().hex
|
|
221
|
+
directory = (
|
|
222
|
+
local_output_directory(Path.cwd() / "equiframe-runs" if work_dir is None else work_dir)
|
|
223
|
+
/ run_id
|
|
224
|
+
/ execution_id
|
|
225
|
+
)
|
|
226
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
227
|
+
store = (
|
|
228
|
+
artifact_store
|
|
229
|
+
if artifact_store is not None
|
|
230
|
+
else LocalArtifactStore(directory / "artifacts")
|
|
231
|
+
)
|
|
232
|
+
started = datetime.now(UTC).isoformat()
|
|
233
|
+
start = time.perf_counter()
|
|
234
|
+
timeout = plan.options.timeout_seconds
|
|
235
|
+
context = RunContext(
|
|
236
|
+
run_id,
|
|
237
|
+
execution_id,
|
|
238
|
+
plan,
|
|
239
|
+
directory,
|
|
240
|
+
store,
|
|
241
|
+
cancellation if cancellation is not None else CancellationToken(),
|
|
242
|
+
start + timeout if timeout is not None else None,
|
|
243
|
+
)
|
|
244
|
+
context.record_configuration(
|
|
245
|
+
"control_scope", "synchronous cooperative checkpoints; no callback preemption or sandbox"
|
|
246
|
+
)
|
|
247
|
+
context.record_configuration(
|
|
248
|
+
"contract_timezone_source",
|
|
249
|
+
"locked Python tzdata package; engine timezone rules recorded separately",
|
|
250
|
+
)
|
|
251
|
+
state: ExecutionState = "completed"
|
|
252
|
+
problem: ExecutionProblem | None = None
|
|
253
|
+
implementation_version: str | None = None
|
|
254
|
+
try:
|
|
255
|
+
with context.measure("preflight"):
|
|
256
|
+
if adapter.engine != plan.engine or capabilities.engine != plan.engine:
|
|
257
|
+
raise InvalidExecutionInput("adapter engine does not match execution plan")
|
|
258
|
+
names = TypeAdapter(tuple[Identifier, ...]).validate_python(
|
|
259
|
+
source.table_names, strict=True
|
|
260
|
+
)
|
|
261
|
+
if len(set(names)) != len(names):
|
|
262
|
+
raise InvalidExecutionInput("source table names must be unique")
|
|
263
|
+
if set(names) != {item.name for item in plan.contract.inputs}:
|
|
264
|
+
raise InvalidExecutionInput("source tables must match declared input names")
|
|
265
|
+
transformation = registry.resolve(plan)
|
|
266
|
+
for dependency in capabilities.required_packages:
|
|
267
|
+
require_dependency(dependency, plan.engine)
|
|
268
|
+
implementation_version = transformation.code_version
|
|
269
|
+
from equiframe.adapters import _timestamp_codec
|
|
270
|
+
|
|
271
|
+
_contract_capabilities(
|
|
272
|
+
plan, capabilities, timestamp_codec=_timestamp_codec(adapter, transformation)
|
|
273
|
+
)
|
|
274
|
+
tables: dict[str, LoadedTable] = {}
|
|
275
|
+
with context.measure("input_load"):
|
|
276
|
+
for item in plan.contract.inputs:
|
|
277
|
+
resolved = source.resolve(item.name, context=context)
|
|
278
|
+
if not isinstance(resolved, LoadedTable):
|
|
279
|
+
raise InvalidExecutionInput("data source must return LoadedTable")
|
|
280
|
+
tables[item.name] = resolved
|
|
281
|
+
context.check_table(tables[item.name].table, output=False)
|
|
282
|
+
capture_artifact(item.name, tables[item.name], context)
|
|
283
|
+
context.stage = "adapter_dispatch"
|
|
284
|
+
loaded = adapter.execute(transformation, tables, context)
|
|
285
|
+
if not isinstance(loaded, LoadedTable):
|
|
286
|
+
raise InvalidExecutionInput("engine adapter must return LoadedTable")
|
|
287
|
+
with context.measure("output_capture"):
|
|
288
|
+
context.check_table(loaded.table, output=True)
|
|
289
|
+
capture_artifact(plan.contract.output.name, loaded, context, output=True)
|
|
290
|
+
except Exception as error:
|
|
291
|
+
if isinstance(error, ExecutionCancelled):
|
|
292
|
+
state = "cancelled"
|
|
293
|
+
elif isinstance(error, ExecutionTimeout):
|
|
294
|
+
state = "timeout"
|
|
295
|
+
elif isinstance(error, (MemoryError, ResourceLimitError)):
|
|
296
|
+
state = "resource_exhausted"
|
|
297
|
+
elif isinstance(error, (InvalidExecutionInput, ValidationError)):
|
|
298
|
+
state = "invalid_input"
|
|
299
|
+
else:
|
|
300
|
+
state = "failed"
|
|
301
|
+
public_message = {
|
|
302
|
+
"cancelled": "Execution was cancelled.",
|
|
303
|
+
"timeout": "Execution exceeded its time budget.",
|
|
304
|
+
"resource_exhausted": "Execution exceeded a resource budget.",
|
|
305
|
+
"invalid_input": "Execution input could not be validated.",
|
|
306
|
+
"failed": "Execution failed. Check the declared engine and configuration.",
|
|
307
|
+
}[state]
|
|
308
|
+
# Engine labels come from this closed map, never exception text or SQL.
|
|
309
|
+
from equiframe.errors import MissingDependencyError
|
|
310
|
+
|
|
311
|
+
if isinstance(error, MissingDependencyError):
|
|
312
|
+
requirements = {
|
|
313
|
+
"arrow": "equiframe[arrow]",
|
|
314
|
+
"pandas": "equiframe[pandas]",
|
|
315
|
+
"polars": "equiframe[polars]",
|
|
316
|
+
"duckdb": "equiframe[duckdb]",
|
|
317
|
+
}
|
|
318
|
+
requirement = requirements.get(plan.engine)
|
|
319
|
+
if requirement is not None:
|
|
320
|
+
public_message += " Install " + requirement + " from your reviewed wheelhouse."
|
|
321
|
+
problem = ExecutionProblem(
|
|
322
|
+
code=error.code if isinstance(error, EquiFrameError) else state,
|
|
323
|
+
stage=context.stage,
|
|
324
|
+
exception_type=type(error).__name__,
|
|
325
|
+
message=report_failure(error, public_message),
|
|
326
|
+
)
|
|
327
|
+
result = ExecutionResult(
|
|
328
|
+
run_id=run_id,
|
|
329
|
+
execution_id=execution_id,
|
|
330
|
+
plan=plan,
|
|
331
|
+
contract_sha256=contract_fingerprint(plan.contract),
|
|
332
|
+
implementation_version=implementation_version,
|
|
333
|
+
state=state,
|
|
334
|
+
started_at=started,
|
|
335
|
+
finished_at=datetime.now(UTC).isoformat(),
|
|
336
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
337
|
+
timings=tuple(context.timings),
|
|
338
|
+
versions=_versions(plan.engine),
|
|
339
|
+
capabilities=capabilities,
|
|
340
|
+
effective_configuration=tuple(context.effective_configuration),
|
|
341
|
+
inputs=tuple(context.captured_inputs),
|
|
342
|
+
output=context.captured_output,
|
|
343
|
+
problem=problem,
|
|
344
|
+
)
|
|
345
|
+
# Directory is owned by this execution. Exclusive publication never replaces a previous run.
|
|
346
|
+
temporary = directory / "run.part.json"
|
|
347
|
+
try:
|
|
348
|
+
with temporary.open("x", encoding="utf-8", newline="\n") as manifest:
|
|
349
|
+
manifest.write(result.model_dump_json())
|
|
350
|
+
manifest.flush()
|
|
351
|
+
os.fsync(manifest.fileno())
|
|
352
|
+
os.link(temporary, directory / "run.json")
|
|
353
|
+
finally:
|
|
354
|
+
temporary.unlink(missing_ok=True)
|
|
355
|
+
return result
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Validated execution metadata. Runtime objects and trusted code live separately."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from equiframe._base import (
|
|
9
|
+
FrozenModel,
|
|
10
|
+
Identifier,
|
|
11
|
+
NonNegativeInt,
|
|
12
|
+
PositiveInt,
|
|
13
|
+
Text,
|
|
14
|
+
_ExplicitOutcome,
|
|
15
|
+
fail,
|
|
16
|
+
)
|
|
17
|
+
from equiframe.contracts import Contract
|
|
18
|
+
from equiframe.types import ArtifactRef, ExecutionState, Sha256
|
|
19
|
+
from equiframe.values import ScalarValue, TimeUnit, TimeZone
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Parameter(FrozenModel):
|
|
23
|
+
name: Identifier
|
|
24
|
+
value: ScalarValue
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExecutionOptions(FrozenModel):
|
|
28
|
+
timeout_seconds: Annotated[float, Field(gt=0)] | None = None
|
|
29
|
+
max_input_rows: NonNegativeInt | None = None
|
|
30
|
+
max_output_rows: NonNegativeInt | None = None
|
|
31
|
+
max_transport_bytes: PositiveInt | None = None
|
|
32
|
+
duckdb_memory_mb: PositiveInt = 512
|
|
33
|
+
duckdb_threads: PositiveInt = 1
|
|
34
|
+
duckdb_timezone: TimeZone = "UTC"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExecutionPlan(FrozenModel):
|
|
38
|
+
contract: Contract
|
|
39
|
+
engine: Identifier
|
|
40
|
+
transformation_id: Identifier
|
|
41
|
+
transformation_revision: PositiveInt = 1
|
|
42
|
+
parameters: tuple[Parameter, ...] = ()
|
|
43
|
+
options: ExecutionOptions = Field(default_factory=ExecutionOptions)
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def unique_parameters(self) -> Self:
|
|
47
|
+
names = tuple(item.name for item in self.parameters)
|
|
48
|
+
if len(set(names)) != len(names):
|
|
49
|
+
fail("duplicate_parameter", "parameter names must be unique", "parameters")
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EngineCapabilities(FrozenModel):
|
|
54
|
+
engine: Identifier
|
|
55
|
+
decimal_precision: Annotated[int, Field(ge=1, le=76)]
|
|
56
|
+
timestamp_units: tuple[TimeUnit, ...]
|
|
57
|
+
zoned_timestamp_units: tuple[TimeUnit, ...]
|
|
58
|
+
lazy: bool
|
|
59
|
+
cancellation: Literal["cooperative", "interrupt_query"]
|
|
60
|
+
resource_controls: tuple[Text, ...]
|
|
61
|
+
required_packages: tuple[Text, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PhysicalColumn(FrozenModel):
|
|
65
|
+
name: Annotated[str, Field(min_length=1)]
|
|
66
|
+
engine_type: Text
|
|
67
|
+
arrow_type: Text
|
|
68
|
+
null_count: NonNegativeInt
|
|
69
|
+
nan_count: NonNegativeInt
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TableArtifact(FrozenModel):
|
|
73
|
+
name: Text
|
|
74
|
+
data: ArtifactRef
|
|
75
|
+
origin: ArtifactRef | None = None
|
|
76
|
+
row_count: NonNegativeInt
|
|
77
|
+
columns: tuple[PhysicalColumn, ...]
|
|
78
|
+
index: ArtifactRef | None = None
|
|
79
|
+
index_columns: tuple[PhysicalColumn, ...] = ()
|
|
80
|
+
notes: tuple[Text, ...] = ()
|
|
81
|
+
|
|
82
|
+
@model_validator(mode="after")
|
|
83
|
+
def consistent_table(self) -> Self:
|
|
84
|
+
if self.data.format not in ("arrow", "parquet"):
|
|
85
|
+
fail("table_artifact_format", "table data requires Arrow/Parquet", "data")
|
|
86
|
+
if self.index is None and self.index_columns:
|
|
87
|
+
fail("missing_index_artifact", "index columns require an index artifact", "index")
|
|
88
|
+
if self.index is not None and self.index.format not in ("arrow", "parquet"):
|
|
89
|
+
fail("table_artifact_format", "index requires Arrow/Parquet", "index")
|
|
90
|
+
for location, columns in (("columns", self.columns), ("index_columns", self.index_columns)):
|
|
91
|
+
if len({c.name for c in columns}) != len(columns):
|
|
92
|
+
fail("duplicate_physical_column", "physical column names must be unique", location)
|
|
93
|
+
for position, column in enumerate(columns):
|
|
94
|
+
if column.null_count + column.nan_count > self.row_count:
|
|
95
|
+
fail(
|
|
96
|
+
"impossible_value_counts",
|
|
97
|
+
"NULL/NaN counts exceed row count",
|
|
98
|
+
location,
|
|
99
|
+
position,
|
|
100
|
+
)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StageTiming(FrozenModel):
|
|
105
|
+
stage: Text
|
|
106
|
+
seconds: Annotated[float, Field(ge=0)]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class VersionInfo(FrozenModel):
|
|
110
|
+
name: Text
|
|
111
|
+
version: Text
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ExecutionProblem(FrozenModel):
|
|
115
|
+
code: Text
|
|
116
|
+
stage: Text
|
|
117
|
+
exception_type: Text
|
|
118
|
+
message: Text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ExecutionResult(_ExplicitOutcome, FrozenModel):
|
|
122
|
+
_truth_value_guidance = (
|
|
123
|
+
"inspect .state explicitly; completed execution does not establish correctness"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
127
|
+
run_id: Identifier
|
|
128
|
+
execution_id: Identifier
|
|
129
|
+
plan: ExecutionPlan
|
|
130
|
+
contract_sha256: Sha256
|
|
131
|
+
implementation_version: Text | None
|
|
132
|
+
state: ExecutionState
|
|
133
|
+
started_at: Text
|
|
134
|
+
finished_at: Text
|
|
135
|
+
elapsed_seconds: Annotated[float, Field(ge=0)]
|
|
136
|
+
timings: tuple[StageTiming, ...]
|
|
137
|
+
versions: tuple[VersionInfo, ...]
|
|
138
|
+
capabilities: EngineCapabilities
|
|
139
|
+
effective_configuration: tuple[Parameter, ...]
|
|
140
|
+
inputs: tuple[TableArtifact, ...]
|
|
141
|
+
output: TableArtifact | None
|
|
142
|
+
problem: ExecutionProblem | None
|
|
143
|
+
|
|
144
|
+
@model_validator(mode="after")
|
|
145
|
+
def consistent_result(self) -> Self:
|
|
146
|
+
if self.state == "completed" and (self.output is None or self.problem is not None):
|
|
147
|
+
fail("incomplete_result", "completed requires output and no problem", "state")
|
|
148
|
+
if self.state != "completed" and self.problem is None:
|
|
149
|
+
fail("missing_problem", "unsuccessful execution requires a diagnostic", "problem")
|
|
150
|
+
from equiframe.serialization import contract_fingerprint
|
|
151
|
+
|
|
152
|
+
if self.contract_sha256 != contract_fingerprint(self.plan.contract):
|
|
153
|
+
fail(
|
|
154
|
+
"contract_fingerprint_mismatch",
|
|
155
|
+
"fingerprint must match the embedded contract",
|
|
156
|
+
"contract_sha256",
|
|
157
|
+
)
|
|
158
|
+
names = tuple(table.name for table in self.inputs)
|
|
159
|
+
declared = {table.name for table in self.plan.contract.inputs}
|
|
160
|
+
if len(set(names)) != len(names) or not set(names) <= declared:
|
|
161
|
+
fail(
|
|
162
|
+
"invalid_input_evidence",
|
|
163
|
+
"captured inputs must be unique declared table names",
|
|
164
|
+
"inputs",
|
|
165
|
+
)
|
|
166
|
+
if self.state == "completed":
|
|
167
|
+
if set(names) != declared:
|
|
168
|
+
fail("missing_input_evidence", "completed requires every input artifact", "inputs")
|
|
169
|
+
if self.capabilities.engine != self.plan.engine:
|
|
170
|
+
fail(
|
|
171
|
+
"engine_evidence_mismatch",
|
|
172
|
+
"completed engine capabilities must match plan",
|
|
173
|
+
"capabilities",
|
|
174
|
+
)
|
|
175
|
+
if self.output is not None and self.output.name != self.plan.contract.output.name:
|
|
176
|
+
fail("output_evidence_mismatch", "output table name must match the plan", "output")
|
|
177
|
+
return self
|