dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Curated public ClinVar and GEO download locations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dnakit.exceptions import ConfigurationError
|
|
10
|
+
|
|
11
|
+
from .files import dataset
|
|
12
|
+
from .models import DatasetDownloadResult, DownloadConfig, DownloadProgress, RemoteFile
|
|
13
|
+
|
|
14
|
+
_GEO_ACCESSION = re.compile(r"^(GSE|GDS|GPL|GSM)(\d+)$", flags=re.IGNORECASE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def variants(
|
|
18
|
+
output_dir: str | Path,
|
|
19
|
+
*,
|
|
20
|
+
source: str = "clinvar",
|
|
21
|
+
format: str = "vcf",
|
|
22
|
+
assembly: str = "GRCh38",
|
|
23
|
+
include_index: bool = True,
|
|
24
|
+
include_checksum_files: bool = True,
|
|
25
|
+
config: DownloadConfig | None = None,
|
|
26
|
+
progress: Callable[[DownloadProgress], None] | None = None,
|
|
27
|
+
) -> DatasetDownloadResult:
|
|
28
|
+
"""Download current public ClinVar VCF, XML, or variant-summary TSV."""
|
|
29
|
+
|
|
30
|
+
source_value = source.casefold()
|
|
31
|
+
if source_value not in {"clinvar", "dbsnp"}:
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
"variants source must be clinvar or dbsnp; use dataset() for other archives."
|
|
34
|
+
)
|
|
35
|
+
kind = format.casefold()
|
|
36
|
+
resources: list[RemoteFile]
|
|
37
|
+
if source_value == "dbsnp":
|
|
38
|
+
if kind != "vcf":
|
|
39
|
+
raise ConfigurationError("dbSNP downloads currently use format='vcf'.")
|
|
40
|
+
accessions = {
|
|
41
|
+
"GRCh37": "GCF_000001405.25",
|
|
42
|
+
"GRCh38": "GCF_000001405.40",
|
|
43
|
+
}
|
|
44
|
+
accession = accessions.get(assembly)
|
|
45
|
+
if accession is None:
|
|
46
|
+
raise ConfigurationError("dbSNP VCF assembly must be GRCh37 or GRCh38.")
|
|
47
|
+
base = "https://ftp.ncbi.nih.gov/snp/latest_release/VCF"
|
|
48
|
+
resources = [RemoteFile(f"{base}/{accession}.gz", filename=f"dbsnp_{assembly}.vcf.gz")]
|
|
49
|
+
if include_index:
|
|
50
|
+
resources.append(
|
|
51
|
+
RemoteFile(f"{base}/{accession}.gz.tbi", filename=f"dbsnp_{assembly}.vcf.gz.tbi")
|
|
52
|
+
)
|
|
53
|
+
if include_checksum_files:
|
|
54
|
+
resources.append(
|
|
55
|
+
RemoteFile(f"{base}/{accession}.gz.md5", filename=f"dbsnp_{assembly}.vcf.gz.md5")
|
|
56
|
+
)
|
|
57
|
+
if include_index:
|
|
58
|
+
resources.append(
|
|
59
|
+
RemoteFile(
|
|
60
|
+
f"{base}/{accession}.gz.tbi.md5",
|
|
61
|
+
filename=f"dbsnp_{assembly}.vcf.gz.tbi.md5",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
elif kind == "vcf":
|
|
65
|
+
if assembly not in {"GRCh37", "GRCh38"}:
|
|
66
|
+
raise ConfigurationError("ClinVar VCF assembly must be GRCh37 or GRCh38.")
|
|
67
|
+
base = f"https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_{assembly}"
|
|
68
|
+
resources = [RemoteFile(f"{base}/clinvar.vcf.gz", filename=f"clinvar_{assembly}.vcf.gz")]
|
|
69
|
+
if include_index:
|
|
70
|
+
resources.append(
|
|
71
|
+
RemoteFile(f"{base}/clinvar.vcf.gz.tbi", filename=f"clinvar_{assembly}.vcf.gz.tbi")
|
|
72
|
+
)
|
|
73
|
+
elif kind == "xml":
|
|
74
|
+
resources = [
|
|
75
|
+
RemoteFile(
|
|
76
|
+
"https://ftp.ncbi.nlm.nih.gov/pub/clinvar/xml/ClinVarVCVRelease_00-latest.xml.gz",
|
|
77
|
+
filename="ClinVarVCVRelease_00-latest.xml.gz",
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
elif kind in {"tsv", "summary"}:
|
|
81
|
+
resources = [
|
|
82
|
+
RemoteFile(
|
|
83
|
+
"https://ftp.ncbi.nlm.nih.gov/pub/clinvar/tab_delimited/variant_summary.txt.gz",
|
|
84
|
+
filename="clinvar_variant_summary.txt.gz",
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
else:
|
|
88
|
+
raise ConfigurationError("ClinVar format must be vcf, xml, or tsv.")
|
|
89
|
+
return dataset(
|
|
90
|
+
resources,
|
|
91
|
+
output_dir,
|
|
92
|
+
kind=f"{source_value}_{kind}",
|
|
93
|
+
source="NCBI ClinVar" if source_value == "clinvar" else "NCBI dbSNP",
|
|
94
|
+
metadata={
|
|
95
|
+
"assembly": assembly if kind == "vcf" else None,
|
|
96
|
+
"format": kind,
|
|
97
|
+
"provider_checksum_files_included": include_checksum_files
|
|
98
|
+
if source_value == "dbsnp"
|
|
99
|
+
else False,
|
|
100
|
+
},
|
|
101
|
+
config=config,
|
|
102
|
+
progress=progress,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _geo_range(accession: str) -> tuple[str, str, str]:
|
|
107
|
+
match = _GEO_ACCESSION.fullmatch(accession.strip())
|
|
108
|
+
if match is None:
|
|
109
|
+
raise ConfigurationError("GEO accession must start with GSE, GDS, GPL, or GSM.")
|
|
110
|
+
prefix = match.group(1).upper()
|
|
111
|
+
digits = match.group(2)
|
|
112
|
+
range_name = f"{prefix}{digits[:-3]}nnn" if len(digits) > 3 else f"{prefix}nnn"
|
|
113
|
+
category = {"GSE": "series", "GDS": "datasets", "GPL": "platforms", "GSM": "samples"}[prefix]
|
|
114
|
+
return prefix + digits, category, range_name
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def expression(
|
|
118
|
+
accession: str,
|
|
119
|
+
output_dir: str | Path,
|
|
120
|
+
*,
|
|
121
|
+
format: str = "soft",
|
|
122
|
+
platform: str | None = None,
|
|
123
|
+
config: DownloadConfig | None = None,
|
|
124
|
+
progress: Callable[[DownloadProgress], None] | None = None,
|
|
125
|
+
) -> DatasetDownloadResult:
|
|
126
|
+
"""Download a standard GEO SOFT, MINiML, or common Series RAW archive."""
|
|
127
|
+
|
|
128
|
+
value, category, range_name = _geo_range(accession)
|
|
129
|
+
kind = format.casefold()
|
|
130
|
+
root = f"https://ftp.ncbi.nlm.nih.gov/geo/{category}/{range_name}/{value}"
|
|
131
|
+
if kind == "soft":
|
|
132
|
+
url = f"{root}/soft/{value}_family.soft.gz"
|
|
133
|
+
filename = f"{value}_family.soft.gz"
|
|
134
|
+
elif kind == "miniml":
|
|
135
|
+
url = f"{root}/miniml/{value}_family.xml.tgz"
|
|
136
|
+
filename = f"{value}_family.xml.tgz"
|
|
137
|
+
elif kind in {"raw", "supplementary"}:
|
|
138
|
+
if not value.startswith("GSE"):
|
|
139
|
+
raise ConfigurationError("The common RAW archive convention applies to GSE accessions.")
|
|
140
|
+
url = f"{root}/suppl/{value}_RAW.tar"
|
|
141
|
+
filename = f"{value}_RAW.tar"
|
|
142
|
+
elif kind in {"matrix", "series_matrix"}:
|
|
143
|
+
if not value.startswith("GSE"):
|
|
144
|
+
raise ConfigurationError("Series Matrix downloads require a GSE accession.")
|
|
145
|
+
platform_value: str | None = None
|
|
146
|
+
if platform is not None:
|
|
147
|
+
platform_value, _, _ = _geo_range(platform)
|
|
148
|
+
if not platform_value.startswith("GPL"):
|
|
149
|
+
raise ConfigurationError("platform must be a GPL accession.")
|
|
150
|
+
stem = value if platform_value is None else f"{value}-{platform_value}"
|
|
151
|
+
filename = f"{stem}_series_matrix.txt.gz"
|
|
152
|
+
url = f"{root}/matrix/{filename}"
|
|
153
|
+
else:
|
|
154
|
+
raise ConfigurationError("GEO format must be soft, miniml, raw, or matrix.")
|
|
155
|
+
return dataset(
|
|
156
|
+
(RemoteFile(url, filename=filename),),
|
|
157
|
+
output_dir,
|
|
158
|
+
kind=f"geo_{kind}",
|
|
159
|
+
source="NCBI GEO",
|
|
160
|
+
metadata={"accession": value, "format": kind, "platform": platform},
|
|
161
|
+
config=config,
|
|
162
|
+
progress=progress,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["expression", "variants"]
|
dnakit/download/ena.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Download public read files discovered through ENA metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from dnakit.exceptions import ConfigurationError, DownloadError
|
|
11
|
+
from dnakit.search import QueryResult, SearchConfig
|
|
12
|
+
from dnakit.search.ena import DEFAULT_ENA_PORTAL_URL, DEFAULT_READ_FIELDS
|
|
13
|
+
from dnakit.search.ena import reads as search_reads
|
|
14
|
+
|
|
15
|
+
from .files import dataset, resolved_config
|
|
16
|
+
from .models import DatasetDownloadResult, DownloadConfig, DownloadProgress, RemoteFile
|
|
17
|
+
|
|
18
|
+
_RUN_ACCESSION = re.compile(r"^[SED]RR\d+$", flags=re.IGNORECASE)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ena_https(value: str) -> str:
|
|
22
|
+
item = value.strip()
|
|
23
|
+
if item.startswith("ftp://"):
|
|
24
|
+
return "https://" + item[len("ftp://") :]
|
|
25
|
+
if item.startswith("https://"):
|
|
26
|
+
return item
|
|
27
|
+
if "://" not in item:
|
|
28
|
+
return "https://" + item.lstrip("/")
|
|
29
|
+
raise ConfigurationError(
|
|
30
|
+
"ENA file URL uses an unsupported scheme.", code="INVALID_DOWNLOAD_URL"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parts(record: object, key: str) -> tuple[str, ...]:
|
|
35
|
+
if not isinstance(record, Mapping):
|
|
36
|
+
return ()
|
|
37
|
+
raw = record.get(key)
|
|
38
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
39
|
+
return ()
|
|
40
|
+
return tuple(item.strip() for item in raw.split(";") if item.strip())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reads(
|
|
44
|
+
query: str | QueryResult,
|
|
45
|
+
output_dir: str | Path,
|
|
46
|
+
*,
|
|
47
|
+
file_kind: str = "fastq",
|
|
48
|
+
config: DownloadConfig | None = None,
|
|
49
|
+
progress: Callable[[DownloadProgress], None] | None = None,
|
|
50
|
+
ena_api_base_url: str = DEFAULT_ENA_PORTAL_URL,
|
|
51
|
+
) -> DatasetDownloadResult:
|
|
52
|
+
"""Download public ENA FASTQ, submitted, or SRA files with advertised MD5 checks."""
|
|
53
|
+
|
|
54
|
+
resolved = resolved_config(config)
|
|
55
|
+
if file_kind not in {"fastq", "submitted", "sra"}:
|
|
56
|
+
raise ConfigurationError("file_kind must be fastq, submitted, or sra.")
|
|
57
|
+
if isinstance(query, QueryResult):
|
|
58
|
+
result = query
|
|
59
|
+
if result.provider != "ENA" or result.query_type != "reads":
|
|
60
|
+
raise ConfigurationError("QueryResult must come from dnakit.search.reads().")
|
|
61
|
+
elif isinstance(query, str):
|
|
62
|
+
expression = (
|
|
63
|
+
f'run_accession="{query}"' if _RUN_ACCESSION.fullmatch(query.strip()) else query
|
|
64
|
+
)
|
|
65
|
+
fields = tuple(DEFAULT_READ_FIELDS)
|
|
66
|
+
if file_kind == "sra":
|
|
67
|
+
fields += ("sra_ftp", "sra_md5", "sra_bytes")
|
|
68
|
+
result = search_reads(
|
|
69
|
+
expression,
|
|
70
|
+
fields=fields,
|
|
71
|
+
limit=resolved.max_files,
|
|
72
|
+
config=SearchConfig(
|
|
73
|
+
timeout=min(float(resolved.timeout), 300.0),
|
|
74
|
+
max_response_bytes=min(resolved.max_file_bytes, 100_000_000),
|
|
75
|
+
max_records=resolved.max_files,
|
|
76
|
+
),
|
|
77
|
+
api_base_url=ena_api_base_url,
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
raise TypeError("query must be ENA query text, run accession, or QueryResult.")
|
|
81
|
+
url_key = f"{file_kind}_ftp"
|
|
82
|
+
md5_key = f"{file_kind}_md5"
|
|
83
|
+
bytes_key = f"{file_kind}_bytes"
|
|
84
|
+
resources: list[RemoteFile] = []
|
|
85
|
+
expected_sizes: dict[str, int | None] = {}
|
|
86
|
+
for record in result.records:
|
|
87
|
+
urls = _parts(record, url_key)
|
|
88
|
+
md5s = _parts(record, md5_key)
|
|
89
|
+
sizes = _parts(record, bytes_key)
|
|
90
|
+
if md5s and len(md5s) != len(urls):
|
|
91
|
+
raise DownloadError(
|
|
92
|
+
"ENA returned mismatched file and MD5 lists.", code="ENA_METADATA_MISMATCH"
|
|
93
|
+
)
|
|
94
|
+
if sizes and len(sizes) != len(urls):
|
|
95
|
+
raise DownloadError(
|
|
96
|
+
"ENA returned mismatched file and size lists.", code="ENA_METADATA_MISMATCH"
|
|
97
|
+
)
|
|
98
|
+
for index, raw_url in enumerate(urls):
|
|
99
|
+
url = _ena_https(raw_url)
|
|
100
|
+
filename = PurePosixPath(urlsplit(url).path).name
|
|
101
|
+
checksum = md5s[index] if md5s else None
|
|
102
|
+
resources.append(RemoteFile(url, filename=filename, expected_md5=checksum))
|
|
103
|
+
try:
|
|
104
|
+
expected_sizes[filename] = int(sizes[index]) if sizes else None
|
|
105
|
+
except ValueError as exc:
|
|
106
|
+
raise DownloadError(
|
|
107
|
+
"ENA returned an invalid file size.", code="ENA_METADATA_MISMATCH"
|
|
108
|
+
) from exc
|
|
109
|
+
if not resources:
|
|
110
|
+
raise DownloadError(
|
|
111
|
+
f"ENA query returned no public {file_kind} file URLs.",
|
|
112
|
+
code="ENA_FILES_NOT_FOUND",
|
|
113
|
+
)
|
|
114
|
+
if len(resources) > resolved.max_files:
|
|
115
|
+
raise DownloadError("ENA files exceed max_files.", code="DOWNLOAD_FILE_LIMIT")
|
|
116
|
+
return dataset(
|
|
117
|
+
resources,
|
|
118
|
+
output_dir,
|
|
119
|
+
kind=f"ena_{file_kind}",
|
|
120
|
+
source="European Nucleotide Archive",
|
|
121
|
+
metadata={
|
|
122
|
+
"query_url": result.request_url,
|
|
123
|
+
"file_kind": file_kind,
|
|
124
|
+
"expected_sizes": expected_sizes,
|
|
125
|
+
},
|
|
126
|
+
config=resolved,
|
|
127
|
+
progress=progress,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = ["reads"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Download public files discovered through ENCODE Portal search results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
from dnakit.exceptions import ConfigurationError, DownloadError
|
|
11
|
+
from dnakit.search import QueryResult
|
|
12
|
+
from dnakit.search._http import require_https_base
|
|
13
|
+
|
|
14
|
+
from .files import dataset, resolved_config
|
|
15
|
+
from .models import DatasetDownloadResult, DownloadConfig, DownloadProgress, RemoteFile
|
|
16
|
+
|
|
17
|
+
DEFAULT_ENCODE_DOWNLOAD_URL = "https://www.encodeproject.org"
|
|
18
|
+
_MD5 = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _url(value: str, base_url: str) -> str:
|
|
22
|
+
raw = value.strip()
|
|
23
|
+
parsed = urlsplit(raw)
|
|
24
|
+
if parsed.scheme:
|
|
25
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.fragment:
|
|
26
|
+
raise ConfigurationError("ENCODE file URL must use HTTPS.")
|
|
27
|
+
return raw
|
|
28
|
+
relative = raw.lstrip("/")
|
|
29
|
+
if not relative or ".." in PurePosixPath(relative).parts:
|
|
30
|
+
raise ConfigurationError("ENCODE file path is unsafe.")
|
|
31
|
+
return f"{require_https_base(base_url)}/{quote(relative, safe='/._-@')}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def files(
|
|
35
|
+
query: QueryResult,
|
|
36
|
+
output_dir: str | Path,
|
|
37
|
+
*,
|
|
38
|
+
config: DownloadConfig | None = None,
|
|
39
|
+
progress: Callable[[DownloadProgress], None] | None = None,
|
|
40
|
+
download_base_url: str = DEFAULT_ENCODE_DOWNLOAD_URL,
|
|
41
|
+
) -> DatasetDownloadResult:
|
|
42
|
+
"""Download public ENCODE file records, verifying advertised MD5 values when present."""
|
|
43
|
+
|
|
44
|
+
if not isinstance(query, QueryResult) or query.provider != "ENCODE":
|
|
45
|
+
raise ConfigurationError("query must be a dnakit.search.encode_search() result.")
|
|
46
|
+
resolved = resolved_config(config)
|
|
47
|
+
resources: list[RemoteFile] = []
|
|
48
|
+
expected_total = 0
|
|
49
|
+
selected: list[dict[str, object]] = []
|
|
50
|
+
for record in query.records:
|
|
51
|
+
href = record.get("href")
|
|
52
|
+
if not isinstance(href, str) or not href.strip():
|
|
53
|
+
continue
|
|
54
|
+
url = _url(href, download_base_url)
|
|
55
|
+
filename = PurePosixPath(urlsplit(url).path).name
|
|
56
|
+
if not filename:
|
|
57
|
+
raise DownloadError("ENCODE file URL has no filename.", code="QUERY_RESPONSE_ERROR")
|
|
58
|
+
raw_md5 = record.get("md5sum")
|
|
59
|
+
expected_md5 = raw_md5 if isinstance(raw_md5, str) and _MD5.fullmatch(raw_md5) else None
|
|
60
|
+
raw_size = record.get("file_size")
|
|
61
|
+
if isinstance(raw_size, int) and not isinstance(raw_size, bool) and raw_size >= 0:
|
|
62
|
+
if raw_size > resolved.max_file_bytes:
|
|
63
|
+
raise DownloadError(
|
|
64
|
+
"ENCODE file exceeds max_file_bytes.", code="DOWNLOAD_SIZE_LIMIT"
|
|
65
|
+
)
|
|
66
|
+
expected_total += raw_size
|
|
67
|
+
resources.append(RemoteFile(url, filename=filename, expected_md5=expected_md5))
|
|
68
|
+
selected.append(
|
|
69
|
+
{
|
|
70
|
+
"accession": record.get("accession"),
|
|
71
|
+
"file_format": record.get("file_format"),
|
|
72
|
+
"output_type": record.get("output_type"),
|
|
73
|
+
"href": href,
|
|
74
|
+
"provider_md5_available": expected_md5 is not None,
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
if not resources:
|
|
78
|
+
raise DownloadError(
|
|
79
|
+
"ENCODE query contains no downloadable file records; search object_type='File'.",
|
|
80
|
+
code="ENCODE_FILES_NOT_FOUND",
|
|
81
|
+
)
|
|
82
|
+
if len(resources) > resolved.max_files:
|
|
83
|
+
raise DownloadError("ENCODE files exceed max_files.", code="DOWNLOAD_FILE_LIMIT")
|
|
84
|
+
if expected_total > resolved.max_total_bytes:
|
|
85
|
+
raise DownloadError(
|
|
86
|
+
"ENCODE files exceed max_total_bytes.", code="DOWNLOAD_TOTAL_SIZE_LIMIT"
|
|
87
|
+
)
|
|
88
|
+
return dataset(
|
|
89
|
+
resources,
|
|
90
|
+
output_dir,
|
|
91
|
+
kind="encode_files",
|
|
92
|
+
source="ENCODE Portal",
|
|
93
|
+
metadata={
|
|
94
|
+
"query_url": query.request_url,
|
|
95
|
+
"expected_total_bytes": expected_total,
|
|
96
|
+
"selected_files": selected,
|
|
97
|
+
},
|
|
98
|
+
config=resolved,
|
|
99
|
+
progress=progress,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = ["DEFAULT_ENCODE_DOWNLOAD_URL", "files"]
|