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
dnakit/download/files.py
ADDED
|
@@ -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"]
|