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.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. 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