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