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/workflow.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Ordered ETL checkpoints over the existing native migration runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from pydantic import BeforeValidator, Field, model_validator
|
|
13
|
+
from typing_extensions import Self
|
|
14
|
+
|
|
15
|
+
from equiframe._base import FrozenModel, Identifier, Text, _ExplicitOutcome, strict_bool_literal
|
|
16
|
+
from equiframe._regular_io import local_output_directory
|
|
17
|
+
from equiframe.errors import ArtifactIntegrityError, ResourceLimitError
|
|
18
|
+
from equiframe.evidence import inventory, load_json, publish_json, verify_files
|
|
19
|
+
from equiframe.execution import CancellationToken, RunContext
|
|
20
|
+
from equiframe.execution_types import ExecutionPlan, TableArtifact
|
|
21
|
+
from equiframe.local import LocalArtifactStore, local_path
|
|
22
|
+
from equiframe.migration.io import read_migration
|
|
23
|
+
from equiframe.migration.runner import migrate
|
|
24
|
+
from equiframe.migration.runner_types import MigrationResult, MigrationSpec
|
|
25
|
+
from equiframe.pipeline_types import EvidenceFile
|
|
26
|
+
from equiframe.ports import DataSource
|
|
27
|
+
from equiframe.tables import ArtifactInput, LoadedTable, capture_artifact
|
|
28
|
+
from equiframe.types import ArtifactRef
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InputBinding(FrozenModel):
|
|
32
|
+
name: Text
|
|
33
|
+
source: Text
|
|
34
|
+
stage: Identifier | None = None
|
|
35
|
+
output: Literal["source", "target"] = "target"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WorkflowStage(FrozenModel):
|
|
39
|
+
stage_id: Identifier
|
|
40
|
+
migration: MigrationSpec
|
|
41
|
+
inputs: tuple[InputBinding, ...]
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def bindings(self) -> Self:
|
|
45
|
+
names = tuple(binding.name for binding in self.inputs)
|
|
46
|
+
if len(names) != len(set(names)) or set(names) != {
|
|
47
|
+
t.name for t in self.migration.contract.inputs
|
|
48
|
+
}:
|
|
49
|
+
raise ValueError("stage bindings must cover exactly the stage contract inputs")
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WorkflowSpec(FrozenModel):
|
|
54
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
55
|
+
workflow_id: Identifier
|
|
56
|
+
stages: tuple[WorkflowStage, ...] = Field(min_length=1, max_length=128)
|
|
57
|
+
failure_policy: Literal["skip_dependents"] = "skip_dependents"
|
|
58
|
+
resume: Annotated[Literal[False], BeforeValidator(strict_bool_literal)] = False
|
|
59
|
+
timeout_seconds: float | None = Field(default=None, gt=0)
|
|
60
|
+
|
|
61
|
+
@model_validator(mode="after")
|
|
62
|
+
def dependencies(self) -> Self:
|
|
63
|
+
prior: dict[str, WorkflowStage] = {}
|
|
64
|
+
for stage in self.stages:
|
|
65
|
+
if stage.stage_id in prior:
|
|
66
|
+
raise ValueError("duplicate workflow stage")
|
|
67
|
+
for binding in stage.inputs:
|
|
68
|
+
if binding.stage is not None:
|
|
69
|
+
if binding.stage not in prior:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"stage dependencies must precede consumers; cycles rejected"
|
|
72
|
+
)
|
|
73
|
+
if binding.source != prior[binding.stage].migration.contract.output.name:
|
|
74
|
+
raise ValueError("binding source must identify the preceding output table")
|
|
75
|
+
prior[stage.stage_id] = stage
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class WorkflowCheckpoint(_ExplicitOutcome, FrozenModel):
|
|
80
|
+
_truth_value_guidance = "inspect .state and its migration decisions explicitly"
|
|
81
|
+
|
|
82
|
+
stage_id: Identifier
|
|
83
|
+
state: Literal["passed", "failed", "not_evaluable", "skipped"]
|
|
84
|
+
code: Text
|
|
85
|
+
migration: MigrationResult | None = None
|
|
86
|
+
elapsed_seconds: float = Field(ge=0)
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def coherent(self) -> Self:
|
|
90
|
+
if self.state == "passed" and (self.migration is None or self.migration.exit_code != 0):
|
|
91
|
+
raise ValueError("passed checkpoint requires an approved native migration")
|
|
92
|
+
if self.state == "skipped" and self.migration is not None:
|
|
93
|
+
raise ValueError("skipped checkpoint cannot have a successful fabricated execution")
|
|
94
|
+
if self.migration is not None:
|
|
95
|
+
expected = (
|
|
96
|
+
"passed"
|
|
97
|
+
if self.migration.exit_code == 0
|
|
98
|
+
else ("failed" if self.migration.exit_code == 1 else "not_evaluable")
|
|
99
|
+
)
|
|
100
|
+
if self.state != expected:
|
|
101
|
+
raise ValueError("checkpoint state contradicts native migration outcome")
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class WorkflowResult(_ExplicitOutcome, FrozenModel):
|
|
106
|
+
_truth_value_guidance = "inspect .complete, .exit_code and the required checkpoints explicitly"
|
|
107
|
+
|
|
108
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
109
|
+
run_id: Identifier
|
|
110
|
+
spec: WorkflowSpec
|
|
111
|
+
inputs: tuple[TableArtifact, ...]
|
|
112
|
+
checkpoints: tuple[WorkflowCheckpoint, ...]
|
|
113
|
+
first_divergent_stage: Identifier | None = None
|
|
114
|
+
elapsed_seconds: float = Field(ge=0)
|
|
115
|
+
evidence: tuple[EvidenceFile, ...]
|
|
116
|
+
|
|
117
|
+
@model_validator(mode="after")
|
|
118
|
+
def coherent(self) -> Self:
|
|
119
|
+
if tuple(item.stage_id for item in self.checkpoints) != tuple(
|
|
120
|
+
s.stage_id for s in self.spec.stages
|
|
121
|
+
):
|
|
122
|
+
raise ValueError("workflow must record every stage in specification order")
|
|
123
|
+
inputs = {table.name: table.data for table in self.inputs}
|
|
124
|
+
if len(inputs) != len(self.inputs):
|
|
125
|
+
raise ValueError("workflow external capture names must be unique")
|
|
126
|
+
prior: dict[str, WorkflowCheckpoint] = {}
|
|
127
|
+
for stage, checkpoint in zip(self.spec.stages, self.checkpoints, strict=True):
|
|
128
|
+
migration = checkpoint.migration
|
|
129
|
+
if migration is not None:
|
|
130
|
+
declared_options = stage.migration.options
|
|
131
|
+
actual_options = migration.spec.options
|
|
132
|
+
options_match = actual_options == declared_options
|
|
133
|
+
if self.spec.timeout_seconds is not None:
|
|
134
|
+
timeout = actual_options.timeout_seconds
|
|
135
|
+
options_match = (
|
|
136
|
+
actual_options.model_copy(
|
|
137
|
+
update={"timeout_seconds": declared_options.timeout_seconds}
|
|
138
|
+
)
|
|
139
|
+
== declared_options
|
|
140
|
+
and timeout is not None
|
|
141
|
+
and timeout <= self.spec.timeout_seconds
|
|
142
|
+
and (
|
|
143
|
+
declared_options.timeout_seconds is None
|
|
144
|
+
or timeout <= declared_options.timeout_seconds
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
if (
|
|
148
|
+
not options_match
|
|
149
|
+
or migration.spec.model_copy(update={"options": declared_options})
|
|
150
|
+
!= stage.migration
|
|
151
|
+
):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"workflow checkpoint migration differs from stage contract/plan/options"
|
|
154
|
+
)
|
|
155
|
+
actual = {table.name: table.data.sha256 for table in migration.inputs}
|
|
156
|
+
for binding in stage.inputs:
|
|
157
|
+
if binding.stage is None:
|
|
158
|
+
reference = inputs.get(binding.source)
|
|
159
|
+
else:
|
|
160
|
+
parent = prior[binding.stage]
|
|
161
|
+
if parent.state != "passed" or parent.migration is None:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"executed workflow checkpoint depends on an unapproved stage"
|
|
164
|
+
)
|
|
165
|
+
pipeline = (
|
|
166
|
+
parent.migration.source
|
|
167
|
+
if binding.output == "source"
|
|
168
|
+
else parent.migration.target
|
|
169
|
+
)
|
|
170
|
+
reference = (
|
|
171
|
+
pipeline.execution.output.data
|
|
172
|
+
if pipeline is not None
|
|
173
|
+
and pipeline.execution is not None
|
|
174
|
+
and pipeline.execution.output
|
|
175
|
+
else None
|
|
176
|
+
)
|
|
177
|
+
# A failed migration capture can retain only a prefix of
|
|
178
|
+
# inputs. MigrationResult already requires every input for
|
|
179
|
+
# a completed capture; bind all bytes actually retained.
|
|
180
|
+
if reference is None or (
|
|
181
|
+
binding.name in actual and actual[binding.name] != reference.sha256
|
|
182
|
+
):
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"workflow checkpoint input differs from fixed dependency capture"
|
|
185
|
+
)
|
|
186
|
+
prior[stage.stage_id] = checkpoint
|
|
187
|
+
divergent = next(
|
|
188
|
+
(
|
|
189
|
+
item.stage_id
|
|
190
|
+
for item in self.checkpoints
|
|
191
|
+
if item.migration is not None
|
|
192
|
+
and (
|
|
193
|
+
item.migration.state == "different"
|
|
194
|
+
or item.migration.verification == "divergent"
|
|
195
|
+
)
|
|
196
|
+
),
|
|
197
|
+
None,
|
|
198
|
+
)
|
|
199
|
+
if self.first_divergent_stage != divergent:
|
|
200
|
+
raise ValueError("first divergent stage contradicts checkpoint evidence")
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def complete(self) -> bool:
|
|
205
|
+
return all(check.state == "passed" for check in self.checkpoints)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def exit_code(self) -> int:
|
|
209
|
+
if self.complete:
|
|
210
|
+
return 0
|
|
211
|
+
return 1 if any(check.state == "failed" for check in self.checkpoints) else 3
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def outputs(self) -> dict[str, ArtifactRef]:
|
|
215
|
+
return {
|
|
216
|
+
checkpoint.stage_id: checkpoint.migration.target.execution.output.data
|
|
217
|
+
for checkpoint in self.checkpoints
|
|
218
|
+
if checkpoint.state == "passed"
|
|
219
|
+
and checkpoint.migration is not None
|
|
220
|
+
and checkpoint.migration.target is not None
|
|
221
|
+
and checkpoint.migration.target.execution is not None
|
|
222
|
+
and checkpoint.migration.target.execution.output
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _CapturedSource:
|
|
227
|
+
"""Replay fixed tables without discarding their separate index or provenance."""
|
|
228
|
+
|
|
229
|
+
def __init__(self, inputs: Mapping[str, tuple[TableArtifact, bool]]) -> None:
|
|
230
|
+
self._inputs = dict(inputs)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def table_names(self) -> tuple[str, ...]:
|
|
234
|
+
return tuple(self._inputs)
|
|
235
|
+
|
|
236
|
+
def resolve(self, name: str, *, context: RunContext) -> LoadedTable:
|
|
237
|
+
artifact, meaningful_index = self._inputs[name]
|
|
238
|
+
loaded = ArtifactInput(artifact.data).load(context)
|
|
239
|
+
index = ArtifactInput(artifact.index).load(context).table if artifact.index else None
|
|
240
|
+
return LoadedTable(
|
|
241
|
+
loaded.table,
|
|
242
|
+
native_types=tuple((column.name, column.engine_type) for column in artifact.columns),
|
|
243
|
+
index=index,
|
|
244
|
+
meaningful_index=meaningful_index,
|
|
245
|
+
origin=artifact.data,
|
|
246
|
+
notes=artifact.notes,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _capture_context(context: RunContext, consumers: tuple[WorkflowStage, ...]) -> RunContext:
|
|
251
|
+
"""Capture once up to the largest consumer budget; each stage enforces its own."""
|
|
252
|
+
first = consumers[0].migration
|
|
253
|
+
rows = []
|
|
254
|
+
sizes = []
|
|
255
|
+
for stage in consumers:
|
|
256
|
+
execution = stage.migration.execution_options
|
|
257
|
+
capture = stage.migration.options
|
|
258
|
+
rows.append(
|
|
259
|
+
min(execution.max_input_rows, capture.max_capture_rows)
|
|
260
|
+
if execution.max_input_rows is not None
|
|
261
|
+
else capture.max_capture_rows
|
|
262
|
+
)
|
|
263
|
+
sizes.append(execution.max_transport_bytes)
|
|
264
|
+
# Transport limits can apply to serialized files as well as decoded tables.
|
|
265
|
+
# A decoded migration capture limit must not become an IPC file-size limit.
|
|
266
|
+
transport_bytes = None if None in sizes else max(size for size in sizes if size is not None)
|
|
267
|
+
plan = ExecutionPlan(
|
|
268
|
+
contract=first.contract,
|
|
269
|
+
engine=first.source_engine,
|
|
270
|
+
transformation_id="workflow_capture",
|
|
271
|
+
options=first.execution_options.model_copy(
|
|
272
|
+
update={"max_input_rows": max(rows), "max_transport_bytes": transport_bytes}
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
# Keep the workflow deadline, cancellation and captured-input inventory.
|
|
276
|
+
return replace(context, plan=plan)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def run_workflow(
|
|
280
|
+
spec: WorkflowSpec,
|
|
281
|
+
*,
|
|
282
|
+
source: DataSource,
|
|
283
|
+
work_dir: Path,
|
|
284
|
+
expected: Mapping[str, ArtifactRef] | None = None,
|
|
285
|
+
cancellation: CancellationToken | None = None,
|
|
286
|
+
) -> WorkflowResult:
|
|
287
|
+
"""Fix external inputs once; downstream stages use verified checkpoint artifacts.
|
|
288
|
+
|
|
289
|
+
Stage correspondences are explicit: each MigrationSpec compares one semantic
|
|
290
|
+
output in two engines. Different internal relational DAGs may use registered
|
|
291
|
+
compilers through migrate; arbitrary untrusted callbacks are not loaded here.
|
|
292
|
+
Shared input capture uses the largest consuming stage's row/byte allowance;
|
|
293
|
+
every migration subsequently enforces its own unchanged per-stage limits.
|
|
294
|
+
"""
|
|
295
|
+
spec = WorkflowSpec.model_validate(spec)
|
|
296
|
+
if expected is not None and not isinstance(expected, Mapping):
|
|
297
|
+
raise TypeError("expected must be a mapping of stage identifiers to artifact references")
|
|
298
|
+
expected = dict({} if expected is None else expected)
|
|
299
|
+
unknown = set(expected) - {s.stage_id for s in spec.stages}
|
|
300
|
+
if unknown:
|
|
301
|
+
raise ValueError("expected references contain undeclared stage identifiers")
|
|
302
|
+
start = time.perf_counter()
|
|
303
|
+
directory = local_output_directory(work_dir) / ("workflow_" + uuid4().hex)
|
|
304
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
305
|
+
token = cancellation or CancellationToken()
|
|
306
|
+
deadline = start + spec.timeout_seconds if spec.timeout_seconds else None
|
|
307
|
+
first = spec.stages[0].migration
|
|
308
|
+
store = LocalArtifactStore(directory / "artifacts")
|
|
309
|
+
context = RunContext(
|
|
310
|
+
directory.name,
|
|
311
|
+
directory.name,
|
|
312
|
+
ExecutionPlan(
|
|
313
|
+
contract=first.contract,
|
|
314
|
+
engine=first.source_engine,
|
|
315
|
+
transformation_id="workflow_capture",
|
|
316
|
+
options=first.execution_options,
|
|
317
|
+
),
|
|
318
|
+
directory,
|
|
319
|
+
store,
|
|
320
|
+
token,
|
|
321
|
+
deadline,
|
|
322
|
+
)
|
|
323
|
+
publish_json(spec, directory / "workflow-spec.json")
|
|
324
|
+
external = sorted({b.source for s in spec.stages for b in s.inputs if b.stage is None})
|
|
325
|
+
if not set(external) <= set(source.table_names):
|
|
326
|
+
raise ValueError("workflow external bindings contain unavailable tables")
|
|
327
|
+
captured: dict[str, tuple[TableArtifact, bool]] = {}
|
|
328
|
+
capture_problems: dict[str, str] = {}
|
|
329
|
+
expected_problems: dict[str, str] = {}
|
|
330
|
+
for name in external:
|
|
331
|
+
try:
|
|
332
|
+
consumers = tuple(
|
|
333
|
+
stage
|
|
334
|
+
for stage in spec.stages
|
|
335
|
+
if any(binding.stage is None and binding.source == name for binding in stage.inputs)
|
|
336
|
+
)
|
|
337
|
+
capture_context = _capture_context(context, consumers)
|
|
338
|
+
capture_context.check()
|
|
339
|
+
loaded = source.resolve(name, context=capture_context)
|
|
340
|
+
capture_context.check_table(loaded.table, output=False)
|
|
341
|
+
decoded_bytes = loaded.table.nbytes + (
|
|
342
|
+
loaded.index.nbytes if loaded.index is not None else 0
|
|
343
|
+
)
|
|
344
|
+
if decoded_bytes > max(
|
|
345
|
+
stage.migration.options.max_capture_bytes for stage in consumers
|
|
346
|
+
):
|
|
347
|
+
raise ResourceLimitError("workflow input exceeds every consumer's capture budget")
|
|
348
|
+
captured[name] = (
|
|
349
|
+
capture_artifact(name, loaded, capture_context),
|
|
350
|
+
loaded.meaningful_index,
|
|
351
|
+
)
|
|
352
|
+
except Exception as error:
|
|
353
|
+
capture_problems[name] = type(error).__name__
|
|
354
|
+
for expected_stage, reference in expected.items():
|
|
355
|
+
try:
|
|
356
|
+
context.check()
|
|
357
|
+
temporary = directory / ("expected-" + expected_stage + "." + reference.format)
|
|
358
|
+
store.get(reference, temporary)
|
|
359
|
+
expected[expected_stage] = store.put(temporary, media_type=reference.media_type)
|
|
360
|
+
temporary.unlink()
|
|
361
|
+
except Exception as error:
|
|
362
|
+
expected_problems[expected_stage] = type(error).__name__
|
|
363
|
+
checkpoints: list[WorkflowCheckpoint] = []
|
|
364
|
+
prior: dict[str, WorkflowCheckpoint] = {}
|
|
365
|
+
for stage in spec.stages:
|
|
366
|
+
stage_start = time.perf_counter()
|
|
367
|
+
code = expected_problems.get(stage.stage_id) or next(
|
|
368
|
+
(
|
|
369
|
+
capture_problems[binding.source]
|
|
370
|
+
for binding in stage.inputs
|
|
371
|
+
if binding.stage is None and binding.source in capture_problems
|
|
372
|
+
),
|
|
373
|
+
None,
|
|
374
|
+
)
|
|
375
|
+
if code is None:
|
|
376
|
+
try:
|
|
377
|
+
context.check()
|
|
378
|
+
except Exception as error:
|
|
379
|
+
code = type(error).__name__
|
|
380
|
+
dependencies = tuple(b.stage for b in stage.inputs if b.stage is not None)
|
|
381
|
+
if code or any(prior[dependency].state != "passed" for dependency in dependencies):
|
|
382
|
+
checkpoint = WorkflowCheckpoint(
|
|
383
|
+
stage_id=stage.stage_id,
|
|
384
|
+
state="skipped",
|
|
385
|
+
code=code or "dependency_not_approved",
|
|
386
|
+
elapsed_seconds=time.perf_counter() - stage_start,
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
inputs = {}
|
|
390
|
+
for binding in stage.inputs:
|
|
391
|
+
if binding.stage is None:
|
|
392
|
+
inputs[binding.name] = captured[binding.source]
|
|
393
|
+
else:
|
|
394
|
+
parent = prior[binding.stage].migration
|
|
395
|
+
if not (parent is not None):
|
|
396
|
+
raise AssertionError("Internal invariant check failed.")
|
|
397
|
+
pipeline = parent.source if binding.output == "source" else parent.target
|
|
398
|
+
if not (
|
|
399
|
+
pipeline is not None
|
|
400
|
+
and pipeline.execution is not None
|
|
401
|
+
and pipeline.execution.output is not None
|
|
402
|
+
):
|
|
403
|
+
raise AssertionError("Internal invariant check failed.")
|
|
404
|
+
# Native migration checkpoints retain separate generated
|
|
405
|
+
# indexes. The next migration still enforces its own index
|
|
406
|
+
# policy rather than silently receiving only data bytes.
|
|
407
|
+
inputs[binding.name] = (pipeline.execution.output, False)
|
|
408
|
+
try:
|
|
409
|
+
migration_spec = stage.migration
|
|
410
|
+
if deadline is not None:
|
|
411
|
+
remaining = deadline - time.perf_counter()
|
|
412
|
+
context.check()
|
|
413
|
+
timeout = migration_spec.options.timeout_seconds
|
|
414
|
+
options = migration_spec.options.model_copy(
|
|
415
|
+
update={
|
|
416
|
+
"timeout_seconds": min(remaining, timeout) if timeout else remaining
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
migration_spec = migration_spec.model_copy(update={"options": options})
|
|
420
|
+
result = migrate(
|
|
421
|
+
migration_spec,
|
|
422
|
+
source=_CapturedSource(inputs),
|
|
423
|
+
work_dir=directory / stage.stage_id,
|
|
424
|
+
expected=expected.get(stage.stage_id),
|
|
425
|
+
cancellation=token,
|
|
426
|
+
)
|
|
427
|
+
state: Literal["passed", "failed", "not_evaluable", "skipped"] = (
|
|
428
|
+
"passed"
|
|
429
|
+
if result.exit_code == 0
|
|
430
|
+
else ("failed" if result.exit_code == 1 else "not_evaluable")
|
|
431
|
+
)
|
|
432
|
+
checkpoint = WorkflowCheckpoint(
|
|
433
|
+
stage_id=stage.stage_id,
|
|
434
|
+
state=state,
|
|
435
|
+
code="migration_" + str(result.exit_code),
|
|
436
|
+
migration=result,
|
|
437
|
+
elapsed_seconds=time.perf_counter() - stage_start,
|
|
438
|
+
)
|
|
439
|
+
except Exception as error:
|
|
440
|
+
checkpoint = WorkflowCheckpoint(
|
|
441
|
+
stage_id=stage.stage_id,
|
|
442
|
+
state="not_evaluable",
|
|
443
|
+
code=type(error).__name__,
|
|
444
|
+
elapsed_seconds=time.perf_counter() - stage_start,
|
|
445
|
+
)
|
|
446
|
+
checkpoints.append(checkpoint)
|
|
447
|
+
prior[stage.stage_id] = checkpoint
|
|
448
|
+
workflow_result = WorkflowResult(
|
|
449
|
+
run_id=directory.name,
|
|
450
|
+
spec=spec,
|
|
451
|
+
inputs=tuple(context.captured_inputs),
|
|
452
|
+
checkpoints=tuple(checkpoints),
|
|
453
|
+
first_divergent_stage=next(
|
|
454
|
+
(
|
|
455
|
+
c.stage_id
|
|
456
|
+
for c in checkpoints
|
|
457
|
+
if c.migration is not None
|
|
458
|
+
and (c.migration.state == "different" or c.migration.verification == "divergent")
|
|
459
|
+
),
|
|
460
|
+
None,
|
|
461
|
+
),
|
|
462
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
463
|
+
evidence=inventory(directory),
|
|
464
|
+
)
|
|
465
|
+
publish_json(workflow_result, directory / "workflow.json")
|
|
466
|
+
return workflow_result
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def read_workflow(path: Path, *, verify: bool = True) -> WorkflowResult:
|
|
470
|
+
"""Read without running ETL; only explicit False skips inventory verification."""
|
|
471
|
+
if type(verify) is not bool:
|
|
472
|
+
raise TypeError("verify must be a bool; only explicit False skips integrity checks")
|
|
473
|
+
path = path / "workflow.json" if path.is_dir() else path
|
|
474
|
+
result = load_json(WorkflowResult, path)
|
|
475
|
+
if not verify:
|
|
476
|
+
return result
|
|
477
|
+
directory = path.parent.resolve()
|
|
478
|
+
verify_files(directory, result.evidence)
|
|
479
|
+
actual = {
|
|
480
|
+
item.relative_to(directory).as_posix()
|
|
481
|
+
for item in directory.rglob("*")
|
|
482
|
+
if item.is_file() and item.resolve() != path.resolve()
|
|
483
|
+
}
|
|
484
|
+
if actual != {item.path for item in result.evidence}:
|
|
485
|
+
raise ArtifactIntegrityError("workflow files contradict the closed inventory")
|
|
486
|
+
if load_json(WorkflowSpec, directory / "workflow-spec.json") != result.spec:
|
|
487
|
+
raise ArtifactIntegrityError("workflow specification differs from evidence")
|
|
488
|
+
listed = {item.path: item for item in result.evidence}
|
|
489
|
+
for table in result.inputs:
|
|
490
|
+
for reference in (table.data, *((table.index,) if table.index else ())):
|
|
491
|
+
file = local_path(reference).resolve()
|
|
492
|
+
if not file.is_relative_to(directory):
|
|
493
|
+
raise ArtifactIntegrityError("workflow captured input escapes inventory")
|
|
494
|
+
entry = listed.get(file.relative_to(directory).as_posix())
|
|
495
|
+
if not entry or (reference.sha256, reference.size_bytes) != (
|
|
496
|
+
entry.sha256,
|
|
497
|
+
entry.size_bytes,
|
|
498
|
+
):
|
|
499
|
+
raise ArtifactIntegrityError("workflow input reference differs from captured bytes")
|
|
500
|
+
for checkpoint in result.checkpoints:
|
|
501
|
+
if checkpoint.migration is not None:
|
|
502
|
+
manifest = (
|
|
503
|
+
directory
|
|
504
|
+
/ checkpoint.stage_id
|
|
505
|
+
/ checkpoint.migration.migration_id
|
|
506
|
+
/ "migration.json"
|
|
507
|
+
)
|
|
508
|
+
if read_migration(manifest) != checkpoint.migration:
|
|
509
|
+
raise ArtifactIntegrityError("workflow checkpoint differs from migration evidence")
|
|
510
|
+
return result
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Strict artifact-only ETL requests; no callback or plugin imports in JSON."""
|
|
2
|
+
|
|
3
|
+
from pydantic import model_validator
|
|
4
|
+
from typing_extensions import Self
|
|
5
|
+
|
|
6
|
+
from equiframe._base import FrozenModel, Identifier
|
|
7
|
+
from equiframe.pipeline_types import PipelineInput
|
|
8
|
+
from equiframe.types import ArtifactRef
|
|
9
|
+
from equiframe.workflow import WorkflowSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkflowExpected(FrozenModel):
|
|
13
|
+
stage_id: Identifier
|
|
14
|
+
artifact: ArtifactRef
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkflowRequest(FrozenModel):
|
|
18
|
+
spec: WorkflowSpec
|
|
19
|
+
inputs: tuple[PipelineInput, ...]
|
|
20
|
+
expected: tuple[WorkflowExpected, ...] = ()
|
|
21
|
+
|
|
22
|
+
@model_validator(mode="after")
|
|
23
|
+
def names(self) -> Self:
|
|
24
|
+
external = {
|
|
25
|
+
binding.source
|
|
26
|
+
for stage in self.spec.stages
|
|
27
|
+
for binding in stage.inputs
|
|
28
|
+
if binding.stage is None
|
|
29
|
+
}
|
|
30
|
+
if len({item.name for item in self.inputs}) != len(self.inputs):
|
|
31
|
+
raise ValueError("workflow input names must be unique")
|
|
32
|
+
if {item.name for item in self.inputs} != external:
|
|
33
|
+
raise ValueError("workflow request must bind exactly its external inputs")
|
|
34
|
+
if len({item.stage_id for item in self.expected}) != len(self.expected):
|
|
35
|
+
raise ValueError("workflow expected stages must be unique")
|
|
36
|
+
if not {item.stage_id for item in self.expected} <= {s.stage_id for s in self.spec.stages}:
|
|
37
|
+
raise ValueError("workflow expected references contain undeclared stages")
|
|
38
|
+
return self
|