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,545 @@
|
|
|
1
|
+
"""Compile and execute two native ETLs over one fixed, faithful input capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from datetime import timezone as _timezone
|
|
9
|
+
from functools import partial
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Literal, TypeVar
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from typing_extensions import TypeAliasType
|
|
15
|
+
|
|
16
|
+
from equiframe._comparison_io import snapshot
|
|
17
|
+
from equiframe._duckdb_transport import duckdb_temporal_capabilities
|
|
18
|
+
from equiframe._physical import physical_size
|
|
19
|
+
from equiframe._regular_io import local_output_directory
|
|
20
|
+
from equiframe.adapters import _input_capabilities, _timestamp_codec, native_adapter
|
|
21
|
+
from equiframe.comparison import compare
|
|
22
|
+
from equiframe.comparison_types import ComparisonResult
|
|
23
|
+
from equiframe.detector_types import DetectorBundle
|
|
24
|
+
from equiframe.errors import (
|
|
25
|
+
CapabilityError,
|
|
26
|
+
ExecutionCancelled,
|
|
27
|
+
ExecutionTimeout,
|
|
28
|
+
InvalidExecutionInput,
|
|
29
|
+
MissingDependencyError,
|
|
30
|
+
ResourceLimitError,
|
|
31
|
+
)
|
|
32
|
+
from equiframe.evidence import EventJournal, inventory, publish_json
|
|
33
|
+
from equiframe.execution import CancellationToken, RunContext, _contract_capabilities
|
|
34
|
+
from equiframe.execution_types import ExecutionPlan, TableArtifact
|
|
35
|
+
from equiframe.local import LocalArtifactStore
|
|
36
|
+
from equiframe.normalization import logical_signature, normalize, physical_value
|
|
37
|
+
from equiframe.pipeline import run_pipeline
|
|
38
|
+
from equiframe.pipeline_types import PipelineResult, PipelineSpec
|
|
39
|
+
from equiframe.ports import DataSource, EngineAdapter
|
|
40
|
+
from equiframe.profile_types import profile_digest
|
|
41
|
+
from equiframe.registry import TransformationRegistry
|
|
42
|
+
from equiframe.rules import RuleRegistry
|
|
43
|
+
from equiframe.serialization import contract_fingerprint
|
|
44
|
+
from equiframe.tables import (
|
|
45
|
+
ArrowInput,
|
|
46
|
+
ArtifactInput,
|
|
47
|
+
LoadedTable,
|
|
48
|
+
capture_artifact,
|
|
49
|
+
detached_arrow,
|
|
50
|
+
require_dependency,
|
|
51
|
+
)
|
|
52
|
+
from equiframe.types import ArtifactRef
|
|
53
|
+
|
|
54
|
+
from .ports import CompiledPlan, CompilerRegistry, native_compiler_registry
|
|
55
|
+
from .runner_types import (
|
|
56
|
+
CompilerRecord,
|
|
57
|
+
MigrationResult,
|
|
58
|
+
MigrationSpec,
|
|
59
|
+
MigrationStage,
|
|
60
|
+
MigrationStageName,
|
|
61
|
+
pipeline_complete,
|
|
62
|
+
)
|
|
63
|
+
from .types import CompilationCheck
|
|
64
|
+
from .validation import ir_fingerprint
|
|
65
|
+
|
|
66
|
+
UTC = _timezone.utc
|
|
67
|
+
_T = TypeVar("_T")
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from equiframe.contracts import TableSpec
|
|
71
|
+
|
|
72
|
+
ExpectedInput = TypeAliasType("ExpectedInput", ArrowInput | ArtifactRef | Path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_migration(
|
|
76
|
+
spec: MigrationSpec,
|
|
77
|
+
*,
|
|
78
|
+
compilers: CompilerRegistry | None = None,
|
|
79
|
+
) -> tuple[CompilationCheck, CompilationCheck]:
|
|
80
|
+
"""Check both compiler capabilities without loading inputs or native engines."""
|
|
81
|
+
spec = MigrationSpec.model_validate(spec)
|
|
82
|
+
registry = compilers if compilers is not None else native_compiler_registry()
|
|
83
|
+
return (
|
|
84
|
+
registry.resolve(spec.source_engine).check(spec.ir, spec.contract),
|
|
85
|
+
registry.resolve(spec.target_engine).check(spec.ir, spec.contract),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Steps:
|
|
90
|
+
def __init__(self, journal: EventJournal, token: CancellationToken, deadline: float | None):
|
|
91
|
+
self.journal = journal
|
|
92
|
+
self.token = token
|
|
93
|
+
self.deadline = deadline
|
|
94
|
+
self.records: list[MigrationStage] = []
|
|
95
|
+
|
|
96
|
+
def check(self) -> None:
|
|
97
|
+
if self.token.cancelled:
|
|
98
|
+
raise ExecutionCancelled("migration cancelled at a cooperative checkpoint")
|
|
99
|
+
if self.deadline is not None and time.perf_counter() >= self.deadline:
|
|
100
|
+
raise ExecutionTimeout("migration budget exceeded at a cooperative checkpoint")
|
|
101
|
+
|
|
102
|
+
def run(
|
|
103
|
+
self,
|
|
104
|
+
name: MigrationStageName,
|
|
105
|
+
action: Callable[[], _T],
|
|
106
|
+
complete: Callable[[_T], bool],
|
|
107
|
+
*,
|
|
108
|
+
skip: str | None = None,
|
|
109
|
+
) -> _T | None:
|
|
110
|
+
if skip:
|
|
111
|
+
record = MigrationStage(stage=name, state="skipped", code=skip, elapsed_seconds=0.0)
|
|
112
|
+
result = None
|
|
113
|
+
else:
|
|
114
|
+
start = time.perf_counter()
|
|
115
|
+
self.journal.emit(name, "started", "stage_started")
|
|
116
|
+
result = None
|
|
117
|
+
try:
|
|
118
|
+
self.check()
|
|
119
|
+
result = action()
|
|
120
|
+
self.check()
|
|
121
|
+
ok = complete(result)
|
|
122
|
+
record = MigrationStage(
|
|
123
|
+
stage=name,
|
|
124
|
+
state="completed" if ok else "not_evaluable",
|
|
125
|
+
code="stage_completed" if ok else "component_unavailable",
|
|
126
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
127
|
+
)
|
|
128
|
+
except Exception as error:
|
|
129
|
+
result = None
|
|
130
|
+
code = (
|
|
131
|
+
"missing_dependency"
|
|
132
|
+
if isinstance(error, MissingDependencyError)
|
|
133
|
+
else "unsupported_capability"
|
|
134
|
+
if isinstance(error, CapabilityError)
|
|
135
|
+
else "cancelled"
|
|
136
|
+
if isinstance(error, ExecutionCancelled)
|
|
137
|
+
else "timeout"
|
|
138
|
+
if isinstance(error, ExecutionTimeout)
|
|
139
|
+
else "resource_exhausted"
|
|
140
|
+
if isinstance(error, (ResourceLimitError, MemoryError))
|
|
141
|
+
else "invalid_input"
|
|
142
|
+
if isinstance(error, (InvalidExecutionInput, ValueError))
|
|
143
|
+
else "operation_failed"
|
|
144
|
+
)
|
|
145
|
+
record = MigrationStage(
|
|
146
|
+
stage=name,
|
|
147
|
+
state="failed",
|
|
148
|
+
code=code,
|
|
149
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
150
|
+
)
|
|
151
|
+
self.records.append(record)
|
|
152
|
+
self.journal.emit(name, record.state, record.code)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
def timeout(self, declared: float | None) -> float | None:
|
|
156
|
+
self.check()
|
|
157
|
+
remaining = self.deadline - time.perf_counter() if self.deadline is not None else None
|
|
158
|
+
if remaining is not None and remaining <= 0:
|
|
159
|
+
raise ExecutionTimeout("migration budget exhausted")
|
|
160
|
+
return (
|
|
161
|
+
min(declared, remaining)
|
|
162
|
+
if declared is not None and remaining is not None
|
|
163
|
+
else (declared if remaining is None else remaining)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _validate_input(loaded: LoadedTable, declared: TableSpec, context: RunContext) -> None:
|
|
168
|
+
"""Check actual data; normalized values are inspected but never written back."""
|
|
169
|
+
context.check_table(loaded.table, output=False)
|
|
170
|
+
columns = declared.schema_spec.columns
|
|
171
|
+
if tuple(loaded.table.column_names) != tuple(c.name for c in columns):
|
|
172
|
+
raise InvalidExecutionInput("captured input columns/order differ from the contract")
|
|
173
|
+
for column in columns:
|
|
174
|
+
values = loaded.table[column.name]
|
|
175
|
+
if logical_signature(values.type) != tuple(column.logical_type.model_dump().values()):
|
|
176
|
+
raise InvalidExecutionInput("captured input physical type differs from the contract")
|
|
177
|
+
for chunk in values.chunks:
|
|
178
|
+
context.check()
|
|
179
|
+
for n, scalar in enumerate(chunk):
|
|
180
|
+
if n % 4096 == 0:
|
|
181
|
+
context.check()
|
|
182
|
+
_, violations = normalize(physical_value(scalar), column)
|
|
183
|
+
if violations:
|
|
184
|
+
raise InvalidExecutionInput(
|
|
185
|
+
"captured input violates declared value constraints"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _PinnedSource:
|
|
190
|
+
def __init__(self, inputs: tuple[TableArtifact, ...]) -> None:
|
|
191
|
+
self._inputs = {t.name: t for t in inputs}
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def table_names(self) -> tuple[str, ...]:
|
|
195
|
+
return tuple(self._inputs)
|
|
196
|
+
|
|
197
|
+
def resolve(self, name: str, *, context: RunContext) -> LoadedTable:
|
|
198
|
+
ref = self._inputs[name]
|
|
199
|
+
value = ArtifactInput(ref.data).load(context)
|
|
200
|
+
index = ArtifactInput(ref.index).load(context).table if ref.index else None
|
|
201
|
+
return LoadedTable(
|
|
202
|
+
value.table,
|
|
203
|
+
native_types=tuple((column.name, column.engine_type) for column in ref.columns),
|
|
204
|
+
index=index,
|
|
205
|
+
origin=ref.data,
|
|
206
|
+
notes=(
|
|
207
|
+
*ref.notes,
|
|
208
|
+
"Fixed migration snapshot; original provider is not called again.",
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _compiler_record(compiled: CompiledPlan, spec: MigrationSpec) -> CompilerRecord:
|
|
214
|
+
t = compiled.transformation
|
|
215
|
+
if (
|
|
216
|
+
compiled.engine != t.engine
|
|
217
|
+
or not compiled.check.supported
|
|
218
|
+
or compiled.check.engine != compiled.engine
|
|
219
|
+
or compiled.ir_sha256 != ir_fingerprint(spec.ir)
|
|
220
|
+
or compiled.contract_sha256 != contract_fingerprint(spec.contract)
|
|
221
|
+
):
|
|
222
|
+
raise InvalidExecutionInput("compiler output contradicts its specification")
|
|
223
|
+
return CompilerRecord(
|
|
224
|
+
engine=t.engine,
|
|
225
|
+
transformation_id=t.transformation_id,
|
|
226
|
+
revision=t.revision,
|
|
227
|
+
code_version=t.code_version,
|
|
228
|
+
ir_sha256=compiled.ir_sha256,
|
|
229
|
+
contract_sha256=compiled.contract_sha256,
|
|
230
|
+
check=compiled.check,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def migrate(
|
|
235
|
+
spec: MigrationSpec,
|
|
236
|
+
*,
|
|
237
|
+
source: DataSource,
|
|
238
|
+
work_dir: Path | None = None,
|
|
239
|
+
expected: ExpectedInput | None = None,
|
|
240
|
+
compilers: CompilerRegistry | None = None,
|
|
241
|
+
adapters: Mapping[str, EngineAdapter] | None = None,
|
|
242
|
+
rule_registry: RuleRegistry | None = None,
|
|
243
|
+
detector_bundle: DetectorBundle | None = None,
|
|
244
|
+
cancellation: CancellationToken | None = None,
|
|
245
|
+
) -> MigrationResult:
|
|
246
|
+
"""Execute native origin/destination; never translate arbitrary Python or execute JSON code.
|
|
247
|
+
|
|
248
|
+
Invalid configuration and publication failures raise. Operational stage failures
|
|
249
|
+
persist a non-success result. Captures and callbacks are synchronous; budgets
|
|
250
|
+
are cooperative checkpoints, not process memory quotas or preemptive threads.
|
|
251
|
+
"""
|
|
252
|
+
spec = MigrationSpec.model_validate(spec)
|
|
253
|
+
if detector_bundle is not None:
|
|
254
|
+
detector_bundle = DetectorBundle.model_validate(detector_bundle)
|
|
255
|
+
if spec.profile is None:
|
|
256
|
+
raise ValueError("a detector bundle requires an explicit profile")
|
|
257
|
+
identifier = "migration_" + uuid4().hex
|
|
258
|
+
directory = (
|
|
259
|
+
local_output_directory(Path.cwd() / "equiframe-runs" if work_dir is None else work_dir)
|
|
260
|
+
/ identifier
|
|
261
|
+
)
|
|
262
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
263
|
+
token = cancellation if cancellation is not None else CancellationToken()
|
|
264
|
+
start = time.perf_counter()
|
|
265
|
+
started = datetime.now(UTC).isoformat()
|
|
266
|
+
deadline = start + spec.options.timeout_seconds if spec.options.timeout_seconds else None
|
|
267
|
+
journal = EventJournal(directory, identifier)
|
|
268
|
+
journal.emit("migration", "started", "migration_started")
|
|
269
|
+
publish_json(spec, directory / "spec.json")
|
|
270
|
+
if detector_bundle is not None:
|
|
271
|
+
publish_json(detector_bundle, directory / "detector-bundle.json")
|
|
272
|
+
steps = _Steps(journal, token, deadline)
|
|
273
|
+
compiler_registry = compilers if compilers is not None else native_compiler_registry()
|
|
274
|
+
resolved_adapters: dict[str, EngineAdapter] = {}
|
|
275
|
+
|
|
276
|
+
def preflight() -> tuple[CompiledPlan, CompiledPlan]:
|
|
277
|
+
engines = (spec.source_engine, spec.target_engine)
|
|
278
|
+
checks = check_migration(spec, compilers=compiler_registry)
|
|
279
|
+
if any(not c.supported for c in checks):
|
|
280
|
+
raise CapabilityError("migration IR is unsupported by a configured compiler")
|
|
281
|
+
compiled = tuple(
|
|
282
|
+
compiler_registry.resolve(e).compile(spec.ir, spec.contract) for e in engines
|
|
283
|
+
)
|
|
284
|
+
records = tuple(_compiler_record(c, spec) for c in compiled)
|
|
285
|
+
if tuple(r.engine for r in records) != engines:
|
|
286
|
+
raise InvalidExecutionInput("compiled engines do not match the pair")
|
|
287
|
+
if len(set(source.table_names)) != len(source.table_names) or set(source.table_names) != {
|
|
288
|
+
t.name for t in spec.contract.inputs
|
|
289
|
+
}:
|
|
290
|
+
raise InvalidExecutionInput("provider bindings do not match the contract")
|
|
291
|
+
for engine, record, implementation in zip(engines, records, compiled, strict=True):
|
|
292
|
+
adapter = (
|
|
293
|
+
adapters[engine] if adapters and engine in adapters else native_adapter(engine)
|
|
294
|
+
)
|
|
295
|
+
capabilities = adapter.capabilities()
|
|
296
|
+
if adapter.engine != engine or capabilities.engine != engine:
|
|
297
|
+
raise InvalidExecutionInput("adapter does not match its configured engine")
|
|
298
|
+
for dependency in capabilities.required_packages:
|
|
299
|
+
require_dependency(dependency, engine)
|
|
300
|
+
_contract_capabilities(
|
|
301
|
+
ExecutionPlan(
|
|
302
|
+
contract=spec.contract,
|
|
303
|
+
engine=engine,
|
|
304
|
+
transformation_id=record.transformation_id,
|
|
305
|
+
transformation_revision=record.revision,
|
|
306
|
+
options=spec.execution_options,
|
|
307
|
+
),
|
|
308
|
+
capabilities,
|
|
309
|
+
timestamp_codec=_timestamp_codec(adapter, implementation.transformation),
|
|
310
|
+
)
|
|
311
|
+
resolved_adapters[engine] = adapter
|
|
312
|
+
steps.check()
|
|
313
|
+
return compiled[0], compiled[1]
|
|
314
|
+
|
|
315
|
+
compiled = steps.run("preflight", preflight, lambda _: True)
|
|
316
|
+
records = tuple(_compiler_record(c, spec) for c in compiled) if compiled else ()
|
|
317
|
+
captured: list[TableArtifact] = []
|
|
318
|
+
expected_ref: ArtifactRef | None = None
|
|
319
|
+
|
|
320
|
+
def capture() -> bool:
|
|
321
|
+
nonlocal expected_ref
|
|
322
|
+
if not (compiled is not None):
|
|
323
|
+
raise AssertionError("Internal invariant check failed.")
|
|
324
|
+
capture_dir = directory / "capture"
|
|
325
|
+
capture_dir.mkdir(exist_ok=False)
|
|
326
|
+
context = RunContext(
|
|
327
|
+
identifier,
|
|
328
|
+
identifier + ".capture",
|
|
329
|
+
ExecutionPlan(
|
|
330
|
+
contract=spec.contract,
|
|
331
|
+
engine=spec.source_engine,
|
|
332
|
+
transformation_id=records[0].transformation_id,
|
|
333
|
+
options=spec.execution_options,
|
|
334
|
+
),
|
|
335
|
+
capture_dir,
|
|
336
|
+
LocalArtifactStore(capture_dir / "artifacts"),
|
|
337
|
+
token,
|
|
338
|
+
deadline,
|
|
339
|
+
)
|
|
340
|
+
rows = total_bytes = 0
|
|
341
|
+
for table in spec.contract.inputs:
|
|
342
|
+
context.check()
|
|
343
|
+
value = source.resolve(table.name, context=context)
|
|
344
|
+
if not isinstance(value, LoadedTable):
|
|
345
|
+
raise InvalidExecutionInput("provider must return LoadedTable")
|
|
346
|
+
context.check_table(value.table, output=False)
|
|
347
|
+
if value.meaningful_index or (
|
|
348
|
+
value.index is not None and spec.comparison_policy.index != "ignore"
|
|
349
|
+
):
|
|
350
|
+
raise CapabilityError(
|
|
351
|
+
"input index must be explicit columns; RangeIndex needs authorization"
|
|
352
|
+
)
|
|
353
|
+
value = LoadedTable(
|
|
354
|
+
detached_arrow(value.table),
|
|
355
|
+
native_types=value.native_types,
|
|
356
|
+
index=detached_arrow(value.index) if value.index is not None else None,
|
|
357
|
+
origin=value.origin,
|
|
358
|
+
notes=value.notes,
|
|
359
|
+
)
|
|
360
|
+
_validate_input(value, table, context)
|
|
361
|
+
for implementation in compiled:
|
|
362
|
+
engine = implementation.engine
|
|
363
|
+
if engine in ("pandas", "polars", "duckdb"):
|
|
364
|
+
codec = _timestamp_codec(
|
|
365
|
+
resolved_adapters[engine], implementation.transformation
|
|
366
|
+
)
|
|
367
|
+
native_value = codec.encode_input(table.name, value) if codec else value
|
|
368
|
+
_input_capabilities(native_value, engine, context)
|
|
369
|
+
if engine == "duckdb":
|
|
370
|
+
duckdb_temporal_capabilities(native_value.table)
|
|
371
|
+
rows += value.table.num_rows
|
|
372
|
+
total_bytes += value.table.nbytes
|
|
373
|
+
if value.index is not None:
|
|
374
|
+
context.check_table(value.index, output=False)
|
|
375
|
+
total_bytes += value.index.nbytes
|
|
376
|
+
if rows > spec.options.max_capture_rows or total_bytes > spec.options.max_capture_bytes:
|
|
377
|
+
raise ResourceLimitError("aggregate migration capture budget exhausted")
|
|
378
|
+
captured.append(capture_artifact(table.name, value, context))
|
|
379
|
+
if expected is not None:
|
|
380
|
+
expected_dir = capture_dir / "expected"
|
|
381
|
+
expected_dir.mkdir(exist_ok=False)
|
|
382
|
+
ref = snapshot(expected, expected_dir, spec.comparison_options, steps.check)
|
|
383
|
+
expected_rows, expected_bytes = physical_size(ref, check=steps.check)
|
|
384
|
+
rows += expected_rows
|
|
385
|
+
total_bytes += expected_bytes
|
|
386
|
+
if rows > spec.options.max_capture_rows or total_bytes > spec.options.max_capture_bytes:
|
|
387
|
+
raise ResourceLimitError("aggregate capture including expected exceeds budget")
|
|
388
|
+
expected_ref = ref
|
|
389
|
+
publish_json(
|
|
390
|
+
{
|
|
391
|
+
"schema_version": "1.0",
|
|
392
|
+
"inputs": [t.model_dump(mode="json") for t in captured],
|
|
393
|
+
"expected": expected_ref.model_dump(mode="json") if expected_ref else None,
|
|
394
|
+
"rows": rows,
|
|
395
|
+
"arrow_buffer_bytes": total_bytes,
|
|
396
|
+
},
|
|
397
|
+
directory / "capture.json",
|
|
398
|
+
)
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
ready = steps.run(
|
|
402
|
+
"capture",
|
|
403
|
+
capture,
|
|
404
|
+
bool,
|
|
405
|
+
skip="preflight_unavailable" if compiled is None else None,
|
|
406
|
+
)
|
|
407
|
+
pinned = _PinnedSource(tuple(captured))
|
|
408
|
+
registry = TransformationRegistry()
|
|
409
|
+
if compiled:
|
|
410
|
+
for c in compiled:
|
|
411
|
+
registry.register(c.transformation)
|
|
412
|
+
|
|
413
|
+
def native(n: int) -> PipelineResult:
|
|
414
|
+
record = records[n]
|
|
415
|
+
options = spec.execution_options.model_copy(
|
|
416
|
+
update={
|
|
417
|
+
"timeout_seconds": steps.timeout(spec.execution_options.timeout_seconds),
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
return run_pipeline(
|
|
421
|
+
PipelineSpec(
|
|
422
|
+
execution=ExecutionPlan(
|
|
423
|
+
contract=spec.contract,
|
|
424
|
+
engine=record.engine,
|
|
425
|
+
transformation_id=record.transformation_id,
|
|
426
|
+
transformation_revision=record.revision,
|
|
427
|
+
options=options,
|
|
428
|
+
),
|
|
429
|
+
profile=spec.profile,
|
|
430
|
+
profile_options=spec.profile_options,
|
|
431
|
+
rule_options=spec.rule_options,
|
|
432
|
+
comparison_policy=spec.comparison_policy,
|
|
433
|
+
comparison_options=spec.comparison_options,
|
|
434
|
+
),
|
|
435
|
+
source=pinned,
|
|
436
|
+
registry=registry,
|
|
437
|
+
work_dir=directory,
|
|
438
|
+
adapter=resolved_adapters[record.engine],
|
|
439
|
+
rule_registry=rule_registry,
|
|
440
|
+
detector_bundle=detector_bundle,
|
|
441
|
+
cancellation=token,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
src = steps.run(
|
|
445
|
+
"source",
|
|
446
|
+
lambda: native(0),
|
|
447
|
+
lambda p: pipeline_complete(spec, p),
|
|
448
|
+
skip="capture_unavailable" if ready is None else None,
|
|
449
|
+
)
|
|
450
|
+
dst = steps.run(
|
|
451
|
+
"target",
|
|
452
|
+
lambda: native(1),
|
|
453
|
+
lambda p: pipeline_complete(spec, p),
|
|
454
|
+
skip="capture_unavailable" if ready is None else None,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def compared(
|
|
458
|
+
left: PipelineResult | None,
|
|
459
|
+
right: PipelineResult | ArtifactRef | None,
|
|
460
|
+
reference: Literal["peer", "expected"],
|
|
461
|
+
) -> ComparisonResult:
|
|
462
|
+
if not (left is not None and left.execution is not None and right is not None):
|
|
463
|
+
raise AssertionError("Internal invariant check failed.")
|
|
464
|
+
target = right.execution if isinstance(right, PipelineResult) else right
|
|
465
|
+
if not (target is not None):
|
|
466
|
+
raise AssertionError("Internal invariant check failed.")
|
|
467
|
+
return compare(
|
|
468
|
+
left.execution,
|
|
469
|
+
target,
|
|
470
|
+
contract=spec.contract,
|
|
471
|
+
policy=spec.comparison_policy,
|
|
472
|
+
options=spec.comparison_options.model_copy(
|
|
473
|
+
update={
|
|
474
|
+
"timeout_seconds": steps.timeout(spec.comparison_options.timeout_seconds),
|
|
475
|
+
}
|
|
476
|
+
),
|
|
477
|
+
reference=reference,
|
|
478
|
+
work_dir=directory,
|
|
479
|
+
cancellation=token,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def has_output(p: PipelineResult | None) -> bool:
|
|
483
|
+
return p is not None and p.execution is not None and p.execution.state == "completed"
|
|
484
|
+
|
|
485
|
+
peer = steps.run(
|
|
486
|
+
"comparison",
|
|
487
|
+
lambda: compared(src, dst, "peer"),
|
|
488
|
+
lambda c: c.complete and c.state != "not_evaluable",
|
|
489
|
+
skip=None if has_output(src) and has_output(dst) else "execution_unavailable",
|
|
490
|
+
)
|
|
491
|
+
verifications: list[ComparisonResult | None] = []
|
|
492
|
+
verification_steps: tuple[tuple[MigrationStageName, PipelineResult | None], ...] = (
|
|
493
|
+
("verification_source", src),
|
|
494
|
+
("verification_target", dst),
|
|
495
|
+
)
|
|
496
|
+
for name, child in verification_steps:
|
|
497
|
+
verifications.append(
|
|
498
|
+
steps.run(
|
|
499
|
+
name,
|
|
500
|
+
partial(compared, child, expected_ref, "expected"),
|
|
501
|
+
lambda c: c.complete and c.state != "not_evaluable",
|
|
502
|
+
skip="not_requested"
|
|
503
|
+
if expected is None
|
|
504
|
+
else "expected_unavailable"
|
|
505
|
+
if expected_ref is None
|
|
506
|
+
else "execution_unavailable"
|
|
507
|
+
if not has_output(child)
|
|
508
|
+
else None,
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
journal.emit("migration", "finished", "migration_finished")
|
|
512
|
+
result = MigrationResult(
|
|
513
|
+
migration_id=identifier,
|
|
514
|
+
spec=spec,
|
|
515
|
+
spec_sha256=profile_digest(spec.model_dump(mode="json")),
|
|
516
|
+
started_at=started,
|
|
517
|
+
finished_at=datetime.now(UTC).isoformat(),
|
|
518
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
519
|
+
stages=tuple(steps.records),
|
|
520
|
+
compilers=records,
|
|
521
|
+
inputs=tuple(captured),
|
|
522
|
+
expected_requested=expected is not None,
|
|
523
|
+
expected=expected_ref,
|
|
524
|
+
detection_requested=detector_bundle is not None,
|
|
525
|
+
source=src,
|
|
526
|
+
target=dst,
|
|
527
|
+
comparison=peer,
|
|
528
|
+
verification_source=verifications[0],
|
|
529
|
+
verification_target=verifications[1],
|
|
530
|
+
cancelled=token.cancelled,
|
|
531
|
+
evidence=(),
|
|
532
|
+
event_count=journal.count,
|
|
533
|
+
event_head_sha256=journal.head,
|
|
534
|
+
)
|
|
535
|
+
from .io import migration_summary, render_migration_report
|
|
536
|
+
|
|
537
|
+
publish_json(migration_summary(result), directory / "report.json")
|
|
538
|
+
from equiframe._document_io import write_document as _write_document
|
|
539
|
+
|
|
540
|
+
_write_document(directory / "report.html", render_migration_report(result).encode("utf-8"))
|
|
541
|
+
result = MigrationResult.model_validate(
|
|
542
|
+
{**result.model_dump(), "evidence": inventory(directory)}
|
|
543
|
+
)
|
|
544
|
+
publish_json(result, directory / "migration.json")
|
|
545
|
+
return result
|