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,164 @@
|
|
|
1
|
+
"""Atomically export query metadata to common interchange formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal, cast
|
|
14
|
+
|
|
15
|
+
from dnakit.core._json import to_json_compatible
|
|
16
|
+
from dnakit.exceptions import ConfigurationError, DownloadError
|
|
17
|
+
from dnakit.search import QueryResult
|
|
18
|
+
|
|
19
|
+
from .files import _install_staged_files, _write_manifest, resolved_config
|
|
20
|
+
from .models import DatasetDownloadResult, DownloadConfig, DownloadedFile
|
|
21
|
+
|
|
22
|
+
MetadataFormat = Literal["json", "jsonl", "csv", "tsv", "xml"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format(value: str | None, path: Path) -> MetadataFormat:
|
|
26
|
+
selected = path.suffix.lstrip(".").casefold() if value is None else value.casefold()
|
|
27
|
+
if selected not in {"json", "jsonl", "csv", "tsv", "xml"}:
|
|
28
|
+
raise ConfigurationError("Metadata format must be json, jsonl, csv, tsv, or xml.")
|
|
29
|
+
return cast(MetadataFormat, selected)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cell(value: object) -> str:
|
|
33
|
+
if value is None:
|
|
34
|
+
return ""
|
|
35
|
+
if isinstance(value, bool):
|
|
36
|
+
return "true" if value else "false"
|
|
37
|
+
if isinstance(value, (str, int, float)):
|
|
38
|
+
return str(value)
|
|
39
|
+
return json.dumps(to_json_compatible(value), ensure_ascii=False, sort_keys=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tabular(result: QueryResult, *, delimiter: str) -> bytes:
|
|
43
|
+
fields = sorted({str(key) for record in result.records for key in record})
|
|
44
|
+
stream = io.StringIO(newline="")
|
|
45
|
+
writer = csv.writer(stream, delimiter=delimiter, lineterminator="\n")
|
|
46
|
+
if fields:
|
|
47
|
+
writer.writerow(fields)
|
|
48
|
+
for record in result.records:
|
|
49
|
+
writer.writerow(_cell(record.get(field)) for field in fields)
|
|
50
|
+
return stream.getvalue().encode("utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _xml(result: QueryResult) -> bytes:
|
|
54
|
+
root = ET.Element(
|
|
55
|
+
"dnakit-query",
|
|
56
|
+
{
|
|
57
|
+
"provider": result.provider,
|
|
58
|
+
"query-type": result.query_type,
|
|
59
|
+
"request-url": result.request_url,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
for index, record in enumerate(result.records):
|
|
63
|
+
node = ET.SubElement(root, "record", {"index": str(index)})
|
|
64
|
+
for key in sorted(record):
|
|
65
|
+
field = ET.SubElement(node, "field", {"name": str(key)})
|
|
66
|
+
field.text = _cell(record[key])
|
|
67
|
+
return cast(bytes, ET.tostring(root, encoding="utf-8", xml_declaration=True)) + b"\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _payload(result: QueryResult, output_format: MetadataFormat) -> bytes:
|
|
71
|
+
if output_format == "json":
|
|
72
|
+
return (
|
|
73
|
+
json.dumps(result.to_dict(), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
|
74
|
+
).encode("utf-8")
|
|
75
|
+
if output_format == "jsonl":
|
|
76
|
+
return (
|
|
77
|
+
"".join(
|
|
78
|
+
json.dumps(to_json_compatible(record), ensure_ascii=False, sort_keys=True) + "\n"
|
|
79
|
+
for record in result.records
|
|
80
|
+
)
|
|
81
|
+
).encode("utf-8")
|
|
82
|
+
if output_format in {"csv", "tsv"}:
|
|
83
|
+
return _tabular(result, delimiter="," if output_format == "csv" else "\t")
|
|
84
|
+
return _xml(result)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def metadata(
|
|
88
|
+
query: QueryResult,
|
|
89
|
+
output_path: str | os.PathLike[str],
|
|
90
|
+
*,
|
|
91
|
+
format: MetadataFormat | None = None,
|
|
92
|
+
config: DownloadConfig | None = None,
|
|
93
|
+
) -> DatasetDownloadResult:
|
|
94
|
+
"""Export a ``QueryResult`` as JSON/JSONL/CSV/TSV/XML plus an audit manifest."""
|
|
95
|
+
|
|
96
|
+
if not isinstance(query, QueryResult):
|
|
97
|
+
raise TypeError("query must be QueryResult.")
|
|
98
|
+
resolved = resolved_config(config)
|
|
99
|
+
target = Path(output_path).expanduser().resolve()
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
if target.exists() and not target.is_file():
|
|
102
|
+
raise IsADirectoryError(f"Metadata output must be a file: {target}")
|
|
103
|
+
manifest = target.with_name(f"{target.name}.manifest.json")
|
|
104
|
+
if manifest.exists() and not manifest.is_file():
|
|
105
|
+
raise IsADirectoryError(f"Metadata manifest must be a file: {manifest}")
|
|
106
|
+
if not resolved.overwrite and (target.exists() or manifest.exists()):
|
|
107
|
+
raise FileExistsError("Refusing to overwrite existing metadata output or manifest.")
|
|
108
|
+
output_format = _format(format, target)
|
|
109
|
+
payload = _payload(query, output_format)
|
|
110
|
+
if len(payload) > resolved.max_file_bytes:
|
|
111
|
+
raise DownloadError("Metadata output exceeds max_file_bytes.", code="DOWNLOAD_SIZE_LIMIT")
|
|
112
|
+
md5 = hashlib.md5(payload, usedforsecurity=False).hexdigest()
|
|
113
|
+
sha256 = hashlib.sha256(payload).hexdigest()
|
|
114
|
+
item = DownloadedFile(
|
|
115
|
+
query.request_url,
|
|
116
|
+
str(target),
|
|
117
|
+
len(payload),
|
|
118
|
+
md5,
|
|
119
|
+
sha256,
|
|
120
|
+
False,
|
|
121
|
+
)
|
|
122
|
+
with tempfile.TemporaryDirectory(prefix=".dnakit-metadata-", dir=str(target.parent)) as raw:
|
|
123
|
+
stage = Path(raw)
|
|
124
|
+
staged_target = stage / target.name
|
|
125
|
+
staged_target.write_bytes(payload)
|
|
126
|
+
staged_manifest = stage / manifest.name
|
|
127
|
+
_write_manifest(
|
|
128
|
+
{
|
|
129
|
+
"kind": "query_metadata",
|
|
130
|
+
"source": query.provider,
|
|
131
|
+
"query_type": query.query_type,
|
|
132
|
+
"request_url": query.request_url,
|
|
133
|
+
"returned_records": len(query.records),
|
|
134
|
+
"reported_total_count": query.total_count,
|
|
135
|
+
"format": output_format,
|
|
136
|
+
"file": {
|
|
137
|
+
"path": str(target),
|
|
138
|
+
"byte_size": len(payload),
|
|
139
|
+
"md5": md5,
|
|
140
|
+
"sha256": sha256,
|
|
141
|
+
},
|
|
142
|
+
"query_metadata": to_json_compatible(query.metadata),
|
|
143
|
+
"provenance": query.provenance.to_dict(),
|
|
144
|
+
},
|
|
145
|
+
staged_manifest,
|
|
146
|
+
overwrite=False,
|
|
147
|
+
)
|
|
148
|
+
_install_staged_files(
|
|
149
|
+
(staged_target, staged_manifest),
|
|
150
|
+
(target, manifest),
|
|
151
|
+
overwrite=resolved.overwrite,
|
|
152
|
+
)
|
|
153
|
+
return DatasetDownloadResult(
|
|
154
|
+
"query_metadata",
|
|
155
|
+
query.provider,
|
|
156
|
+
str(target.parent),
|
|
157
|
+
(item,),
|
|
158
|
+
str(manifest),
|
|
159
|
+
query.provenance,
|
|
160
|
+
metadata={"format": output_format, "query_type": query.query_type},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["MetadataFormat", "metadata"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Immutable configuration and manifests for public data downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from dnakit.core import Provenance
|
|
12
|
+
from dnakit.core._json import FrozenDict, freeze_mapping, to_json_compatible
|
|
13
|
+
from dnakit.exceptions import ConfigurationError
|
|
14
|
+
|
|
15
|
+
_MD5 = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
16
|
+
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class DownloadConfig:
|
|
21
|
+
"""Resource limits and credentials for bounded public downloads."""
|
|
22
|
+
|
|
23
|
+
timeout: float = 60.0
|
|
24
|
+
chunk_size: int = 1_048_576
|
|
25
|
+
max_file_bytes: int = 20_000_000_000
|
|
26
|
+
max_total_bytes: int = 50_000_000_000
|
|
27
|
+
max_files: int = 100
|
|
28
|
+
overwrite: bool = False
|
|
29
|
+
api_key: str | None = None
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if (
|
|
33
|
+
isinstance(self.timeout, bool)
|
|
34
|
+
or not isinstance(self.timeout, (int, float))
|
|
35
|
+
or not math.isfinite(self.timeout)
|
|
36
|
+
or not 0 < self.timeout <= 3_600
|
|
37
|
+
):
|
|
38
|
+
raise ConfigurationError(
|
|
39
|
+
"DownloadConfig timeout must be in (0, 3600].",
|
|
40
|
+
code="INVALID_DOWNLOAD_CONFIG",
|
|
41
|
+
)
|
|
42
|
+
for name, value, maximum in (
|
|
43
|
+
("chunk_size", self.chunk_size, 64 * 1024 * 1024),
|
|
44
|
+
("max_file_bytes", self.max_file_bytes, 10**12),
|
|
45
|
+
("max_total_bytes", self.max_total_bytes, 10**12),
|
|
46
|
+
("max_files", self.max_files, 10_000),
|
|
47
|
+
):
|
|
48
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
|
49
|
+
raise ConfigurationError(
|
|
50
|
+
f"DownloadConfig {name} must be in [1, {maximum}].",
|
|
51
|
+
code="INVALID_DOWNLOAD_CONFIG",
|
|
52
|
+
)
|
|
53
|
+
if self.max_total_bytes < self.max_file_bytes:
|
|
54
|
+
raise ConfigurationError(
|
|
55
|
+
"DownloadConfig max_total_bytes cannot be smaller than max_file_bytes.",
|
|
56
|
+
code="INVALID_DOWNLOAD_CONFIG",
|
|
57
|
+
)
|
|
58
|
+
if not isinstance(self.overwrite, bool):
|
|
59
|
+
raise ConfigurationError(
|
|
60
|
+
"DownloadConfig overwrite must be boolean.", code="INVALID_DOWNLOAD_CONFIG"
|
|
61
|
+
)
|
|
62
|
+
if self.api_key is not None and (
|
|
63
|
+
not isinstance(self.api_key, str) or not self.api_key.strip()
|
|
64
|
+
):
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
"DownloadConfig api_key must be None or non-empty text.",
|
|
67
|
+
code="INVALID_DOWNLOAD_CONFIG",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class DownloadProgress:
|
|
73
|
+
"""One progress event for a streamed public file download."""
|
|
74
|
+
|
|
75
|
+
url: str
|
|
76
|
+
target: str
|
|
77
|
+
file_index: int
|
|
78
|
+
file_count: int
|
|
79
|
+
bytes_completed: int
|
|
80
|
+
total_bytes: int | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class RemoteFile:
|
|
85
|
+
"""One explicit HTTPS resource and optional integrity expectations."""
|
|
86
|
+
|
|
87
|
+
url: str
|
|
88
|
+
filename: str | None = None
|
|
89
|
+
expected_md5: str | None = None
|
|
90
|
+
expected_sha256: str | None = None
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
if not isinstance(self.url, str) or not self.url.strip():
|
|
94
|
+
raise ConfigurationError("RemoteFile url must be non-empty text.")
|
|
95
|
+
if self.filename is not None and (
|
|
96
|
+
not isinstance(self.filename, str) or not self.filename.strip()
|
|
97
|
+
):
|
|
98
|
+
raise ConfigurationError("RemoteFile filename must be None or non-empty text.")
|
|
99
|
+
if self.expected_md5 is not None and _MD5.fullmatch(self.expected_md5) is None:
|
|
100
|
+
raise ConfigurationError("RemoteFile expected_md5 must contain 32 hexadecimal digits.")
|
|
101
|
+
if self.expected_sha256 is not None and _SHA256.fullmatch(self.expected_sha256) is None:
|
|
102
|
+
raise ConfigurationError(
|
|
103
|
+
"RemoteFile expected_sha256 must contain 64 hexadecimal digits."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class DownloadedFile:
|
|
109
|
+
"""Integrity metadata for one atomically installed file."""
|
|
110
|
+
|
|
111
|
+
url: str
|
|
112
|
+
path: str
|
|
113
|
+
byte_size: int
|
|
114
|
+
md5: str
|
|
115
|
+
sha256: str
|
|
116
|
+
checksum_verified: bool
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
if (
|
|
120
|
+
isinstance(self.byte_size, bool)
|
|
121
|
+
or not isinstance(self.byte_size, int)
|
|
122
|
+
or self.byte_size < 0
|
|
123
|
+
):
|
|
124
|
+
raise ConfigurationError("DownloadedFile byte_size is invalid.")
|
|
125
|
+
if _MD5.fullmatch(self.md5) is None or _SHA256.fullmatch(self.sha256) is None:
|
|
126
|
+
raise ConfigurationError("DownloadedFile checksums are invalid.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, init=False)
|
|
130
|
+
class DatasetDownloadResult:
|
|
131
|
+
"""Auditable files and manifest produced by one logical download."""
|
|
132
|
+
|
|
133
|
+
kind: str
|
|
134
|
+
source: str
|
|
135
|
+
output_directory: str
|
|
136
|
+
files: tuple[DownloadedFile, ...]
|
|
137
|
+
manifest_path: str
|
|
138
|
+
metadata: FrozenDict
|
|
139
|
+
provenance: Provenance
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
kind: str,
|
|
144
|
+
source: str,
|
|
145
|
+
output_directory: str,
|
|
146
|
+
files: Iterable[DownloadedFile],
|
|
147
|
+
manifest_path: str,
|
|
148
|
+
provenance: Provenance,
|
|
149
|
+
*,
|
|
150
|
+
metadata: Mapping[str, object] | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
for name, value in (
|
|
153
|
+
("kind", kind),
|
|
154
|
+
("source", source),
|
|
155
|
+
("output_directory", output_directory),
|
|
156
|
+
("manifest_path", manifest_path),
|
|
157
|
+
):
|
|
158
|
+
if not isinstance(value, str) or not value.strip():
|
|
159
|
+
raise ConfigurationError(f"DatasetDownloadResult {name} must be non-empty text.")
|
|
160
|
+
resolved_files = tuple(files)
|
|
161
|
+
if any(not isinstance(item, DownloadedFile) for item in resolved_files):
|
|
162
|
+
raise ConfigurationError("DatasetDownloadResult files must be DownloadedFile objects.")
|
|
163
|
+
if not isinstance(provenance, Provenance):
|
|
164
|
+
raise ConfigurationError("DatasetDownloadResult provenance must be Provenance.")
|
|
165
|
+
object.__setattr__(self, "kind", kind)
|
|
166
|
+
object.__setattr__(self, "source", source)
|
|
167
|
+
object.__setattr__(self, "output_directory", output_directory)
|
|
168
|
+
object.__setattr__(self, "files", resolved_files)
|
|
169
|
+
object.__setattr__(self, "manifest_path", manifest_path)
|
|
170
|
+
object.__setattr__(self, "metadata", freeze_mapping(metadata))
|
|
171
|
+
object.__setattr__(self, "provenance", provenance)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def downloaded_bytes(self) -> int:
|
|
175
|
+
return sum(item.byte_size for item in self.files)
|
|
176
|
+
|
|
177
|
+
def to_dict(self) -> dict[str, Any]:
|
|
178
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True, slots=True)
|
|
182
|
+
class IndexArtifact:
|
|
183
|
+
"""One locally generated sequence-index artifact."""
|
|
184
|
+
|
|
185
|
+
path: str
|
|
186
|
+
byte_size: int
|
|
187
|
+
sha256: str
|
|
188
|
+
|
|
189
|
+
def __post_init__(self) -> None:
|
|
190
|
+
if not isinstance(self.path, str) or not self.path.strip():
|
|
191
|
+
raise ConfigurationError("IndexArtifact path must be non-empty text.")
|
|
192
|
+
if (
|
|
193
|
+
isinstance(self.byte_size, bool)
|
|
194
|
+
or not isinstance(self.byte_size, int)
|
|
195
|
+
or self.byte_size < 0
|
|
196
|
+
):
|
|
197
|
+
raise ConfigurationError("IndexArtifact byte_size is invalid.")
|
|
198
|
+
if _SHA256.fullmatch(self.sha256) is None:
|
|
199
|
+
raise ConfigurationError("IndexArtifact sha256 is invalid.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class IndexBuildResult:
|
|
204
|
+
"""Auditable artifacts produced by an explicit external index tool."""
|
|
205
|
+
|
|
206
|
+
tool: str
|
|
207
|
+
fasta_path: str
|
|
208
|
+
output_prefix: str
|
|
209
|
+
artifacts: tuple[IndexArtifact, ...]
|
|
210
|
+
command_output: str
|
|
211
|
+
elapsed_seconds: float
|
|
212
|
+
manifest_path: str
|
|
213
|
+
provenance: Provenance
|
|
214
|
+
|
|
215
|
+
def to_dict(self) -> dict[str, Any]:
|
|
216
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
__all__ = [
|
|
220
|
+
"DatasetDownloadResult",
|
|
221
|
+
"DownloadConfig",
|
|
222
|
+
"DownloadProgress",
|
|
223
|
+
"DownloadedFile",
|
|
224
|
+
"IndexArtifact",
|
|
225
|
+
"IndexBuildResult",
|
|
226
|
+
"RemoteFile",
|
|
227
|
+
]
|