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/blast.py ADDED
@@ -0,0 +1,516 @@
1
+ """Asynchronous NCBI BLAST URL API adapter for sequence identification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import re
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal, cast
12
+ from urllib.parse import urlencode
13
+
14
+ from dnakit.core import DNASequence, Provenance
15
+ from dnakit.core._json import to_json_compatible
16
+ from dnakit.exceptions import ConfigurationError, QueryError
17
+
18
+ from ._http import (
19
+ build_url,
20
+ redact_url,
21
+ request_json,
22
+ request_text,
23
+ require_https_base,
24
+ require_text,
25
+ )
26
+ from ._shared import adapter_provenance, resolved_config
27
+ from .models import QueryResult, SearchConfig
28
+
29
+ DEFAULT_NCBI_BLAST_URL = "https://blast.ncbi.nlm.nih.gov/blast/Blast.cgi"
30
+ _RID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
31
+ _DNA = re.compile(r"^[ACGTRYSWKMBDHVN]+$", flags=re.IGNORECASE)
32
+ _PROGRAMS = frozenset({"blastn", "blastp", "blastx", "tblastn", "tblastx"})
33
+ _STATUS = re.compile(r"Status=(?P<status>[A-Z]+)")
34
+ _HITS = re.compile(r"ThereAreHits=(?P<hits>yes|no)", flags=re.IGNORECASE)
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class BlastJob:
39
+ """An NCBI BLAST request identifier that can be polled explicitly."""
40
+
41
+ rid: str
42
+ estimated_seconds: int
43
+ database: str
44
+ program: str
45
+ query_sha256: str
46
+ request_url: str
47
+ provenance: Provenance
48
+
49
+ def __post_init__(self) -> None:
50
+ if not isinstance(self.rid, str) or _RID.fullmatch(self.rid) is None:
51
+ raise ConfigurationError("BlastJob rid is invalid.", code="INVALID_BLAST_JOB")
52
+ if (
53
+ isinstance(self.estimated_seconds, bool)
54
+ or not isinstance(self.estimated_seconds, int)
55
+ or self.estimated_seconds < 0
56
+ ):
57
+ raise ConfigurationError(
58
+ "BlastJob estimated_seconds is invalid.", code="INVALID_BLAST_JOB"
59
+ )
60
+ if not re.fullmatch(r"[0-9a-f]{64}", self.query_sha256):
61
+ raise ConfigurationError("BlastJob query_sha256 is invalid.", code="INVALID_BLAST_JOB")
62
+ if not isinstance(self.provenance, Provenance):
63
+ raise ConfigurationError("BlastJob provenance is invalid.", code="INVALID_BLAST_JOB")
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return cast(dict[str, Any], to_json_compatible(self))
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class BlastStatus:
71
+ """Current state of one submitted BLAST request."""
72
+
73
+ rid: str
74
+ status: Literal["waiting", "ready", "failed", "unknown"]
75
+ has_hits: bool | None
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class BlastProgress:
80
+ """Polling progress emitted by ``wait_for_blast``."""
81
+
82
+ rid: str
83
+ status: str
84
+ elapsed_seconds: float
85
+
86
+
87
+ def _endpoint_origin(url: str) -> tuple[str, str]:
88
+ value = require_text(url, "api_url", max_length=2_048)
89
+ if "?" in value or "#" in value:
90
+ raise ConfigurationError(
91
+ "BLAST api_url cannot contain query or fragment.", code="INVALID_QUERY"
92
+ )
93
+ slash = value.rfind("/")
94
+ if slash <= len("https://"):
95
+ raise ConfigurationError(
96
+ "BLAST api_url must include an endpoint path.", code="INVALID_QUERY"
97
+ )
98
+ base = require_https_base(value[:slash])
99
+ path = value[slash:]
100
+ return base, path
101
+
102
+
103
+ def _sequence_text(sequence: str | DNASequence, program: str) -> str:
104
+ if isinstance(sequence, DNASequence):
105
+ if sequence.is_gapped:
106
+ raise ConfigurationError(
107
+ "BLAST query cannot contain structured gaps.", code="INVALID_QUERY"
108
+ )
109
+ value = sequence.symbols
110
+ elif isinstance(sequence, str):
111
+ value = "".join(sequence.split())
112
+ else:
113
+ raise TypeError("sequence must be str or DNASequence.")
114
+ if not value or len(value) > 1_000_000:
115
+ raise ConfigurationError(
116
+ "BLAST query length must be in [1, 1000000].", code="QUERY_REQUEST_SIZE_LIMIT"
117
+ )
118
+ if program in {"blastn", "blastx", "tblastx"} and _DNA.fullmatch(value) is None:
119
+ raise ConfigurationError(
120
+ "Nucleotide BLAST queries must contain IUPAC DNA symbols only.", code="INVALID_QUERY"
121
+ )
122
+ return value.upper()
123
+
124
+
125
+ def _contact_parameters(config: SearchConfig) -> list[tuple[str, str]]:
126
+ if config.email is None:
127
+ raise ConfigurationError(
128
+ "NCBI BLAST requires SearchConfig(email=...) for responsible API use.",
129
+ code="BLAST_CONTACT_REQUIRED",
130
+ )
131
+ return [("TOOL", config.tool), ("EMAIL", config.email)]
132
+
133
+
134
+ def submit_blast(
135
+ sequence: str | DNASequence,
136
+ *,
137
+ database: str = "core_nt",
138
+ program: str = "blastn",
139
+ hitlist_size: int = 50,
140
+ expect: float = 10.0,
141
+ megablast: bool = True,
142
+ config: SearchConfig | None = None,
143
+ api_url: str = DEFAULT_NCBI_BLAST_URL,
144
+ ) -> BlastJob:
145
+ """Submit one bounded BLAST query; no implicit polling or sleeping occurs."""
146
+
147
+ resolved = resolved_config(config)
148
+ program_value = require_text(program, "program", max_length=32).casefold()
149
+ if program_value not in _PROGRAMS:
150
+ raise ConfigurationError("Unsupported BLAST program.", code="INVALID_QUERY")
151
+ database_value = require_text(database, "database", max_length=256)
152
+ if not re.fullmatch(r"[A-Za-z0-9_./-]+", database_value):
153
+ raise ConfigurationError("BLAST database name is invalid.", code="INVALID_QUERY")
154
+ if (
155
+ isinstance(hitlist_size, bool)
156
+ or not isinstance(hitlist_size, int)
157
+ or not 1 <= hitlist_size <= min(5_000, resolved.max_records)
158
+ ):
159
+ raise ConfigurationError(
160
+ "hitlist_size exceeds the configured record limit.", code="QUERY_RECORD_LIMIT"
161
+ )
162
+ if (
163
+ isinstance(expect, bool)
164
+ or not isinstance(expect, (int, float))
165
+ or not math.isfinite(expect)
166
+ or expect <= 0
167
+ ):
168
+ raise ConfigurationError("expect must be a positive finite number.", code="INVALID_QUERY")
169
+ query = _sequence_text(sequence, program_value)
170
+ base, path = _endpoint_origin(api_url)
171
+ endpoint = build_url(base, path)
172
+ parameters: list[tuple[str, object]] = [
173
+ ("CMD", "Put"),
174
+ ("PROGRAM", program_value),
175
+ ("DATABASE", database_value),
176
+ ("QUERY", query),
177
+ ("HITLIST_SIZE", hitlist_size),
178
+ ("EXPECT", expect),
179
+ ("FORMAT_TYPE", "JSON2"),
180
+ *_contact_parameters(resolved),
181
+ ]
182
+ if program_value == "blastn" and megablast:
183
+ parameters.append(("MEGABLAST", "on"))
184
+ body = urlencode(parameters).encode("utf-8")
185
+ response = request_text(
186
+ endpoint,
187
+ resolved,
188
+ provider="NCBI BLAST",
189
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
190
+ method="POST",
191
+ data=body,
192
+ )
193
+ rid_match = re.search(r"^\s*RID\s*=\s*(\S+)\s*$", response, flags=re.MULTILINE)
194
+ rtoe_match = re.search(r"^\s*RTOE\s*=\s*(\d+)\s*$", response, flags=re.MULTILINE)
195
+ if rid_match is None or rtoe_match is None:
196
+ raise QueryError("NCBI BLAST did not return RID/RTOE.", code="BLAST_SUBMISSION_ERROR")
197
+ rid = rid_match.group(1)
198
+ return BlastJob(
199
+ rid=rid,
200
+ estimated_seconds=int(rtoe_match.group(1)),
201
+ database=database_value,
202
+ program=program_value,
203
+ query_sha256=hashlib.sha256(query.encode("ascii")).hexdigest(),
204
+ request_url=endpoint,
205
+ provenance=adapter_provenance(
206
+ "NCBI BLAST",
207
+ citation_url="https://blast.ncbi.nlm.nih.gov/doc/blast-help/urlapi.html",
208
+ filters={
209
+ "database": database_value,
210
+ "program": program_value,
211
+ "hitlist_size": hitlist_size,
212
+ "expect": float(expect),
213
+ "megablast": megablast,
214
+ "query_sha256": hashlib.sha256(query.encode("ascii")).hexdigest(),
215
+ },
216
+ ),
217
+ )
218
+
219
+
220
+ def blast_status(
221
+ job: BlastJob | str,
222
+ *,
223
+ config: SearchConfig | None = None,
224
+ api_url: str = DEFAULT_NCBI_BLAST_URL,
225
+ ) -> BlastStatus:
226
+ """Check one RID without automatic retrying."""
227
+
228
+ resolved = resolved_config(config)
229
+ rid = job.rid if isinstance(job, BlastJob) else require_text(job, "rid", max_length=64)
230
+ if _RID.fullmatch(rid) is None:
231
+ raise ConfigurationError("rid is invalid.", code="INVALID_BLAST_JOB")
232
+ base, path = _endpoint_origin(api_url)
233
+ url = build_url(
234
+ base,
235
+ path,
236
+ (
237
+ ("CMD", "Get"),
238
+ ("FORMAT_OBJECT", "SearchInfo"),
239
+ ("RID", rid),
240
+ *_contact_parameters(resolved),
241
+ ),
242
+ )
243
+ response = request_text(url, resolved, provider="NCBI BLAST")
244
+ status_match = _STATUS.search(response)
245
+ raw_status = status_match.group("status").casefold() if status_match is not None else "unknown"
246
+ status = raw_status if raw_status in {"waiting", "ready", "failed"} else "unknown"
247
+ hits_match = _HITS.search(response)
248
+ has_hits = None if hits_match is None else hits_match.group("hits").casefold() == "yes"
249
+ return BlastStatus(
250
+ rid, cast(Literal["waiting", "ready", "failed", "unknown"], status), has_hits
251
+ )
252
+
253
+
254
+ def _blast_hits(
255
+ payload: object, *, query_length_hint: int | None = None
256
+ ) -> tuple[dict[str, object], ...]:
257
+ if not isinstance(payload, Mapping):
258
+ return ()
259
+ outputs = payload.get("BlastOutput2")
260
+ if not isinstance(outputs, list):
261
+ return ()
262
+ records: list[dict[str, object]] = []
263
+ for output in outputs:
264
+ if not isinstance(output, Mapping):
265
+ continue
266
+ report = output.get("report")
267
+ results = report.get("results") if isinstance(report, Mapping) else None
268
+ search = results.get("search") if isinstance(results, Mapping) else None
269
+ if not isinstance(search, Mapping):
270
+ continue
271
+ query_length = search.get("query_len")
272
+ if not isinstance(query_length, int) or query_length < 1:
273
+ query_length = query_length_hint
274
+ hits = search.get("hits")
275
+ if not isinstance(hits, list):
276
+ continue
277
+ for hit in hits:
278
+ if not isinstance(hit, Mapping):
279
+ continue
280
+ descriptions = hit.get("description")
281
+ description = (
282
+ descriptions[0]
283
+ if isinstance(descriptions, list)
284
+ and descriptions
285
+ and isinstance(descriptions[0], Mapping)
286
+ else {}
287
+ )
288
+ hsps = hit.get("hsps")
289
+ valid_hsps = (
290
+ tuple(item for item in hsps if isinstance(item, Mapping))
291
+ if isinstance(hsps, list)
292
+ else ()
293
+ )
294
+ best = max(
295
+ valid_hsps,
296
+ key=lambda item: (
297
+ float(item.get("bit_score", 0.0))
298
+ if isinstance(item.get("bit_score"), (int, float))
299
+ else 0.0
300
+ ),
301
+ default={},
302
+ )
303
+ align_len = best.get("align_len") if isinstance(best, Mapping) else None
304
+ identities = best.get("identity") if isinstance(best, Mapping) else None
305
+ identity = (
306
+ float(identities) / align_len
307
+ if isinstance(identities, int) and isinstance(align_len, int) and align_len > 0
308
+ else None
309
+ )
310
+ coverage = (
311
+ float(align_len) / query_length
312
+ if isinstance(align_len, int) and isinstance(query_length, int) and query_length > 0
313
+ else None
314
+ )
315
+ record: dict[str, object] = {
316
+ "accession": description.get("accession")
317
+ if isinstance(description, Mapping)
318
+ else None,
319
+ "title": description.get("title") if isinstance(description, Mapping) else None,
320
+ "tax_id": description.get("taxid") if isinstance(description, Mapping) else None,
321
+ "scientific_name": description.get("sciname")
322
+ if isinstance(description, Mapping)
323
+ else None,
324
+ "identity": identity,
325
+ "query_coverage": coverage,
326
+ "evalue": best.get("evalue") if isinstance(best, Mapping) else None,
327
+ "bit_score": best.get("bit_score") if isinstance(best, Mapping) else None,
328
+ "alignment_length": align_len,
329
+ "query_length": query_length,
330
+ "hsp_count": len(valid_hsps),
331
+ }
332
+ records.append(record)
333
+ return tuple(records)
334
+
335
+
336
+ def blast_results(
337
+ job: BlastJob | str,
338
+ *,
339
+ config: SearchConfig | None = None,
340
+ api_url: str = DEFAULT_NCBI_BLAST_URL,
341
+ ) -> QueryResult:
342
+ """Retrieve ready BLAST JSON2 results and normalize the best HSP per hit."""
343
+
344
+ resolved = resolved_config(config)
345
+ rid = job.rid if isinstance(job, BlastJob) else require_text(job, "rid", max_length=64)
346
+ if _RID.fullmatch(rid) is None:
347
+ raise ConfigurationError("rid is invalid.", code="INVALID_BLAST_JOB")
348
+ base, path = _endpoint_origin(api_url)
349
+ url = build_url(
350
+ base,
351
+ path,
352
+ (("CMD", "Get"), ("RID", rid), ("FORMAT_TYPE", "JSON2"), *_contact_parameters(resolved)),
353
+ )
354
+ payload = request_json(url, resolved, provider="NCBI BLAST")
355
+ hits = _blast_hits(payload)
356
+ if len(hits) > resolved.max_records:
357
+ raise QueryError("BLAST results exceeded max_records.", code="QUERY_RECORD_LIMIT")
358
+ provenance = (
359
+ job.provenance
360
+ if isinstance(job, BlastJob)
361
+ else adapter_provenance(
362
+ "NCBI BLAST",
363
+ citation_url="https://blast.ncbi.nlm.nih.gov/doc/blast-help/urlapi.html",
364
+ filters={"rid": rid},
365
+ )
366
+ )
367
+ identity_values = tuple(
368
+ float(value) for hit in hits if isinstance((value := hit.get("identity")), float)
369
+ )
370
+ max_identity = max(
371
+ identity_values,
372
+ default=None,
373
+ )
374
+ coverage_values = tuple(
375
+ float(value) for hit in hits if isinstance((value := hit.get("query_coverage")), float)
376
+ )
377
+ max_coverage = max(
378
+ coverage_values,
379
+ default=None,
380
+ )
381
+ return QueryResult(
382
+ "sequence_similarity",
383
+ "NCBI BLAST",
384
+ redact_url(url),
385
+ hits,
386
+ provenance,
387
+ total_count=len(hits),
388
+ metadata={
389
+ "rid": rid,
390
+ "max_identity": max_identity,
391
+ "max_query_coverage": max_coverage,
392
+ "novelty_score": None if max_identity is None else 1.0 - max_identity,
393
+ "novelty_definition": "1 - maximum best-HSP identity; inspect coverage separately",
394
+ },
395
+ )
396
+
397
+
398
+ def wait_for_blast(
399
+ job: BlastJob,
400
+ *,
401
+ poll_interval: float = 60.0,
402
+ timeout: float = 1_800.0,
403
+ progress: Callable[[BlastProgress], None] | None = None,
404
+ config: SearchConfig | None = None,
405
+ api_url: str = DEFAULT_NCBI_BLAST_URL,
406
+ ) -> QueryResult:
407
+ """Poll at NCBI-compliant intervals until a submitted BLAST job is ready."""
408
+
409
+ if not isinstance(job, BlastJob):
410
+ raise TypeError("job must be BlastJob.")
411
+ if (
412
+ isinstance(poll_interval, bool)
413
+ or not isinstance(poll_interval, (int, float))
414
+ or not math.isfinite(poll_interval)
415
+ or poll_interval < 60
416
+ ):
417
+ raise ConfigurationError(
418
+ "poll_interval must be at least 60 seconds for NCBI BLAST.", code="INVALID_QUERY"
419
+ )
420
+ if (
421
+ isinstance(timeout, bool)
422
+ or not isinstance(timeout, (int, float))
423
+ or not math.isfinite(timeout)
424
+ or not 60 <= timeout <= 86_400
425
+ ):
426
+ raise ConfigurationError("timeout must be in [60, 86400].", code="INVALID_QUERY")
427
+ if progress is not None and not callable(progress):
428
+ raise ConfigurationError("progress must be callable or None.", code="INVALID_QUERY")
429
+ resolved = resolved_config(config)
430
+ started = time.monotonic()
431
+ while True:
432
+ elapsed = time.monotonic() - started
433
+ if elapsed + poll_interval > timeout:
434
+ raise QueryError(
435
+ "NCBI BLAST job did not finish before timeout.",
436
+ code="BLAST_TIMEOUT",
437
+ context={"rid": job.rid, "timeout": float(timeout)},
438
+ )
439
+ time.sleep(float(poll_interval))
440
+ status = blast_status(job, config=resolved, api_url=api_url)
441
+ elapsed = time.monotonic() - started
442
+ if progress is not None:
443
+ progress(BlastProgress(job.rid, status.status, elapsed))
444
+ if status.status == "ready":
445
+ if status.has_hits is False:
446
+ return QueryResult(
447
+ "sequence_similarity",
448
+ "NCBI BLAST",
449
+ job.request_url,
450
+ (),
451
+ job.provenance,
452
+ total_count=0,
453
+ metadata={"rid": job.rid, "max_identity": None, "novelty_score": 1.0},
454
+ )
455
+ return blast_results(job, config=resolved, api_url=api_url)
456
+ if status.status in {"failed", "unknown"}:
457
+ raise QueryError(
458
+ "NCBI BLAST job failed or expired.",
459
+ code="BLAST_JOB_FAILED",
460
+ context={"rid": job.rid, "status": status.status},
461
+ )
462
+
463
+
464
+ def identify(
465
+ sequence: str | DNASequence,
466
+ *,
467
+ wait: bool = False,
468
+ config: SearchConfig | None = None,
469
+ **kwargs: object,
470
+ ) -> BlastJob | QueryResult:
471
+ """Submit an NCBI nucleotide search for likely sequence source identification.
472
+
473
+ The default is asynchronous and returns ``BlastJob``. Set ``wait=True``
474
+ to poll no faster than once per minute and return normalized hits.
475
+ """
476
+
477
+ submit_keys = {"database", "program", "hitlist_size", "expect", "megablast", "api_url"}
478
+ unknown = set(kwargs) - submit_keys - {"poll_interval", "timeout", "progress"}
479
+ if unknown:
480
+ raise TypeError(f"Unexpected identify arguments: {sorted(unknown)}")
481
+ submit_arguments = {key: value for key, value in kwargs.items() if key in submit_keys}
482
+ job = submit_blast(sequence, config=config, **submit_arguments) # type: ignore[arg-type]
483
+ if not wait:
484
+ return job
485
+ wait_arguments = {
486
+ key: value
487
+ for key, value in kwargs.items()
488
+ if key in {"poll_interval", "timeout", "progress", "api_url"}
489
+ }
490
+ return wait_for_blast(job, config=config, **wait_arguments) # type: ignore[arg-type]
491
+
492
+
493
+ def novelty(
494
+ sequence: str | DNASequence,
495
+ *,
496
+ wait: bool = False,
497
+ config: SearchConfig | None = None,
498
+ **kwargs: object,
499
+ ) -> BlastJob | QueryResult:
500
+ """Submit or run the same BLAST search used for remote novelty inspection."""
501
+
502
+ return identify(sequence, wait=wait, config=config, **kwargs)
503
+
504
+
505
+ __all__ = [
506
+ "DEFAULT_NCBI_BLAST_URL",
507
+ "BlastJob",
508
+ "BlastProgress",
509
+ "BlastStatus",
510
+ "blast_results",
511
+ "blast_status",
512
+ "identify",
513
+ "novelty",
514
+ "submit_blast",
515
+ "wait_for_blast",
516
+ ]