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
dnakit/search/ncbi.py ADDED
@@ -0,0 +1,756 @@
1
+ """NCBI Datasets and Entrez E-utilities query adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any, Literal
8
+ from urllib.parse import quote
9
+
10
+ from dnakit.exceptions import ConfigurationError, QueryError
11
+
12
+ from ._http import (
13
+ build_url,
14
+ limited_records,
15
+ mapping_records,
16
+ redact_url,
17
+ request_json,
18
+ require_text,
19
+ )
20
+ from ._shared import (
21
+ adapter_provenance,
22
+ query_values,
23
+ quoted_path_values,
24
+ resolved_config,
25
+ )
26
+ from ._shared import (
27
+ page_size as validate_page_size,
28
+ )
29
+ from .models import QueryResult, SearchConfig
30
+
31
+ DEFAULT_NCBI_DATASETS_URL = "https://api.ncbi.nlm.nih.gov/datasets/v2"
32
+ DEFAULT_NCBI_EUTILS_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
33
+ _ASSEMBLY_ACCESSION = re.compile(r"^GC[AF]_\d+\.\d+$", flags=re.IGNORECASE)
34
+ _REFSEQ_ACCESSION = re.compile(r"^[A-Z]{1,6}_\d+(?:\.\d+)?$", flags=re.IGNORECASE)
35
+ _VIRUS_ACCESSION = re.compile(r"^[A-Z]{1,6}_?\d{3,}(?:\.\d+)?$", flags=re.IGNORECASE)
36
+ _ENTREZ_DATABASES = frozenset(
37
+ {
38
+ "assembly",
39
+ "bioproject",
40
+ "biosample",
41
+ "clinvar",
42
+ "gds",
43
+ "gene",
44
+ "geoprofiles",
45
+ "nuccore",
46
+ "protein",
47
+ "pubmed",
48
+ "snp",
49
+ "sra",
50
+ }
51
+ )
52
+
53
+
54
+ def _datasets_headers(config: SearchConfig) -> dict[str, str]:
55
+ return {} if config.api_key is None else {"api-key": config.api_key}
56
+
57
+
58
+ def _page_metadata(payload: Mapping[str, Any]) -> tuple[int | None, str | None]:
59
+ raw_total = payload.get("total_count")
60
+ total = raw_total if isinstance(raw_total, int) and not isinstance(raw_total, bool) else None
61
+ token = payload.get("next_page_token")
62
+ next_token = token if isinstance(token, str) and token.strip() else None
63
+ return total, next_token
64
+
65
+
66
+ def taxonomy(
67
+ query: str | int | Sequence[str | int],
68
+ *,
69
+ report: Literal["summary", "names", "related"] = "summary",
70
+ children: bool = False,
71
+ include_lineage: bool = True,
72
+ ranks: Sequence[str] = (),
73
+ page_size: int = 20,
74
+ page_token: str | None = None,
75
+ config: SearchConfig | None = None,
76
+ api_base_url: str = DEFAULT_NCBI_DATASETS_URL,
77
+ ) -> QueryResult:
78
+ """Query NCBI Taxonomy by TaxID, scientific name, common name, or alias.
79
+
80
+ Use ``report="names"`` for the complete names/aliases report. Use
81
+ ``report="related"`` with one numeric TaxID for child nodes and lineage.
82
+ """
83
+
84
+ resolved = resolved_config(config)
85
+ size = validate_page_size(page_size, resolved)
86
+ values = query_values(query, maximum=100)
87
+ if report not in {"summary", "names", "related"}:
88
+ raise ConfigurationError("Unsupported taxonomy report.", code="INVALID_QUERY")
89
+ params: list[tuple[str, object]] = [("page_size", size)]
90
+ if page_token is not None:
91
+ params.append(("page_token", require_text(page_token, "page_token")))
92
+ if ranks:
93
+ params.append(
94
+ ("ranks", tuple(require_text(rank, "rank", max_length=128) for rank in ranks))
95
+ )
96
+ if report == "related":
97
+ if len(values) != 1 or not values[0].isdigit():
98
+ raise ConfigurationError(
99
+ "taxonomy report='related' requires one numeric TaxID.", code="INVALID_QUERY"
100
+ )
101
+ path = f"/taxonomy/taxon/{values[0]}/related_ids"
102
+ params.append(("include_lineage", include_lineage))
103
+ else:
104
+ suffix = "name_report" if report == "names" else "dataset_report"
105
+ path = f"/taxonomy/taxon/{quoted_path_values(values)}/{suffix}"
106
+ params.append(("children", children))
107
+ url = build_url(api_base_url, path, params)
108
+ payload = request_json(
109
+ url,
110
+ resolved,
111
+ provider="NCBI Datasets",
112
+ headers=_datasets_headers(resolved),
113
+ )
114
+ if not isinstance(payload, Mapping):
115
+ raise QueryError(
116
+ "NCBI Taxonomy returned an unexpected response.", code="QUERY_RESPONSE_ERROR"
117
+ )
118
+ records: tuple[dict[str, object], ...]
119
+ if report == "related":
120
+ raw_ids = payload.get("tax_ids")
121
+ records = (
122
+ tuple(
123
+ {"tax_id": item}
124
+ for item in raw_ids
125
+ if isinstance(item, int) and not isinstance(item, bool)
126
+ )
127
+ if isinstance(raw_ids, list)
128
+ else ()
129
+ )
130
+ else:
131
+ records = mapping_records(payload, key="reports")
132
+ records = limited_records(records, resolved)
133
+ total, next_token = _page_metadata(payload)
134
+ total = max(total, len(records)) if total is not None else len(records)
135
+ return QueryResult(
136
+ "taxonomy",
137
+ "NCBI Datasets",
138
+ redact_url(url),
139
+ records,
140
+ adapter_provenance(
141
+ "NCBI Taxonomy",
142
+ citation_url="https://www.ncbi.nlm.nih.gov/datasets/docs/v2/how-tos/taxonomy/taxonomy/",
143
+ filters={
144
+ "query": values,
145
+ "report": report,
146
+ "children": children,
147
+ "ranks": tuple(ranks),
148
+ },
149
+ ),
150
+ total_count=total,
151
+ next_page_token=next_token,
152
+ metadata={"coordinate_system": None, "report": report},
153
+ )
154
+
155
+
156
+ def assembly(
157
+ query: str | Sequence[str],
158
+ *,
159
+ by: Literal["auto", "accession", "taxon"] = "auto",
160
+ reference_only: bool = False,
161
+ assembly_source: Literal["all", "refseq", "genbank"] = "all",
162
+ current_only: bool = True,
163
+ page_size: int = 20,
164
+ page_token: str | None = None,
165
+ config: SearchConfig | None = None,
166
+ api_base_url: str = DEFAULT_NCBI_DATASETS_URL,
167
+ ) -> QueryResult:
168
+ """Query genome assemblies and their statistics from NCBI Datasets."""
169
+
170
+ resolved = resolved_config(config)
171
+ size = validate_page_size(page_size, resolved)
172
+ values = query_values(query, maximum=100)
173
+ if by == "auto":
174
+ accession_flags = tuple(
175
+ _ASSEMBLY_ACCESSION.fullmatch(value) is not None for value in values
176
+ )
177
+ if any(accession_flags) and not all(accession_flags):
178
+ raise ConfigurationError(
179
+ "Do not mix assembly accessions and taxon names in one query.", code="INVALID_QUERY"
180
+ )
181
+ by = "accession" if all(accession_flags) else "taxon"
182
+ if by not in {"accession", "taxon"}:
183
+ raise ConfigurationError(
184
+ "assembly by must be auto, accession, or taxon.", code="INVALID_QUERY"
185
+ )
186
+ path = f"/genome/{by}/{quoted_path_values(values)}/dataset_report"
187
+ params: list[tuple[str, object]] = [
188
+ ("filters.reference_only", reference_only),
189
+ ("filters.assembly_source", assembly_source),
190
+ ("filters.assembly_version", "current" if current_only else "all_assemblies"),
191
+ ("page_size", size),
192
+ ]
193
+ if page_token is not None:
194
+ params.append(("page_token", require_text(page_token, "page_token")))
195
+ url = build_url(api_base_url, path, params)
196
+ payload = request_json(
197
+ url, resolved, provider="NCBI Datasets", headers=_datasets_headers(resolved)
198
+ )
199
+ if not isinstance(payload, Mapping):
200
+ raise QueryError(
201
+ "NCBI Assembly returned an unexpected response.", code="QUERY_RESPONSE_ERROR"
202
+ )
203
+ records = limited_records(mapping_records(payload, key="reports"), resolved)
204
+ total, next_token = _page_metadata(payload)
205
+ total = max(total, len(records)) if total is not None else len(records)
206
+ return QueryResult(
207
+ "assembly",
208
+ "NCBI Datasets",
209
+ redact_url(url),
210
+ records,
211
+ adapter_provenance(
212
+ "NCBI Genome",
213
+ citation_url="https://www.ncbi.nlm.nih.gov/datasets/docs/v2/how-tos/genomes/get-genome-metadata/",
214
+ filters={
215
+ "query": values,
216
+ "by": by,
217
+ "reference_only": reference_only,
218
+ "assembly_source": assembly_source,
219
+ "current_only": current_only,
220
+ },
221
+ ),
222
+ total_count=total,
223
+ next_page_token=next_token,
224
+ )
225
+
226
+
227
+ def gene(
228
+ query: str | int | Sequence[str | int],
229
+ *,
230
+ taxon: str | int | None = None,
231
+ by: Literal["auto", "id", "accession", "symbol", "search"] = "auto",
232
+ report: Literal["gene", "product"] = "gene",
233
+ gene_types: Sequence[str] = (),
234
+ page_size: int = 20,
235
+ page_token: str | None = None,
236
+ config: SearchConfig | None = None,
237
+ api_base_url: str = DEFAULT_NCBI_DATASETS_URL,
238
+ ) -> QueryResult:
239
+ """Query genes by GeneID, RefSeq accession, symbol, alias, or name."""
240
+
241
+ resolved = resolved_config(config)
242
+ size = validate_page_size(page_size, resolved)
243
+ values = query_values(query, maximum=100)
244
+ taxon_text = None if taxon is None else require_text(str(taxon), "taxon", max_length=1_024)
245
+ if report not in {"gene", "product"}:
246
+ raise ConfigurationError("gene report must be gene or product.", code="INVALID_QUERY")
247
+ if by == "auto":
248
+ if all(value.isdigit() for value in values):
249
+ by = "id"
250
+ elif all(_REFSEQ_ACCESSION.fullmatch(value) is not None for value in values):
251
+ by = "accession"
252
+ elif taxon_text is not None:
253
+ by = "symbol"
254
+ else:
255
+ raise ConfigurationError(
256
+ "Gene symbols, aliases, and names require taxon; otherwise set by explicitly.",
257
+ code="INVALID_QUERY",
258
+ )
259
+ suffix = "product_report" if report == "product" else "dataset_report"
260
+ params: list[tuple[str, object]] = [("page_size", size)]
261
+ if page_token is not None:
262
+ params.append(("page_token", require_text(page_token, "page_token")))
263
+ if gene_types:
264
+ params.append(
265
+ ("types", tuple(require_text(item, "gene_type", max_length=128) for item in gene_types))
266
+ )
267
+ if by == "id":
268
+ if not all(value.isdigit() for value in values):
269
+ raise ConfigurationError("GeneID queries must be numeric.", code="INVALID_QUERY")
270
+ path = f"/gene/id/{quoted_path_values(values)}/{suffix}"
271
+ elif by == "accession":
272
+ path = f"/gene/accession/{quoted_path_values(values)}/{suffix}"
273
+ elif by == "symbol":
274
+ if taxon_text is None:
275
+ raise ConfigurationError("Gene symbol queries require taxon.", code="INVALID_QUERY")
276
+ path = (
277
+ f"/gene/symbol/{quoted_path_values(values)}/taxon/{quote(taxon_text, safe='')}/{suffix}"
278
+ )
279
+ elif by == "search":
280
+ if taxon_text is None or len(values) != 1:
281
+ raise ConfigurationError(
282
+ "Gene alias/name search requires one query and taxon.", code="INVALID_QUERY"
283
+ )
284
+ path = f"/gene/taxon/{quote(taxon_text, safe='')}/{suffix}"
285
+ params.append(("query", values[0]))
286
+ else:
287
+ raise ConfigurationError("Unsupported gene query mode.", code="INVALID_QUERY")
288
+ url = build_url(api_base_url, path, params)
289
+ payload = request_json(
290
+ url, resolved, provider="NCBI Datasets", headers=_datasets_headers(resolved)
291
+ )
292
+ if not isinstance(payload, Mapping):
293
+ raise QueryError("NCBI Gene returned an unexpected response.", code="QUERY_RESPONSE_ERROR")
294
+ records = limited_records(mapping_records(payload, key="reports"), resolved)
295
+ total, next_token = _page_metadata(payload)
296
+ total = max(total, len(records)) if total is not None else len(records)
297
+ return QueryResult(
298
+ "gene",
299
+ "NCBI Datasets",
300
+ redact_url(url),
301
+ records,
302
+ adapter_provenance(
303
+ "NCBI Gene",
304
+ citation_url="https://www.ncbi.nlm.nih.gov/datasets/docs/v2/how-tos/genes/get-gene-metadata/",
305
+ filters={"query": values, "taxon": taxon_text, "by": by, "report": report},
306
+ ),
307
+ total_count=total,
308
+ next_page_token=next_token,
309
+ metadata={"report": report},
310
+ )
311
+
312
+
313
+ def ncbi_orthologs(
314
+ gene_id: int | str,
315
+ *,
316
+ returned_content: Literal["complete", "ids_only"] = "complete",
317
+ page_size: int = 20,
318
+ page_token: str | None = None,
319
+ config: SearchConfig | None = None,
320
+ api_base_url: str = DEFAULT_NCBI_DATASETS_URL,
321
+ ) -> QueryResult:
322
+ """Query the NCBI ortholog set for one numeric GeneID."""
323
+
324
+ resolved = resolved_config(config)
325
+ value = require_text(str(gene_id), "gene_id", max_length=32)
326
+ if not value.isdigit():
327
+ raise ConfigurationError("gene_id must be numeric.", code="INVALID_QUERY")
328
+ size = validate_page_size(page_size, resolved)
329
+ if returned_content not in {"complete", "ids_only"}:
330
+ raise ConfigurationError("Unsupported ortholog returned_content.", code="INVALID_QUERY")
331
+ params: list[tuple[str, object]] = [
332
+ ("returned_content", returned_content.upper()),
333
+ ("page_size", size),
334
+ ]
335
+ if page_token is not None:
336
+ params.append(("page_token", require_text(page_token, "page_token")))
337
+ url = build_url(api_base_url, f"/gene/id/{value}/orthologs", params)
338
+ payload = request_json(
339
+ url, resolved, provider="NCBI Datasets", headers=_datasets_headers(resolved)
340
+ )
341
+ if not isinstance(payload, Mapping):
342
+ raise QueryError(
343
+ "NCBI Orthologs returned an unexpected response.", code="QUERY_RESPONSE_ERROR"
344
+ )
345
+ records = limited_records(mapping_records(payload, key="reports"), resolved)
346
+ total, next_token = _page_metadata(payload)
347
+ total = max(total, len(records)) if total is not None else len(records)
348
+ return QueryResult(
349
+ "homology",
350
+ "NCBI Datasets",
351
+ redact_url(url),
352
+ records,
353
+ adapter_provenance(
354
+ "NCBI Gene Orthologs",
355
+ citation_url="https://www.ncbi.nlm.nih.gov/datasets/docs/v2/how-tos/genes/download-ortholog-set/",
356
+ filters={"gene_id": value},
357
+ ),
358
+ total_count=total,
359
+ next_page_token=next_token,
360
+ )
361
+
362
+
363
+ def virus(
364
+ query: str | Sequence[str],
365
+ *,
366
+ by: Literal["auto", "accession", "taxon"] = "auto",
367
+ report: Literal["dataset", "annotation"] = "dataset",
368
+ refseq_only: bool = False,
369
+ annotated_only: bool = False,
370
+ complete_only: bool = False,
371
+ host: str | None = None,
372
+ geo_location: str | None = None,
373
+ released_since: str | None = None,
374
+ updated_since: str | None = None,
375
+ page_size: int = 20,
376
+ page_token: str | None = None,
377
+ config: SearchConfig | None = None,
378
+ api_base_url: str = DEFAULT_NCBI_DATASETS_URL,
379
+ ) -> QueryResult:
380
+ """Query NCBI Virus genome and annotation metadata by taxon or accession."""
381
+
382
+ resolved = resolved_config(config)
383
+ values = query_values(query, maximum=100)
384
+ size = validate_page_size(page_size, resolved)
385
+ if by == "auto":
386
+ accession_flags = tuple(_VIRUS_ACCESSION.fullmatch(value) is not None for value in values)
387
+ if any(accession_flags) and not all(accession_flags):
388
+ raise ConfigurationError(
389
+ "Do not mix virus accessions and taxon names.", code="INVALID_QUERY"
390
+ )
391
+ by = "accession" if all(accession_flags) else "taxon"
392
+ if by not in {"accession", "taxon"}:
393
+ raise ConfigurationError("virus by must be auto, accession, or taxon.")
394
+ if by == "taxon" and len(values) != 1:
395
+ raise ConfigurationError("Virus taxon queries accept one taxon at a time.")
396
+ if report not in {"dataset", "annotation"}:
397
+ raise ConfigurationError("virus report must be dataset or annotation.")
398
+ for name, value in (
399
+ ("refseq_only", refseq_only),
400
+ ("annotated_only", annotated_only),
401
+ ("complete_only", complete_only),
402
+ ):
403
+ if not isinstance(value, bool):
404
+ raise ConfigurationError(f"{name} must be boolean.")
405
+ suffix = "dataset_report" if report == "dataset" else "annotation_report"
406
+ path_values = values if by == "accession" else (values[0],)
407
+ path = f"/virus/{by}/{quoted_path_values(path_values)}/{suffix}"
408
+ params: list[tuple[str, object]] = [
409
+ ("refseq_only", refseq_only),
410
+ ("annotated_only", annotated_only),
411
+ ("complete_only", complete_only),
412
+ ("host", None if host is None else require_text(host, "host", max_length=256)),
413
+ (
414
+ "geo_location",
415
+ None
416
+ if geo_location is None
417
+ else require_text(geo_location, "geo_location", max_length=256),
418
+ ),
419
+ (
420
+ "released_since",
421
+ None
422
+ if released_since is None
423
+ else require_text(released_since, "released_since", max_length=64),
424
+ ),
425
+ (
426
+ "updated_since",
427
+ None
428
+ if updated_since is None
429
+ else require_text(updated_since, "updated_since", max_length=64),
430
+ ),
431
+ ("page_size", size),
432
+ ]
433
+ if page_token is not None:
434
+ params.append(("page_token", require_text(page_token, "page_token")))
435
+ url = build_url(api_base_url, path, params)
436
+ payload = request_json(
437
+ url, resolved, provider="NCBI Datasets", headers=_datasets_headers(resolved)
438
+ )
439
+ if not isinstance(payload, Mapping):
440
+ raise QueryError("NCBI Virus returned an unexpected response.", code="QUERY_RESPONSE_ERROR")
441
+ records = limited_records(mapping_records(payload, key="reports"), resolved)
442
+ total, next_token = _page_metadata(payload)
443
+ total = max(total, len(records)) if total is not None else len(records)
444
+ return QueryResult(
445
+ "virus",
446
+ "NCBI Datasets",
447
+ redact_url(url),
448
+ records,
449
+ adapter_provenance(
450
+ "NCBI Virus",
451
+ citation_url="https://www.ncbi.nlm.nih.gov/datasets/docs/v2/how-tos/virus/virus-metadata/",
452
+ filters={
453
+ "query": values,
454
+ "by": by,
455
+ "report": report,
456
+ "refseq_only": refseq_only,
457
+ "annotated_only": annotated_only,
458
+ "complete_only": complete_only,
459
+ "host": host,
460
+ "geo_location": geo_location,
461
+ "released_since": released_since,
462
+ "updated_since": updated_since,
463
+ },
464
+ ),
465
+ total_count=total,
466
+ next_page_token=next_token,
467
+ metadata={"report": report},
468
+ )
469
+
470
+
471
+ def _eutils_parameters(config: SearchConfig) -> list[tuple[str, object]]:
472
+ parameters: list[tuple[str, object]] = [("tool", config.tool)]
473
+ if config.email is not None:
474
+ parameters.append(("email", config.email))
475
+ if config.api_key is not None:
476
+ parameters.append(("api_key", config.api_key))
477
+ return parameters
478
+
479
+
480
+ def entrez(
481
+ database: str,
482
+ term: str,
483
+ *,
484
+ retmax: int = 20,
485
+ retstart: int = 0,
486
+ sort: str | None = None,
487
+ config: SearchConfig | None = None,
488
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
489
+ query_type: str | None = None,
490
+ ) -> QueryResult:
491
+ """Run a bounded ESearch followed by ESummary against an Entrez database."""
492
+
493
+ resolved = resolved_config(config)
494
+ db = require_text(database, "database", max_length=64).casefold()
495
+ if db not in _ENTREZ_DATABASES:
496
+ raise ConfigurationError(
497
+ "Unsupported Entrez database.",
498
+ code="INVALID_QUERY",
499
+ context={"database": db, "allowed": sorted(_ENTREZ_DATABASES)},
500
+ )
501
+ query = require_text(term, "term")
502
+ size = validate_page_size(retmax, resolved, maximum=10_000)
503
+ if isinstance(retstart, bool) or not isinstance(retstart, int) or retstart < 0:
504
+ raise ConfigurationError("retstart must be a non-negative integer.", code="INVALID_QUERY")
505
+ search_params: list[tuple[str, object]] = [
506
+ ("db", db),
507
+ ("term", query),
508
+ ("retmode", "json"),
509
+ ("retmax", size),
510
+ ("retstart", retstart),
511
+ *_eutils_parameters(resolved),
512
+ ]
513
+ if sort is not None:
514
+ search_params.append(("sort", require_text(sort, "sort", max_length=128)))
515
+ search_url = build_url(api_base_url, "/esearch.fcgi", search_params)
516
+ search_payload = request_json(search_url, resolved, provider="NCBI Entrez")
517
+ if not isinstance(search_payload, Mapping) or not isinstance(
518
+ search_payload.get("esearchresult"), Mapping
519
+ ):
520
+ raise QueryError(
521
+ "NCBI ESearch returned an unexpected response.", code="QUERY_RESPONSE_ERROR"
522
+ )
523
+ search_result = search_payload["esearchresult"]
524
+ raw_ids = search_result.get("idlist")
525
+ ids = tuple(str(item) for item in raw_ids) if isinstance(raw_ids, list) else ()
526
+ if len(ids) > resolved.max_records:
527
+ raise QueryError("NCBI ESearch exceeded max_records.", code="QUERY_RECORD_LIMIT")
528
+ count_raw = search_result.get("count")
529
+ try:
530
+ total = int(count_raw)
531
+ except (TypeError, ValueError):
532
+ total = len(ids)
533
+ records: tuple[dict[str, object], ...] = ()
534
+ summary_url: str | None = None
535
+ if ids:
536
+ summary_url = build_url(
537
+ api_base_url,
538
+ "/esummary.fcgi",
539
+ [
540
+ ("db", db),
541
+ ("id", ",".join(ids)),
542
+ ("retmode", "json"),
543
+ ("version", "2.0"),
544
+ *_eutils_parameters(resolved),
545
+ ],
546
+ )
547
+ summary_payload = request_json(summary_url, resolved, provider="NCBI Entrez")
548
+ result = summary_payload.get("result") if isinstance(summary_payload, Mapping) else None
549
+ if not isinstance(result, Mapping):
550
+ raise QueryError(
551
+ "NCBI ESummary returned an unexpected response.", code="QUERY_RESPONSE_ERROR"
552
+ )
553
+ uid_order = result.get("uids")
554
+ ordered = tuple(str(item) for item in uid_order) if isinstance(uid_order, list) else ids
555
+ records = tuple(
556
+ dict(item) for uid in ordered if isinstance((item := result.get(uid)), Mapping)
557
+ )
558
+ records = limited_records(records, resolved)
559
+ kind = query_type or db
560
+ return QueryResult(
561
+ kind,
562
+ "NCBI Entrez",
563
+ redact_url(search_url),
564
+ records,
565
+ adapter_provenance(
566
+ f"NCBI Entrez {db}",
567
+ citation_url="https://www.ncbi.nlm.nih.gov/books/NBK25501/",
568
+ filters={"database": db, "term": query, "retstart": retstart, "retmax": size},
569
+ ),
570
+ total_count=max(total, len(records)),
571
+ metadata={
572
+ "database": db,
573
+ "uids": ids,
574
+ "summary_url": None if summary_url is None else redact_url(summary_url),
575
+ },
576
+ )
577
+
578
+
579
+ def accession(
580
+ identifier: str,
581
+ *,
582
+ database: Literal["nuccore", "protein", "assembly", "sra"] = "nuccore",
583
+ config: SearchConfig | None = None,
584
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
585
+ ) -> QueryResult:
586
+ """Check whether an accession exists and retrieve its current summary/version."""
587
+
588
+ value = require_text(identifier, "identifier", max_length=256)
589
+ return entrez(
590
+ database,
591
+ f'"{value}"[Accession]',
592
+ retmax=20,
593
+ config=config,
594
+ api_base_url=api_base_url,
595
+ query_type="accession",
596
+ )
597
+
598
+
599
+ def project(
600
+ term: str,
601
+ *,
602
+ retmax: int = 20,
603
+ config: SearchConfig | None = None,
604
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
605
+ ) -> QueryResult:
606
+ """Search BioProject metadata with the Entrez query language."""
607
+
608
+ return entrez(
609
+ "bioproject",
610
+ term,
611
+ retmax=retmax,
612
+ config=config,
613
+ api_base_url=api_base_url,
614
+ query_type="project",
615
+ )
616
+
617
+
618
+ def sample(
619
+ term: str,
620
+ *,
621
+ retmax: int = 20,
622
+ config: SearchConfig | None = None,
623
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
624
+ ) -> QueryResult:
625
+ """Search BioSample metadata with the Entrez query language."""
626
+
627
+ return entrez(
628
+ "biosample",
629
+ term,
630
+ retmax=retmax,
631
+ config=config,
632
+ api_base_url=api_base_url,
633
+ query_type="sample",
634
+ )
635
+
636
+
637
+ def clinical_variant(
638
+ term: str,
639
+ *,
640
+ retmax: int = 20,
641
+ config: SearchConfig | None = None,
642
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
643
+ ) -> QueryResult:
644
+ """Search ClinVar by rsID, HGVS, gene, condition, or Entrez expression."""
645
+
646
+ return entrez(
647
+ "clinvar",
648
+ term,
649
+ retmax=retmax,
650
+ config=config,
651
+ api_base_url=api_base_url,
652
+ query_type="clinical_variant",
653
+ )
654
+
655
+
656
+ def expression(
657
+ term: str,
658
+ *,
659
+ retmax: int = 20,
660
+ config: SearchConfig | None = None,
661
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
662
+ ) -> QueryResult:
663
+ """Search GEO DataSets/Series metadata through NCBI E-utilities."""
664
+
665
+ return entrez(
666
+ "gds",
667
+ term,
668
+ retmax=retmax,
669
+ config=config,
670
+ api_base_url=api_base_url,
671
+ query_type="expression",
672
+ )
673
+
674
+
675
+ def literature(
676
+ term: str,
677
+ *,
678
+ retmax: int = 20,
679
+ sort: str = "relevance",
680
+ config: SearchConfig | None = None,
681
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
682
+ ) -> QueryResult:
683
+ """Search linked biological literature in PubMed."""
684
+
685
+ resolved = resolved_config(config)
686
+ return entrez(
687
+ "pubmed",
688
+ term,
689
+ retmax=retmax,
690
+ sort=sort,
691
+ config=resolved,
692
+ api_base_url=api_base_url,
693
+ query_type="literature",
694
+ )
695
+
696
+
697
+ def database_version(
698
+ database: str,
699
+ *,
700
+ config: SearchConfig | None = None,
701
+ api_base_url: str = DEFAULT_NCBI_EUTILS_URL,
702
+ ) -> QueryResult:
703
+ """Return Entrez database record counts, indexed fields, and last-update metadata."""
704
+
705
+ resolved = resolved_config(config)
706
+ db = require_text(database, "database", max_length=64).casefold()
707
+ if db not in _ENTREZ_DATABASES:
708
+ raise ConfigurationError("Unsupported Entrez database.", code="INVALID_QUERY")
709
+ url = build_url(
710
+ api_base_url,
711
+ "/einfo.fcgi",
712
+ [("db", db), ("retmode", "json"), ("version", "2.0"), *_eutils_parameters(resolved)],
713
+ )
714
+ payload = request_json(url, resolved, provider="NCBI Entrez")
715
+ if not isinstance(payload, Mapping):
716
+ raise QueryError("NCBI EInfo returned an unexpected response.", code="QUERY_RESPONSE_ERROR")
717
+ root = payload.get("einforesult")
718
+ raw_info = root.get("dbinfo") if isinstance(root, Mapping) else None
719
+ if isinstance(raw_info, list):
720
+ records = mapping_records(raw_info)
721
+ elif isinstance(raw_info, Mapping):
722
+ records = (dict(raw_info),)
723
+ else:
724
+ records = ()
725
+ records = limited_records(records, resolved)
726
+ return QueryResult(
727
+ "database_version",
728
+ "NCBI Entrez",
729
+ redact_url(url),
730
+ records,
731
+ adapter_provenance(
732
+ f"NCBI Entrez {db}",
733
+ citation_url="https://www.ncbi.nlm.nih.gov/books/NBK25499/#chapter4.EInfo",
734
+ filters={"database": db},
735
+ ),
736
+ total_count=len(records),
737
+ )
738
+
739
+
740
+ __all__ = [
741
+ "DEFAULT_NCBI_DATASETS_URL",
742
+ "DEFAULT_NCBI_EUTILS_URL",
743
+ "accession",
744
+ "assembly",
745
+ "clinical_variant",
746
+ "database_version",
747
+ "entrez",
748
+ "expression",
749
+ "gene",
750
+ "literature",
751
+ "ncbi_orthologs",
752
+ "project",
753
+ "sample",
754
+ "taxonomy",
755
+ "virus",
756
+ ]