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,469 @@
1
+ """Bounded, checksummed, atomic HTTPS downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from collections.abc import Callable, Iterable, Mapping, Sequence
11
+ from dataclasses import replace
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import Any
14
+ from urllib.error import HTTPError, URLError
15
+ from urllib.parse import unquote, urlsplit
16
+ from urllib.request import Request, urlopen
17
+
18
+ from dnakit._version import __version__
19
+ from dnakit.exceptions import ConfigurationError, DownloadError
20
+ from dnakit.search._http import redact_url
21
+ from dnakit.search._shared import adapter_provenance
22
+
23
+ from .models import (
24
+ DatasetDownloadResult,
25
+ DownloadConfig,
26
+ DownloadedFile,
27
+ DownloadProgress,
28
+ RemoteFile,
29
+ )
30
+
31
+ ProgressCallback = Callable[[DownloadProgress], None]
32
+ _SAFE_FILENAME = re.compile(r"^[^/\\\x00]{1,255}$")
33
+
34
+
35
+ def resolved_config(config: DownloadConfig | None) -> DownloadConfig:
36
+ if config is None:
37
+ return DownloadConfig()
38
+ if not isinstance(config, DownloadConfig):
39
+ raise TypeError("config must be DownloadConfig or None.")
40
+ return config
41
+
42
+
43
+ def _https_url(url: str) -> str:
44
+ if not isinstance(url, str) or not url.strip():
45
+ raise ConfigurationError("Download URL must be non-empty text.")
46
+ value = url.strip()
47
+ parsed = urlsplit(value)
48
+ if (
49
+ parsed.scheme != "https"
50
+ or not parsed.netloc
51
+ or parsed.username
52
+ or parsed.password
53
+ or parsed.fragment
54
+ ):
55
+ raise ConfigurationError(
56
+ "Downloads require an HTTPS URL without credentials or fragments.",
57
+ code="INVALID_DOWNLOAD_URL",
58
+ )
59
+ return value
60
+
61
+
62
+ def _filename(resource: RemoteFile) -> str:
63
+ raw = resource.filename
64
+ if raw is None:
65
+ raw = unquote(PurePosixPath(urlsplit(resource.url).path).name)
66
+ if not isinstance(raw, str) or _SAFE_FILENAME.fullmatch(raw) is None or raw in {".", ".."}:
67
+ raise ConfigurationError("Download filename is unsafe.", code="INVALID_DOWNLOAD_FILENAME")
68
+ return raw
69
+
70
+
71
+ def _content_length(response: Any) -> int | None:
72
+ headers = getattr(response, "headers", None)
73
+ raw = headers.get("Content-Length") if headers is not None else None
74
+ if not isinstance(raw, (str, bytes, int)) or isinstance(raw, bool):
75
+ return None
76
+ try:
77
+ value = int(raw)
78
+ except (TypeError, ValueError):
79
+ return None
80
+ return value if value >= 0 else None
81
+
82
+
83
+ def download_file(
84
+ resource: RemoteFile,
85
+ target: str | os.PathLike[str],
86
+ *,
87
+ config: DownloadConfig | None = None,
88
+ progress: ProgressCallback | None = None,
89
+ file_index: int = 1,
90
+ file_count: int = 1,
91
+ headers: Mapping[str, str] | None = None,
92
+ max_bytes_override: int | None = None,
93
+ ) -> DownloadedFile:
94
+ """Stream one HTTPS resource to an atomic, checksummed local file."""
95
+
96
+ if not isinstance(resource, RemoteFile):
97
+ raise TypeError("resource must be RemoteFile.")
98
+ resolved = resolved_config(config)
99
+ url = _https_url(resource.url)
100
+ if progress is not None and not callable(progress):
101
+ raise ConfigurationError("progress must be callable or None.")
102
+ if (
103
+ isinstance(file_index, bool)
104
+ or not isinstance(file_index, int)
105
+ or isinstance(file_count, bool)
106
+ or not isinstance(file_count, int)
107
+ or not 1 <= file_index <= file_count
108
+ ):
109
+ raise ConfigurationError("file_index/file_count are invalid.")
110
+ max_bytes = resolved.max_file_bytes
111
+ if max_bytes_override is not None:
112
+ if (
113
+ isinstance(max_bytes_override, bool)
114
+ or not isinstance(max_bytes_override, int)
115
+ or max_bytes_override < 1
116
+ ):
117
+ raise ConfigurationError("max_bytes_override must be positive.")
118
+ max_bytes = min(max_bytes, max_bytes_override)
119
+ destination = Path(target).expanduser().resolve()
120
+ if destination.exists() and not resolved.overwrite:
121
+ raise FileExistsError(f"Refusing to overwrite existing download: {destination}")
122
+ if destination.exists() and not destination.is_file():
123
+ raise IsADirectoryError(f"Download target must be a file: {destination}")
124
+ destination.parent.mkdir(parents=True, exist_ok=True)
125
+ request_headers = {
126
+ "Accept": "*/*",
127
+ "User-Agent": f"DNAKit/{__version__} public-data-downloader",
128
+ **dict(headers or {}),
129
+ }
130
+ request = Request(url, headers=request_headers, method="GET")
131
+ temp_path: Path | None = None
132
+ try:
133
+ with tempfile.NamedTemporaryFile(
134
+ prefix=f".{destination.name}.dnakit-",
135
+ suffix=".part",
136
+ dir=str(destination.parent),
137
+ delete=False,
138
+ ) as output:
139
+ temp_path = Path(output.name)
140
+ try:
141
+ response_context = urlopen(request, timeout=float(resolved.timeout))
142
+ with response_context as response:
143
+ final_url = getattr(response, "geturl", lambda: url)()
144
+ if urlsplit(final_url).scheme != "https":
145
+ raise DownloadError(
146
+ "Download redirected to a non-HTTPS URL.",
147
+ code="INSECURE_DOWNLOAD_REDIRECT",
148
+ )
149
+ total = _content_length(response)
150
+ if total is not None and total > max_bytes:
151
+ raise DownloadError(
152
+ "Remote file exceeds the configured byte limit.",
153
+ code="DOWNLOAD_SIZE_LIMIT",
154
+ context={"content_length": total, "max_bytes": max_bytes},
155
+ )
156
+ md5 = hashlib.md5(usedforsecurity=False)
157
+ sha256 = hashlib.sha256()
158
+ completed = 0
159
+ while True:
160
+ chunk = response.read(resolved.chunk_size)
161
+ if not chunk:
162
+ break
163
+ completed += len(chunk)
164
+ if completed > max_bytes:
165
+ raise DownloadError(
166
+ "Remote file exceeds the configured byte limit.",
167
+ code="DOWNLOAD_SIZE_LIMIT",
168
+ context={"downloaded_bytes": completed, "max_bytes": max_bytes},
169
+ )
170
+ output.write(chunk)
171
+ md5.update(chunk)
172
+ sha256.update(chunk)
173
+ if progress is not None:
174
+ progress(
175
+ DownloadProgress(
176
+ redact_url(url),
177
+ str(destination),
178
+ file_index,
179
+ file_count,
180
+ completed,
181
+ total,
182
+ )
183
+ )
184
+ except HTTPError as exc:
185
+ raise DownloadError(
186
+ "Remote server returned an HTTP error.",
187
+ code="DOWNLOAD_HTTP_ERROR",
188
+ context={"status": exc.code, "url": redact_url(url)},
189
+ ) from exc
190
+ except DownloadError:
191
+ raise
192
+ except (OSError, TimeoutError, URLError) as exc:
193
+ raise DownloadError(
194
+ "Could not download the remote file.",
195
+ code="DOWNLOAD_NETWORK_ERROR",
196
+ context={"url": redact_url(url)},
197
+ ) from exc
198
+ actual_md5 = md5.hexdigest()
199
+ actual_sha256 = sha256.hexdigest()
200
+ if resource.expected_md5 is not None and actual_md5 != resource.expected_md5.lower():
201
+ raise DownloadError(
202
+ "Downloaded file failed its MD5 check.",
203
+ code="CHECKSUM_MISMATCH",
204
+ context={
205
+ "algorithm": "md5",
206
+ "expected": resource.expected_md5.lower(),
207
+ "actual": actual_md5,
208
+ },
209
+ )
210
+ if (
211
+ resource.expected_sha256 is not None
212
+ and actual_sha256 != resource.expected_sha256.lower()
213
+ ):
214
+ raise DownloadError(
215
+ "Downloaded file failed its SHA-256 check.",
216
+ code="CHECKSUM_MISMATCH",
217
+ context={
218
+ "algorithm": "sha256",
219
+ "expected": resource.expected_sha256.lower(),
220
+ "actual": actual_sha256,
221
+ },
222
+ )
223
+ if resolved.overwrite:
224
+ os.replace(temp_path, destination)
225
+ else:
226
+ os.link(temp_path, destination)
227
+ temp_path.unlink()
228
+ temp_path = None
229
+ return DownloadedFile(
230
+ redact_url(url),
231
+ str(destination),
232
+ completed,
233
+ actual_md5,
234
+ actual_sha256,
235
+ resource.expected_md5 is not None or resource.expected_sha256 is not None,
236
+ )
237
+ finally:
238
+ if temp_path is not None:
239
+ temp_path.unlink(missing_ok=True)
240
+
241
+
242
+ def _write_manifest(
243
+ result_values: Mapping[str, object],
244
+ path: Path,
245
+ *,
246
+ overwrite: bool,
247
+ ) -> None:
248
+ payload = json.dumps(result_values, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
249
+ if path.exists() and not overwrite:
250
+ raise FileExistsError(f"Refusing to overwrite existing manifest: {path}")
251
+ with tempfile.NamedTemporaryFile(
252
+ mode="w",
253
+ encoding="utf-8",
254
+ prefix=f".{path.name}.dnakit-",
255
+ suffix=".part",
256
+ dir=str(path.parent),
257
+ delete=False,
258
+ ) as output:
259
+ output.write(payload)
260
+ temp_path = Path(output.name)
261
+ try:
262
+ if overwrite:
263
+ os.replace(temp_path, path)
264
+ else:
265
+ os.link(temp_path, path)
266
+ temp_path.unlink()
267
+ finally:
268
+ temp_path.unlink(missing_ok=True)
269
+
270
+
271
+ def _install_staged_files(
272
+ staged: Sequence[Path],
273
+ targets: Sequence[Path],
274
+ *,
275
+ overwrite: bool,
276
+ ) -> None:
277
+ """Install same-filesystem staged files as one rollback-capable transaction."""
278
+
279
+ sources = tuple(staged)
280
+ destinations = tuple(targets)
281
+ if (
282
+ not sources
283
+ or len(sources) != len(destinations)
284
+ or len(set(destinations)) != len(destinations)
285
+ ):
286
+ raise ConfigurationError("Staged output paths collide.", code="OUTPUT_COLLISION")
287
+ if any(not source.is_file() for source in sources):
288
+ raise ConfigurationError("Every staged output must be an existing file.")
289
+ if not overwrite and any(target.exists() for target in destinations):
290
+ raise FileExistsError("Refusing to overwrite existing outputs.")
291
+ if any(target.exists() and not target.is_file() for target in destinations):
292
+ raise IsADirectoryError("Every existing output target must be a file.")
293
+
294
+ backup_dir = sources[0].parent / ".dnakit-backups"
295
+ backup_dir.mkdir()
296
+ backups: list[tuple[Path, Path]] = []
297
+ installed: list[Path] = []
298
+ try:
299
+ for index, (source, target) in enumerate(zip(sources, destinations, strict=True)):
300
+ if target.exists():
301
+ backup = backup_dir / str(index)
302
+ os.replace(target, backup)
303
+ backups.append((target, backup))
304
+ os.replace(source, target)
305
+ installed.append(target)
306
+ except BaseException:
307
+ for target in installed:
308
+ target.unlink(missing_ok=True)
309
+ for target, backup in reversed(backups):
310
+ if backup.exists():
311
+ os.replace(backup, target)
312
+ raise
313
+ finally:
314
+ for _, backup in backups:
315
+ backup.unlink(missing_ok=True)
316
+ backup_dir.rmdir()
317
+
318
+
319
+ def dataset(
320
+ resources: Iterable[RemoteFile],
321
+ output_dir: str | os.PathLike[str],
322
+ *,
323
+ kind: str = "dataset",
324
+ source: str = "explicit HTTPS resources",
325
+ metadata: Mapping[str, object] | None = None,
326
+ config: DownloadConfig | None = None,
327
+ progress: ProgressCallback | None = None,
328
+ headers: Mapping[str, str] | None = None,
329
+ ) -> DatasetDownloadResult:
330
+ """Download an explicit list of public files and write an integrity manifest."""
331
+
332
+ resolved = resolved_config(config)
333
+ values = tuple(resources)
334
+ if not values or len(values) > resolved.max_files:
335
+ raise ConfigurationError(
336
+ "resources must contain between 1 and max_files entries.",
337
+ code="DOWNLOAD_FILE_LIMIT",
338
+ )
339
+ if any(not isinstance(resource, RemoteFile) for resource in values):
340
+ raise TypeError("resources must contain RemoteFile objects.")
341
+ kind_value = kind.strip() if isinstance(kind, str) else ""
342
+ source_value = source.strip() if isinstance(source, str) else ""
343
+ if not kind_value or not source_value:
344
+ raise ConfigurationError("kind and source must be non-empty text.")
345
+ output = Path(output_dir).expanduser().resolve()
346
+ if output.exists() and not output.is_dir():
347
+ raise NotADirectoryError(f"output_dir must be a directory: {output}")
348
+ output.mkdir(parents=True, exist_ok=True)
349
+ names = tuple(_filename(resource) for resource in values)
350
+ if len(set(names)) != len(names):
351
+ raise ConfigurationError("Download filenames collide.", code="OUTPUT_COLLISION")
352
+ manifest = output / f"{re.sub(r'[^A-Za-z0-9_.-]+', '_', kind_value)}_manifest.json"
353
+ prospective = (*tuple(output / name for name in names), manifest)
354
+ if not resolved.overwrite and any(path.exists() for path in prospective):
355
+ raise FileExistsError("Refusing to overwrite existing dataset outputs.")
356
+ downloaded: list[DownloadedFile] = []
357
+ total_bytes = 0
358
+ with tempfile.TemporaryDirectory(prefix=".dnakit-dataset-", dir=str(output)) as stage_raw:
359
+ stage = Path(stage_raw)
360
+ stage_config = replace(resolved, overwrite=False)
361
+ staged_files: list[Path] = []
362
+ final_files: list[Path] = []
363
+ for index, (resource, name) in enumerate(zip(values, names, strict=True), start=1):
364
+ remaining = resolved.max_total_bytes - total_bytes
365
+ if remaining < 1:
366
+ raise DownloadError(
367
+ "Dataset exceeds max_total_bytes.", code="DOWNLOAD_TOTAL_SIZE_LIMIT"
368
+ )
369
+ final_path = output / name
370
+
371
+ def report(event: DownloadProgress, *, target: Path = final_path) -> None:
372
+ if progress is not None:
373
+ progress(
374
+ DownloadProgress(
375
+ event.url,
376
+ str(target),
377
+ event.file_index,
378
+ event.file_count,
379
+ event.bytes_completed,
380
+ event.total_bytes,
381
+ )
382
+ )
383
+
384
+ staged_path = stage / name
385
+ item = download_file(
386
+ resource,
387
+ staged_path,
388
+ config=stage_config,
389
+ progress=report if progress is not None else None,
390
+ file_index=index,
391
+ file_count=len(values),
392
+ headers=headers,
393
+ max_bytes_override=remaining,
394
+ )
395
+ downloaded.append(
396
+ DownloadedFile(
397
+ item.url,
398
+ str(final_path),
399
+ item.byte_size,
400
+ item.md5,
401
+ item.sha256,
402
+ item.checksum_verified,
403
+ )
404
+ )
405
+ staged_files.append(staged_path)
406
+ final_files.append(final_path)
407
+ total_bytes += item.byte_size
408
+ provenance = adapter_provenance(
409
+ source_value,
410
+ citation_url=redact_url(values[0].url),
411
+ filters={"kind": kind_value, "resource_count": len(values)},
412
+ )
413
+ manifest_payload = {
414
+ "kind": kind_value,
415
+ "source": source_value,
416
+ "output_directory": str(output),
417
+ "files": [
418
+ {
419
+ "url": item.url,
420
+ "path": item.path,
421
+ "byte_size": item.byte_size,
422
+ "md5": item.md5,
423
+ "sha256": item.sha256,
424
+ "checksum_verified": item.checksum_verified,
425
+ }
426
+ for item in downloaded
427
+ ],
428
+ "metadata": dict(metadata or {}),
429
+ "provenance": provenance.to_dict(),
430
+ }
431
+ staged_manifest = stage / manifest.name
432
+ _write_manifest(manifest_payload, staged_manifest, overwrite=False)
433
+ _install_staged_files(
434
+ (*staged_files, staged_manifest),
435
+ (*final_files, manifest),
436
+ overwrite=resolved.overwrite,
437
+ )
438
+ return DatasetDownloadResult(
439
+ kind_value,
440
+ source_value,
441
+ str(output),
442
+ downloaded,
443
+ str(manifest),
444
+ provenance,
445
+ metadata=metadata,
446
+ )
447
+
448
+
449
+ def tracks(
450
+ resources: Sequence[RemoteFile],
451
+ output_dir: str | os.PathLike[str],
452
+ *,
453
+ source: str = "public genomic tracks",
454
+ config: DownloadConfig | None = None,
455
+ progress: ProgressCallback | None = None,
456
+ ) -> DatasetDownloadResult:
457
+ """Download explicit BED/bigBed/bedGraph/bigWig/peak resources."""
458
+
459
+ return dataset(
460
+ resources,
461
+ output_dir,
462
+ kind="tracks",
463
+ source=source,
464
+ config=config,
465
+ progress=progress,
466
+ )
467
+
468
+
469
+ __all__ = ["ProgressCallback", "dataset", "download_file", "resolved_config", "tracks"]
@@ -0,0 +1,233 @@
1
+ """Explicit, bounded adapters for common nucleotide index builders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import tempfile
8
+ from collections.abc import Callable, Iterable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from dnakit.backends.external import execute_bounded_command
13
+ from dnakit.exceptions import BackendExecutionError, ConfigurationError
14
+ from dnakit.search._shared import adapter_provenance
15
+
16
+ from .files import _install_staged_files, _write_manifest, resolved_config
17
+ from .models import DownloadConfig, IndexArtifact, IndexBuildResult
18
+
19
+ _TOOLS = frozenset(
20
+ {"makeblastdb", "bwa", "bowtie2-build", "minimap2", "samtools-fai", "samtools-dict"}
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class IndexProgress:
26
+ """Start/finish progress for one external index build."""
27
+
28
+ tool: str
29
+ stage: str
30
+
31
+
32
+ def _known_outputs(tool: str, prefix: Path) -> tuple[Path, ...]:
33
+ if tool == "makeblastdb":
34
+ return tuple(
35
+ Path(f"{prefix}{suffix}")
36
+ for suffix in (".ndb", ".nhr", ".nin", ".not", ".nsq", ".ntf", ".nto")
37
+ )
38
+ if tool == "bwa":
39
+ return tuple(
40
+ Path(f"{prefix}{suffix}") for suffix in (".amb", ".ann", ".bwt", ".pac", ".sa")
41
+ )
42
+ if tool == "bowtie2-build":
43
+ return tuple(
44
+ Path(f"{prefix}{suffix}")
45
+ for suffix in (
46
+ ".1.bt2",
47
+ ".2.bt2",
48
+ ".3.bt2",
49
+ ".4.bt2",
50
+ ".rev.1.bt2",
51
+ ".rev.2.bt2",
52
+ ".1.bt2l",
53
+ ".2.bt2l",
54
+ ".3.bt2l",
55
+ ".4.bt2l",
56
+ ".rev.1.bt2l",
57
+ ".rev.2.bt2l",
58
+ )
59
+ )
60
+ if tool == "minimap2":
61
+ return (Path(f"{prefix}.mmi"),)
62
+ if tool == "samtools-fai":
63
+ return (Path(f"{prefix}.fai"),)
64
+ return (Path(f"{prefix}.dict"),)
65
+
66
+
67
+ def _arguments(tool: str, fasta: Path, prefix: Path, threads: int) -> tuple[str, ...]:
68
+ if tool == "makeblastdb":
69
+ return ("-in", str(fasta), "-dbtype", "nucl", "-out", str(prefix), "-parse_seqids")
70
+ if tool == "bwa":
71
+ return ("index", "-p", str(prefix), str(fasta))
72
+ if tool == "bowtie2-build":
73
+ return ("--threads", str(threads), str(fasta), str(prefix))
74
+ if tool == "minimap2":
75
+ return ("-d", f"{prefix}.mmi", "-t", str(threads), str(fasta))
76
+ if tool == "samtools-fai":
77
+ return ("faidx", "-o", f"{prefix}.fai", str(fasta))
78
+ return ("dict", "-o", f"{prefix}.dict", str(fasta))
79
+
80
+
81
+ def _actual_outputs(tool: str, known: Iterable[Path]) -> tuple[Path, ...]:
82
+ existing = tuple(path for path in known if path.is_file())
83
+ if tool == "bowtie2-build":
84
+ standard = tuple(path for path in existing if path.suffix == ".bt2")
85
+ large = tuple(path for path in existing if path.suffix == ".bt2l")
86
+ return standard or large
87
+ return existing
88
+
89
+
90
+ def _sha256_path(path: Path) -> str:
91
+ digest = hashlib.sha256()
92
+ with path.open("rb") as stream:
93
+ while chunk := stream.read(1_048_576):
94
+ digest.update(chunk)
95
+ return digest.hexdigest()
96
+
97
+
98
+ def build_index(
99
+ fasta_path: str | os.PathLike[str],
100
+ *,
101
+ tool: str,
102
+ executable_path: str | os.PathLike[str],
103
+ output_dir: str | os.PathLike[str],
104
+ prefix: str | None = None,
105
+ threads: int = 1,
106
+ timeout_seconds: float = 3_600.0,
107
+ config: DownloadConfig | None = None,
108
+ progress: Callable[[IndexProgress], None] | None = None,
109
+ ) -> IndexBuildResult:
110
+ """Build BLAST/BWA/Bowtie2/minimap2/samtools indexes using an explicit executable."""
111
+
112
+ resolved = resolved_config(config)
113
+ tool_value = tool.strip() if isinstance(tool, str) else ""
114
+ if tool_value not in _TOOLS:
115
+ raise ConfigurationError(
116
+ "Unsupported index tool.",
117
+ code="INVALID_INDEX_TOOL",
118
+ context={"allowed": sorted(_TOOLS)},
119
+ )
120
+ try:
121
+ fasta = Path(fasta_path).expanduser().resolve(strict=True)
122
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
123
+ raise ConfigurationError("fasta_path must be an existing file.") from exc
124
+ if not fasta.is_file():
125
+ raise ConfigurationError("fasta_path must be an existing file.")
126
+ if isinstance(threads, bool) or not isinstance(threads, int) or not 1 <= threads <= 256:
127
+ raise ConfigurationError("threads must be in [1, 256].")
128
+ if progress is not None and not callable(progress):
129
+ raise ConfigurationError("progress must be callable or None.")
130
+ output = Path(output_dir).expanduser().resolve()
131
+ if output.exists() and not output.is_dir():
132
+ raise NotADirectoryError(f"output_dir must be a directory: {output}")
133
+ output.mkdir(parents=True, exist_ok=True)
134
+ prefix_value = fasta.stem if prefix is None else prefix
135
+ if (
136
+ not isinstance(prefix_value, str)
137
+ or not prefix_value
138
+ or prefix_value in {".", ".."}
139
+ or "/" in prefix_value
140
+ or "\\" in prefix_value
141
+ or "\x00" in prefix_value
142
+ ):
143
+ raise ConfigurationError("prefix must be one safe filename component.")
144
+ final_prefix = output / prefix_value
145
+ manifest = output / f"{prefix_value}.{tool_value}.manifest.json"
146
+ possible_targets = (*_known_outputs(tool_value, final_prefix), manifest)
147
+ if any(path.exists() and not path.is_file() for path in possible_targets):
148
+ raise IsADirectoryError("Every existing index output must be a file.")
149
+ if not resolved.overwrite and any(path.exists() for path in possible_targets):
150
+ raise FileExistsError("Refusing to overwrite existing index outputs.")
151
+ if progress is not None:
152
+ progress(IndexProgress(tool_value, "start"))
153
+ with tempfile.TemporaryDirectory(prefix=".dnakit-index-", dir=str(output)) as work_raw:
154
+ work = Path(work_raw)
155
+ staged_prefix = work / prefix_value
156
+ known = _known_outputs(tool_value, staged_prefix)
157
+ command = execute_bounded_command(
158
+ Path(executable_path),
159
+ _arguments(tool_value, fasta, staged_prefix, threads),
160
+ backend_id=tool_value,
161
+ cwd=work,
162
+ timeout_seconds=timeout_seconds,
163
+ max_output_bytes=10_000_000,
164
+ monitored_output_paths=known,
165
+ max_monitored_output_bytes=min(resolved.max_total_bytes, 10_000_000_000),
166
+ )
167
+ if command.return_code != 0:
168
+ raise BackendExecutionError(
169
+ "Index builder returned a non-zero status.",
170
+ code="INDEX_BUILD_FAILED",
171
+ context={"tool": tool_value, "return_code": command.return_code},
172
+ )
173
+ staged = _actual_outputs(tool_value, known)
174
+ minimum = 6 if tool_value == "bowtie2-build" else 1
175
+ if len(staged) < minimum:
176
+ raise BackendExecutionError(
177
+ "Index builder did not create its expected artifacts.",
178
+ code="INDEX_OUTPUT_MISSING",
179
+ context={"tool": tool_value},
180
+ )
181
+ targets = tuple(
182
+ output / path.name.replace(prefix_value, final_prefix.name, 1) for path in staged
183
+ )
184
+ artifacts = tuple(
185
+ IndexArtifact(
186
+ str(target),
187
+ source.stat().st_size,
188
+ _sha256_path(source),
189
+ )
190
+ for source, target in zip(staged, targets, strict=True)
191
+ )
192
+ provenance = adapter_provenance(
193
+ tool_value,
194
+ citation_url="https://www.ncbi.nlm.nih.gov/books/NBK569856/",
195
+ filters={
196
+ "tool": tool_value,
197
+ "fasta_sha256": _sha256_path(fasta),
198
+ "threads": threads,
199
+ },
200
+ )
201
+ payload = {
202
+ "tool": tool_value,
203
+ "fasta_path": str(fasta),
204
+ "output_prefix": str(final_prefix),
205
+ "artifacts": [
206
+ {"path": item.path, "byte_size": item.byte_size, "sha256": item.sha256}
207
+ for item in artifacts
208
+ ],
209
+ "elapsed_seconds": command.elapsed_seconds,
210
+ "provenance": provenance.to_dict(),
211
+ }
212
+ staged_manifest = work / manifest.name
213
+ _write_manifest(payload, staged_manifest, overwrite=False)
214
+ _install_staged_files(
215
+ (*staged, staged_manifest),
216
+ (*targets, manifest),
217
+ overwrite=resolved.overwrite,
218
+ )
219
+ if progress is not None:
220
+ progress(IndexProgress(tool_value, "complete"))
221
+ return IndexBuildResult(
222
+ tool_value,
223
+ str(fasta),
224
+ str(final_prefix),
225
+ artifacts,
226
+ command.output,
227
+ command.elapsed_seconds,
228
+ str(manifest),
229
+ provenance,
230
+ )
231
+
232
+
233
+ __all__ = ["IndexProgress", "build_index"]