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,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
+ ]