dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/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
|
+
]
|