gapit 0.2.2__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.
- gapit/__init__.py +3 -0
- gapit/blast.py +239 -0
- gapit/cli.py +128 -0
- gapit/cmd_db.py +113 -0
- gapit/cmd_db_build.py +72 -0
- gapit/cmd_db_install.py +126 -0
- gapit/cmd_db_outdated.py +51 -0
- gapit/cmd_db_search.py +66 -0
- gapit/cmd_screen.py +214 -0
- gapit/cmd_summary.py +65 -0
- gapit/config.py +51 -0
- gapit/data/snapshots/card.tar.gz +0 -0
- gapit/data/snapshots/vfdb.tar.gz +0 -0
- gapit/db.py +226 -0
- gapit/db_build_ops.py +210 -0
- gapit/db_ops.py +128 -0
- gapit/db_query_ops.py +252 -0
- gapit/dbbuild.py +207 -0
- gapit/dbcodec.py +117 -0
- gapit/dispatch.py +31 -0
- gapit/errors.py +87 -0
- gapit/fasta.py +123 -0
- gapit/formats/__init__.py +1 -0
- gapit/formats/json.py +309 -0
- gapit/formats/md.py +190 -0
- gapit/formats/schemas.py +30 -0
- gapit/formats/summary.py +103 -0
- gapit/formats/tsv.py +45 -0
- gapit/hits.py +107 -0
- gapit/mcp.py +158 -0
- gapit/mcp_schemas.py +123 -0
- gapit/mcp_tools.py +289 -0
- gapit/minimap.py +20 -0
- gapit/minimap2_run.py +114 -0
- gapit/paf.py +115 -0
- gapit/proctools.py +24 -0
- gapit/providers/__init__.py +39 -0
- gapit/providers/argannot.py +94 -0
- gapit/providers/bacmet2.py +59 -0
- gapit/providers/card.py +150 -0
- gapit/providers/common.py +245 -0
- gapit/providers/ecoh.py +63 -0
- gapit/providers/ecoli_vf.py +74 -0
- gapit/providers/megares.py +71 -0
- gapit/providers/ncbi.py +103 -0
- gapit/providers/plasmidfinder.py +69 -0
- gapit/providers/resfinder.py +123 -0
- gapit/providers/snapshots.py +119 -0
- gapit/providers/upec_expec_vf.py +85 -0
- gapit/providers/vfdb.py +92 -0
- gapit/providers/victors.py +109 -0
- gapit/py.typed +0 -0
- gapit/reads.py +221 -0
- gapit/records.py +152 -0
- gapit/report.py +25 -0
- gapit/screening.py +145 -0
- gapit/screening_reads.py +255 -0
- gapit/seqconvert.py +203 -0
- gapit/summary.py +151 -0
- gapit-0.2.2.dist-info/METADATA +183 -0
- gapit-0.2.2.dist-info/RECORD +64 -0
- gapit-0.2.2.dist-info/WHEEL +4 -0
- gapit-0.2.2.dist-info/entry_points.txt +3 -0
- gapit-0.2.2.dist-info/licenses/LICENSE +21 -0
gapit/reads.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""FASTQ/FASTA read screening via minimap2 (SPEC.md §10 — gapit extension)."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import gzip
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from gapit.db import Database
|
|
12
|
+
from gapit.dbcodec import decode_seqid
|
|
13
|
+
from gapit.errors import InputError
|
|
14
|
+
from gapit.fasta import iter_fasta_headers
|
|
15
|
+
from gapit.minimap2_run import run_minimap2
|
|
16
|
+
from gapit.paf import (
|
|
17
|
+
PafRecord,
|
|
18
|
+
ReadType,
|
|
19
|
+
alignment_identity,
|
|
20
|
+
filter_alignments,
|
|
21
|
+
union_length,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_GZIP_MAGIC = b"\x1f\x8b"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ByteStream(Protocol):
|
|
28
|
+
"""The byte-read surface shared by raw and gzip-decompressed handles."""
|
|
29
|
+
|
|
30
|
+
def read(self, size: int = -1, /) -> bytes: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ReadTypeEnum(enum.Enum):
|
|
34
|
+
"""CLI-facing read-type preset choices."""
|
|
35
|
+
|
|
36
|
+
sr = "sr"
|
|
37
|
+
map_ont = "map-ont"
|
|
38
|
+
map_hifi = "map-hifi"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReadFileKind(enum.Enum):
|
|
42
|
+
"""Input file kind detected from content (minimap2 takes FASTA and FASTQ
|
|
43
|
+
queries natively; gapit detects to resolve the preset)."""
|
|
44
|
+
|
|
45
|
+
fasta = "fasta"
|
|
46
|
+
fastq = "fastq"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _peek_read_kind(handle: _ByteStream, path: Path) -> ReadFileKind:
|
|
50
|
+
"""First non-whitespace byte: '>' = FASTA, '@' = FASTQ; anything else (or
|
|
51
|
+
EOF) is a typed input error."""
|
|
52
|
+
while True:
|
|
53
|
+
byte = handle.read(1)
|
|
54
|
+
if not byte:
|
|
55
|
+
raise InputError(
|
|
56
|
+
f"reads file is empty: {path}",
|
|
57
|
+
code="INVALID_READS_FORMAT",
|
|
58
|
+
context={"file": str(path)},
|
|
59
|
+
)
|
|
60
|
+
if byte.isspace():
|
|
61
|
+
continue
|
|
62
|
+
if byte == b">":
|
|
63
|
+
return ReadFileKind.fasta
|
|
64
|
+
if byte == b"@":
|
|
65
|
+
return ReadFileKind.fastq
|
|
66
|
+
raise InputError(
|
|
67
|
+
f"reads file is neither FASTA nor FASTQ: {path}",
|
|
68
|
+
code="INVALID_READS_FORMAT",
|
|
69
|
+
context={"file": str(path)},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect_read_kind(path: Path) -> ReadFileKind:
|
|
74
|
+
"""Detect a --r1/--r2 file's kind from content. Gzip-wrapped files
|
|
75
|
+
(magic 1f 8b) are peeked through the decompressor: minimap2 reads them
|
|
76
|
+
natively, so detection must not reject them. A truncated or corrupt
|
|
77
|
+
gzip stream raises the same typed INVALID_READS_FORMAT error as any
|
|
78
|
+
other unreadable reads file, never a raw EOFError."""
|
|
79
|
+
with path.open("rb") as raw:
|
|
80
|
+
compressed = raw.read(2) == _GZIP_MAGIC
|
|
81
|
+
if compressed:
|
|
82
|
+
with gzip.open(path, "rb") as handle:
|
|
83
|
+
try:
|
|
84
|
+
return _peek_read_kind(handle, path)
|
|
85
|
+
except (OSError, EOFError, UnicodeDecodeError) as exc:
|
|
86
|
+
raise InputError(
|
|
87
|
+
f"cannot decompress reads file: {path}",
|
|
88
|
+
code="INVALID_READS_FORMAT",
|
|
89
|
+
context={"file": str(path)},
|
|
90
|
+
) from exc
|
|
91
|
+
with path.open("rb") as handle:
|
|
92
|
+
return _peek_read_kind(handle, path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ReadsParams(BaseModel, frozen=True):
|
|
96
|
+
"""Parameters for one read-screening run (SPEC.md §10). The reads/2
|
|
97
|
+
thresholds default off; gapit.reads/1 rendering ignores them entirely."""
|
|
98
|
+
|
|
99
|
+
db: str
|
|
100
|
+
read_type: ReadType = "sr"
|
|
101
|
+
min_breadth: float = Field(default=90.0, ge=0.0, le=100.0)
|
|
102
|
+
threads: int = Field(default=1, ge=1)
|
|
103
|
+
min_identity: float = Field(default=0.0, ge=0.0, le=100.0)
|
|
104
|
+
min_mapq: int = Field(default=0, ge=0)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GeneCoverage(BaseModel, frozen=True):
|
|
108
|
+
"""Per-gene presence call over primary alignments. mean_identity_pct is
|
|
109
|
+
the alen-weighted mean of per-alignment identity over the aggregated
|
|
110
|
+
(kept) rows — the weighting favors long alignments; computed in reads/1
|
|
111
|
+
mode too but rendered only by gapit.reads/2."""
|
|
112
|
+
|
|
113
|
+
database: str
|
|
114
|
+
gene: str
|
|
115
|
+
accession: str
|
|
116
|
+
function: str
|
|
117
|
+
product: str
|
|
118
|
+
tlen: int
|
|
119
|
+
breadth_pct: float
|
|
120
|
+
mean_depth: float
|
|
121
|
+
reads_mapped: int
|
|
122
|
+
present: bool
|
|
123
|
+
mean_identity_pct: float
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ReadsReport(BaseModel, frozen=True):
|
|
127
|
+
"""Result of screening one set of read files."""
|
|
128
|
+
|
|
129
|
+
reads: tuple[str, ...]
|
|
130
|
+
genes: tuple[GeneCoverage, ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def aggregate_coverage(
|
|
134
|
+
rows: Iterable[PafRecord],
|
|
135
|
+
*,
|
|
136
|
+
default_db: str,
|
|
137
|
+
min_breadth: float,
|
|
138
|
+
products: Mapping[str, str] | None = None,
|
|
139
|
+
) -> list[GeneCoverage]:
|
|
140
|
+
"""Aggregate primary alignments per target: breadth = union of
|
|
141
|
+
tstart..tend intervals, depth = summed interval lengths, reads = distinct
|
|
142
|
+
qnames. tlen is the FIRST-SEEN row's tlen per target (denominator
|
|
143
|
+
contract). Zero-read genes are omitted; output sorts by (-breadth, gene)."""
|
|
144
|
+
intervals: dict[str, list[tuple[int, int]]] = {}
|
|
145
|
+
tlens: dict[str, int] = {}
|
|
146
|
+
qnames: dict[str, set[str]] = {}
|
|
147
|
+
weights: dict[str, list[tuple[float, int]]] = {}
|
|
148
|
+
for row in rows:
|
|
149
|
+
if not row.is_primary:
|
|
150
|
+
continue
|
|
151
|
+
if row.tname not in intervals:
|
|
152
|
+
intervals[row.tname] = []
|
|
153
|
+
tlens[row.tname] = row.tlen
|
|
154
|
+
qnames[row.tname] = set()
|
|
155
|
+
weights[row.tname] = []
|
|
156
|
+
intervals[row.tname].append((row.tstart, row.tend))
|
|
157
|
+
qnames[row.tname].add(row.qname)
|
|
158
|
+
weights[row.tname].append((alignment_identity(row), row.alen))
|
|
159
|
+
descriptions = products or {}
|
|
160
|
+
coverages: list[GeneCoverage] = []
|
|
161
|
+
for tname, spans in intervals.items():
|
|
162
|
+
tlen = tlens[tname]
|
|
163
|
+
covered = union_length(spans)
|
|
164
|
+
breadth_pct = 100.0 * covered / tlen
|
|
165
|
+
header = decode_seqid(tname, default_db)
|
|
166
|
+
pairs = weights[tname]
|
|
167
|
+
weight_sum = sum(weight for _, weight in pairs)
|
|
168
|
+
coverages.append(
|
|
169
|
+
GeneCoverage(
|
|
170
|
+
database=header.database,
|
|
171
|
+
gene=header.gene,
|
|
172
|
+
accession=header.accession,
|
|
173
|
+
function=header.function,
|
|
174
|
+
product=descriptions.get(tname, ""),
|
|
175
|
+
tlen=tlen,
|
|
176
|
+
breadth_pct=breadth_pct,
|
|
177
|
+
mean_depth=sum(end - start for start, end in spans) / tlen,
|
|
178
|
+
reads_mapped=len(qnames[tname]),
|
|
179
|
+
present=breadth_pct >= min_breadth,
|
|
180
|
+
mean_identity_pct=(
|
|
181
|
+
sum(identity * weight for identity, weight in pairs) / weight_sum
|
|
182
|
+
if weight_sum
|
|
183
|
+
else 0.0
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return sorted(coverages, key=lambda entry: (-entry.breadth_pct, entry.gene))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def screen_reads(
|
|
191
|
+
lanes: list[tuple[Path, Path | None]],
|
|
192
|
+
database: Database,
|
|
193
|
+
*,
|
|
194
|
+
read_type: ReadType,
|
|
195
|
+
min_breadth: float,
|
|
196
|
+
threads: int,
|
|
197
|
+
debug: bool = False,
|
|
198
|
+
min_identity: float = 0.0,
|
|
199
|
+
min_mapq: int = 0,
|
|
200
|
+
) -> ReadsReport:
|
|
201
|
+
"""Screen one sample's lanes against one database into a sample-level
|
|
202
|
+
ReadsReport (union of all lanes' primary alignments). With a nonzero
|
|
203
|
+
min_identity/min_mapq (gapit.reads/2), alignments are filtered BEFORE
|
|
204
|
+
aggregation and the minimap2 run emits NM tags for the identity rule."""
|
|
205
|
+
reads2 = min_identity > 0.0 or min_mapq > 0
|
|
206
|
+
rows = run_minimap2(
|
|
207
|
+
lanes,
|
|
208
|
+
database,
|
|
209
|
+
read_type=read_type,
|
|
210
|
+
threads=threads,
|
|
211
|
+
debug=debug,
|
|
212
|
+
nm_tags=reads2,
|
|
213
|
+
)
|
|
214
|
+
if reads2:
|
|
215
|
+
rows = filter_alignments(rows, min_identity=min_identity, min_mapq=min_mapq)
|
|
216
|
+
products = dict(iter_fasta_headers(database.sequences_path))
|
|
217
|
+
genes = aggregate_coverage(
|
|
218
|
+
rows, default_db=database.name, min_breadth=min_breadth, products=products
|
|
219
|
+
)
|
|
220
|
+
files = [str(r1) for r1, _ in lanes] + [str(r2) for _, r2 in lanes if r2 is not None]
|
|
221
|
+
return ReadsReport(reads=tuple(files), genes=tuple(genes))
|
gapit/records.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The per-database truth source: Record JSONL store and gapit.manifest/1.
|
|
2
|
+
|
|
3
|
+
``records.jsonl`` is the editable truth a database directory is built from
|
|
4
|
+
(the ``sequences`` FASTA and its BLAST index are generated projections, built
|
|
5
|
+
later in Wave A3); ``gapit-manifest.json`` is the provenance sidecar describing
|
|
6
|
+
how that truth was obtained. Both are FILE contracts — they never appear on
|
|
7
|
+
stdout and are not registered in ``gapit schema``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable, Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
16
|
+
|
|
17
|
+
from gapit.errors import InputError
|
|
18
|
+
from gapit.formats.json import ToolDocument
|
|
19
|
+
|
|
20
|
+
_SHA256_SHAPE = re.compile(r"[0-9a-fA-F]{64}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Record(BaseModel, frozen=True):
|
|
24
|
+
"""One known gene — the unit of truth a database projection is built from.
|
|
25
|
+
|
|
26
|
+
``function`` carries functional categories (AMR classes, virulence,
|
|
27
|
+
O-antigen, replicon, biocide) — gapit answers presence/absence, so the
|
|
28
|
+
slot's old ``resistance`` name was a misnomer (Wave F1).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
db: str
|
|
32
|
+
gene: str
|
|
33
|
+
sequence: str
|
|
34
|
+
accession: str = ""
|
|
35
|
+
function: tuple[str, ...] = ()
|
|
36
|
+
product: str = "n/a"
|
|
37
|
+
source_id: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_records(records: Iterable[Record], path: Path) -> None:
|
|
41
|
+
"""Write records as deterministic JSONL: given order, one JSON object per
|
|
42
|
+
line, LF endings, UTF-8."""
|
|
43
|
+
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
44
|
+
for record in records:
|
|
45
|
+
handle.write(record.model_dump_json())
|
|
46
|
+
handle.write("\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_records(path: Path) -> Iterator[Record]:
|
|
50
|
+
"""Stream records back line by line (nothing is materialized).
|
|
51
|
+
|
|
52
|
+
Blank lines are skipped; a non-parsing line raises InputError
|
|
53
|
+
RECORDS_MALFORMED with the physical line number in context. The path is
|
|
54
|
+
checked eagerly; iteration itself is lazy.
|
|
55
|
+
"""
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
raise InputError(
|
|
58
|
+
f"records file not found or unreadable: {path}",
|
|
59
|
+
code="INPUT_NOT_FOUND",
|
|
60
|
+
context={"file": str(path)},
|
|
61
|
+
)
|
|
62
|
+
return _iter_records(path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _iter_records(path: Path) -> Iterator[Record]:
|
|
66
|
+
with path.open(encoding="utf-8") as handle:
|
|
67
|
+
for line_number, line in enumerate(handle, start=1):
|
|
68
|
+
if not line.strip():
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
yield Record.model_validate_json(line)
|
|
72
|
+
except ValidationError as exc:
|
|
73
|
+
raise InputError(
|
|
74
|
+
f"malformed record at {path}:{line_number}: {_reason(exc)}",
|
|
75
|
+
code="RECORDS_MALFORMED",
|
|
76
|
+
context={"file": str(path), "line": str(line_number)},
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def count_records(path: Path) -> int:
|
|
81
|
+
"""Count record lines by streaming (blank lines excluded, no list built)."""
|
|
82
|
+
return sum(1 for _ in read_records(path))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Manifest(BaseModel, frozen=True):
|
|
86
|
+
"""gapit.manifest/1 — provenance sidecar for one database directory."""
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
89
|
+
|
|
90
|
+
schema_name: Literal["gapit.manifest/1"] = Field(default="gapit.manifest/1", alias="schema")
|
|
91
|
+
name: str
|
|
92
|
+
source_urls: tuple[str, ...]
|
|
93
|
+
fetched_at: str
|
|
94
|
+
sha256: str
|
|
95
|
+
n_records: int = Field(ge=0)
|
|
96
|
+
dbtype: Literal["nucl", "prot"]
|
|
97
|
+
header_format: Literal["gapit/v1"] = "gapit/v1"
|
|
98
|
+
upstream_version: str = ""
|
|
99
|
+
tool: ToolDocument = ToolDocument()
|
|
100
|
+
makeblastdb_version: str = ""
|
|
101
|
+
minimap2_version: str = ""
|
|
102
|
+
|
|
103
|
+
@field_validator("sha256")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _sha256_hex(cls, value: str) -> str:
|
|
106
|
+
"""Exactly 64 hex chars; case-insensitive on input, stored lowercase."""
|
|
107
|
+
if not _SHA256_SHAPE.fullmatch(value):
|
|
108
|
+
raise ValueError("sha256 must be exactly 64 hex characters")
|
|
109
|
+
return value.lower()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _reason(exc: ValidationError) -> str:
|
|
113
|
+
"""The validation failure rendered as one line (str(exc) is multiline)."""
|
|
114
|
+
return " ".join(str(exc).split())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_manifest(manifest: Manifest, path: Path) -> None:
|
|
118
|
+
"""Write the manifest as indented JSON (by alias, ``schema`` first) with a
|
|
119
|
+
trailing newline, LF endings, UTF-8."""
|
|
120
|
+
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
121
|
+
handle.write(manifest.model_dump_json(indent=2, by_alias=True))
|
|
122
|
+
handle.write("\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_manifest(path: Path) -> Manifest:
|
|
126
|
+
"""Parse and validate a gapit.manifest/1 file."""
|
|
127
|
+
if not path.is_file():
|
|
128
|
+
raise InputError(
|
|
129
|
+
f"manifest file not found or unreadable: {path}",
|
|
130
|
+
code="INPUT_NOT_FOUND",
|
|
131
|
+
context={"file": str(path)},
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
return Manifest.model_validate_json(path.read_text(encoding="utf-8"))
|
|
135
|
+
except ValidationError as exc:
|
|
136
|
+
raise InputError(
|
|
137
|
+
f"malformed manifest {path}: {_reason(exc)}",
|
|
138
|
+
code="MANIFEST_MALFORMED",
|
|
139
|
+
context={"file": str(path)},
|
|
140
|
+
) from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def installed_db_dirs(root: Path) -> list[Path]:
|
|
144
|
+
"""Datadir subdirectories holding a gapit-manifest.json, sorted by name."""
|
|
145
|
+
return sorted(
|
|
146
|
+
(
|
|
147
|
+
child
|
|
148
|
+
for child in root.iterdir()
|
|
149
|
+
if child.is_dir() and (child / "gapit-manifest.json").is_file()
|
|
150
|
+
),
|
|
151
|
+
key=lambda child: child.name,
|
|
152
|
+
)
|
gapit/report.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Screening parameters and the canonical Report model."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from gapit.hits import Hit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ScreeningParams(BaseModel, frozen=True):
|
|
9
|
+
"""Parameters for one screening run (SPEC.md §1 defaults and bounds)."""
|
|
10
|
+
|
|
11
|
+
db: str
|
|
12
|
+
minid: float = Field(default=80.0, gt=0.0, le=100.0)
|
|
13
|
+
mincov: float = Field(default=80.0, ge=0.0, le=100.0)
|
|
14
|
+
threads: int = Field(default=1, ge=1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Report(BaseModel, frozen=True):
|
|
18
|
+
"""Canonical in-memory result of screening one input file.
|
|
19
|
+
|
|
20
|
+
Hits are sorted (sequence, start) before construction; the tuple is the
|
|
21
|
+
final, stable order.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
file: str
|
|
25
|
+
hits: tuple[Hit, ...]
|
gapit/screening.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The blastn contig-screening use-case plus helpers shared by both engines.
|
|
2
|
+
|
|
3
|
+
The minimap2 engine use-cases (--r1/--r2 reads, --aligner minimap2 assemblies)
|
|
4
|
+
live in screening_reads.py; this module keeps the abricate-parity contig
|
|
5
|
+
pipeline and the shared OutputFormat / AlignerEnum / database lookup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
import os
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from gapit import config, db
|
|
17
|
+
from gapit.blast import ensure_blast, screen_file
|
|
18
|
+
from gapit.errors import DatabaseError, InputError, ensure_input_file, usage_fail
|
|
19
|
+
from gapit.formats.json import render_json
|
|
20
|
+
from gapit.formats.md import render_markdown
|
|
21
|
+
from gapit.formats.tsv import format_tsv
|
|
22
|
+
from gapit.report import Report, ScreeningParams
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OutputFormat(enum.Enum):
|
|
26
|
+
"""Screen output formats."""
|
|
27
|
+
|
|
28
|
+
tsv = "tsv"
|
|
29
|
+
csv = "csv"
|
|
30
|
+
json = "json"
|
|
31
|
+
md = "md"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AlignerEnum(enum.Enum):
|
|
35
|
+
"""Alignment engines for screen (SPEC.md §1/§10)."""
|
|
36
|
+
|
|
37
|
+
blastn = "blastn"
|
|
38
|
+
minimap2 = "minimap2"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_inputs(files: list[Path] | None, fofn: Path | None) -> list[Path]:
|
|
42
|
+
"""Input files: --fofn (lines stripped, empties dropped) REPLACES positionals."""
|
|
43
|
+
if fofn is not None:
|
|
44
|
+
if not fofn.is_file():
|
|
45
|
+
raise InputError(
|
|
46
|
+
f"--fofn file not found: {fofn}",
|
|
47
|
+
code="INPUT_NOT_FOUND",
|
|
48
|
+
context={"file": str(fofn)},
|
|
49
|
+
)
|
|
50
|
+
inputs = [
|
|
51
|
+
Path(line.strip())
|
|
52
|
+
for line in fofn.read_text(encoding="utf-8").splitlines()
|
|
53
|
+
if line.strip()
|
|
54
|
+
]
|
|
55
|
+
elif files:
|
|
56
|
+
inputs = list(files)
|
|
57
|
+
else:
|
|
58
|
+
usage_fail("no input files given (positional FILEs or --fofn)")
|
|
59
|
+
for path in inputs:
|
|
60
|
+
ensure_input_file(path)
|
|
61
|
+
return inputs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_database(datadir: Path, name: str) -> db.Database:
|
|
65
|
+
"""Look up a database by name under the datadir; unknown names list what exists."""
|
|
66
|
+
databases = db.discover_databases(datadir)
|
|
67
|
+
for database in databases:
|
|
68
|
+
if database.name == name:
|
|
69
|
+
return database
|
|
70
|
+
available = ", ".join(entry.name for entry in databases) or "(none)"
|
|
71
|
+
raise DatabaseError(
|
|
72
|
+
f"Database {name} is not in {datadir}. Available: {available}",
|
|
73
|
+
code="DATABASE_NOT_FOUND",
|
|
74
|
+
context={"db": name, "datadir": str(datadir)},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_screen(
|
|
79
|
+
files: list[Path] | None,
|
|
80
|
+
db_name: str,
|
|
81
|
+
datadir: Path | None,
|
|
82
|
+
minid: float,
|
|
83
|
+
mincov: float,
|
|
84
|
+
threads: int,
|
|
85
|
+
jobs: int,
|
|
86
|
+
fofn: Path | None,
|
|
87
|
+
quiet: bool,
|
|
88
|
+
noheader: bool,
|
|
89
|
+
nopath: bool,
|
|
90
|
+
debug: bool,
|
|
91
|
+
output_format: OutputFormat,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Screen each input file in order; buffer reports; render once at the
|
|
94
|
+
end and return the output for the caller to echo.
|
|
95
|
+
|
|
96
|
+
The per-run gates (blastn presence via ``ensure_blast``, dbtype via one
|
|
97
|
+
``blastdbcmd -info``) fire once up front, so MISSING_DEPENDENCY and
|
|
98
|
+
DATABASE_NOT_INDEXED errors precede any "Processing:" stderr lines.
|
|
99
|
+
"""
|
|
100
|
+
if not 0.0 < minid <= 100.0:
|
|
101
|
+
usage_fail(f"--minid must be in (0, 100]: got {minid}")
|
|
102
|
+
if not 0.0 <= mincov <= 100.0:
|
|
103
|
+
usage_fail(f"--mincov must be in [0, 100]: got {mincov}")
|
|
104
|
+
if threads < 1:
|
|
105
|
+
usage_fail(f"--threads must be >= 1: got {threads}")
|
|
106
|
+
if jobs < 1:
|
|
107
|
+
usage_fail(f"--jobs must be >= 1: got {jobs}")
|
|
108
|
+
inputs = _resolve_inputs(files, fofn)
|
|
109
|
+
params = ScreeningParams(db=db_name, minid=minid, mincov=mincov, threads=threads)
|
|
110
|
+
database = find_database(config.resolve_datadir(datadir), db_name)
|
|
111
|
+
ensure_blast()
|
|
112
|
+
dbtype = db.blast_db_info(database.sequences_path).dbtype
|
|
113
|
+
cpu_count = os.cpu_count()
|
|
114
|
+
if not quiet and cpu_count is not None and jobs * threads > cpu_count:
|
|
115
|
+
typer.echo(f"--jobs {jobs} --threads {threads} oversubscribes {cpu_count} cpus", err=True)
|
|
116
|
+
reports: list[Report] = []
|
|
117
|
+
if jobs == 1:
|
|
118
|
+
for path in inputs:
|
|
119
|
+
if not quiet:
|
|
120
|
+
typer.echo(f"Processing: {path}", err=True)
|
|
121
|
+
report = screen_file(path, database, params, dbtype=dbtype, debug=debug)
|
|
122
|
+
if not quiet:
|
|
123
|
+
typer.echo(f"Found {len(report.hits)} genes in {path}", err=True)
|
|
124
|
+
reports.append(report)
|
|
125
|
+
else:
|
|
126
|
+
|
|
127
|
+
def screen_one(path: Path) -> Report:
|
|
128
|
+
if not quiet:
|
|
129
|
+
typer.echo(f"Processing: {path}", err=True)
|
|
130
|
+
report = screen_file(path, database, params, dbtype=dbtype, debug=debug)
|
|
131
|
+
if not quiet:
|
|
132
|
+
typer.echo(f"Found {len(report.hits)} genes in {path}", err=True)
|
|
133
|
+
return report
|
|
134
|
+
|
|
135
|
+
# Subprocess-bound work (GIL irrelevant); executor.map collects
|
|
136
|
+
# positionally, so reports stay in input order (SPEC.md §4) and a
|
|
137
|
+
# failing file raises at its position, like the sequential loop.
|
|
138
|
+
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
|
139
|
+
reports = list(executor.map(screen_one, inputs))
|
|
140
|
+
if output_format is OutputFormat.json:
|
|
141
|
+
return render_json(reports, params, now=datetime.now(UTC))
|
|
142
|
+
if output_format is OutputFormat.md:
|
|
143
|
+
return render_markdown(reports, params, now=datetime.now(UTC))
|
|
144
|
+
as_csv = output_format is OutputFormat.csv
|
|
145
|
+
return format_tsv(reports, csv=as_csv, noheader=noheader, nopath=nopath)
|