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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. 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
+ ]