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,365 @@
|
|
|
1
|
+
"""Create, persist, and verify reproducible run manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from dnakit.core import ArtifactRef, Issue, Provenance, RunManifest
|
|
16
|
+
from dnakit.exceptions import ConfigurationError, InputFormatError
|
|
17
|
+
|
|
18
|
+
RunStatus = Literal["running", "succeeded", "failed", "cancelled"]
|
|
19
|
+
_MAX_MANIFEST_BYTES = 100_000_000
|
|
20
|
+
_MAX_MANIFEST_DEPTH = 64
|
|
21
|
+
_MAX_MANIFEST_NODES = 1_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
25
|
+
result: dict[str, object] = {}
|
|
26
|
+
for key, value in pairs:
|
|
27
|
+
if key in result:
|
|
28
|
+
raise InputFormatError(
|
|
29
|
+
"Run manifest contains a duplicate JSON key.",
|
|
30
|
+
code="INVALID_RUN_MANIFEST",
|
|
31
|
+
context={"key": key},
|
|
32
|
+
)
|
|
33
|
+
result[key] = value
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _utc_now() -> str:
|
|
38
|
+
return datetime.now(timezone.utc).isoformat()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_manifest_shape(value: object) -> None:
|
|
42
|
+
nodes = 0
|
|
43
|
+
stack: list[tuple[object, int]] = [(value, 0)]
|
|
44
|
+
while stack:
|
|
45
|
+
item, depth = stack.pop()
|
|
46
|
+
nodes += 1
|
|
47
|
+
if nodes > _MAX_MANIFEST_NODES or depth > _MAX_MANIFEST_DEPTH:
|
|
48
|
+
raise InputFormatError(
|
|
49
|
+
"Run manifest exceeds structural limits.",
|
|
50
|
+
code="RUN_MANIFEST_STRUCTURE_LIMIT",
|
|
51
|
+
context={
|
|
52
|
+
"max_nodes": _MAX_MANIFEST_NODES,
|
|
53
|
+
"max_depth": _MAX_MANIFEST_DEPTH,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
if isinstance(item, Mapping):
|
|
57
|
+
stack.extend((key, depth + 1) for key in item)
|
|
58
|
+
stack.extend((child, depth + 1) for child in item.values())
|
|
59
|
+
elif isinstance(item, list):
|
|
60
|
+
stack.extend((child, depth + 1) for child in item)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def artifact_from_path(
|
|
64
|
+
path: str | os.PathLike[str],
|
|
65
|
+
*,
|
|
66
|
+
media_type: str,
|
|
67
|
+
schema_version: str,
|
|
68
|
+
max_bytes: int | None = None,
|
|
69
|
+
) -> ArtifactRef:
|
|
70
|
+
"""Hash one regular file without copying its contents into the manifest."""
|
|
71
|
+
|
|
72
|
+
if not isinstance(media_type, str) or not media_type.strip():
|
|
73
|
+
raise ConfigurationError("media_type must be a non-empty string.")
|
|
74
|
+
if not isinstance(schema_version, str) or not schema_version.strip():
|
|
75
|
+
raise ConfigurationError("schema_version must be a non-empty string.")
|
|
76
|
+
if max_bytes is not None and (
|
|
77
|
+
isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1
|
|
78
|
+
):
|
|
79
|
+
raise ConfigurationError("max_bytes must be a positive integer or None.")
|
|
80
|
+
try:
|
|
81
|
+
resolved = Path(path).expanduser().absolute()
|
|
82
|
+
except TypeError as exc:
|
|
83
|
+
raise ConfigurationError("Artifact path must be a filesystem path.") from exc
|
|
84
|
+
if not resolved.is_file() or resolved.is_symlink():
|
|
85
|
+
raise InputFormatError(
|
|
86
|
+
"Artifact path must be a regular non-symlink file.",
|
|
87
|
+
code="INVALID_ARTIFACT_PATH",
|
|
88
|
+
context={"path": str(resolved)},
|
|
89
|
+
)
|
|
90
|
+
before = resolved.stat()
|
|
91
|
+
if max_bytes is not None and before.st_size > max_bytes:
|
|
92
|
+
raise InputFormatError(
|
|
93
|
+
"Artifact exceeds max_bytes.",
|
|
94
|
+
code="ARTIFACT_SIZE_LIMIT",
|
|
95
|
+
context={"path": str(resolved), "byte_size": before.st_size, "max_bytes": max_bytes},
|
|
96
|
+
)
|
|
97
|
+
digest = hashlib.sha256()
|
|
98
|
+
byte_size = 0
|
|
99
|
+
with resolved.open("rb") as handle:
|
|
100
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
101
|
+
byte_size += len(chunk)
|
|
102
|
+
if max_bytes is not None and byte_size > max_bytes:
|
|
103
|
+
raise InputFormatError(
|
|
104
|
+
"Artifact grew beyond max_bytes while being hashed.",
|
|
105
|
+
code="ARTIFACT_SIZE_LIMIT",
|
|
106
|
+
context={"path": str(resolved), "max_bytes": max_bytes},
|
|
107
|
+
)
|
|
108
|
+
digest.update(chunk)
|
|
109
|
+
stat = resolved.stat()
|
|
110
|
+
identity_before = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
|
111
|
+
identity_after = (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
|
112
|
+
if identity_before != identity_after or byte_size != stat.st_size:
|
|
113
|
+
raise InputFormatError(
|
|
114
|
+
"Artifact changed while it was being hashed.",
|
|
115
|
+
code="ARTIFACT_CHANGED_DURING_HASH",
|
|
116
|
+
context={"path": str(resolved)},
|
|
117
|
+
)
|
|
118
|
+
return ArtifactRef(
|
|
119
|
+
relative_path=os.path.relpath(resolved, Path.cwd()),
|
|
120
|
+
media_type=media_type,
|
|
121
|
+
schema_version=schema_version,
|
|
122
|
+
sha256=digest.hexdigest(),
|
|
123
|
+
byte_size=byte_size,
|
|
124
|
+
created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(slots=True)
|
|
129
|
+
class RunManifestBuilder:
|
|
130
|
+
"""Mutable builder whose public product is an immutable RunManifest."""
|
|
131
|
+
|
|
132
|
+
run_id: str
|
|
133
|
+
command: tuple[str, ...]
|
|
134
|
+
resolved_config: Mapping[str, object]
|
|
135
|
+
seed: int | None = None
|
|
136
|
+
seed_derivation: str | None = None
|
|
137
|
+
provenance: Provenance = field(default_factory=Provenance)
|
|
138
|
+
started_at: str = field(default_factory=_utc_now)
|
|
139
|
+
inputs: list[ArtifactRef] = field(default_factory=list)
|
|
140
|
+
outputs: list[ArtifactRef] = field(default_factory=list)
|
|
141
|
+
issues: list[Issue] = field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
def __post_init__(self) -> None:
|
|
144
|
+
RunManifest(
|
|
145
|
+
self.run_id,
|
|
146
|
+
self.command,
|
|
147
|
+
self.resolved_config,
|
|
148
|
+
self.provenance,
|
|
149
|
+
self.started_at,
|
|
150
|
+
"running",
|
|
151
|
+
seed=self.seed,
|
|
152
|
+
seed_derivation=self.seed_derivation,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def add_input(self, artifact: ArtifactRef) -> None:
|
|
156
|
+
if not isinstance(artifact, ArtifactRef):
|
|
157
|
+
raise ConfigurationError("artifact must be ArtifactRef.")
|
|
158
|
+
self.inputs.append(artifact)
|
|
159
|
+
|
|
160
|
+
def add_output(self, artifact: ArtifactRef) -> None:
|
|
161
|
+
if not isinstance(artifact, ArtifactRef):
|
|
162
|
+
raise ConfigurationError("artifact must be ArtifactRef.")
|
|
163
|
+
self.outputs.append(artifact)
|
|
164
|
+
|
|
165
|
+
def add_issue(self, issue: Issue) -> None:
|
|
166
|
+
if not isinstance(issue, Issue):
|
|
167
|
+
raise ConfigurationError("issue must be Issue.")
|
|
168
|
+
self.issues.append(issue)
|
|
169
|
+
|
|
170
|
+
def build(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
status: RunStatus = "running",
|
|
174
|
+
finished_at: str | None = None,
|
|
175
|
+
) -> RunManifest:
|
|
176
|
+
if status not in ("running", "succeeded", "failed", "cancelled"):
|
|
177
|
+
raise ConfigurationError("Unknown run status.", code="INVALID_RUN_STATUS")
|
|
178
|
+
if status == "running" and finished_at is not None:
|
|
179
|
+
raise ConfigurationError(
|
|
180
|
+
"A running manifest cannot have finished_at.", code="INVALID_RUN_TIMESTAMPS"
|
|
181
|
+
)
|
|
182
|
+
if status != "running" and finished_at is None:
|
|
183
|
+
finished_at = _utc_now()
|
|
184
|
+
return RunManifest(
|
|
185
|
+
self.run_id,
|
|
186
|
+
self.command,
|
|
187
|
+
self.resolved_config,
|
|
188
|
+
self.provenance,
|
|
189
|
+
self.started_at,
|
|
190
|
+
status,
|
|
191
|
+
seed=self.seed,
|
|
192
|
+
seed_derivation=self.seed_derivation,
|
|
193
|
+
inputs=tuple(self.inputs),
|
|
194
|
+
outputs=tuple(self.outputs),
|
|
195
|
+
finished_at=finished_at,
|
|
196
|
+
issues=tuple(self.issues),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def save_manifest(
|
|
201
|
+
manifest: RunManifest,
|
|
202
|
+
path: str | os.PathLike[str],
|
|
203
|
+
*,
|
|
204
|
+
overwrite: bool = False,
|
|
205
|
+
) -> ArtifactRef:
|
|
206
|
+
"""Persist a manifest atomically as canonical JSON."""
|
|
207
|
+
|
|
208
|
+
if not isinstance(manifest, RunManifest):
|
|
209
|
+
raise ConfigurationError("manifest must be RunManifest.")
|
|
210
|
+
if not isinstance(overwrite, bool):
|
|
211
|
+
raise ConfigurationError("overwrite must be boolean.")
|
|
212
|
+
target = Path(path)
|
|
213
|
+
if target.exists() and not overwrite:
|
|
214
|
+
raise FileExistsError(f"Refusing to overwrite manifest: {target}")
|
|
215
|
+
if not target.parent.is_dir():
|
|
216
|
+
raise FileNotFoundError(f"Manifest parent does not exist: {target.parent}")
|
|
217
|
+
payload = manifest.to_dict()
|
|
218
|
+
_validate_manifest_shape(payload)
|
|
219
|
+
encoded = json.dumps(
|
|
220
|
+
payload,
|
|
221
|
+
ensure_ascii=False,
|
|
222
|
+
sort_keys=True,
|
|
223
|
+
separators=(",", ":"),
|
|
224
|
+
allow_nan=False,
|
|
225
|
+
).encode("utf-8")
|
|
226
|
+
if len(encoded) > _MAX_MANIFEST_BYTES:
|
|
227
|
+
raise ConfigurationError(
|
|
228
|
+
"Run manifest exceeds the byte limit.",
|
|
229
|
+
code="RUN_MANIFEST_SIZE_LIMIT",
|
|
230
|
+
context={"byte_size": len(encoded), "max_bytes": _MAX_MANIFEST_BYTES},
|
|
231
|
+
)
|
|
232
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
233
|
+
prefix=f".{target.name}.", suffix=".tmp", dir=target.parent
|
|
234
|
+
)
|
|
235
|
+
temporary = Path(temporary_name)
|
|
236
|
+
try:
|
|
237
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
238
|
+
handle.write(encoded)
|
|
239
|
+
handle.flush()
|
|
240
|
+
os.fsync(handle.fileno())
|
|
241
|
+
if overwrite:
|
|
242
|
+
os.replace(temporary, target)
|
|
243
|
+
else:
|
|
244
|
+
os.link(temporary, target)
|
|
245
|
+
temporary.unlink()
|
|
246
|
+
except BaseException:
|
|
247
|
+
temporary.unlink(missing_ok=True)
|
|
248
|
+
raise
|
|
249
|
+
return artifact_from_path(
|
|
250
|
+
target,
|
|
251
|
+
media_type="application/vnd.dnakit.run-manifest+json",
|
|
252
|
+
schema_version="dnakit-run-manifest-v1",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def load_manifest(path: str | os.PathLike[str]) -> dict[str, object]:
|
|
257
|
+
"""Load and minimally validate a persisted manifest without executing it."""
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
source = Path(path).expanduser().absolute()
|
|
261
|
+
except TypeError as exc:
|
|
262
|
+
raise InputFormatError(
|
|
263
|
+
"Run manifest path must be a filesystem path.", code="INVALID_RUN_MANIFEST"
|
|
264
|
+
) from exc
|
|
265
|
+
if source.is_symlink() or not source.is_file():
|
|
266
|
+
raise InputFormatError(
|
|
267
|
+
"Run manifest path must be a regular non-symlink file.",
|
|
268
|
+
code="INVALID_RUN_MANIFEST",
|
|
269
|
+
context={"path": str(source)},
|
|
270
|
+
)
|
|
271
|
+
byte_size = source.stat().st_size
|
|
272
|
+
if byte_size > _MAX_MANIFEST_BYTES:
|
|
273
|
+
raise InputFormatError(
|
|
274
|
+
"Run manifest exceeds the byte limit.",
|
|
275
|
+
code="RUN_MANIFEST_SIZE_LIMIT",
|
|
276
|
+
context={"byte_size": byte_size, "max_bytes": _MAX_MANIFEST_BYTES},
|
|
277
|
+
)
|
|
278
|
+
try:
|
|
279
|
+
with source.open("rb") as handle:
|
|
280
|
+
raw = handle.read(_MAX_MANIFEST_BYTES + 1)
|
|
281
|
+
if len(raw) > _MAX_MANIFEST_BYTES:
|
|
282
|
+
raise InputFormatError(
|
|
283
|
+
"Run manifest grew beyond the byte limit while being read.",
|
|
284
|
+
code="RUN_MANIFEST_SIZE_LIMIT",
|
|
285
|
+
context={"max_bytes": _MAX_MANIFEST_BYTES},
|
|
286
|
+
)
|
|
287
|
+
payload: object = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
|
288
|
+
except InputFormatError:
|
|
289
|
+
raise
|
|
290
|
+
except RecursionError as exc:
|
|
291
|
+
raise InputFormatError(
|
|
292
|
+
"Run manifest exceeds structural limits.",
|
|
293
|
+
code="RUN_MANIFEST_STRUCTURE_LIMIT",
|
|
294
|
+
context={
|
|
295
|
+
"path": str(source),
|
|
296
|
+
"max_nodes": _MAX_MANIFEST_NODES,
|
|
297
|
+
"max_depth": _MAX_MANIFEST_DEPTH,
|
|
298
|
+
},
|
|
299
|
+
) from exc
|
|
300
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
301
|
+
raise InputFormatError(
|
|
302
|
+
"Run manifest cannot be decoded.",
|
|
303
|
+
code="INVALID_RUN_MANIFEST",
|
|
304
|
+
context={"path": str(source)},
|
|
305
|
+
) from exc
|
|
306
|
+
_validate_manifest_shape(payload)
|
|
307
|
+
if not isinstance(payload, dict):
|
|
308
|
+
raise InputFormatError("Run manifest root must be an object.", code="INVALID_RUN_MANIFEST")
|
|
309
|
+
required = {"run_id", "command", "resolved_config", "provenance", "started_at", "status"}
|
|
310
|
+
missing = sorted(required - payload.keys())
|
|
311
|
+
if missing:
|
|
312
|
+
raise InputFormatError(
|
|
313
|
+
"Run manifest is missing required fields.",
|
|
314
|
+
code="INVALID_RUN_MANIFEST",
|
|
315
|
+
context={"missing": missing},
|
|
316
|
+
)
|
|
317
|
+
if not isinstance(payload["run_id"], str) or not payload["run_id"].strip():
|
|
318
|
+
raise InputFormatError("Run manifest run_id is invalid.", code="INVALID_RUN_MANIFEST")
|
|
319
|
+
command = payload["command"]
|
|
320
|
+
if (
|
|
321
|
+
not isinstance(command, list)
|
|
322
|
+
or not command
|
|
323
|
+
or any(not isinstance(argument, str) or not argument for argument in command)
|
|
324
|
+
):
|
|
325
|
+
raise InputFormatError("Run manifest command is invalid.", code="INVALID_RUN_MANIFEST")
|
|
326
|
+
if not isinstance(payload["resolved_config"], dict) or not isinstance(
|
|
327
|
+
payload["provenance"], dict
|
|
328
|
+
):
|
|
329
|
+
raise InputFormatError(
|
|
330
|
+
"Run manifest configuration or provenance is invalid.",
|
|
331
|
+
code="INVALID_RUN_MANIFEST",
|
|
332
|
+
)
|
|
333
|
+
if not isinstance(payload["started_at"], str) or not payload["started_at"].strip():
|
|
334
|
+
raise InputFormatError("Run manifest started_at is invalid.", code="INVALID_RUN_MANIFEST")
|
|
335
|
+
status = payload["status"]
|
|
336
|
+
if status not in ("running", "succeeded", "failed", "cancelled"):
|
|
337
|
+
raise InputFormatError("Run manifest status is invalid.", code="INVALID_RUN_MANIFEST")
|
|
338
|
+
finished_at = payload.get("finished_at")
|
|
339
|
+
if finished_at is not None and (not isinstance(finished_at, str) or not finished_at.strip()):
|
|
340
|
+
raise InputFormatError("Run manifest finished_at is invalid.", code="INVALID_RUN_MANIFEST")
|
|
341
|
+
if (status == "running") != (finished_at is None):
|
|
342
|
+
raise InputFormatError(
|
|
343
|
+
"Run manifest status and finished_at are inconsistent.",
|
|
344
|
+
code="INVALID_RUN_MANIFEST",
|
|
345
|
+
)
|
|
346
|
+
seed = payload.get("seed")
|
|
347
|
+
if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int)):
|
|
348
|
+
raise InputFormatError("Run manifest seed is invalid.", code="INVALID_RUN_MANIFEST")
|
|
349
|
+
seed_derivation = payload.get("seed_derivation")
|
|
350
|
+
if seed_derivation is not None and (
|
|
351
|
+
not isinstance(seed_derivation, str) or not seed_derivation.strip()
|
|
352
|
+
):
|
|
353
|
+
raise InputFormatError(
|
|
354
|
+
"Run manifest seed_derivation is invalid.", code="INVALID_RUN_MANIFEST"
|
|
355
|
+
)
|
|
356
|
+
for field_name in ("inputs", "outputs", "issues"):
|
|
357
|
+
value = payload.get(field_name, [])
|
|
358
|
+
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
|
359
|
+
raise InputFormatError(
|
|
360
|
+
f"Run manifest {field_name} is invalid.", code="INVALID_RUN_MANIFEST"
|
|
361
|
+
)
|
|
362
|
+
return {str(key): value for key, value in payload.items()}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
__all__ = ["RunManifestBuilder", "artifact_from_path", "load_manifest", "save_manifest"]
|