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