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,898 @@
|
|
|
1
|
+
"""Strict, non-executable schema for local DNAKit workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Literal, cast
|
|
13
|
+
|
|
14
|
+
import yaml # type: ignore[import-untyped]
|
|
15
|
+
|
|
16
|
+
from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
|
|
17
|
+
from dnakit.datasets import DeduplicationConfig, SplitConfig
|
|
18
|
+
from dnakit.exceptions import ConfigurationError, InputFormatError
|
|
19
|
+
from dnakit.standardize import (
|
|
20
|
+
DatasetValidationConfig,
|
|
21
|
+
NormalizationConfig,
|
|
22
|
+
ValidationConfig,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
WorkflowErrorPolicy = Literal["raise", "collect"]
|
|
26
|
+
WorkflowOperation = Literal[
|
|
27
|
+
"normalize",
|
|
28
|
+
"validate",
|
|
29
|
+
"descriptors",
|
|
30
|
+
"fingerprint",
|
|
31
|
+
"deduplicate",
|
|
32
|
+
"split",
|
|
33
|
+
"write",
|
|
34
|
+
"report",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
_SCHEMA_VERSION = "dnakit-workflow-v1"
|
|
38
|
+
_MAX_CONFIG_BYTES = 1_000_000
|
|
39
|
+
_MAX_CONFIG_DEPTH = 32
|
|
40
|
+
_MAX_CONFIG_NODES = 100_000
|
|
41
|
+
_SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
42
|
+
_OPERATIONS: frozenset[str] = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"normalize",
|
|
45
|
+
"validate",
|
|
46
|
+
"descriptors",
|
|
47
|
+
"fingerprint",
|
|
48
|
+
"deduplicate",
|
|
49
|
+
"split",
|
|
50
|
+
"write",
|
|
51
|
+
"report",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
_INPUT_FORMATS = frozenset({"fasta", "fastq", "csv", "tsv", "json", "jsonl", "genbank"})
|
|
55
|
+
_OUTPUT_FORMATS = _INPUT_FORMATS
|
|
56
|
+
_RESERVED_OUTPUTS = frozenset({".dnakit-workflow-output-v1", "run-manifest.json"})
|
|
57
|
+
|
|
58
|
+
_STEP_PARAMETERS: Mapping[str, frozenset[str]] = {
|
|
59
|
+
"normalize": frozenset(
|
|
60
|
+
{
|
|
61
|
+
"alphabet",
|
|
62
|
+
"uppercase",
|
|
63
|
+
"remove_whitespace",
|
|
64
|
+
"remove_invisible",
|
|
65
|
+
"removable_separators",
|
|
66
|
+
"keep_ambiguous",
|
|
67
|
+
"keep_u",
|
|
68
|
+
"keep_other",
|
|
69
|
+
"u_policy",
|
|
70
|
+
"ambiguity_policy",
|
|
71
|
+
"ambiguity_mask",
|
|
72
|
+
"allow_gaps",
|
|
73
|
+
"raise_on_error",
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
"validate": frozenset(
|
|
77
|
+
{
|
|
78
|
+
"alphabet",
|
|
79
|
+
"allow_empty",
|
|
80
|
+
"allow_gaps",
|
|
81
|
+
"allow_unknown_gap_length",
|
|
82
|
+
"min_length",
|
|
83
|
+
"max_length",
|
|
84
|
+
"sequence_length",
|
|
85
|
+
"max_ambiguity_fraction",
|
|
86
|
+
"ambiguity_denominator_includes_gap",
|
|
87
|
+
"required_metadata_fields",
|
|
88
|
+
"required_letter_annotations",
|
|
89
|
+
"check_phred_quality",
|
|
90
|
+
"minimum_phred",
|
|
91
|
+
"maximum_phred",
|
|
92
|
+
"minimum_mean_phred",
|
|
93
|
+
"require_unique_ids",
|
|
94
|
+
"collect_record_reports",
|
|
95
|
+
"fail_on_invalid",
|
|
96
|
+
}
|
|
97
|
+
),
|
|
98
|
+
"descriptors": frozenset({"metrics", "ambiguity_policy"}),
|
|
99
|
+
"fingerprint": frozenset(
|
|
100
|
+
{
|
|
101
|
+
"k",
|
|
102
|
+
"canonical",
|
|
103
|
+
"mode",
|
|
104
|
+
"representation",
|
|
105
|
+
"ambiguity_policy",
|
|
106
|
+
"overlapping",
|
|
107
|
+
"cross_gaps",
|
|
108
|
+
"max_dimension",
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
"deduplicate": frozenset(
|
|
112
|
+
{
|
|
113
|
+
"equivalence",
|
|
114
|
+
"representative_policy",
|
|
115
|
+
"conflict_field",
|
|
116
|
+
"conflict_policy",
|
|
117
|
+
"merge_metadata",
|
|
118
|
+
}
|
|
119
|
+
),
|
|
120
|
+
"split": frozenset(
|
|
121
|
+
{
|
|
122
|
+
"method",
|
|
123
|
+
"ratios",
|
|
124
|
+
"shuffle",
|
|
125
|
+
"preserve_order",
|
|
126
|
+
"metadata_key",
|
|
127
|
+
"missing_metadata_policy",
|
|
128
|
+
"similarity_k",
|
|
129
|
+
"similarity_threshold",
|
|
130
|
+
"similarity_ambiguity_policy",
|
|
131
|
+
"similarity_gap_policy",
|
|
132
|
+
"max_pairwise_records",
|
|
133
|
+
}
|
|
134
|
+
),
|
|
135
|
+
"write": frozenset(
|
|
136
|
+
{"target", "format", "subset", "compression", "compression_level", "line_width"}
|
|
137
|
+
),
|
|
138
|
+
"report": frozenset({"target", "subset", "title", "max_records", "max_result_bytes"}),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _configuration_error(
|
|
143
|
+
message: str,
|
|
144
|
+
*,
|
|
145
|
+
code: str = "INVALID_WORKFLOW_CONFIG",
|
|
146
|
+
context: Mapping[str, object] | None = None,
|
|
147
|
+
) -> ConfigurationError:
|
|
148
|
+
return ConfigurationError(message, code=code, context=context)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _require_mapping(value: object, *, field: str) -> dict[str, object]:
|
|
152
|
+
if not isinstance(value, Mapping):
|
|
153
|
+
raise _configuration_error(f"Workflow {field} must be an object.", context={"field": field})
|
|
154
|
+
result: dict[str, object] = {}
|
|
155
|
+
for key, item in value.items():
|
|
156
|
+
if not isinstance(key, str) or not key:
|
|
157
|
+
raise _configuration_error(
|
|
158
|
+
f"Workflow {field} keys must be non-empty strings.",
|
|
159
|
+
context={"field": field},
|
|
160
|
+
)
|
|
161
|
+
result[key] = item
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _reject_unknown(values: Mapping[str, object], allowed: frozenset[str], *, field: str) -> None:
|
|
166
|
+
unknown = sorted(set(values) - allowed)
|
|
167
|
+
if unknown:
|
|
168
|
+
raise _configuration_error(
|
|
169
|
+
f"Workflow {field} contains unknown fields.",
|
|
170
|
+
code="UNKNOWN_WORKFLOW_FIELD",
|
|
171
|
+
context={"field": field, "unknown": unknown},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _require_string(value: object, *, field: str) -> str:
|
|
176
|
+
if not isinstance(value, str) or not value.strip():
|
|
177
|
+
raise _configuration_error(
|
|
178
|
+
f"Workflow {field} must be a non-empty string.", context={"field": field}
|
|
179
|
+
)
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _require_bool(value: object, *, field: str) -> bool:
|
|
184
|
+
if not isinstance(value, bool):
|
|
185
|
+
raise _configuration_error(f"Workflow {field} must be a boolean.", context={"field": field})
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _positive_int(value: object, *, field: str, maximum: int) -> int:
|
|
190
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > maximum:
|
|
191
|
+
raise _configuration_error(
|
|
192
|
+
f"Workflow {field} must be an integer in [1, {maximum}].",
|
|
193
|
+
code="INVALID_WORKFLOW_LIMIT",
|
|
194
|
+
context={"field": field, "value": value},
|
|
195
|
+
)
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _safe_relative_output(value: object, *, field: str) -> str:
|
|
200
|
+
text = _require_string(value, field=field)
|
|
201
|
+
path = Path(text)
|
|
202
|
+
if (
|
|
203
|
+
"\x00" in text
|
|
204
|
+
or "\\" in text
|
|
205
|
+
or ":" in text
|
|
206
|
+
or len(text) > 1_024
|
|
207
|
+
or path.is_absolute()
|
|
208
|
+
or text in {".", ".."}
|
|
209
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
210
|
+
or any(len(part) > 255 for part in path.parts)
|
|
211
|
+
or path.name in _RESERVED_OUTPUTS
|
|
212
|
+
):
|
|
213
|
+
raise _configuration_error(
|
|
214
|
+
f"Workflow {field} must be a safe relative path below output_dir.",
|
|
215
|
+
code="UNSAFE_WORKFLOW_OUTPUT_PATH",
|
|
216
|
+
context={"field": field, "path": text},
|
|
217
|
+
)
|
|
218
|
+
return path.as_posix()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _plain_mapping(values: Mapping[str, object]) -> dict[str, object]:
|
|
222
|
+
return cast(dict[str, object], to_json_compatible(values))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True)
|
|
226
|
+
class WorkflowLimits:
|
|
227
|
+
"""Hard resource ceilings applied before or during local execution."""
|
|
228
|
+
|
|
229
|
+
max_steps: int = 50
|
|
230
|
+
max_records: int = 100_000
|
|
231
|
+
max_sequence_length: int = 10_000_000
|
|
232
|
+
max_total_bases: int = 100_000_000
|
|
233
|
+
max_input_bytes: int = 250_000_000
|
|
234
|
+
max_fingerprint_dimension: int = 1_000_000
|
|
235
|
+
max_result_bytes: int = 20_000_000
|
|
236
|
+
max_output_bytes: int = 250_000_000
|
|
237
|
+
|
|
238
|
+
def __post_init__(self) -> None:
|
|
239
|
+
maxima = {
|
|
240
|
+
"max_steps": 1_000,
|
|
241
|
+
"max_records": 1_000_000,
|
|
242
|
+
"max_sequence_length": 100_000_000,
|
|
243
|
+
"max_total_bases": 1_000_000_000,
|
|
244
|
+
"max_input_bytes": 2_000_000_000,
|
|
245
|
+
"max_fingerprint_dimension": 1_000_000,
|
|
246
|
+
"max_result_bytes": 100_000_000,
|
|
247
|
+
"max_output_bytes": 2_000_000_000,
|
|
248
|
+
}
|
|
249
|
+
for field, maximum in maxima.items():
|
|
250
|
+
_positive_int(getattr(self, field), field=f"limits.{field}", maximum=maximum)
|
|
251
|
+
|
|
252
|
+
def to_dict(self) -> dict[str, object]:
|
|
253
|
+
return cast(dict[str, object], to_json_compatible(self))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True, slots=True)
|
|
257
|
+
class WorkflowInput:
|
|
258
|
+
"""One explicit local sequence input artifact."""
|
|
259
|
+
|
|
260
|
+
path: str
|
|
261
|
+
format: str | None = None
|
|
262
|
+
alphabet: str = "iupac"
|
|
263
|
+
compression: str = "auto"
|
|
264
|
+
|
|
265
|
+
def __post_init__(self) -> None:
|
|
266
|
+
_require_string(self.path, field="input.path")
|
|
267
|
+
if "\x00" in self.path:
|
|
268
|
+
raise _configuration_error("Workflow input.path must not contain NUL.")
|
|
269
|
+
if self.format is not None and self.format not in _INPUT_FORMATS:
|
|
270
|
+
raise _configuration_error(
|
|
271
|
+
"Workflow input.format is unsupported.",
|
|
272
|
+
code="UNSUPPORTED_WORKFLOW_INPUT_FORMAT",
|
|
273
|
+
context={"format": self.format, "allowed": sorted(_INPUT_FORMATS)},
|
|
274
|
+
)
|
|
275
|
+
if self.alphabet not in {"strict", "iupac"}:
|
|
276
|
+
raise _configuration_error("Workflow input.alphabet must be 'strict' or 'iupac'.")
|
|
277
|
+
if self.compression not in {"auto", "none", "gzip"}:
|
|
278
|
+
raise _configuration_error("Workflow input.compression must be auto, none, or gzip.")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class WorkflowStep:
|
|
283
|
+
"""One whitelisted step referencing only an earlier workflow value."""
|
|
284
|
+
|
|
285
|
+
id: str
|
|
286
|
+
operation: WorkflowOperation
|
|
287
|
+
input_ref: str
|
|
288
|
+
params: FrozenDict
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
id: str,
|
|
293
|
+
operation: WorkflowOperation | str,
|
|
294
|
+
input_ref: str,
|
|
295
|
+
params: Mapping[str, object] | None = None,
|
|
296
|
+
) -> None:
|
|
297
|
+
if not isinstance(id, str) or _SAFE_IDENTIFIER.fullmatch(id) is None:
|
|
298
|
+
raise _configuration_error(
|
|
299
|
+
"Workflow step id must be a safe identifier of at most 64 characters.",
|
|
300
|
+
context={"step_id": id},
|
|
301
|
+
)
|
|
302
|
+
if operation not in _OPERATIONS:
|
|
303
|
+
raise _configuration_error(
|
|
304
|
+
"Workflow step operation is not whitelisted.",
|
|
305
|
+
code="UNKNOWN_WORKFLOW_OPERATION",
|
|
306
|
+
context={"step_id": id, "operation": operation},
|
|
307
|
+
)
|
|
308
|
+
if not isinstance(input_ref, str) or (
|
|
309
|
+
input_ref != "input" and _SAFE_IDENTIFIER.fullmatch(input_ref) is None
|
|
310
|
+
):
|
|
311
|
+
raise _configuration_error(
|
|
312
|
+
"Workflow step input must reference 'input' or a safe earlier step id.",
|
|
313
|
+
context={"step_id": id, "input": input_ref},
|
|
314
|
+
)
|
|
315
|
+
resolved_params = (
|
|
316
|
+
{} if params is None else _require_mapping(params, field=f"steps.{id}.params")
|
|
317
|
+
)
|
|
318
|
+
_reject_unknown(
|
|
319
|
+
resolved_params,
|
|
320
|
+
_STEP_PARAMETERS[operation],
|
|
321
|
+
field=f"steps.{id}.params",
|
|
322
|
+
)
|
|
323
|
+
object.__setattr__(self, "id", id)
|
|
324
|
+
object.__setattr__(self, "operation", cast(WorkflowOperation, operation))
|
|
325
|
+
object.__setattr__(self, "input_ref", input_ref)
|
|
326
|
+
object.__setattr__(self, "params", freeze_mapping(resolved_params))
|
|
327
|
+
|
|
328
|
+
def to_dict(self) -> dict[str, object]:
|
|
329
|
+
return {
|
|
330
|
+
"id": self.id,
|
|
331
|
+
"operation": self.operation,
|
|
332
|
+
"input": self.input_ref,
|
|
333
|
+
"params": _plain_mapping(self.params),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True, slots=True)
|
|
338
|
+
class WorkflowSpec:
|
|
339
|
+
"""Fully resolved and validated workflow definition."""
|
|
340
|
+
|
|
341
|
+
schema_version: str
|
|
342
|
+
run_id: str
|
|
343
|
+
input: WorkflowInput
|
|
344
|
+
output_dir: str
|
|
345
|
+
seed: int
|
|
346
|
+
error_policy: WorkflowErrorPolicy
|
|
347
|
+
overwrite: bool
|
|
348
|
+
limits: WorkflowLimits
|
|
349
|
+
steps: tuple[WorkflowStep, ...]
|
|
350
|
+
|
|
351
|
+
def __post_init__(self) -> None:
|
|
352
|
+
if self.schema_version != _SCHEMA_VERSION:
|
|
353
|
+
raise _configuration_error(
|
|
354
|
+
"Unsupported workflow schema_version.",
|
|
355
|
+
code="UNSUPPORTED_WORKFLOW_SCHEMA",
|
|
356
|
+
context={"schema_version": self.schema_version, "expected": _SCHEMA_VERSION},
|
|
357
|
+
)
|
|
358
|
+
if not isinstance(self.run_id, str) or _SAFE_IDENTIFIER.fullmatch(self.run_id) is None:
|
|
359
|
+
raise _configuration_error(
|
|
360
|
+
"Workflow run_id must be a safe identifier of at most 64 characters."
|
|
361
|
+
)
|
|
362
|
+
if not isinstance(self.input, WorkflowInput):
|
|
363
|
+
raise _configuration_error("Workflow input must be WorkflowInput.")
|
|
364
|
+
_safe_relative_output(self.output_dir, field="output_dir")
|
|
365
|
+
if isinstance(self.seed, bool) or not isinstance(self.seed, int):
|
|
366
|
+
raise _configuration_error("Workflow seed must be an integer.")
|
|
367
|
+
if self.error_policy not in {"raise", "collect"}:
|
|
368
|
+
raise _configuration_error("Workflow error_policy must be 'raise' or 'collect'.")
|
|
369
|
+
if not isinstance(self.overwrite, bool):
|
|
370
|
+
raise _configuration_error("Workflow overwrite must be a boolean.")
|
|
371
|
+
if not isinstance(self.limits, WorkflowLimits):
|
|
372
|
+
raise _configuration_error("Workflow limits must be WorkflowLimits.")
|
|
373
|
+
if (
|
|
374
|
+
not isinstance(self.steps, tuple)
|
|
375
|
+
or any(not isinstance(step, WorkflowStep) for step in self.steps)
|
|
376
|
+
or not self.steps
|
|
377
|
+
or len(self.steps) > self.limits.max_steps
|
|
378
|
+
):
|
|
379
|
+
raise _configuration_error(
|
|
380
|
+
"Workflow steps must contain WorkflowStep objects and stay within "
|
|
381
|
+
"limits.max_steps.",
|
|
382
|
+
code="WORKFLOW_STEP_LIMIT",
|
|
383
|
+
context={"count": len(self.steps), "max_steps": self.limits.max_steps},
|
|
384
|
+
)
|
|
385
|
+
known = {"input"}
|
|
386
|
+
targets: set[str] = set()
|
|
387
|
+
for step in self.steps:
|
|
388
|
+
if step.id in known:
|
|
389
|
+
raise _configuration_error(
|
|
390
|
+
"Workflow step ids must be unique and cannot be 'input'.",
|
|
391
|
+
code="DUPLICATE_WORKFLOW_STEP",
|
|
392
|
+
context={"step_id": step.id},
|
|
393
|
+
)
|
|
394
|
+
if step.input_ref not in known:
|
|
395
|
+
raise _configuration_error(
|
|
396
|
+
"Workflow steps may reference only the input or an earlier step.",
|
|
397
|
+
code="UNKNOWN_WORKFLOW_INPUT_REFERENCE",
|
|
398
|
+
context={"step_id": step.id, "input": step.input_ref},
|
|
399
|
+
)
|
|
400
|
+
known.add(step.id)
|
|
401
|
+
if step.operation in {"write", "report"}:
|
|
402
|
+
target = _safe_relative_output(
|
|
403
|
+
step.params.get("target"), field=f"steps.{step.id}.params.target"
|
|
404
|
+
)
|
|
405
|
+
if target in targets:
|
|
406
|
+
raise _configuration_error(
|
|
407
|
+
"Workflow output targets must be unique.",
|
|
408
|
+
code="DUPLICATE_WORKFLOW_OUTPUT",
|
|
409
|
+
context={"target": target},
|
|
410
|
+
)
|
|
411
|
+
targets.add(target)
|
|
412
|
+
_validate_step_parameters(self.steps, seed=self.seed, limits=self.limits)
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def sha256(self) -> str:
|
|
416
|
+
encoded = json.dumps(
|
|
417
|
+
self.to_dict(),
|
|
418
|
+
ensure_ascii=False,
|
|
419
|
+
sort_keys=True,
|
|
420
|
+
separators=(",", ":"),
|
|
421
|
+
allow_nan=False,
|
|
422
|
+
).encode("utf-8")
|
|
423
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
424
|
+
|
|
425
|
+
def to_dict(self) -> dict[str, object]:
|
|
426
|
+
return {
|
|
427
|
+
"schema_version": self.schema_version,
|
|
428
|
+
"run_id": self.run_id,
|
|
429
|
+
"input": cast(dict[str, object], to_json_compatible(self.input)),
|
|
430
|
+
"output_dir": self.output_dir,
|
|
431
|
+
"seed": self.seed,
|
|
432
|
+
"error_policy": self.error_policy,
|
|
433
|
+
"overwrite": self.overwrite,
|
|
434
|
+
"limits": self.limits.to_dict(),
|
|
435
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True, slots=True)
|
|
440
|
+
class LoadedWorkflow:
|
|
441
|
+
"""Validated workflow plus resolved local paths."""
|
|
442
|
+
|
|
443
|
+
spec: WorkflowSpec
|
|
444
|
+
config_path: Path
|
|
445
|
+
input_path: Path
|
|
446
|
+
output_dir: Path
|
|
447
|
+
config_file_sha256: str
|
|
448
|
+
config_file_size: int
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _validate_shape(value: object) -> None:
|
|
452
|
+
nodes = 0
|
|
453
|
+
stack: list[tuple[object, int]] = [(value, 0)]
|
|
454
|
+
while stack:
|
|
455
|
+
item, depth = stack.pop()
|
|
456
|
+
nodes += 1
|
|
457
|
+
if nodes > _MAX_CONFIG_NODES or depth > _MAX_CONFIG_DEPTH:
|
|
458
|
+
raise InputFormatError(
|
|
459
|
+
"Workflow configuration exceeds structural limits.",
|
|
460
|
+
code="WORKFLOW_CONFIG_STRUCTURE_LIMIT",
|
|
461
|
+
context={
|
|
462
|
+
"max_nodes": _MAX_CONFIG_NODES,
|
|
463
|
+
"max_depth": _MAX_CONFIG_DEPTH,
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
if isinstance(item, Mapping):
|
|
467
|
+
stack.extend((key, depth + 1) for key in item)
|
|
468
|
+
stack.extend((child, depth + 1) for child in item.values())
|
|
469
|
+
elif isinstance(item, Sequence) and not isinstance(item, (str, bytes, bytearray)):
|
|
470
|
+
stack.extend((child, depth + 1) for child in item)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
474
|
+
result: dict[str, object] = {}
|
|
475
|
+
for key, value in pairs:
|
|
476
|
+
if key in result:
|
|
477
|
+
raise InputFormatError(
|
|
478
|
+
"Workflow JSON contains a duplicate object key.",
|
|
479
|
+
code="DUPLICATE_WORKFLOW_FIELD",
|
|
480
|
+
context={"key": key},
|
|
481
|
+
)
|
|
482
|
+
result[key] = value
|
|
483
|
+
return result
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class _UniqueKeySafeLoader(yaml.SafeLoader): # type: ignore[misc]
|
|
487
|
+
"""SafeLoader variant that rejects duplicate mapping keys."""
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _construct_unique_mapping(
|
|
491
|
+
loader: yaml.SafeLoader, node: yaml.nodes.MappingNode, deep: bool = False
|
|
492
|
+
) -> dict[object, object]:
|
|
493
|
+
loader.flatten_mapping(node)
|
|
494
|
+
result: dict[object, object] = {}
|
|
495
|
+
for key_node, value_node in node.value:
|
|
496
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
497
|
+
try:
|
|
498
|
+
duplicate = key in result
|
|
499
|
+
except TypeError as exc:
|
|
500
|
+
raise InputFormatError(
|
|
501
|
+
"Workflow YAML mapping keys must be hashable strings.",
|
|
502
|
+
code="INVALID_WORKFLOW_CONFIG",
|
|
503
|
+
) from exc
|
|
504
|
+
if duplicate:
|
|
505
|
+
raise InputFormatError(
|
|
506
|
+
"Workflow YAML contains a duplicate mapping key.",
|
|
507
|
+
code="DUPLICATE_WORKFLOW_FIELD",
|
|
508
|
+
context={"key": str(key)},
|
|
509
|
+
)
|
|
510
|
+
result[key] = loader.construct_object(value_node, deep=deep)
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
_UniqueKeySafeLoader.add_constructor(
|
|
515
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _parse_payload(path: Path) -> tuple[dict[str, object], bytes]:
|
|
520
|
+
if path.suffix.lower() not in {".json", ".yaml", ".yml"}:
|
|
521
|
+
raise InputFormatError(
|
|
522
|
+
"Workflow configuration must use JSON or YAML.",
|
|
523
|
+
code="UNSUPPORTED_WORKFLOW_CONFIG_FORMAT",
|
|
524
|
+
context={"path": str(path)},
|
|
525
|
+
)
|
|
526
|
+
if path.is_symlink() or not path.is_file():
|
|
527
|
+
raise InputFormatError(
|
|
528
|
+
"Workflow configuration must be a regular non-symlink file.",
|
|
529
|
+
code="INVALID_WORKFLOW_CONFIG_PATH",
|
|
530
|
+
context={"path": str(path)},
|
|
531
|
+
)
|
|
532
|
+
try:
|
|
533
|
+
with path.open("rb") as handle:
|
|
534
|
+
payload = handle.read(_MAX_CONFIG_BYTES + 1)
|
|
535
|
+
if len(payload) > _MAX_CONFIG_BYTES:
|
|
536
|
+
raise InputFormatError(
|
|
537
|
+
"Workflow configuration exceeds the byte limit.",
|
|
538
|
+
code="WORKFLOW_CONFIG_SIZE_LIMIT",
|
|
539
|
+
context={"byte_size": len(payload), "max_bytes": _MAX_CONFIG_BYTES},
|
|
540
|
+
)
|
|
541
|
+
text = payload.decode("utf-8")
|
|
542
|
+
if path.suffix.lower() == ".json":
|
|
543
|
+
parsed: object = json.loads(text, object_pairs_hook=_unique_json_object)
|
|
544
|
+
else:
|
|
545
|
+
if any(
|
|
546
|
+
isinstance(token, yaml.tokens.AliasToken)
|
|
547
|
+
for token in yaml.scan(text, Loader=_UniqueKeySafeLoader)
|
|
548
|
+
):
|
|
549
|
+
raise InputFormatError(
|
|
550
|
+
"Workflow YAML aliases are disabled.",
|
|
551
|
+
code="WORKFLOW_YAML_ALIAS_DISABLED",
|
|
552
|
+
)
|
|
553
|
+
parsed = yaml.load(text, Loader=_UniqueKeySafeLoader)
|
|
554
|
+
except InputFormatError:
|
|
555
|
+
raise
|
|
556
|
+
except RecursionError as exc:
|
|
557
|
+
raise InputFormatError(
|
|
558
|
+
"Workflow configuration exceeds structural limits.",
|
|
559
|
+
code="WORKFLOW_CONFIG_STRUCTURE_LIMIT",
|
|
560
|
+
context={
|
|
561
|
+
"path": str(path),
|
|
562
|
+
"max_nodes": _MAX_CONFIG_NODES,
|
|
563
|
+
"max_depth": _MAX_CONFIG_DEPTH,
|
|
564
|
+
},
|
|
565
|
+
) from exc
|
|
566
|
+
except (OSError, UnicodeError, json.JSONDecodeError, yaml.YAMLError) as exc:
|
|
567
|
+
raise InputFormatError(
|
|
568
|
+
"Workflow configuration could not be parsed.",
|
|
569
|
+
code="INVALID_WORKFLOW_CONFIG",
|
|
570
|
+
context={"path": str(path)},
|
|
571
|
+
) from exc
|
|
572
|
+
_validate_shape(parsed)
|
|
573
|
+
return _require_mapping(parsed, field="root"), payload
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _parse_limits(value: object) -> WorkflowLimits:
|
|
577
|
+
if value is None:
|
|
578
|
+
return WorkflowLimits()
|
|
579
|
+
values = _require_mapping(value, field="limits")
|
|
580
|
+
allowed = frozenset(
|
|
581
|
+
{
|
|
582
|
+
"max_steps",
|
|
583
|
+
"max_records",
|
|
584
|
+
"max_sequence_length",
|
|
585
|
+
"max_total_bases",
|
|
586
|
+
"max_input_bytes",
|
|
587
|
+
"max_fingerprint_dimension",
|
|
588
|
+
"max_result_bytes",
|
|
589
|
+
"max_output_bytes",
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
_reject_unknown(values, allowed, field="limits")
|
|
593
|
+
defaults = WorkflowLimits()
|
|
594
|
+
return WorkflowLimits(
|
|
595
|
+
max_steps=cast(int, values.get("max_steps", defaults.max_steps)),
|
|
596
|
+
max_records=cast(int, values.get("max_records", defaults.max_records)),
|
|
597
|
+
max_sequence_length=cast(
|
|
598
|
+
int, values.get("max_sequence_length", defaults.max_sequence_length)
|
|
599
|
+
),
|
|
600
|
+
max_total_bases=cast(int, values.get("max_total_bases", defaults.max_total_bases)),
|
|
601
|
+
max_input_bytes=cast(int, values.get("max_input_bytes", defaults.max_input_bytes)),
|
|
602
|
+
max_fingerprint_dimension=cast(
|
|
603
|
+
int,
|
|
604
|
+
values.get("max_fingerprint_dimension", defaults.max_fingerprint_dimension),
|
|
605
|
+
),
|
|
606
|
+
max_result_bytes=cast(int, values.get("max_result_bytes", defaults.max_result_bytes)),
|
|
607
|
+
max_output_bytes=cast(int, values.get("max_output_bytes", defaults.max_output_bytes)),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _parse_input(value: object) -> WorkflowInput:
|
|
612
|
+
values = _require_mapping(value, field="input")
|
|
613
|
+
_reject_unknown(values, frozenset({"path", "format", "alphabet", "compression"}), field="input")
|
|
614
|
+
if "path" not in values:
|
|
615
|
+
raise _configuration_error("Workflow input.path is required.")
|
|
616
|
+
format_value = values.get("format")
|
|
617
|
+
if format_value is not None and not isinstance(format_value, str):
|
|
618
|
+
raise _configuration_error("Workflow input.format must be a string or null.")
|
|
619
|
+
return WorkflowInput(
|
|
620
|
+
path=_require_string(values["path"], field="input.path"),
|
|
621
|
+
format=format_value,
|
|
622
|
+
alphabet=cast(str, values.get("alphabet", "iupac")),
|
|
623
|
+
compression=cast(str, values.get("compression", "auto")),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _parse_steps(value: object) -> tuple[WorkflowStep, ...]:
|
|
628
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
|
629
|
+
raise _configuration_error("Workflow steps must be an array.")
|
|
630
|
+
steps: list[WorkflowStep] = []
|
|
631
|
+
for index, raw_step in enumerate(value):
|
|
632
|
+
values = _require_mapping(raw_step, field=f"steps[{index}]")
|
|
633
|
+
_reject_unknown(
|
|
634
|
+
values,
|
|
635
|
+
frozenset({"id", "operation", "input", "params"}),
|
|
636
|
+
field=f"steps[{index}]",
|
|
637
|
+
)
|
|
638
|
+
missing = sorted({"id", "operation", "input"} - values.keys())
|
|
639
|
+
if missing:
|
|
640
|
+
raise _configuration_error(
|
|
641
|
+
"Workflow step is missing required fields.",
|
|
642
|
+
context={"index": index, "missing": missing},
|
|
643
|
+
)
|
|
644
|
+
operation = _require_string(values["operation"], field=f"steps[{index}].operation")
|
|
645
|
+
steps.append(
|
|
646
|
+
WorkflowStep(
|
|
647
|
+
_require_string(values["id"], field=f"steps[{index}].id"),
|
|
648
|
+
operation,
|
|
649
|
+
_require_string(values["input"], field=f"steps[{index}].input"),
|
|
650
|
+
_require_mapping(values.get("params", {}), field=f"steps[{index}].params"),
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
return tuple(steps)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _validate_step_parameters(
|
|
657
|
+
steps: tuple[WorkflowStep, ...], *, seed: int, limits: WorkflowLimits
|
|
658
|
+
) -> None:
|
|
659
|
+
descriptor_metrics = {"length", "gc", "composition", "entropy"}
|
|
660
|
+
for step in steps:
|
|
661
|
+
params = _plain_mapping(step.params)
|
|
662
|
+
if step.operation == "normalize":
|
|
663
|
+
NormalizationConfig(**cast(dict[str, Any], params))
|
|
664
|
+
elif step.operation == "validate":
|
|
665
|
+
record_params = {
|
|
666
|
+
key: value
|
|
667
|
+
for key, value in params.items()
|
|
668
|
+
if key not in {"require_unique_ids", "collect_record_reports", "fail_on_invalid"}
|
|
669
|
+
}
|
|
670
|
+
record_config = ValidationConfig(**cast(dict[str, Any], record_params))
|
|
671
|
+
DatasetValidationConfig(
|
|
672
|
+
record_config,
|
|
673
|
+
require_unique_ids=cast(bool, params.get("require_unique_ids", True)),
|
|
674
|
+
collect_record_reports=cast(bool, params.get("collect_record_reports", True)),
|
|
675
|
+
)
|
|
676
|
+
if "fail_on_invalid" in params:
|
|
677
|
+
_require_bool(params["fail_on_invalid"], field=f"steps.{step.id}.fail_on_invalid")
|
|
678
|
+
elif step.operation == "descriptors":
|
|
679
|
+
metrics = params.get("metrics", ["length", "gc"])
|
|
680
|
+
if not isinstance(metrics, Sequence) or isinstance(metrics, (str, bytes, bytearray)):
|
|
681
|
+
raise _configuration_error("Descriptor metrics must be an array of names.")
|
|
682
|
+
metric_names = tuple(metrics)
|
|
683
|
+
if not metric_names or any(
|
|
684
|
+
not isinstance(metric, str) or metric not in descriptor_metrics
|
|
685
|
+
for metric in metric_names
|
|
686
|
+
):
|
|
687
|
+
raise _configuration_error(
|
|
688
|
+
"Descriptor metrics contain an unsupported name.",
|
|
689
|
+
context={"step_id": step.id, "allowed": sorted(descriptor_metrics)},
|
|
690
|
+
)
|
|
691
|
+
if len(set(metric_names)) != len(metric_names):
|
|
692
|
+
raise _configuration_error("Descriptor metrics must not contain duplicates.")
|
|
693
|
+
ambiguity = params.get("ambiguity_policy", "error")
|
|
694
|
+
if ambiguity not in {"error", "ignore"}:
|
|
695
|
+
raise _configuration_error("Descriptor ambiguity_policy is invalid.")
|
|
696
|
+
elif step.operation == "fingerprint":
|
|
697
|
+
k = _positive_int(params.get("k", 3), field=f"steps.{step.id}.k", maximum=30)
|
|
698
|
+
for flag in ("canonical", "overlapping", "cross_gaps"):
|
|
699
|
+
if flag in params:
|
|
700
|
+
_require_bool(params[flag], field=f"steps.{step.id}.{flag}")
|
|
701
|
+
if params.get("mode", "count") not in {"count", "binary", "presence", "frequency"}:
|
|
702
|
+
raise _configuration_error("Fingerprint mode is invalid.")
|
|
703
|
+
if params.get("representation", "dense") not in {"dense", "sparse"}:
|
|
704
|
+
raise _configuration_error("Fingerprint representation is invalid.")
|
|
705
|
+
if params.get("ambiguity_policy", "error") not in {"error", "ignore"}:
|
|
706
|
+
raise _configuration_error("Fingerprint ambiguity_policy is invalid.")
|
|
707
|
+
requested_dimension = _positive_int(
|
|
708
|
+
params.get("max_dimension", limits.max_fingerprint_dimension),
|
|
709
|
+
field=f"steps.{step.id}.max_dimension",
|
|
710
|
+
maximum=limits.max_fingerprint_dimension,
|
|
711
|
+
)
|
|
712
|
+
canonical = cast(bool, params.get("canonical", False))
|
|
713
|
+
raw_dimension = 4**k
|
|
714
|
+
schema_dimension = (
|
|
715
|
+
(raw_dimension + (0 if k % 2 else 4 ** (k // 2))) // 2
|
|
716
|
+
if canonical
|
|
717
|
+
else raw_dimension
|
|
718
|
+
)
|
|
719
|
+
if schema_dimension > requested_dimension:
|
|
720
|
+
raise _configuration_error(
|
|
721
|
+
"Fingerprint schema exceeds the configured dimension limit.",
|
|
722
|
+
code="WORKFLOW_FINGERPRINT_LIMIT",
|
|
723
|
+
context={
|
|
724
|
+
"step_id": step.id,
|
|
725
|
+
"dimension": schema_dimension,
|
|
726
|
+
"max_dimension": requested_dimension,
|
|
727
|
+
},
|
|
728
|
+
)
|
|
729
|
+
elif step.operation == "deduplicate":
|
|
730
|
+
equivalence = params.pop("equivalence", "exact")
|
|
731
|
+
if equivalence not in {
|
|
732
|
+
"exact",
|
|
733
|
+
"reverse_complement",
|
|
734
|
+
"circular",
|
|
735
|
+
"circular_reverse_complement",
|
|
736
|
+
}:
|
|
737
|
+
raise _configuration_error("Deduplication equivalence is invalid.")
|
|
738
|
+
DeduplicationConfig(**cast(dict[str, Any], params))
|
|
739
|
+
elif step.operation == "split":
|
|
740
|
+
SplitConfig(seed=seed, **cast(dict[str, Any], params))
|
|
741
|
+
elif step.operation == "write":
|
|
742
|
+
target = _safe_relative_output(params.get("target"), field=f"steps.{step.id}.target")
|
|
743
|
+
format_value = params.get("format")
|
|
744
|
+
if format_value is not None and format_value not in _OUTPUT_FORMATS:
|
|
745
|
+
raise _configuration_error(
|
|
746
|
+
"Workflow write format is unsupported.",
|
|
747
|
+
code="UNSUPPORTED_WORKFLOW_OUTPUT_FORMAT",
|
|
748
|
+
)
|
|
749
|
+
if format_value is None:
|
|
750
|
+
name = target[:-3] if target.lower().endswith(".gz") else target
|
|
751
|
+
suffix = Path(name).suffix.lower().lstrip(".")
|
|
752
|
+
aliases = {
|
|
753
|
+
"fa": "fasta",
|
|
754
|
+
"fna": "fasta",
|
|
755
|
+
"fq": "fastq",
|
|
756
|
+
"gb": "genbank",
|
|
757
|
+
"gbk": "genbank",
|
|
758
|
+
"ndjson": "jsonl",
|
|
759
|
+
}
|
|
760
|
+
if aliases.get(suffix, suffix) not in _OUTPUT_FORMATS:
|
|
761
|
+
raise _configuration_error(
|
|
762
|
+
"Workflow write target does not identify a supported format.",
|
|
763
|
+
code="UNSUPPORTED_WORKFLOW_OUTPUT_FORMAT",
|
|
764
|
+
)
|
|
765
|
+
subset = params.get("subset")
|
|
766
|
+
if subset is not None:
|
|
767
|
+
_require_string(subset, field=f"steps.{step.id}.subset")
|
|
768
|
+
if params.get("compression", "auto") not in {"auto", "none", "gzip"}:
|
|
769
|
+
raise _configuration_error("Workflow write compression is invalid.")
|
|
770
|
+
_positive_int(
|
|
771
|
+
params.get("line_width", 80),
|
|
772
|
+
field=f"steps.{step.id}.line_width",
|
|
773
|
+
maximum=1_000_000,
|
|
774
|
+
)
|
|
775
|
+
compression_level = params.get("compression_level", 6)
|
|
776
|
+
if (
|
|
777
|
+
isinstance(compression_level, bool)
|
|
778
|
+
or not isinstance(compression_level, int)
|
|
779
|
+
or not 0 <= compression_level <= 9
|
|
780
|
+
):
|
|
781
|
+
raise _configuration_error("Workflow write compression_level must be in [0, 9].")
|
|
782
|
+
else:
|
|
783
|
+
target = _safe_relative_output(params.get("target"), field=f"steps.{step.id}.target")
|
|
784
|
+
if Path(target).suffix.lower() not in {".html", ".htm"}:
|
|
785
|
+
raise _configuration_error(
|
|
786
|
+
"Workflow report target must end with .html or .htm.",
|
|
787
|
+
code="UNSUPPORTED_WORKFLOW_OUTPUT_FORMAT",
|
|
788
|
+
)
|
|
789
|
+
subset = params.get("subset")
|
|
790
|
+
if subset is not None:
|
|
791
|
+
_require_string(subset, field=f"steps.{step.id}.subset")
|
|
792
|
+
title = _require_string(
|
|
793
|
+
params.get("title", "DNAKit report"), field=f"steps.{step.id}.title"
|
|
794
|
+
)
|
|
795
|
+
if len(title) > 500:
|
|
796
|
+
raise _configuration_error("Workflow report title is too long.")
|
|
797
|
+
max_records = _positive_int(
|
|
798
|
+
params.get("max_records", limits.max_records),
|
|
799
|
+
field=f"steps.{step.id}.max_records",
|
|
800
|
+
maximum=limits.max_records,
|
|
801
|
+
)
|
|
802
|
+
del max_records
|
|
803
|
+
_positive_int(
|
|
804
|
+
params.get("max_result_bytes", limits.max_result_bytes),
|
|
805
|
+
field=f"steps.{step.id}.max_result_bytes",
|
|
806
|
+
maximum=limits.max_result_bytes,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _resolve_output_dir(config_path: Path, raw: str) -> Path:
|
|
811
|
+
normalized = _safe_relative_output(raw, field="output_dir")
|
|
812
|
+
base = config_path.parent.resolve()
|
|
813
|
+
candidate = base.joinpath(*Path(normalized).parts)
|
|
814
|
+
resolved = candidate.resolve(strict=False)
|
|
815
|
+
if not resolved.is_relative_to(base) or resolved == base:
|
|
816
|
+
raise _configuration_error(
|
|
817
|
+
"Workflow output_dir escapes the configuration directory.",
|
|
818
|
+
code="UNSAFE_WORKFLOW_OUTPUT_DIR",
|
|
819
|
+
context={"output_dir": raw},
|
|
820
|
+
)
|
|
821
|
+
current = base
|
|
822
|
+
for part in Path(normalized).parts:
|
|
823
|
+
current /= part
|
|
824
|
+
if current.exists() and current.is_symlink():
|
|
825
|
+
raise _configuration_error(
|
|
826
|
+
"Workflow output_dir must not traverse symbolic links.",
|
|
827
|
+
code="UNSAFE_WORKFLOW_OUTPUT_DIR",
|
|
828
|
+
context={"path": str(current)},
|
|
829
|
+
)
|
|
830
|
+
return resolved
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def load_workflow(path: str | os.PathLike[str]) -> LoadedWorkflow:
|
|
834
|
+
"""Load JSON/YAML without evaluating commands, Python, tags, or templates."""
|
|
835
|
+
|
|
836
|
+
try:
|
|
837
|
+
config_path = Path(path).expanduser().absolute()
|
|
838
|
+
except TypeError as exc:
|
|
839
|
+
raise _configuration_error("Workflow path must be a filesystem path.") from exc
|
|
840
|
+
values, config_bytes = _parse_payload(config_path)
|
|
841
|
+
_reject_unknown(
|
|
842
|
+
values,
|
|
843
|
+
frozenset(
|
|
844
|
+
{
|
|
845
|
+
"schema_version",
|
|
846
|
+
"run_id",
|
|
847
|
+
"input",
|
|
848
|
+
"output_dir",
|
|
849
|
+
"seed",
|
|
850
|
+
"error_policy",
|
|
851
|
+
"overwrite",
|
|
852
|
+
"limits",
|
|
853
|
+
"steps",
|
|
854
|
+
}
|
|
855
|
+
),
|
|
856
|
+
field="root",
|
|
857
|
+
)
|
|
858
|
+
missing = sorted({"schema_version", "run_id", "input", "output_dir", "steps"} - values.keys())
|
|
859
|
+
if missing:
|
|
860
|
+
raise _configuration_error(
|
|
861
|
+
"Workflow configuration is missing required fields.", context={"missing": missing}
|
|
862
|
+
)
|
|
863
|
+
limits = _parse_limits(values.get("limits"))
|
|
864
|
+
spec = WorkflowSpec(
|
|
865
|
+
schema_version=_require_string(values["schema_version"], field="schema_version"),
|
|
866
|
+
run_id=_require_string(values["run_id"], field="run_id"),
|
|
867
|
+
input=_parse_input(values["input"]),
|
|
868
|
+
output_dir=_safe_relative_output(values["output_dir"], field="output_dir"),
|
|
869
|
+
seed=cast(int, values.get("seed", 0)),
|
|
870
|
+
error_policy=cast(WorkflowErrorPolicy, values.get("error_policy", "raise")),
|
|
871
|
+
overwrite=_require_bool(values.get("overwrite", False), field="overwrite"),
|
|
872
|
+
limits=limits,
|
|
873
|
+
steps=_parse_steps(values["steps"]),
|
|
874
|
+
)
|
|
875
|
+
input_path = Path(spec.input.path).expanduser()
|
|
876
|
+
if not input_path.is_absolute():
|
|
877
|
+
input_path = config_path.parent / input_path
|
|
878
|
+
input_path = input_path.absolute()
|
|
879
|
+
return LoadedWorkflow(
|
|
880
|
+
spec=spec,
|
|
881
|
+
config_path=config_path,
|
|
882
|
+
input_path=input_path,
|
|
883
|
+
output_dir=_resolve_output_dir(config_path, spec.output_dir),
|
|
884
|
+
config_file_sha256=hashlib.sha256(config_bytes).hexdigest(),
|
|
885
|
+
config_file_size=len(config_bytes),
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
__all__ = [
|
|
890
|
+
"LoadedWorkflow",
|
|
891
|
+
"WorkflowErrorPolicy",
|
|
892
|
+
"WorkflowInput",
|
|
893
|
+
"WorkflowLimits",
|
|
894
|
+
"WorkflowOperation",
|
|
895
|
+
"WorkflowSpec",
|
|
896
|
+
"WorkflowStep",
|
|
897
|
+
"load_workflow",
|
|
898
|
+
]
|