dnakit 0.1.0.dev0__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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1548 @@
|
|
|
1
|
+
"""Bounded local execution of strictly whitelisted DNAKit workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from time import perf_counter
|
|
15
|
+
from typing import Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
from dnakit.core import ArtifactRef, DNAAlphabet, DNARecord, DNASet, Issue, IssueSeverity
|
|
18
|
+
from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
|
|
19
|
+
from dnakit.core.provenance import Provenance
|
|
20
|
+
from dnakit.datasets import (
|
|
21
|
+
DeduplicationConfig,
|
|
22
|
+
SplitConfig,
|
|
23
|
+
SplitResult,
|
|
24
|
+
deduplicate,
|
|
25
|
+
split,
|
|
26
|
+
)
|
|
27
|
+
from dnakit.descriptors import (
|
|
28
|
+
base_composition,
|
|
29
|
+
gc_at_content,
|
|
30
|
+
length_features,
|
|
31
|
+
shannon_entropy,
|
|
32
|
+
)
|
|
33
|
+
from dnakit.exceptions import ConfigurationError, InputFormatError, SequenceError
|
|
34
|
+
from dnakit.fingerprints import kmer_fingerprint
|
|
35
|
+
from dnakit.io import ReadConfig, WriteConfig, read, write
|
|
36
|
+
from dnakit.standardize import (
|
|
37
|
+
DatasetValidationConfig,
|
|
38
|
+
NormalizationConfig,
|
|
39
|
+
ValidationConfig,
|
|
40
|
+
normalize,
|
|
41
|
+
validate,
|
|
42
|
+
)
|
|
43
|
+
from dnakit.visualization import SaveConfig, build_html_report, save_html_report
|
|
44
|
+
from dnakit.workflows.manifest import (
|
|
45
|
+
RunManifestBuilder,
|
|
46
|
+
artifact_from_path,
|
|
47
|
+
load_manifest,
|
|
48
|
+
save_manifest,
|
|
49
|
+
)
|
|
50
|
+
from dnakit.workflows.schema import LoadedWorkflow, WorkflowSpec, WorkflowStep, load_workflow
|
|
51
|
+
|
|
52
|
+
WorkflowProgressStatus = Literal["planned", "started", "succeeded", "failed", "skipped", "blocked"]
|
|
53
|
+
WorkflowStepStatus = Literal["planned", "succeeded", "failed", "skipped", "blocked"]
|
|
54
|
+
WorkflowRunStatus = Literal["dry-run", "succeeded", "failed"]
|
|
55
|
+
ProgressCallback = Callable[["WorkflowProgress"], None]
|
|
56
|
+
|
|
57
|
+
_OUTPUT_MARKER = ".dnakit-workflow-output-v1"
|
|
58
|
+
_MANIFEST_NAME = "run-manifest.json"
|
|
59
|
+
_EXECUTION_SCHEMA = "dnakit-workflow-execution-v1"
|
|
60
|
+
_MEDIA_TYPES = {
|
|
61
|
+
"fasta": "text/x-fasta",
|
|
62
|
+
"fastq": "text/x-fastq",
|
|
63
|
+
"csv": "text/csv",
|
|
64
|
+
"tsv": "text/tab-separated-values",
|
|
65
|
+
"json": "application/json",
|
|
66
|
+
"jsonl": "application/x-ndjson",
|
|
67
|
+
"genbank": "text/x-genbank",
|
|
68
|
+
"html": "text/html",
|
|
69
|
+
}
|
|
70
|
+
_FORMAT_ALIASES = {
|
|
71
|
+
"fa": "fasta",
|
|
72
|
+
"fna": "fasta",
|
|
73
|
+
"fq": "fastq",
|
|
74
|
+
"gb": "genbank",
|
|
75
|
+
"gbk": "genbank",
|
|
76
|
+
"ndjson": "jsonl",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _utc_now() -> str:
|
|
81
|
+
return datetime.now(timezone.utc).isoformat()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _plain(value: object) -> object:
|
|
85
|
+
return to_json_compatible(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _plain_dict(value: Mapping[str, object]) -> dict[str, object]:
|
|
89
|
+
return cast(dict[str, object], _plain(value))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _canonical_bytes(value: object) -> bytes:
|
|
93
|
+
try:
|
|
94
|
+
return json.dumps(
|
|
95
|
+
_plain(value),
|
|
96
|
+
ensure_ascii=False,
|
|
97
|
+
sort_keys=True,
|
|
98
|
+
separators=(",", ":"),
|
|
99
|
+
allow_nan=False,
|
|
100
|
+
).encode("utf-8")
|
|
101
|
+
except (TypeError, ValueError) as exc:
|
|
102
|
+
raise ConfigurationError(
|
|
103
|
+
"Workflow state must remain finite and JSON-compatible.",
|
|
104
|
+
code="INVALID_WORKFLOW_STATE",
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _sha256(value: object) -> str:
|
|
109
|
+
return hashlib.sha256(_canonical_bytes(value)).hexdigest()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _error_payload(error: Exception) -> dict[str, object]:
|
|
113
|
+
raw_code = getattr(error, "code", "WORKFLOW_STEP_ERROR")
|
|
114
|
+
code = raw_code if isinstance(raw_code, str) and raw_code else "WORKFLOW_STEP_ERROR"
|
|
115
|
+
payload: dict[str, object] = {
|
|
116
|
+
"type": type(error).__name__,
|
|
117
|
+
"code": code,
|
|
118
|
+
"message": str(error),
|
|
119
|
+
}
|
|
120
|
+
context = getattr(error, "context", None)
|
|
121
|
+
if isinstance(context, Mapping) and context:
|
|
122
|
+
payload["context"] = _plain(context)
|
|
123
|
+
hint = getattr(error, "hint", None)
|
|
124
|
+
if isinstance(hint, str) and hint:
|
|
125
|
+
payload["hint"] = hint
|
|
126
|
+
return payload
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class WorkflowProgress:
|
|
131
|
+
"""Library-level progress event; DNAKit never prints it itself."""
|
|
132
|
+
|
|
133
|
+
run_id: str
|
|
134
|
+
step_id: str
|
|
135
|
+
operation: str
|
|
136
|
+
index: int
|
|
137
|
+
total: int
|
|
138
|
+
status: WorkflowProgressStatus
|
|
139
|
+
message: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
143
|
+
class WorkflowStepResult:
|
|
144
|
+
"""Auditable outcome for one planned workflow step."""
|
|
145
|
+
|
|
146
|
+
id: str
|
|
147
|
+
operation: str
|
|
148
|
+
input_ref: str
|
|
149
|
+
status: WorkflowStepStatus
|
|
150
|
+
params: FrozenDict
|
|
151
|
+
started_at: str | None
|
|
152
|
+
finished_at: str | None
|
|
153
|
+
duration_seconds: float
|
|
154
|
+
input_sha256: str | None
|
|
155
|
+
record_count: int | None
|
|
156
|
+
summary: FrozenDict
|
|
157
|
+
artifacts: tuple[FrozenDict, ...]
|
|
158
|
+
error: FrozenDict | None
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
id: str,
|
|
163
|
+
operation: str,
|
|
164
|
+
input_ref: str,
|
|
165
|
+
status: WorkflowStepStatus,
|
|
166
|
+
params: Mapping[str, object],
|
|
167
|
+
*,
|
|
168
|
+
started_at: str | None = None,
|
|
169
|
+
finished_at: str | None = None,
|
|
170
|
+
duration_seconds: float = 0.0,
|
|
171
|
+
input_sha256: str | None = None,
|
|
172
|
+
record_count: int | None = None,
|
|
173
|
+
summary: Mapping[str, object] | None = None,
|
|
174
|
+
artifacts: tuple[Mapping[str, object], ...] = (),
|
|
175
|
+
error: Mapping[str, object] | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
if not id or not operation or not input_ref:
|
|
178
|
+
raise ConfigurationError("Workflow step result identifiers must be non-empty.")
|
|
179
|
+
if status not in {"planned", "succeeded", "failed", "skipped", "blocked"}:
|
|
180
|
+
raise ConfigurationError("Workflow step result status is invalid.")
|
|
181
|
+
if duration_seconds < 0:
|
|
182
|
+
raise ConfigurationError("Workflow step duration must be non-negative.")
|
|
183
|
+
if record_count is not None and (
|
|
184
|
+
isinstance(record_count, bool) or not isinstance(record_count, int) or record_count < 0
|
|
185
|
+
):
|
|
186
|
+
raise ConfigurationError("Workflow step record_count must be non-negative or None.")
|
|
187
|
+
object.__setattr__(self, "id", id)
|
|
188
|
+
object.__setattr__(self, "operation", operation)
|
|
189
|
+
object.__setattr__(self, "input_ref", input_ref)
|
|
190
|
+
object.__setattr__(self, "status", status)
|
|
191
|
+
object.__setattr__(self, "params", freeze_mapping(params))
|
|
192
|
+
object.__setattr__(self, "started_at", started_at)
|
|
193
|
+
object.__setattr__(self, "finished_at", finished_at)
|
|
194
|
+
object.__setattr__(self, "duration_seconds", float(duration_seconds))
|
|
195
|
+
object.__setattr__(self, "input_sha256", input_sha256)
|
|
196
|
+
object.__setattr__(self, "record_count", record_count)
|
|
197
|
+
object.__setattr__(self, "summary", freeze_mapping(summary))
|
|
198
|
+
object.__setattr__(
|
|
199
|
+
self,
|
|
200
|
+
"artifacts",
|
|
201
|
+
tuple(freeze_mapping(artifact) for artifact in artifacts),
|
|
202
|
+
)
|
|
203
|
+
object.__setattr__(self, "error", None if error is None else freeze_mapping(error))
|
|
204
|
+
|
|
205
|
+
def to_dict(self) -> dict[str, object]:
|
|
206
|
+
return cast(dict[str, object], _plain(self))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class WorkflowRunResult:
|
|
211
|
+
"""Final local workflow outcome and persisted artifact references."""
|
|
212
|
+
|
|
213
|
+
run_id: str
|
|
214
|
+
status: WorkflowRunStatus
|
|
215
|
+
dry_run: bool
|
|
216
|
+
resumed: bool
|
|
217
|
+
spec_sha256: str
|
|
218
|
+
input_sha256: str
|
|
219
|
+
output_dir: str
|
|
220
|
+
manifest_path: str | None
|
|
221
|
+
manifest_artifact: ArtifactRef | None
|
|
222
|
+
steps: tuple[WorkflowStepResult, ...]
|
|
223
|
+
artifacts: tuple[ArtifactRef, ...]
|
|
224
|
+
|
|
225
|
+
def to_dict(self) -> dict[str, object]:
|
|
226
|
+
return cast(dict[str, object], _plain(self))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True)
|
|
230
|
+
class _State:
|
|
231
|
+
records: DNASet
|
|
232
|
+
results: Mapping[str, object]
|
|
233
|
+
split_result: SplitResult | None = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True, slots=True)
|
|
237
|
+
class _OperationOutcome:
|
|
238
|
+
state: _State
|
|
239
|
+
summary: Mapping[str, object]
|
|
240
|
+
artifacts: tuple[ArtifactRef, ...] = ()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True, slots=True)
|
|
244
|
+
class _ResumeContext:
|
|
245
|
+
entries: Mapping[str, Mapping[str, object]]
|
|
246
|
+
prior_steps: tuple[Mapping[str, object], ...]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _emit(
|
|
250
|
+
callback: ProgressCallback | None,
|
|
251
|
+
*,
|
|
252
|
+
run_id: str,
|
|
253
|
+
step_id: str,
|
|
254
|
+
operation: str,
|
|
255
|
+
index: int,
|
|
256
|
+
total: int,
|
|
257
|
+
status: WorkflowProgressStatus,
|
|
258
|
+
message: str,
|
|
259
|
+
) -> None:
|
|
260
|
+
if callback is None:
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
callback(WorkflowProgress(run_id, step_id, operation, index, total, status, message))
|
|
264
|
+
except Exception:
|
|
265
|
+
# Progress is advisory and must never change scientific output or leave
|
|
266
|
+
# an already-persisted step in an ambiguous failed state.
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _best_effort_emit(
|
|
271
|
+
callback: ProgressCallback | None,
|
|
272
|
+
*,
|
|
273
|
+
run_id: str,
|
|
274
|
+
step_id: str,
|
|
275
|
+
operation: str,
|
|
276
|
+
index: int,
|
|
277
|
+
total: int,
|
|
278
|
+
status: WorkflowProgressStatus,
|
|
279
|
+
message: str,
|
|
280
|
+
) -> None:
|
|
281
|
+
_emit(
|
|
282
|
+
callback,
|
|
283
|
+
run_id=run_id,
|
|
284
|
+
step_id=step_id,
|
|
285
|
+
operation=operation,
|
|
286
|
+
index=index,
|
|
287
|
+
total=total,
|
|
288
|
+
status=status,
|
|
289
|
+
message=message,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _prepare_output_dir(path: Path, *, create: bool) -> None:
|
|
294
|
+
marker = path / _OUTPUT_MARKER
|
|
295
|
+
if path.exists():
|
|
296
|
+
if path.is_symlink() or not path.is_dir():
|
|
297
|
+
raise ConfigurationError(
|
|
298
|
+
"Workflow output_dir must be a non-symlink directory.",
|
|
299
|
+
code="UNSAFE_WORKFLOW_OUTPUT_DIR",
|
|
300
|
+
context={"output_dir": str(path)},
|
|
301
|
+
)
|
|
302
|
+
nonempty = next(path.iterdir(), None) is not None
|
|
303
|
+
if nonempty and (not marker.is_file() or marker.is_symlink()):
|
|
304
|
+
raise ConfigurationError(
|
|
305
|
+
"Existing non-empty output_dir lacks the DNAKit workflow marker.",
|
|
306
|
+
code="UNSAFE_WORKFLOW_OUTPUT_DIR",
|
|
307
|
+
context={"output_dir": str(path)},
|
|
308
|
+
hint="Choose an empty dedicated directory.",
|
|
309
|
+
)
|
|
310
|
+
elif create:
|
|
311
|
+
path.mkdir(parents=True, exist_ok=False)
|
|
312
|
+
if create and not marker.exists():
|
|
313
|
+
try:
|
|
314
|
+
with marker.open("x", encoding="utf-8") as handle:
|
|
315
|
+
handle.write(_EXECUTION_SCHEMA + "\n")
|
|
316
|
+
handle.flush()
|
|
317
|
+
os.fsync(handle.fileno())
|
|
318
|
+
except FileExistsError as exc:
|
|
319
|
+
if not marker.is_file() or marker.is_symlink():
|
|
320
|
+
raise ConfigurationError(
|
|
321
|
+
"Workflow output marker is unsafe.",
|
|
322
|
+
code="UNSAFE_WORKFLOW_OUTPUT_DIR",
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _ensure_target(root: Path, relative: str, *, create_parent: bool) -> Path:
|
|
327
|
+
raw = Path(relative)
|
|
328
|
+
candidate = root.joinpath(*raw.parts)
|
|
329
|
+
resolved = candidate.resolve(strict=False)
|
|
330
|
+
if not resolved.is_relative_to(root) or resolved == root:
|
|
331
|
+
raise ConfigurationError(
|
|
332
|
+
"Workflow output target escapes output_dir.",
|
|
333
|
+
code="UNSAFE_WORKFLOW_OUTPUT_PATH",
|
|
334
|
+
context={"target": relative},
|
|
335
|
+
)
|
|
336
|
+
current = root
|
|
337
|
+
for part in raw.parts[:-1]:
|
|
338
|
+
current /= part
|
|
339
|
+
if current.exists() and current.is_symlink():
|
|
340
|
+
raise ConfigurationError(
|
|
341
|
+
"Workflow output target traverses a symbolic link.",
|
|
342
|
+
code="UNSAFE_WORKFLOW_OUTPUT_PATH",
|
|
343
|
+
context={"path": str(current)},
|
|
344
|
+
)
|
|
345
|
+
if create_parent:
|
|
346
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
if resolved.parent.resolve() != resolved.parent or not resolved.parent.is_relative_to(root):
|
|
348
|
+
raise ConfigurationError(
|
|
349
|
+
"Workflow output parent is unsafe.", code="UNSAFE_WORKFLOW_OUTPUT_PATH"
|
|
350
|
+
)
|
|
351
|
+
if resolved.exists() and resolved.is_symlink():
|
|
352
|
+
raise ConfigurationError(
|
|
353
|
+
"Workflow output target must not be a symbolic link.",
|
|
354
|
+
code="UNSAFE_WORKFLOW_OUTPUT_PATH",
|
|
355
|
+
context={"target": relative},
|
|
356
|
+
)
|
|
357
|
+
return resolved
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _input_media_type(plan: LoadedWorkflow) -> str:
|
|
361
|
+
format_name = plan.spec.input.format
|
|
362
|
+
if format_name is None:
|
|
363
|
+
name = plan.input_path.name.lower()
|
|
364
|
+
if name.endswith(".gz"):
|
|
365
|
+
name = name[:-3]
|
|
366
|
+
suffix = Path(name).suffix.lower().lstrip(".")
|
|
367
|
+
format_name = _FORMAT_ALIASES.get(suffix, suffix)
|
|
368
|
+
return _MEDIA_TYPES.get(format_name, "application/octet-stream")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _read_input(plan: LoadedWorkflow) -> DNASet:
|
|
372
|
+
config = ReadConfig(
|
|
373
|
+
alphabet=DNAAlphabet(plan.spec.input.alphabet),
|
|
374
|
+
compression=cast(Any, plan.spec.input.compression),
|
|
375
|
+
max_records=plan.spec.limits.max_records,
|
|
376
|
+
max_sequence_symbols=plan.spec.limits.max_sequence_length,
|
|
377
|
+
max_input_bytes=plan.spec.limits.max_input_bytes,
|
|
378
|
+
)
|
|
379
|
+
records: list[DNARecord] = []
|
|
380
|
+
total = 0
|
|
381
|
+
with read(
|
|
382
|
+
plan.input_path,
|
|
383
|
+
format=plan.spec.input.format,
|
|
384
|
+
config=config,
|
|
385
|
+
) as source:
|
|
386
|
+
for index, record in enumerate(source):
|
|
387
|
+
symbol_length = record.sequence.symbol_length
|
|
388
|
+
coordinate_span = record.sequence.coordinate_span
|
|
389
|
+
if symbol_length > plan.spec.limits.max_sequence_length or (
|
|
390
|
+
coordinate_span is not None
|
|
391
|
+
and coordinate_span > plan.spec.limits.max_sequence_length
|
|
392
|
+
):
|
|
393
|
+
raise ConfigurationError(
|
|
394
|
+
"Input record exceeds limits.max_sequence_length.",
|
|
395
|
+
code="WORKFLOW_SEQUENCE_LIMIT",
|
|
396
|
+
context={
|
|
397
|
+
"index": index,
|
|
398
|
+
"record_id": record.id,
|
|
399
|
+
"symbol_length": symbol_length,
|
|
400
|
+
"coordinate_span": coordinate_span,
|
|
401
|
+
"maximum": plan.spec.limits.max_sequence_length,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
total += symbol_length if coordinate_span is None else coordinate_span
|
|
405
|
+
if total > plan.spec.limits.max_total_bases:
|
|
406
|
+
raise ConfigurationError(
|
|
407
|
+
"Input exceeds limits.max_total_bases.",
|
|
408
|
+
code="WORKFLOW_TOTAL_BASE_LIMIT",
|
|
409
|
+
context={"observed": total, "maximum": plan.spec.limits.max_total_bases},
|
|
410
|
+
)
|
|
411
|
+
records.append(record)
|
|
412
|
+
return DNASet.from_records(records, source=str(plan.input_path))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _params(step: WorkflowStep) -> dict[str, object]:
|
|
416
|
+
return _plain_dict(step.params)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _with_result(
|
|
420
|
+
state: _State,
|
|
421
|
+
step_id: str,
|
|
422
|
+
summary: Mapping[str, object],
|
|
423
|
+
*,
|
|
424
|
+
records: DNASet | None = None,
|
|
425
|
+
split_result: SplitResult | Literal["preserve"] | None = "preserve",
|
|
426
|
+
) -> _State:
|
|
427
|
+
results = dict(state.results)
|
|
428
|
+
results[step_id] = _plain_dict(summary)
|
|
429
|
+
resolved_split = state.split_result if split_result == "preserve" else split_result
|
|
430
|
+
return _State(state.records if records is None else records, results, resolved_split)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _check_result_size(value: object, spec: WorkflowSpec, *, step_id: str) -> None:
|
|
434
|
+
size = len(_canonical_bytes(value))
|
|
435
|
+
if size > spec.limits.max_result_bytes:
|
|
436
|
+
raise ConfigurationError(
|
|
437
|
+
"Workflow step result exceeds limits.max_result_bytes.",
|
|
438
|
+
code="WORKFLOW_RESULT_LIMIT",
|
|
439
|
+
context={
|
|
440
|
+
"step_id": step_id,
|
|
441
|
+
"byte_size": size,
|
|
442
|
+
"maximum": spec.limits.max_result_bytes,
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _normalize_operation(
|
|
448
|
+
step: WorkflowStep, state: _State, spec: WorkflowSpec
|
|
449
|
+
) -> _OperationOutcome:
|
|
450
|
+
config = NormalizationConfig(**cast(dict[str, Any], _params(step)))
|
|
451
|
+
records: list[DNARecord] = []
|
|
452
|
+
audit: list[dict[str, object]] = []
|
|
453
|
+
for record in state.records:
|
|
454
|
+
result = normalize(record.sequence, config=config)
|
|
455
|
+
if result.sequence is None:
|
|
456
|
+
raise SequenceError(
|
|
457
|
+
"Workflow normalization did not produce a valid DNA sequence.",
|
|
458
|
+
code="WORKFLOW_NORMALIZATION_FAILED",
|
|
459
|
+
context={
|
|
460
|
+
"record_id": record.id,
|
|
461
|
+
"issue_codes": [issue.code for issue in result.issues],
|
|
462
|
+
},
|
|
463
|
+
)
|
|
464
|
+
if result.sequence.symbol_length != record.sequence.symbol_length and (
|
|
465
|
+
record.features or record.letter_annotations
|
|
466
|
+
):
|
|
467
|
+
raise SequenceError(
|
|
468
|
+
"Length-changing normalization cannot preserve record annotations.",
|
|
469
|
+
code="WORKFLOW_ANNOTATION_LENGTH_CHANGED",
|
|
470
|
+
context={"record_id": record.id},
|
|
471
|
+
)
|
|
472
|
+
records.append(
|
|
473
|
+
DNARecord(
|
|
474
|
+
result.sequence,
|
|
475
|
+
record.id,
|
|
476
|
+
description=record.description,
|
|
477
|
+
features=record.features,
|
|
478
|
+
metadata=record.metadata,
|
|
479
|
+
letter_annotations=record.letter_annotations,
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
audit.append(
|
|
483
|
+
{
|
|
484
|
+
"record_id": record.id,
|
|
485
|
+
"modified": result.was_modified,
|
|
486
|
+
"change_count": len(result.changes),
|
|
487
|
+
"issue_codes": [issue.code for issue in result.issues],
|
|
488
|
+
}
|
|
489
|
+
)
|
|
490
|
+
normalized = DNASet(
|
|
491
|
+
records,
|
|
492
|
+
name=state.records.name,
|
|
493
|
+
source=state.records.source,
|
|
494
|
+
version=state.records.version,
|
|
495
|
+
metadata=state.records.metadata,
|
|
496
|
+
)
|
|
497
|
+
summary = {
|
|
498
|
+
"record_count": len(normalized),
|
|
499
|
+
"modified_record_count": sum(bool(row["modified"]) for row in audit),
|
|
500
|
+
"records": audit,
|
|
501
|
+
}
|
|
502
|
+
_check_result_size(summary, spec, step_id=step.id)
|
|
503
|
+
return _OperationOutcome(
|
|
504
|
+
_with_result(state, step.id, summary, records=normalized, split_result=None), summary
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _validation_operation(
|
|
509
|
+
step: WorkflowStep, state: _State, spec: WorkflowSpec
|
|
510
|
+
) -> _OperationOutcome:
|
|
511
|
+
params = _params(step)
|
|
512
|
+
fail_on_invalid = cast(bool, params.pop("fail_on_invalid", True))
|
|
513
|
+
require_unique_ids = cast(bool, params.pop("require_unique_ids", True))
|
|
514
|
+
collect_record_reports = cast(bool, params.pop("collect_record_reports", True))
|
|
515
|
+
record_config = ValidationConfig(**cast(dict[str, Any], params))
|
|
516
|
+
report = validate(
|
|
517
|
+
state.records,
|
|
518
|
+
config=DatasetValidationConfig(
|
|
519
|
+
record_config,
|
|
520
|
+
require_unique_ids=require_unique_ids,
|
|
521
|
+
collect_record_reports=collect_record_reports,
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
invalid_ids = (
|
|
525
|
+
[
|
|
526
|
+
item.record_id
|
|
527
|
+
for item in report.record_reports or ()
|
|
528
|
+
if not item.is_valid and item.record_id is not None
|
|
529
|
+
]
|
|
530
|
+
if collect_record_reports
|
|
531
|
+
else []
|
|
532
|
+
)
|
|
533
|
+
summary = {
|
|
534
|
+
"record_count": report.record_count,
|
|
535
|
+
"is_valid": report.is_valid,
|
|
536
|
+
"ids_unique": report.ids_unique,
|
|
537
|
+
"duplicate_ids": [item.id for item in report.duplicate_ids],
|
|
538
|
+
"invalid_record_ids": invalid_ids,
|
|
539
|
+
"issue_codes": [item.code for item in report.issues],
|
|
540
|
+
}
|
|
541
|
+
if fail_on_invalid and not report.is_valid:
|
|
542
|
+
raise ConfigurationError(
|
|
543
|
+
"Workflow validation failed.",
|
|
544
|
+
code="WORKFLOW_VALIDATION_FAILED",
|
|
545
|
+
context=summary,
|
|
546
|
+
hint="Set fail_on_invalid=false only when downstream handling is intentional.",
|
|
547
|
+
)
|
|
548
|
+
_check_result_size(summary, spec, step_id=step.id)
|
|
549
|
+
return _OperationOutcome(_with_result(state, step.id, summary), summary)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _descriptor_operation(
|
|
553
|
+
step: WorkflowStep, state: _State, spec: WorkflowSpec
|
|
554
|
+
) -> _OperationOutcome:
|
|
555
|
+
params = _params(step)
|
|
556
|
+
metrics = cast(list[str], params.get("metrics", ["length", "gc"]))
|
|
557
|
+
ambiguity_policy = cast(str, params.get("ambiguity_policy", "error"))
|
|
558
|
+
if len(state.records) * len(metrics) * 256 > spec.limits.max_result_bytes:
|
|
559
|
+
raise ConfigurationError(
|
|
560
|
+
"Workflow descriptor table would exceed limits.max_result_bytes.",
|
|
561
|
+
code="WORKFLOW_RESULT_LIMIT",
|
|
562
|
+
context={"step_id": step.id, "record_count": len(state.records)},
|
|
563
|
+
)
|
|
564
|
+
rows: list[dict[str, object]] = []
|
|
565
|
+
for record in state.records:
|
|
566
|
+
row: dict[str, object] = {"record_id": record.id}
|
|
567
|
+
for metric in metrics:
|
|
568
|
+
if metric == "length":
|
|
569
|
+
payload = length_features(record).to_dict()
|
|
570
|
+
elif metric == "gc":
|
|
571
|
+
payload = gc_at_content(record, ambiguity_policy=ambiguity_policy).to_dict()
|
|
572
|
+
elif metric == "composition":
|
|
573
|
+
payload = base_composition(record, ambiguity_policy=ambiguity_policy).to_dict()
|
|
574
|
+
else:
|
|
575
|
+
payload = shannon_entropy(record, ambiguity_policy=ambiguity_policy).to_dict()
|
|
576
|
+
row[metric] = payload
|
|
577
|
+
rows.append(row)
|
|
578
|
+
summary = {"record_count": len(rows), "metrics": metrics, "rows": rows}
|
|
579
|
+
_check_result_size(summary, spec, step_id=step.id)
|
|
580
|
+
return _OperationOutcome(_with_result(state, step.id, summary), summary)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _fingerprint_operation(
|
|
584
|
+
step: WorkflowStep, state: _State, spec: WorkflowSpec
|
|
585
|
+
) -> _OperationOutcome:
|
|
586
|
+
params = _params(step)
|
|
587
|
+
params.setdefault("k", 3)
|
|
588
|
+
params.setdefault("max_dimension", spec.limits.max_fingerprint_dimension)
|
|
589
|
+
k = cast(int, params["k"])
|
|
590
|
+
raw_dimension = 4**k
|
|
591
|
+
canonical = cast(bool, params.get("canonical", False))
|
|
592
|
+
dimension = (
|
|
593
|
+
(raw_dimension + (0 if k % 2 else 4 ** (k // 2))) // 2 if canonical else raw_dimension
|
|
594
|
+
)
|
|
595
|
+
representation = cast(str, params.get("representation", "dense"))
|
|
596
|
+
schema_bytes = dimension * (k + 4)
|
|
597
|
+
value_bytes = (
|
|
598
|
+
dimension * max(1, len(state.records)) * 2
|
|
599
|
+
if representation == "dense"
|
|
600
|
+
else sum(record.sequence.symbol_length for record in state.records) * (k + 16)
|
|
601
|
+
)
|
|
602
|
+
if schema_bytes + value_bytes > spec.limits.max_result_bytes:
|
|
603
|
+
raise ConfigurationError(
|
|
604
|
+
"Workflow fingerprint result would exceed limits.max_result_bytes.",
|
|
605
|
+
code="WORKFLOW_RESULT_LIMIT",
|
|
606
|
+
context={
|
|
607
|
+
"step_id": step.id,
|
|
608
|
+
"estimated_minimum_bytes": schema_bytes + value_bytes,
|
|
609
|
+
"maximum": spec.limits.max_result_bytes,
|
|
610
|
+
},
|
|
611
|
+
)
|
|
612
|
+
rows: list[dict[str, object]] = []
|
|
613
|
+
feature_names: tuple[str, ...] | None = None
|
|
614
|
+
for record in state.records:
|
|
615
|
+
result = kmer_fingerprint(record, **cast(dict[str, Any], params))
|
|
616
|
+
if feature_names is None:
|
|
617
|
+
feature_names = result.feature_names
|
|
618
|
+
rows.append(
|
|
619
|
+
{
|
|
620
|
+
"record_id": record.id,
|
|
621
|
+
"schema_version": result.schema_version,
|
|
622
|
+
"dimension": len(result.feature_names),
|
|
623
|
+
"observation_count": result.observation_count,
|
|
624
|
+
"values": result.values,
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
summary = {
|
|
628
|
+
"record_count": len(rows),
|
|
629
|
+
"k": params["k"],
|
|
630
|
+
"canonical": params.get("canonical", False),
|
|
631
|
+
"mode": params.get("mode", "count"),
|
|
632
|
+
"representation": params.get("representation", "dense"),
|
|
633
|
+
"feature_names": () if feature_names is None else feature_names,
|
|
634
|
+
"rows": rows,
|
|
635
|
+
}
|
|
636
|
+
_check_result_size(summary, spec, step_id=step.id)
|
|
637
|
+
return _OperationOutcome(_with_result(state, step.id, summary), summary)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _deduplicate_operation(
|
|
641
|
+
step: WorkflowStep, state: _State, spec: WorkflowSpec
|
|
642
|
+
) -> _OperationOutcome:
|
|
643
|
+
params = _params(step)
|
|
644
|
+
equivalence = cast(str, params.pop("equivalence", "exact"))
|
|
645
|
+
result = deduplicate(
|
|
646
|
+
state.records,
|
|
647
|
+
equivalence=cast(Any, equivalence),
|
|
648
|
+
config=DeduplicationConfig(**cast(dict[str, Any], params)),
|
|
649
|
+
)
|
|
650
|
+
summary = result.to_dict()
|
|
651
|
+
_check_result_size(summary, spec, step_id=step.id)
|
|
652
|
+
return _OperationOutcome(
|
|
653
|
+
_with_result(state, step.id, summary, records=result.records, split_result=None), summary
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _split_operation(step: WorkflowStep, state: _State, spec: WorkflowSpec) -> _OperationOutcome:
|
|
658
|
+
params = _params(step)
|
|
659
|
+
result = split(
|
|
660
|
+
state.records,
|
|
661
|
+
config=SplitConfig(seed=spec.seed, **cast(dict[str, Any], params)),
|
|
662
|
+
)
|
|
663
|
+
summary = result.to_dict()
|
|
664
|
+
_check_result_size(summary, spec, step_id=step.id)
|
|
665
|
+
return _OperationOutcome(_with_result(state, step.id, summary, split_result=result), summary)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _selected_records(state: _State, subset: object, *, step_id: str) -> DNASet:
|
|
669
|
+
if subset is None:
|
|
670
|
+
return state.records
|
|
671
|
+
if not isinstance(subset, str):
|
|
672
|
+
raise ConfigurationError("Workflow subset must be a string.")
|
|
673
|
+
if state.split_result is None:
|
|
674
|
+
raise ConfigurationError(
|
|
675
|
+
"Workflow subset selection requires an upstream split step.",
|
|
676
|
+
code="WORKFLOW_SPLIT_REQUIRED",
|
|
677
|
+
context={"step_id": step_id, "subset": subset},
|
|
678
|
+
)
|
|
679
|
+
try:
|
|
680
|
+
return state.split_result.get(subset)
|
|
681
|
+
except KeyError as exc:
|
|
682
|
+
raise ConfigurationError(
|
|
683
|
+
"Workflow subset name is not present in the upstream split.",
|
|
684
|
+
code="UNKNOWN_WORKFLOW_SUBSET",
|
|
685
|
+
context={"step_id": step_id, "subset": subset},
|
|
686
|
+
) from exc
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _output_format(target: str, explicit: object) -> str:
|
|
690
|
+
if isinstance(explicit, str):
|
|
691
|
+
return explicit
|
|
692
|
+
name = target[:-3] if target.lower().endswith(".gz") else target
|
|
693
|
+
suffix = Path(name).suffix.lower().lstrip(".")
|
|
694
|
+
return _FORMAT_ALIASES.get(suffix, suffix)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _publish_stage(stage: Path, target: Path, *, overwrite: bool) -> bool:
|
|
698
|
+
existed = target.exists()
|
|
699
|
+
if existed and not overwrite:
|
|
700
|
+
raise FileExistsError(f"Refusing to overwrite existing workflow output: {target}")
|
|
701
|
+
if overwrite:
|
|
702
|
+
os.replace(stage, target)
|
|
703
|
+
else:
|
|
704
|
+
os.link(stage, target)
|
|
705
|
+
stage.unlink()
|
|
706
|
+
return existed
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _write_operation(
|
|
710
|
+
step: WorkflowStep,
|
|
711
|
+
state: _State,
|
|
712
|
+
spec: WorkflowSpec,
|
|
713
|
+
output_root: Path,
|
|
714
|
+
available_output_bytes: int,
|
|
715
|
+
) -> _OperationOutcome:
|
|
716
|
+
params = _params(step)
|
|
717
|
+
relative_target = cast(str, params["target"])
|
|
718
|
+
records = _selected_records(state, params.get("subset"), step_id=step.id)
|
|
719
|
+
format_name = _output_format(relative_target, params.get("format"))
|
|
720
|
+
compression = cast(str, params.get("compression", "auto"))
|
|
721
|
+
if compression == "auto":
|
|
722
|
+
compression = "gzip" if relative_target.lower().endswith(".gz") else "none"
|
|
723
|
+
preliminary_summary = {
|
|
724
|
+
"target": relative_target,
|
|
725
|
+
"format": format_name,
|
|
726
|
+
"record_count": len(records),
|
|
727
|
+
"byte_count": spec.limits.max_output_bytes,
|
|
728
|
+
"sha256": "0" * 64,
|
|
729
|
+
"overwritten": False,
|
|
730
|
+
"subset": params.get("subset"),
|
|
731
|
+
}
|
|
732
|
+
_check_result_size({**state.results, step.id: preliminary_summary}, spec, step_id=step.id)
|
|
733
|
+
target = _ensure_target(output_root, relative_target, create_parent=True)
|
|
734
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
735
|
+
prefix=f".{target.name}.{step.id}.", suffix=".stage", dir=target.parent
|
|
736
|
+
)
|
|
737
|
+
os.close(descriptor)
|
|
738
|
+
stage = Path(temporary_name)
|
|
739
|
+
stage.unlink()
|
|
740
|
+
try:
|
|
741
|
+
try:
|
|
742
|
+
result = write(
|
|
743
|
+
records,
|
|
744
|
+
stage,
|
|
745
|
+
format=format_name,
|
|
746
|
+
config=WriteConfig(
|
|
747
|
+
overwrite=False,
|
|
748
|
+
create_parents=False,
|
|
749
|
+
compression=cast(Any, compression),
|
|
750
|
+
compression_level=cast(int, params.get("compression_level", 6)),
|
|
751
|
+
line_width=cast(int, params.get("line_width", 80)),
|
|
752
|
+
max_output_bytes=available_output_bytes,
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
except InputFormatError as exc:
|
|
756
|
+
if exc.code != "OUTPUT_BYTE_LIMIT_EXCEEDED":
|
|
757
|
+
raise
|
|
758
|
+
raise ConfigurationError(
|
|
759
|
+
"Workflow outputs exceed the total limits.max_output_bytes.",
|
|
760
|
+
code="WORKFLOW_OUTPUT_LIMIT",
|
|
761
|
+
context={"step_id": step.id, "remaining": available_output_bytes},
|
|
762
|
+
) from exc
|
|
763
|
+
byte_count = result.byte_count or 0
|
|
764
|
+
if byte_count > available_output_bytes:
|
|
765
|
+
raise ConfigurationError(
|
|
766
|
+
"Workflow outputs exceed the total limits.max_output_bytes.",
|
|
767
|
+
code="WORKFLOW_OUTPUT_LIMIT",
|
|
768
|
+
context={
|
|
769
|
+
"step_id": step.id,
|
|
770
|
+
"byte_size": byte_count,
|
|
771
|
+
"remaining": available_output_bytes,
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
overwritten = _publish_stage(stage, target, overwrite=spec.overwrite)
|
|
775
|
+
finally:
|
|
776
|
+
stage.unlink(missing_ok=True)
|
|
777
|
+
artifact = artifact_from_path(
|
|
778
|
+
target,
|
|
779
|
+
media_type=_MEDIA_TYPES[format_name],
|
|
780
|
+
schema_version="dnakit-io-v1",
|
|
781
|
+
)
|
|
782
|
+
summary = {
|
|
783
|
+
"target": relative_target,
|
|
784
|
+
"format": format_name,
|
|
785
|
+
"record_count": len(records),
|
|
786
|
+
"byte_count": artifact.byte_size,
|
|
787
|
+
"sha256": artifact.sha256,
|
|
788
|
+
"overwritten": overwritten,
|
|
789
|
+
"subset": params.get("subset"),
|
|
790
|
+
}
|
|
791
|
+
return _OperationOutcome(_with_result(state, step.id, summary), summary, (artifact,))
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _report_operation(
|
|
795
|
+
step: WorkflowStep,
|
|
796
|
+
state: _State,
|
|
797
|
+
spec: WorkflowSpec,
|
|
798
|
+
output_root: Path,
|
|
799
|
+
available_output_bytes: int,
|
|
800
|
+
) -> _OperationOutcome:
|
|
801
|
+
params = _params(step)
|
|
802
|
+
relative_target = cast(str, params["target"])
|
|
803
|
+
records = _selected_records(state, params.get("subset"), step_id=step.id)
|
|
804
|
+
max_result_bytes = cast(int, params.get("max_result_bytes", spec.limits.max_result_bytes))
|
|
805
|
+
preliminary_summary = {
|
|
806
|
+
"target": relative_target,
|
|
807
|
+
"format": "html",
|
|
808
|
+
"record_count": len(records),
|
|
809
|
+
"result_names": tuple(sorted(state.results)),
|
|
810
|
+
"byte_count": spec.limits.max_output_bytes,
|
|
811
|
+
"sha256": "0" * 64,
|
|
812
|
+
"overwritten": False,
|
|
813
|
+
"subset": params.get("subset"),
|
|
814
|
+
}
|
|
815
|
+
_check_result_size({**state.results, step.id: preliminary_summary}, spec, step_id=step.id)
|
|
816
|
+
target = _ensure_target(output_root, relative_target, create_parent=True)
|
|
817
|
+
artifact = build_html_report(
|
|
818
|
+
records,
|
|
819
|
+
results=state.results,
|
|
820
|
+
title=cast(str, params.get("title", "DNAKit report")),
|
|
821
|
+
max_records=cast(int, params.get("max_records", spec.limits.max_records)),
|
|
822
|
+
max_result_bytes=max_result_bytes,
|
|
823
|
+
max_total_sequence_symbols=spec.limits.max_total_bases,
|
|
824
|
+
max_output_bytes=available_output_bytes,
|
|
825
|
+
)
|
|
826
|
+
byte_size = len(artifact.html.encode("utf-8"))
|
|
827
|
+
if byte_size > available_output_bytes:
|
|
828
|
+
raise ConfigurationError(
|
|
829
|
+
"Workflow outputs exceed the total limits.max_output_bytes.",
|
|
830
|
+
code="WORKFLOW_OUTPUT_LIMIT",
|
|
831
|
+
context={
|
|
832
|
+
"step_id": step.id,
|
|
833
|
+
"byte_size": byte_size,
|
|
834
|
+
"remaining": available_output_bytes,
|
|
835
|
+
},
|
|
836
|
+
)
|
|
837
|
+
saved = save_html_report(
|
|
838
|
+
artifact,
|
|
839
|
+
target,
|
|
840
|
+
config=SaveConfig(overwrite=spec.overwrite, create_parents=True),
|
|
841
|
+
)
|
|
842
|
+
summary = {
|
|
843
|
+
"target": relative_target,
|
|
844
|
+
"format": "html",
|
|
845
|
+
"record_count": len(records),
|
|
846
|
+
"result_names": artifact.result_names,
|
|
847
|
+
"byte_count": saved.target_artifact.byte_size,
|
|
848
|
+
"sha256": saved.target_artifact.sha256,
|
|
849
|
+
"overwritten": saved.overwritten,
|
|
850
|
+
"subset": params.get("subset"),
|
|
851
|
+
}
|
|
852
|
+
return _OperationOutcome(
|
|
853
|
+
_with_result(state, step.id, summary), summary, (saved.target_artifact,)
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _execute_operation(
|
|
858
|
+
step: WorkflowStep,
|
|
859
|
+
state: _State,
|
|
860
|
+
spec: WorkflowSpec,
|
|
861
|
+
output_root: Path,
|
|
862
|
+
available_output_bytes: int,
|
|
863
|
+
) -> _OperationOutcome:
|
|
864
|
+
if step.operation == "normalize":
|
|
865
|
+
return _normalize_operation(step, state, spec)
|
|
866
|
+
if step.operation == "validate":
|
|
867
|
+
return _validation_operation(step, state, spec)
|
|
868
|
+
if step.operation == "descriptors":
|
|
869
|
+
return _descriptor_operation(step, state, spec)
|
|
870
|
+
if step.operation == "fingerprint":
|
|
871
|
+
return _fingerprint_operation(step, state, spec)
|
|
872
|
+
if step.operation == "deduplicate":
|
|
873
|
+
return _deduplicate_operation(step, state, spec)
|
|
874
|
+
if step.operation == "split":
|
|
875
|
+
return _split_operation(step, state, spec)
|
|
876
|
+
if step.operation == "write":
|
|
877
|
+
return _write_operation(step, state, spec, output_root, available_output_bytes)
|
|
878
|
+
return _report_operation(step, state, spec, output_root, available_output_bytes)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _state_digest(state: _State, step: WorkflowStep) -> str:
|
|
882
|
+
record_digest = hashlib.sha256()
|
|
883
|
+
for record in state.records:
|
|
884
|
+
encoded = _canonical_bytes(record)
|
|
885
|
+
record_digest.update(len(encoded).to_bytes(8, "big"))
|
|
886
|
+
record_digest.update(encoded)
|
|
887
|
+
split_payload: object = None if state.split_result is None else state.split_result.to_dict()
|
|
888
|
+
return _sha256(
|
|
889
|
+
{
|
|
890
|
+
"records_sha256": record_digest.hexdigest(),
|
|
891
|
+
"record_count": len(state.records),
|
|
892
|
+
"results_sha256": _sha256(state.results),
|
|
893
|
+
"split_sha256": _sha256(split_payload),
|
|
894
|
+
"step": step.to_dict(),
|
|
895
|
+
}
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _artifact_summary(artifact: ArtifactRef, output_root: Path) -> dict[str, object]:
|
|
900
|
+
absolute = (Path.cwd() / artifact.relative_path).resolve()
|
|
901
|
+
if not absolute.is_relative_to(output_root):
|
|
902
|
+
raise ConfigurationError(
|
|
903
|
+
"Workflow output artifact escaped output_dir.",
|
|
904
|
+
code="UNSAFE_WORKFLOW_OUTPUT_PATH",
|
|
905
|
+
context={"path": str(absolute)},
|
|
906
|
+
)
|
|
907
|
+
return {
|
|
908
|
+
"path": absolute.relative_to(output_root).as_posix(),
|
|
909
|
+
"media_type": artifact.media_type,
|
|
910
|
+
"schema_version": artifact.schema_version,
|
|
911
|
+
"sha256": artifact.sha256,
|
|
912
|
+
"byte_size": artifact.byte_size,
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _resume_entry_map(steps: object) -> dict[str, Mapping[str, object]]:
|
|
917
|
+
result: dict[str, Mapping[str, object]] = {}
|
|
918
|
+
if not isinstance(steps, list):
|
|
919
|
+
return result
|
|
920
|
+
for raw in steps:
|
|
921
|
+
if not isinstance(raw, Mapping):
|
|
922
|
+
continue
|
|
923
|
+
step_id = raw.get("id")
|
|
924
|
+
status = raw.get("status")
|
|
925
|
+
if isinstance(step_id, str) and status in {"succeeded", "skipped"}:
|
|
926
|
+
result[step_id] = cast(Mapping[str, object], raw)
|
|
927
|
+
return result
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _load_resume_context(
|
|
931
|
+
manifest_path: Path,
|
|
932
|
+
*,
|
|
933
|
+
spec_sha256: str,
|
|
934
|
+
input_sha256: str,
|
|
935
|
+
) -> _ResumeContext:
|
|
936
|
+
payload = load_manifest(manifest_path)
|
|
937
|
+
resolved = payload.get("resolved_config")
|
|
938
|
+
if not isinstance(resolved, dict) or resolved.get("schema_version") != _EXECUTION_SCHEMA:
|
|
939
|
+
raise ConfigurationError(
|
|
940
|
+
"Existing manifest is not a DNAKit workflow execution manifest.",
|
|
941
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
942
|
+
)
|
|
943
|
+
if resolved.get("spec_sha256") != spec_sha256:
|
|
944
|
+
raise ConfigurationError(
|
|
945
|
+
"Workflow configuration hash differs from the resumable manifest.",
|
|
946
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
947
|
+
)
|
|
948
|
+
execution = resolved.get("execution")
|
|
949
|
+
if not isinstance(execution, dict) or execution.get("input_sha256") != input_sha256:
|
|
950
|
+
raise ConfigurationError(
|
|
951
|
+
"Workflow input hash differs from the resumable manifest.",
|
|
952
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
953
|
+
)
|
|
954
|
+
current = execution.get("steps", [])
|
|
955
|
+
prior = execution.get("resume_candidates", [])
|
|
956
|
+
entries = _resume_entry_map(prior)
|
|
957
|
+
entries.update(_resume_entry_map(current))
|
|
958
|
+
prior_steps = tuple(
|
|
959
|
+
cast(Mapping[str, object], item)
|
|
960
|
+
for item in [
|
|
961
|
+
*(prior if isinstance(prior, list) else []),
|
|
962
|
+
*(current if isinstance(current, list) else []),
|
|
963
|
+
]
|
|
964
|
+
if isinstance(item, Mapping)
|
|
965
|
+
)
|
|
966
|
+
return _ResumeContext(entries, prior_steps)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _verified_resume_artifacts(
|
|
970
|
+
entry: Mapping[str, object],
|
|
971
|
+
*,
|
|
972
|
+
step: WorkflowStep,
|
|
973
|
+
input_sha256: str,
|
|
974
|
+
output_root: Path,
|
|
975
|
+
) -> tuple[tuple[ArtifactRef, ...], Mapping[str, object]]:
|
|
976
|
+
if entry.get("operation") != step.operation or entry.get("input_sha256") != input_sha256:
|
|
977
|
+
raise ConfigurationError(
|
|
978
|
+
"A resumable step no longer has the same operation or input state hash.",
|
|
979
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
980
|
+
context={"step_id": step.id},
|
|
981
|
+
)
|
|
982
|
+
raw_artifacts = entry.get("artifacts")
|
|
983
|
+
if not isinstance(raw_artifacts, list) or len(raw_artifacts) != 1:
|
|
984
|
+
raise ConfigurationError(
|
|
985
|
+
"A resumable output step must have exactly one artifact audit.",
|
|
986
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
987
|
+
context={"step_id": step.id},
|
|
988
|
+
)
|
|
989
|
+
expected_target = step.params.get("target")
|
|
990
|
+
verified: list[ArtifactRef] = []
|
|
991
|
+
for raw in raw_artifacts:
|
|
992
|
+
if not isinstance(raw, Mapping):
|
|
993
|
+
raise ConfigurationError(
|
|
994
|
+
"A resumable artifact audit is invalid.", code="WORKFLOW_RESUME_MISMATCH"
|
|
995
|
+
)
|
|
996
|
+
path_value = raw.get("path")
|
|
997
|
+
media_type = raw.get("media_type")
|
|
998
|
+
schema_version = raw.get("schema_version")
|
|
999
|
+
expected_hash = raw.get("sha256")
|
|
1000
|
+
expected_size = raw.get("byte_size")
|
|
1001
|
+
if not all(
|
|
1002
|
+
isinstance(value, str) and value
|
|
1003
|
+
for value in (path_value, media_type, schema_version, expected_hash)
|
|
1004
|
+
):
|
|
1005
|
+
raise ConfigurationError(
|
|
1006
|
+
"A resumable artifact audit is incomplete.", code="WORKFLOW_RESUME_MISMATCH"
|
|
1007
|
+
)
|
|
1008
|
+
if (
|
|
1009
|
+
isinstance(expected_size, bool)
|
|
1010
|
+
or not isinstance(expected_size, int)
|
|
1011
|
+
or expected_size < 0
|
|
1012
|
+
):
|
|
1013
|
+
raise ConfigurationError(
|
|
1014
|
+
"A resumable artifact byte size is invalid.",
|
|
1015
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
1016
|
+
context={"step_id": step.id},
|
|
1017
|
+
)
|
|
1018
|
+
if path_value != expected_target:
|
|
1019
|
+
raise ConfigurationError(
|
|
1020
|
+
"A resumable artifact path differs from the configured output target.",
|
|
1021
|
+
code="WORKFLOW_RESUME_MISMATCH",
|
|
1022
|
+
context={
|
|
1023
|
+
"step_id": step.id,
|
|
1024
|
+
"configured_target": expected_target,
|
|
1025
|
+
"manifest_target": path_value,
|
|
1026
|
+
},
|
|
1027
|
+
)
|
|
1028
|
+
target = _ensure_target(output_root, cast(str, path_value), create_parent=False)
|
|
1029
|
+
if target.stat().st_size != expected_size:
|
|
1030
|
+
raise ConfigurationError(
|
|
1031
|
+
"A resumable output artifact failed size validation.",
|
|
1032
|
+
code="WORKFLOW_RESUME_ARTIFACT_MISMATCH",
|
|
1033
|
+
context={
|
|
1034
|
+
"step_id": step.id,
|
|
1035
|
+
"path": cast(str, path_value),
|
|
1036
|
+
"expected_size": expected_size,
|
|
1037
|
+
"actual_size": target.stat().st_size,
|
|
1038
|
+
},
|
|
1039
|
+
)
|
|
1040
|
+
artifact = artifact_from_path(
|
|
1041
|
+
target,
|
|
1042
|
+
media_type=cast(str, media_type),
|
|
1043
|
+
schema_version=cast(str, schema_version),
|
|
1044
|
+
max_bytes=max(1, expected_size),
|
|
1045
|
+
)
|
|
1046
|
+
if artifact.sha256 != expected_hash or artifact.byte_size != expected_size:
|
|
1047
|
+
raise ConfigurationError(
|
|
1048
|
+
"A resumable output artifact failed checksum validation.",
|
|
1049
|
+
code="WORKFLOW_RESUME_ARTIFACT_MISMATCH",
|
|
1050
|
+
context={
|
|
1051
|
+
"step_id": step.id,
|
|
1052
|
+
"path": cast(str, path_value),
|
|
1053
|
+
"expected_sha256": expected_hash,
|
|
1054
|
+
"actual_sha256": artifact.sha256,
|
|
1055
|
+
},
|
|
1056
|
+
)
|
|
1057
|
+
verified.append(artifact)
|
|
1058
|
+
summary = entry.get("summary")
|
|
1059
|
+
if not isinstance(summary, Mapping):
|
|
1060
|
+
summary = {"resume": "verified"}
|
|
1061
|
+
return tuple(verified), cast(Mapping[str, object], summary)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _workflow_provenance() -> Provenance:
|
|
1065
|
+
try:
|
|
1066
|
+
yaml_version = version("PyYAML")
|
|
1067
|
+
except PackageNotFoundError:
|
|
1068
|
+
yaml_version = "unknown"
|
|
1069
|
+
return Provenance(dependency_versions={"PyYAML": yaml_version})
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _resolved_manifest_config(
|
|
1073
|
+
plan: LoadedWorkflow,
|
|
1074
|
+
*,
|
|
1075
|
+
input_sha256: str,
|
|
1076
|
+
resume_requested: bool,
|
|
1077
|
+
steps: tuple[WorkflowStepResult, ...],
|
|
1078
|
+
resume_candidates: tuple[Mapping[str, object], ...],
|
|
1079
|
+
) -> dict[str, object]:
|
|
1080
|
+
return {
|
|
1081
|
+
"schema_version": _EXECUTION_SCHEMA,
|
|
1082
|
+
"spec_sha256": plan.spec.sha256,
|
|
1083
|
+
"workflow": plan.spec.to_dict(),
|
|
1084
|
+
"runtime": {
|
|
1085
|
+
"resume_requested": resume_requested,
|
|
1086
|
+
"network_access": False,
|
|
1087
|
+
"arbitrary_command_execution": False,
|
|
1088
|
+
},
|
|
1089
|
+
"execution": {
|
|
1090
|
+
"input_sha256": input_sha256,
|
|
1091
|
+
"steps": [step.to_dict() for step in steps],
|
|
1092
|
+
"resume_candidates": [_plain_dict(item) for item in resume_candidates],
|
|
1093
|
+
},
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _save_execution_manifest(
|
|
1098
|
+
plan: LoadedWorkflow,
|
|
1099
|
+
*,
|
|
1100
|
+
input_artifact: ArtifactRef,
|
|
1101
|
+
config_artifact: ArtifactRef,
|
|
1102
|
+
resume_requested: bool,
|
|
1103
|
+
steps: tuple[WorkflowStepResult, ...],
|
|
1104
|
+
resume_candidates: tuple[Mapping[str, object], ...],
|
|
1105
|
+
outputs: tuple[ArtifactRef, ...],
|
|
1106
|
+
issues: tuple[Issue, ...],
|
|
1107
|
+
status: Literal["running", "succeeded", "failed"],
|
|
1108
|
+
started_at: str,
|
|
1109
|
+
) -> ArtifactRef:
|
|
1110
|
+
builder = RunManifestBuilder(
|
|
1111
|
+
plan.spec.run_id,
|
|
1112
|
+
("dnakit", "workflow", str(plan.config_path)),
|
|
1113
|
+
_resolved_manifest_config(
|
|
1114
|
+
plan,
|
|
1115
|
+
input_sha256=input_artifact.sha256,
|
|
1116
|
+
resume_requested=resume_requested,
|
|
1117
|
+
steps=steps,
|
|
1118
|
+
resume_candidates=resume_candidates,
|
|
1119
|
+
),
|
|
1120
|
+
seed=plan.spec.seed,
|
|
1121
|
+
seed_derivation="workflow master seed applied to deterministic dataset steps",
|
|
1122
|
+
provenance=_workflow_provenance(),
|
|
1123
|
+
started_at=started_at,
|
|
1124
|
+
)
|
|
1125
|
+
builder.add_input(config_artifact)
|
|
1126
|
+
builder.add_input(input_artifact)
|
|
1127
|
+
for artifact in outputs:
|
|
1128
|
+
builder.add_output(artifact)
|
|
1129
|
+
for issue in issues:
|
|
1130
|
+
builder.add_issue(issue)
|
|
1131
|
+
manifest = builder.build(status=status)
|
|
1132
|
+
target = plan.output_dir / _MANIFEST_NAME
|
|
1133
|
+
return save_manifest(manifest, target, overwrite=target.exists())
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _failed_issue(error: Exception, step_id: str | None) -> Issue:
|
|
1137
|
+
payload = _error_payload(error)
|
|
1138
|
+
details: dict[str, object] = {"error": payload}
|
|
1139
|
+
if step_id is not None:
|
|
1140
|
+
details["step_id"] = step_id
|
|
1141
|
+
return Issue(
|
|
1142
|
+
cast(str, payload["code"]),
|
|
1143
|
+
IssueSeverity.ERROR,
|
|
1144
|
+
f"Workflow execution failed: {error}",
|
|
1145
|
+
details=details,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _planned_result(step: WorkflowStep) -> WorkflowStepResult:
|
|
1150
|
+
return WorkflowStepResult(
|
|
1151
|
+
step.id,
|
|
1152
|
+
step.operation,
|
|
1153
|
+
step.input_ref,
|
|
1154
|
+
"planned",
|
|
1155
|
+
_params(step),
|
|
1156
|
+
summary={"execution": "not started"},
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def run_workflow(
|
|
1161
|
+
path: str | os.PathLike[str],
|
|
1162
|
+
*,
|
|
1163
|
+
dry_run: bool = False,
|
|
1164
|
+
resume: bool = False,
|
|
1165
|
+
progress: ProgressCallback | None = None,
|
|
1166
|
+
) -> WorkflowRunResult:
|
|
1167
|
+
"""Execute one bounded local JSON/YAML workflow without commands or network access.
|
|
1168
|
+
|
|
1169
|
+
``dry_run`` validates the complete schema, paths, input artifact, dependencies,
|
|
1170
|
+
and operation parameters without creating ``output_dir``. ``resume`` reruns
|
|
1171
|
+
deterministic in-memory computation but skips a prior ``write`` or ``report``
|
|
1172
|
+
only after validating the workflow hash, input-state hash, output path, byte
|
|
1173
|
+
size, and SHA-256 digest.
|
|
1174
|
+
"""
|
|
1175
|
+
|
|
1176
|
+
if not isinstance(dry_run, bool) or not isinstance(resume, bool):
|
|
1177
|
+
raise ConfigurationError("dry_run and resume must be booleans.")
|
|
1178
|
+
if dry_run and resume:
|
|
1179
|
+
raise ConfigurationError(
|
|
1180
|
+
"dry_run and resume are mutually exclusive.",
|
|
1181
|
+
code="INVALID_WORKFLOW_MODE",
|
|
1182
|
+
)
|
|
1183
|
+
if progress is not None and not callable(progress):
|
|
1184
|
+
raise ConfigurationError("progress must be callable or None.")
|
|
1185
|
+
plan = load_workflow(path)
|
|
1186
|
+
_prepare_output_dir(plan.output_dir, create=False)
|
|
1187
|
+
config_artifact = artifact_from_path(
|
|
1188
|
+
plan.config_path,
|
|
1189
|
+
media_type=(
|
|
1190
|
+
"application/json" if plan.config_path.suffix.lower() == ".json" else "application/yaml"
|
|
1191
|
+
),
|
|
1192
|
+
schema_version=_EXECUTION_SCHEMA,
|
|
1193
|
+
max_bytes=1_000_000,
|
|
1194
|
+
)
|
|
1195
|
+
if (
|
|
1196
|
+
config_artifact.sha256 != plan.config_file_sha256
|
|
1197
|
+
or config_artifact.byte_size != plan.config_file_size
|
|
1198
|
+
):
|
|
1199
|
+
raise InputFormatError(
|
|
1200
|
+
"Workflow configuration changed after it was parsed.",
|
|
1201
|
+
code="WORKFLOW_CONFIG_CHANGED",
|
|
1202
|
+
context={"path": str(plan.config_path)},
|
|
1203
|
+
)
|
|
1204
|
+
input_artifact = artifact_from_path(
|
|
1205
|
+
plan.input_path,
|
|
1206
|
+
media_type=_input_media_type(plan),
|
|
1207
|
+
schema_version="dnakit-workflow-input-v1",
|
|
1208
|
+
max_bytes=plan.spec.limits.max_input_bytes,
|
|
1209
|
+
)
|
|
1210
|
+
if dry_run:
|
|
1211
|
+
for step in plan.spec.steps:
|
|
1212
|
+
if step.operation not in {"write", "report"}:
|
|
1213
|
+
continue
|
|
1214
|
+
target = _ensure_target(
|
|
1215
|
+
plan.output_dir,
|
|
1216
|
+
cast(str, step.params["target"]),
|
|
1217
|
+
create_parent=False,
|
|
1218
|
+
)
|
|
1219
|
+
if target.exists() and not plan.spec.overwrite:
|
|
1220
|
+
raise FileExistsError(f"Workflow output exists and overwrite is disabled: {target}")
|
|
1221
|
+
planned = tuple(_planned_result(step) for step in plan.spec.steps)
|
|
1222
|
+
for index, step in enumerate(plan.spec.steps, start=1):
|
|
1223
|
+
_emit(
|
|
1224
|
+
progress,
|
|
1225
|
+
run_id=plan.spec.run_id,
|
|
1226
|
+
step_id=step.id,
|
|
1227
|
+
operation=step.operation,
|
|
1228
|
+
index=index,
|
|
1229
|
+
total=len(plan.spec.steps),
|
|
1230
|
+
status="planned",
|
|
1231
|
+
message="validated and planned",
|
|
1232
|
+
)
|
|
1233
|
+
return WorkflowRunResult(
|
|
1234
|
+
plan.spec.run_id,
|
|
1235
|
+
"dry-run",
|
|
1236
|
+
True,
|
|
1237
|
+
False,
|
|
1238
|
+
plan.spec.sha256,
|
|
1239
|
+
input_artifact.sha256,
|
|
1240
|
+
str(plan.output_dir),
|
|
1241
|
+
None,
|
|
1242
|
+
None,
|
|
1243
|
+
planned,
|
|
1244
|
+
(),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
_prepare_output_dir(plan.output_dir, create=True)
|
|
1248
|
+
manifest_path = plan.output_dir / _MANIFEST_NAME
|
|
1249
|
+
resume_context = _ResumeContext({}, ())
|
|
1250
|
+
if manifest_path.exists():
|
|
1251
|
+
if resume:
|
|
1252
|
+
resume_context = _load_resume_context(
|
|
1253
|
+
manifest_path,
|
|
1254
|
+
spec_sha256=plan.spec.sha256,
|
|
1255
|
+
input_sha256=input_artifact.sha256,
|
|
1256
|
+
)
|
|
1257
|
+
elif not plan.spec.overwrite:
|
|
1258
|
+
raise FileExistsError(
|
|
1259
|
+
"Workflow manifest already exists; enable resume or explicit overwrite: "
|
|
1260
|
+
f"{manifest_path}"
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
started_at = _utc_now()
|
|
1264
|
+
step_results: list[WorkflowStepResult] = []
|
|
1265
|
+
outputs: list[ArtifactRef] = []
|
|
1266
|
+
issues: list[Issue] = []
|
|
1267
|
+
manifest_artifact = _save_execution_manifest(
|
|
1268
|
+
plan,
|
|
1269
|
+
input_artifact=input_artifact,
|
|
1270
|
+
config_artifact=config_artifact,
|
|
1271
|
+
resume_requested=resume,
|
|
1272
|
+
steps=(),
|
|
1273
|
+
resume_candidates=resume_context.prior_steps,
|
|
1274
|
+
outputs=(),
|
|
1275
|
+
issues=(),
|
|
1276
|
+
status="running",
|
|
1277
|
+
started_at=started_at,
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
try:
|
|
1281
|
+
_emit(
|
|
1282
|
+
progress,
|
|
1283
|
+
run_id=plan.spec.run_id,
|
|
1284
|
+
step_id="input",
|
|
1285
|
+
operation="read",
|
|
1286
|
+
index=0,
|
|
1287
|
+
total=len(plan.spec.steps),
|
|
1288
|
+
status="started",
|
|
1289
|
+
message="reading input artifact",
|
|
1290
|
+
)
|
|
1291
|
+
records = _read_input(plan)
|
|
1292
|
+
verified_input = artifact_from_path(
|
|
1293
|
+
plan.input_path,
|
|
1294
|
+
media_type=_input_media_type(plan),
|
|
1295
|
+
schema_version="dnakit-workflow-input-v1",
|
|
1296
|
+
max_bytes=plan.spec.limits.max_input_bytes,
|
|
1297
|
+
)
|
|
1298
|
+
if (
|
|
1299
|
+
verified_input.sha256 != input_artifact.sha256
|
|
1300
|
+
or verified_input.byte_size != input_artifact.byte_size
|
|
1301
|
+
):
|
|
1302
|
+
raise InputFormatError(
|
|
1303
|
+
"Workflow input changed between hashing and parsing.",
|
|
1304
|
+
code="WORKFLOW_INPUT_CHANGED",
|
|
1305
|
+
context={"path": str(plan.input_path)},
|
|
1306
|
+
)
|
|
1307
|
+
_emit(
|
|
1308
|
+
progress,
|
|
1309
|
+
run_id=plan.spec.run_id,
|
|
1310
|
+
step_id="input",
|
|
1311
|
+
operation="read",
|
|
1312
|
+
index=0,
|
|
1313
|
+
total=len(plan.spec.steps),
|
|
1314
|
+
status="succeeded",
|
|
1315
|
+
message=f"read {len(records)} records",
|
|
1316
|
+
)
|
|
1317
|
+
except Exception as error:
|
|
1318
|
+
issue = _failed_issue(error, None)
|
|
1319
|
+
issues.append(issue)
|
|
1320
|
+
manifest_artifact = _save_execution_manifest(
|
|
1321
|
+
plan,
|
|
1322
|
+
input_artifact=input_artifact,
|
|
1323
|
+
config_artifact=config_artifact,
|
|
1324
|
+
resume_requested=resume,
|
|
1325
|
+
steps=(),
|
|
1326
|
+
resume_candidates=resume_context.prior_steps,
|
|
1327
|
+
outputs=(),
|
|
1328
|
+
issues=tuple(issues),
|
|
1329
|
+
status="failed",
|
|
1330
|
+
started_at=started_at,
|
|
1331
|
+
)
|
|
1332
|
+
if plan.spec.error_policy == "raise":
|
|
1333
|
+
raise
|
|
1334
|
+
return WorkflowRunResult(
|
|
1335
|
+
plan.spec.run_id,
|
|
1336
|
+
"failed",
|
|
1337
|
+
False,
|
|
1338
|
+
resume,
|
|
1339
|
+
plan.spec.sha256,
|
|
1340
|
+
input_artifact.sha256,
|
|
1341
|
+
str(plan.output_dir),
|
|
1342
|
+
str(manifest_path),
|
|
1343
|
+
manifest_artifact,
|
|
1344
|
+
(),
|
|
1345
|
+
(),
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
states: dict[str, _State] = {"input": _State(records, {})}
|
|
1349
|
+
total_steps = len(plan.spec.steps)
|
|
1350
|
+
resumed_any = False
|
|
1351
|
+
for index, step in enumerate(plan.spec.steps, start=1):
|
|
1352
|
+
state = states.get(step.input_ref)
|
|
1353
|
+
if state is None:
|
|
1354
|
+
result = WorkflowStepResult(
|
|
1355
|
+
step.id,
|
|
1356
|
+
step.operation,
|
|
1357
|
+
step.input_ref,
|
|
1358
|
+
"blocked",
|
|
1359
|
+
_params(step),
|
|
1360
|
+
summary={"reason": "upstream step did not produce a state"},
|
|
1361
|
+
error={
|
|
1362
|
+
"code": "WORKFLOW_UPSTREAM_FAILED",
|
|
1363
|
+
"message": "The referenced upstream step failed or was blocked.",
|
|
1364
|
+
},
|
|
1365
|
+
)
|
|
1366
|
+
step_results.append(result)
|
|
1367
|
+
_best_effort_emit(
|
|
1368
|
+
progress,
|
|
1369
|
+
run_id=plan.spec.run_id,
|
|
1370
|
+
step_id=step.id,
|
|
1371
|
+
operation=step.operation,
|
|
1372
|
+
index=index,
|
|
1373
|
+
total=total_steps,
|
|
1374
|
+
status="blocked",
|
|
1375
|
+
message="upstream state unavailable",
|
|
1376
|
+
)
|
|
1377
|
+
continue
|
|
1378
|
+
|
|
1379
|
+
step_started_at = _utc_now()
|
|
1380
|
+
timer = perf_counter()
|
|
1381
|
+
input_digest = _state_digest(state, step)
|
|
1382
|
+
try:
|
|
1383
|
+
_emit(
|
|
1384
|
+
progress,
|
|
1385
|
+
run_id=plan.spec.run_id,
|
|
1386
|
+
step_id=step.id,
|
|
1387
|
+
operation=step.operation,
|
|
1388
|
+
index=index,
|
|
1389
|
+
total=total_steps,
|
|
1390
|
+
status="started",
|
|
1391
|
+
message="step started",
|
|
1392
|
+
)
|
|
1393
|
+
resume_entry = resume_context.entries.get(step.id) if resume else None
|
|
1394
|
+
if resume_entry is not None and step.operation in {"write", "report"}:
|
|
1395
|
+
artifacts, summary = _verified_resume_artifacts(
|
|
1396
|
+
resume_entry,
|
|
1397
|
+
step=step,
|
|
1398
|
+
input_sha256=input_digest,
|
|
1399
|
+
output_root=plan.output_dir,
|
|
1400
|
+
)
|
|
1401
|
+
outcome = _OperationOutcome(
|
|
1402
|
+
_with_result(state, step.id, summary), summary, artifacts
|
|
1403
|
+
)
|
|
1404
|
+
status: WorkflowStepStatus = "skipped"
|
|
1405
|
+
resumed_any = True
|
|
1406
|
+
else:
|
|
1407
|
+
used_output_bytes = sum(artifact.byte_size for artifact in outputs)
|
|
1408
|
+
outcome = _execute_operation(
|
|
1409
|
+
step,
|
|
1410
|
+
state,
|
|
1411
|
+
plan.spec,
|
|
1412
|
+
plan.output_dir,
|
|
1413
|
+
plan.spec.limits.max_output_bytes - used_output_bytes,
|
|
1414
|
+
)
|
|
1415
|
+
status = "succeeded"
|
|
1416
|
+
if (
|
|
1417
|
+
sum(artifact.byte_size for artifact in outputs)
|
|
1418
|
+
+ sum(artifact.byte_size for artifact in outcome.artifacts)
|
|
1419
|
+
> plan.spec.limits.max_output_bytes
|
|
1420
|
+
):
|
|
1421
|
+
raise ConfigurationError(
|
|
1422
|
+
"Workflow outputs exceed the total limits.max_output_bytes.",
|
|
1423
|
+
code="WORKFLOW_OUTPUT_LIMIT",
|
|
1424
|
+
context={"step_id": step.id},
|
|
1425
|
+
)
|
|
1426
|
+
_check_result_size(outcome.state.results, plan.spec, step_id=step.id)
|
|
1427
|
+
states[step.id] = outcome.state
|
|
1428
|
+
outputs.extend(outcome.artifacts)
|
|
1429
|
+
elapsed = perf_counter() - timer
|
|
1430
|
+
artifact_summaries = tuple(
|
|
1431
|
+
_artifact_summary(artifact, plan.output_dir) for artifact in outcome.artifacts
|
|
1432
|
+
)
|
|
1433
|
+
result = WorkflowStepResult(
|
|
1434
|
+
step.id,
|
|
1435
|
+
step.operation,
|
|
1436
|
+
step.input_ref,
|
|
1437
|
+
status,
|
|
1438
|
+
_params(step),
|
|
1439
|
+
started_at=step_started_at,
|
|
1440
|
+
finished_at=_utc_now(),
|
|
1441
|
+
duration_seconds=elapsed,
|
|
1442
|
+
input_sha256=input_digest,
|
|
1443
|
+
record_count=len(outcome.state.records),
|
|
1444
|
+
summary=outcome.summary,
|
|
1445
|
+
artifacts=artifact_summaries,
|
|
1446
|
+
)
|
|
1447
|
+
step_results.append(result)
|
|
1448
|
+
_emit(
|
|
1449
|
+
progress,
|
|
1450
|
+
run_id=plan.spec.run_id,
|
|
1451
|
+
step_id=step.id,
|
|
1452
|
+
operation=step.operation,
|
|
1453
|
+
index=index,
|
|
1454
|
+
total=total_steps,
|
|
1455
|
+
status=status,
|
|
1456
|
+
message="verified and skipped" if status == "skipped" else "step succeeded",
|
|
1457
|
+
)
|
|
1458
|
+
manifest_artifact = _save_execution_manifest(
|
|
1459
|
+
plan,
|
|
1460
|
+
input_artifact=input_artifact,
|
|
1461
|
+
config_artifact=config_artifact,
|
|
1462
|
+
resume_requested=resume,
|
|
1463
|
+
steps=tuple(step_results),
|
|
1464
|
+
resume_candidates=resume_context.prior_steps,
|
|
1465
|
+
outputs=tuple(outputs),
|
|
1466
|
+
issues=tuple(issues),
|
|
1467
|
+
status="running",
|
|
1468
|
+
started_at=started_at,
|
|
1469
|
+
)
|
|
1470
|
+
except Exception as error:
|
|
1471
|
+
elapsed = perf_counter() - timer
|
|
1472
|
+
payload = _error_payload(error)
|
|
1473
|
+
result = WorkflowStepResult(
|
|
1474
|
+
step.id,
|
|
1475
|
+
step.operation,
|
|
1476
|
+
step.input_ref,
|
|
1477
|
+
"failed",
|
|
1478
|
+
_params(step),
|
|
1479
|
+
started_at=step_started_at,
|
|
1480
|
+
finished_at=_utc_now(),
|
|
1481
|
+
duration_seconds=elapsed,
|
|
1482
|
+
input_sha256=input_digest,
|
|
1483
|
+
record_count=len(state.records),
|
|
1484
|
+
error=payload,
|
|
1485
|
+
)
|
|
1486
|
+
step_results.append(result)
|
|
1487
|
+
issues.append(_failed_issue(error, step.id))
|
|
1488
|
+
_best_effort_emit(
|
|
1489
|
+
progress,
|
|
1490
|
+
run_id=plan.spec.run_id,
|
|
1491
|
+
step_id=step.id,
|
|
1492
|
+
operation=step.operation,
|
|
1493
|
+
index=index,
|
|
1494
|
+
total=total_steps,
|
|
1495
|
+
status="failed",
|
|
1496
|
+
message=str(error),
|
|
1497
|
+
)
|
|
1498
|
+
if plan.spec.error_policy == "raise":
|
|
1499
|
+
manifest_artifact = _save_execution_manifest(
|
|
1500
|
+
plan,
|
|
1501
|
+
input_artifact=input_artifact,
|
|
1502
|
+
config_artifact=config_artifact,
|
|
1503
|
+
resume_requested=resume,
|
|
1504
|
+
steps=tuple(step_results),
|
|
1505
|
+
resume_candidates=resume_context.prior_steps,
|
|
1506
|
+
outputs=tuple(outputs),
|
|
1507
|
+
issues=tuple(issues),
|
|
1508
|
+
status="failed",
|
|
1509
|
+
started_at=started_at,
|
|
1510
|
+
)
|
|
1511
|
+
raise
|
|
1512
|
+
|
|
1513
|
+
failed = any(result.status in {"failed", "blocked"} for result in step_results)
|
|
1514
|
+
final_status: WorkflowRunStatus = "failed" if failed else "succeeded"
|
|
1515
|
+
manifest_artifact = _save_execution_manifest(
|
|
1516
|
+
plan,
|
|
1517
|
+
input_artifact=input_artifact,
|
|
1518
|
+
config_artifact=config_artifact,
|
|
1519
|
+
resume_requested=resume,
|
|
1520
|
+
steps=tuple(step_results),
|
|
1521
|
+
resume_candidates=resume_context.prior_steps if failed else (),
|
|
1522
|
+
outputs=tuple(outputs),
|
|
1523
|
+
issues=tuple(issues),
|
|
1524
|
+
status="failed" if failed else "succeeded",
|
|
1525
|
+
started_at=started_at,
|
|
1526
|
+
)
|
|
1527
|
+
return WorkflowRunResult(
|
|
1528
|
+
plan.spec.run_id,
|
|
1529
|
+
final_status,
|
|
1530
|
+
False,
|
|
1531
|
+
resumed_any,
|
|
1532
|
+
plan.spec.sha256,
|
|
1533
|
+
input_artifact.sha256,
|
|
1534
|
+
str(plan.output_dir),
|
|
1535
|
+
str(manifest_path),
|
|
1536
|
+
manifest_artifact,
|
|
1537
|
+
tuple(step_results),
|
|
1538
|
+
tuple(outputs),
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
__all__ = [
|
|
1543
|
+
"ProgressCallback",
|
|
1544
|
+
"WorkflowProgress",
|
|
1545
|
+
"WorkflowRunResult",
|
|
1546
|
+
"WorkflowStepResult",
|
|
1547
|
+
"run_workflow",
|
|
1548
|
+
]
|