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/__init__.py
ADDED
gapit/blast.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""BLAST invocation, tabular parsing, and the screening pipeline (SPEC.md §3)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from gapit.db import Database
|
|
13
|
+
from gapit.errors import DependencyError, GapitError, InputError
|
|
14
|
+
from gapit.hits import process_rows
|
|
15
|
+
from gapit.report import Report, ScreeningParams
|
|
16
|
+
from gapit.seqconvert import SeqFormat, detect_format, to_fasta_lines
|
|
17
|
+
|
|
18
|
+
BLAST_FIELDS = [
|
|
19
|
+
"qseqid",
|
|
20
|
+
"qstart",
|
|
21
|
+
"qend",
|
|
22
|
+
"qlen",
|
|
23
|
+
"sseqid",
|
|
24
|
+
"sstart",
|
|
25
|
+
"send",
|
|
26
|
+
"slen",
|
|
27
|
+
"sstrand",
|
|
28
|
+
"evalue",
|
|
29
|
+
"length",
|
|
30
|
+
"pident",
|
|
31
|
+
"gaps",
|
|
32
|
+
"gapopen",
|
|
33
|
+
"stitle",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_OUTFMT = "6 " + " ".join(BLAST_FIELDS)
|
|
37
|
+
_MIN_BLAST_VERSION = (2, 2, 30)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BlastRow(BaseModel, frozen=True):
|
|
41
|
+
"""One outfmt-6 row, typed at the boundary (SPEC.md §3 field order)."""
|
|
42
|
+
|
|
43
|
+
qseqid: str
|
|
44
|
+
qstart: int
|
|
45
|
+
qend: int
|
|
46
|
+
qlen: int
|
|
47
|
+
sseqid: str
|
|
48
|
+
sstart: int
|
|
49
|
+
send: int
|
|
50
|
+
slen: int
|
|
51
|
+
sstrand: str
|
|
52
|
+
evalue: float
|
|
53
|
+
length: int
|
|
54
|
+
pident: float
|
|
55
|
+
gaps: int
|
|
56
|
+
gapopen: int
|
|
57
|
+
stitle: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_blast_row(line: str) -> BlastRow:
|
|
61
|
+
"""Parse one tab-delimited outfmt-6 line; a row with != 15 columns is a
|
|
62
|
+
hard error (upstream wording)."""
|
|
63
|
+
fields = line.split("\t")
|
|
64
|
+
if len(fields) != 15:
|
|
65
|
+
raise GapitError("can not find sequence data", code="BLAST_PARSE_FAILED")
|
|
66
|
+
return BlastRow(
|
|
67
|
+
qseqid=fields[0],
|
|
68
|
+
qstart=int(fields[1]),
|
|
69
|
+
qend=int(fields[2]),
|
|
70
|
+
qlen=int(fields[3]),
|
|
71
|
+
sseqid=fields[4],
|
|
72
|
+
sstart=int(fields[5]),
|
|
73
|
+
send=int(fields[6]),
|
|
74
|
+
slen=int(fields[7]),
|
|
75
|
+
sstrand=fields[8],
|
|
76
|
+
evalue=float(fields[9]),
|
|
77
|
+
length=int(fields[10]),
|
|
78
|
+
pident=float(fields[11]),
|
|
79
|
+
gaps=int(fields[12]),
|
|
80
|
+
gapopen=int(fields[13]),
|
|
81
|
+
stitle=fields[14],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_blast() -> None:
|
|
86
|
+
"""Require blastn >= 2.2.30 on PATH (SPEC.md §1 version gate)."""
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(["blastn", "-version"], check=False, capture_output=True, text=True)
|
|
89
|
+
except FileNotFoundError as exc:
|
|
90
|
+
raise DependencyError(
|
|
91
|
+
"required binary not found on PATH: blastn",
|
|
92
|
+
code="MISSING_DEPENDENCY",
|
|
93
|
+
context={"binary": "blastn"},
|
|
94
|
+
) from exc
|
|
95
|
+
if result.returncode != 0:
|
|
96
|
+
raise DependencyError(
|
|
97
|
+
f"blastn -version failed: {result.stderr.strip()}",
|
|
98
|
+
code="BLAST_VERSION_CHECK_FAILED",
|
|
99
|
+
)
|
|
100
|
+
match = re.search(r"blastn:\s*(\d+)\.(\d+)\.(\d+)", result.stdout)
|
|
101
|
+
if match is None:
|
|
102
|
+
raise DependencyError(
|
|
103
|
+
f"could not parse blastn version from: {result.stdout!r}",
|
|
104
|
+
code="BLAST_VERSION_CHECK_FAILED",
|
|
105
|
+
)
|
|
106
|
+
major, minor, revision = (int(part) for part in match.groups())
|
|
107
|
+
if (major, minor, revision) < _MIN_BLAST_VERSION:
|
|
108
|
+
raise DependencyError(
|
|
109
|
+
f"blastn {major}.{minor}.{revision} is older than 2.2.30",
|
|
110
|
+
code="BLAST_VERSION_TOO_OLD",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _normalize(query: Path) -> tuple[str, SeqFormat]:
|
|
115
|
+
"""Convert one input file to FASTA text in-process (seqconvert), wrapping
|
|
116
|
+
any parser failure in the blast-path error shape
|
|
117
|
+
``invalid input file {query}: <reason>``.
|
|
118
|
+
|
|
119
|
+
The whole normalized FASTA is held in memory and fed to blast via
|
|
120
|
+
``input=`` — genome-scale inputs are a few MB, a deliberate trade-off:
|
|
121
|
+
``subprocess.run``'s communicate() owns stdin/stdout concurrency, so the
|
|
122
|
+
retired ``any2fasta | blastn`` Popen chain needs no stderr-drain thread
|
|
123
|
+
here anymore.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
fmt = detect_format(query)
|
|
127
|
+
text = "".join(to_fasta_lines(query, fmt))
|
|
128
|
+
except InputError as exc:
|
|
129
|
+
raise InputError(
|
|
130
|
+
f"invalid input file {query}: {exc}",
|
|
131
|
+
code="INVALID_INPUT",
|
|
132
|
+
context={"file": str(query)},
|
|
133
|
+
) from exc
|
|
134
|
+
return text, fmt
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _pipeline(fasta_text: str, argv: list[str]) -> str:
|
|
138
|
+
"""``<argv>`` reading the normalized FASTA on stdin; returns stdout as text."""
|
|
139
|
+
try:
|
|
140
|
+
result = subprocess.run(argv, input=fasta_text, capture_output=True, text=True)
|
|
141
|
+
except FileNotFoundError as exc:
|
|
142
|
+
raise DependencyError(
|
|
143
|
+
f"required binary not found on PATH: {argv[0]}",
|
|
144
|
+
code="MISSING_DEPENDENCY",
|
|
145
|
+
context={"binary": argv[0]},
|
|
146
|
+
) from exc
|
|
147
|
+
if result.returncode != 0:
|
|
148
|
+
raise GapitError(
|
|
149
|
+
f"{argv[0]} failed: {result.stderr.strip()}",
|
|
150
|
+
code="BLAST_FAILED",
|
|
151
|
+
context={"binary": argv[0]},
|
|
152
|
+
)
|
|
153
|
+
return result.stdout
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_blastn(
|
|
157
|
+
query: Path,
|
|
158
|
+
database: Database,
|
|
159
|
+
params: ScreeningParams,
|
|
160
|
+
*,
|
|
161
|
+
dbtype: Literal["nucl", "prot"],
|
|
162
|
+
debug: bool = False,
|
|
163
|
+
) -> list[BlastRow]:
|
|
164
|
+
"""Run the normalize (native seqconvert) -> blastn/blastx pipeline for one
|
|
165
|
+
query file.
|
|
166
|
+
|
|
167
|
+
The database must already be indexed; protein databases switch to blastx
|
|
168
|
+
without -perc_identity (upstream quirk, minid silently ignored). With
|
|
169
|
+
``debug``, echo the normalization step and the exact blast argv to stderr
|
|
170
|
+
(abricate --debug parity). ``dbtype`` comes from the caller resolving it
|
|
171
|
+
once per run, so dependency/index errors surface before any per-file work.
|
|
172
|
+
"""
|
|
173
|
+
if dbtype == "prot":
|
|
174
|
+
argv = [
|
|
175
|
+
"blastx",
|
|
176
|
+
"-task",
|
|
177
|
+
"blastx-fast",
|
|
178
|
+
"-seg",
|
|
179
|
+
"no",
|
|
180
|
+
"-db",
|
|
181
|
+
str(database.sequences_path),
|
|
182
|
+
"-outfmt",
|
|
183
|
+
_OUTFMT,
|
|
184
|
+
"-num_threads",
|
|
185
|
+
str(params.threads),
|
|
186
|
+
"-evalue",
|
|
187
|
+
"1E-20",
|
|
188
|
+
"-culling_limit",
|
|
189
|
+
"1",
|
|
190
|
+
"-max_target_seqs",
|
|
191
|
+
"10000",
|
|
192
|
+
]
|
|
193
|
+
sys.stderr.write("--minid is not applied to protein databases (abricate parity)\n")
|
|
194
|
+
else:
|
|
195
|
+
argv = [
|
|
196
|
+
"blastn",
|
|
197
|
+
"-task",
|
|
198
|
+
"blastn",
|
|
199
|
+
"-dust",
|
|
200
|
+
"no",
|
|
201
|
+
"-perc_identity",
|
|
202
|
+
str(params.minid),
|
|
203
|
+
"-db",
|
|
204
|
+
str(database.sequences_path),
|
|
205
|
+
"-outfmt",
|
|
206
|
+
_OUTFMT,
|
|
207
|
+
"-num_threads",
|
|
208
|
+
str(params.threads),
|
|
209
|
+
"-evalue",
|
|
210
|
+
"1E-20",
|
|
211
|
+
"-culling_limit",
|
|
212
|
+
"1",
|
|
213
|
+
"-max_target_seqs",
|
|
214
|
+
"10000",
|
|
215
|
+
]
|
|
216
|
+
fasta_text, fmt = _normalize(query)
|
|
217
|
+
if debug:
|
|
218
|
+
sys.stderr.write(f"gapit: normalize: {query} ({fmt.value})\n")
|
|
219
|
+
sys.stderr.write(f"gapit: run: {shlex.join(argv)}\n")
|
|
220
|
+
output = _pipeline(fasta_text, argv)
|
|
221
|
+
return [parse_blast_row(line) for line in output.splitlines() if line.strip()]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def screen_file(
|
|
225
|
+
query: Path,
|
|
226
|
+
database: Database,
|
|
227
|
+
params: ScreeningParams,
|
|
228
|
+
*,
|
|
229
|
+
dbtype: Literal["nucl", "prot"],
|
|
230
|
+
debug: bool = False,
|
|
231
|
+
) -> Report:
|
|
232
|
+
"""Screen one input file against one database into a sorted Report.
|
|
233
|
+
|
|
234
|
+
The caller owns the per-run gates (``ensure_blast`` and the one-shot
|
|
235
|
+
``dbtype`` resolution) so a multi-file run pays each probe once."""
|
|
236
|
+
rows = run_blastn(query, database, params, dbtype=dbtype, debug=debug)
|
|
237
|
+
hits = process_rows(rows, mincov=params.mincov, default_db=params.db)
|
|
238
|
+
ordered = sorted(hits, key=lambda hit: (hit.sequence, hit.start))
|
|
239
|
+
return Report(file=str(query), hits=tuple(ordered))
|
gapit/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""gapit command-line interface (typer entrypoint)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from gapit import __version__, config, db
|
|
10
|
+
from gapit.cmd_db import register_db_command
|
|
11
|
+
from gapit.cmd_screen import register_screen_command
|
|
12
|
+
from gapit.cmd_summary import register_summary_command
|
|
13
|
+
from gapit.dispatch import Datadir, dispatch
|
|
14
|
+
from gapit.errors import usage_fail
|
|
15
|
+
from gapit.formats.json import ListDocument, ListEntryDocument, VersionDocument
|
|
16
|
+
from gapit.formats.schemas import SCHEMA_MODELS
|
|
17
|
+
from gapit.mcp import register_mcp_command
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="gapit",
|
|
21
|
+
help="Mass screening of contigs for antimicrobial resistance and virulence genes.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback(invoke_without_command=True)
|
|
28
|
+
def main(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
show_version: Annotated[
|
|
31
|
+
bool | None,
|
|
32
|
+
typer.Option("--version", help="Show version and exit."),
|
|
33
|
+
] = None,
|
|
34
|
+
as_json: Annotated[
|
|
35
|
+
bool,
|
|
36
|
+
typer.Option("--json", help="With --version: emit gapit.version/1 JSON."),
|
|
37
|
+
] = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Mass screening of contigs for AMR and virulence genes."""
|
|
40
|
+
if show_version:
|
|
41
|
+
if as_json:
|
|
42
|
+
typer.echo(VersionDocument(version=__version__).model_dump_json(by_alias=True))
|
|
43
|
+
else:
|
|
44
|
+
typer.echo(f"gapit {__version__}")
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
if ctx.invoked_subcommand is None:
|
|
47
|
+
typer.echo(ctx.get_help())
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _list(datadir: Path | None, as_json: bool) -> None:
|
|
52
|
+
infos = db.list_databases(config.resolve_datadir(datadir), setupdb=False)
|
|
53
|
+
if as_json:
|
|
54
|
+
document = ListDocument(
|
|
55
|
+
databases=[
|
|
56
|
+
ListEntryDocument(
|
|
57
|
+
name=info.name,
|
|
58
|
+
sequences=info.n_sequences,
|
|
59
|
+
dbtype=info.dbtype,
|
|
60
|
+
date=info.date,
|
|
61
|
+
)
|
|
62
|
+
for info in infos
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
typer.echo(document.model_dump_json(indent=2, by_alias=True))
|
|
66
|
+
return
|
|
67
|
+
typer.echo("DATABASE\tSEQUENCES\tDBTYPE\tDATE")
|
|
68
|
+
for info in infos:
|
|
69
|
+
typer.echo(f"{info.name}\t{info.n_sequences}\t{info.dbtype}\t{info.date}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _setupdb(datadir: Path | None, debug: bool) -> None:
|
|
73
|
+
infos = db.list_databases(config.resolve_datadir(datadir), setupdb=True, debug=debug)
|
|
74
|
+
for info in infos:
|
|
75
|
+
typer.echo(
|
|
76
|
+
f"Indexed {info.name} ({info.n_sequences} sequences, {info.dbtype})",
|
|
77
|
+
err=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("list")
|
|
82
|
+
def list_dbs(
|
|
83
|
+
datadir: Datadir = None,
|
|
84
|
+
as_json: Annotated[
|
|
85
|
+
bool,
|
|
86
|
+
typer.Option("--json", help="Print machine-readable JSON instead of a table."),
|
|
87
|
+
] = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""List installed databases (abricate --list compatible)."""
|
|
90
|
+
dispatch(lambda: _list(datadir, as_json))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("setupdb")
|
|
94
|
+
def setupdb(
|
|
95
|
+
datadir: Datadir = None,
|
|
96
|
+
debug: Annotated[
|
|
97
|
+
bool,
|
|
98
|
+
typer.Option("--debug", help="Echo external command lines to stderr."),
|
|
99
|
+
] = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Build BLAST indices for all databases under the datadir."""
|
|
102
|
+
dispatch(lambda: _setupdb(datadir, debug))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
register_screen_command(app)
|
|
106
|
+
register_summary_command(app)
|
|
107
|
+
register_db_command(app)
|
|
108
|
+
register_mcp_command(app)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command("schema")
|
|
112
|
+
def schema(
|
|
113
|
+
name: Annotated[
|
|
114
|
+
str,
|
|
115
|
+
typer.Argument(
|
|
116
|
+
help="Document to introspect: report, reads, reads2, summary, list, error, or version."
|
|
117
|
+
),
|
|
118
|
+
],
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Print the JSON Schema of a gapit output document."""
|
|
121
|
+
|
|
122
|
+
def run() -> None:
|
|
123
|
+
model = SCHEMA_MODELS.get(name)
|
|
124
|
+
if model is None:
|
|
125
|
+
usage_fail(f"unknown schema name: {name} (choose from: {', '.join(SCHEMA_MODELS)})")
|
|
126
|
+
typer.echo(json.dumps(model.model_json_schema(by_alias=True), indent=2))
|
|
127
|
+
|
|
128
|
+
dispatch(run)
|
gapit/cmd_db.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The `gapit db` command group: build, fetch, list, install.
|
|
2
|
+
|
|
3
|
+
- ``db build NAME FASTA``: build a custom gapit-native database from a
|
|
4
|
+
user-supplied FASTA (+ optional TSV metadata) — the whole acquisition
|
|
5
|
+
pipeline without a provider (use-case: :mod:`gapit.db_build_ops`,
|
|
6
|
+
command: :mod:`gapit.cmd_db_build`).
|
|
7
|
+
- ``db fetch [NAME]``: acquire provider database(s) under the datadir. With
|
|
8
|
+
no NAME the default set (``DEFAULT_DBS``: card, vfdb) installs in order —
|
|
9
|
+
each from its BUNDLED SNAPSHOT (Wave G) when one resolves, else over the
|
|
10
|
+
network; ``--from-source`` forces the upstream download even when a
|
|
11
|
+
snapshot exists. One JSON receipt line per database on stdout
|
|
12
|
+
(use-case: :mod:`gapit.db_ops`).
|
|
13
|
+
- ``db list``: show every known provider with its installed state
|
|
14
|
+
(use-case: :mod:`gapit.db_ops`).
|
|
15
|
+
- ``db outdated`` / ``db search``: read-only queries over the installed
|
|
16
|
+
databases (use-case: :mod:`gapit.db_query_ops`; commands:
|
|
17
|
+
:mod:`gapit.cmd_db_outdated`, :mod:`gapit.cmd_db_search`).
|
|
18
|
+
- ``db install``: checksum-verified LOCAL-FILE installation
|
|
19
|
+
(:mod:`gapit.cmd_db_install`, split off in Wave G at the 250 LOC ceiling).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Annotated
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from gapit import config
|
|
27
|
+
from gapit.cmd_db_build import db_build_command
|
|
28
|
+
from gapit.cmd_db_install import db_install_command
|
|
29
|
+
from gapit.cmd_db_outdated import db_outdated_command
|
|
30
|
+
from gapit.cmd_db_search import db_search_command
|
|
31
|
+
from gapit.db_ops import db_list_entries, db_list_json, perform_fetch
|
|
32
|
+
from gapit.dispatch import Datadir, dispatch
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def db_fetch_command(
|
|
36
|
+
name: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
typer.Argument(
|
|
39
|
+
help=(
|
|
40
|
+
"Provider name (see: gapit db list). Omitted: install the default set "
|
|
41
|
+
"(card, vfdb) from their bundled snapshots."
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
] = None,
|
|
45
|
+
datadir: Datadir = None,
|
|
46
|
+
force: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option("--force", help="Overwrite the database if it already exists."),
|
|
49
|
+
] = False,
|
|
50
|
+
from_source: Annotated[
|
|
51
|
+
bool,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--from-source", help="Ignore bundled snapshots, download from upstream sources."
|
|
54
|
+
),
|
|
55
|
+
] = False,
|
|
56
|
+
quiet: Annotated[bool, typer.Option("--quiet", help="Silence stderr diagnostics.")] = False,
|
|
57
|
+
debug: Annotated[
|
|
58
|
+
bool,
|
|
59
|
+
typer.Option("--debug", help="Echo external command lines to stderr."),
|
|
60
|
+
] = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Fetch and build provider database(s) into <datadir>/NAME.
|
|
63
|
+
|
|
64
|
+
NAME omitted installs every database in DEFAULT_DBS order; each success
|
|
65
|
+
prints its own one-line JSON receipt to stdout (stdout purity: receipts
|
|
66
|
+
are data).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def run() -> None:
|
|
70
|
+
for receipt in perform_fetch(
|
|
71
|
+
name, datadir, force=force, from_source=from_source, quiet=quiet, debug=debug
|
|
72
|
+
):
|
|
73
|
+
typer.echo(receipt.model_dump_json())
|
|
74
|
+
|
|
75
|
+
dispatch(run)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def db_list_command(
|
|
79
|
+
datadir: Datadir = None,
|
|
80
|
+
as_json: Annotated[
|
|
81
|
+
bool,
|
|
82
|
+
typer.Option("--json", help="Print machine-readable JSON instead of a table."),
|
|
83
|
+
] = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""List database providers and their installed state under the datadir."""
|
|
86
|
+
|
|
87
|
+
def run() -> None:
|
|
88
|
+
root = config.resolve_datadir(datadir)
|
|
89
|
+
if as_json:
|
|
90
|
+
typer.echo(db_list_json(root))
|
|
91
|
+
return
|
|
92
|
+
entries = db_list_entries(root)
|
|
93
|
+
typer.echo("PROVIDER\tSTATUS\tDBTYPE\tDESCRIPTION")
|
|
94
|
+
for entry in entries:
|
|
95
|
+
status = f"installed ({entry.records})" if entry.installed else "available"
|
|
96
|
+
typer.echo(f"{entry.name}\t{status}\t{entry.dbtype}\t{entry.description}")
|
|
97
|
+
|
|
98
|
+
dispatch(run)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def register_db_command(app: typer.Typer) -> None:
|
|
102
|
+
"""Attach the `db` command group (install, fetch, list) to the CLI app."""
|
|
103
|
+
db_app = typer.Typer(
|
|
104
|
+
help="Database acquisition and maintenance (provider fetch, verified local install).",
|
|
105
|
+
no_args_is_help=True,
|
|
106
|
+
)
|
|
107
|
+
db_app.command("install")(db_install_command)
|
|
108
|
+
db_app.command("build")(db_build_command)
|
|
109
|
+
db_app.command("fetch")(db_fetch_command)
|
|
110
|
+
db_app.command("list")(db_list_command)
|
|
111
|
+
db_app.command("outdated")(db_outdated_command)
|
|
112
|
+
db_app.command("search")(db_search_command)
|
|
113
|
+
app.add_typer(db_app, name="db")
|
gapit/cmd_db_build.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The `gapit db build` command: typer shell over the use-case
|
|
2
|
+
(:mod:`gapit.db_build_ops`), which turns a user-supplied FASTA into a
|
|
3
|
+
fully built gapit-native database (records.jsonl -> sequences + BLAST
|
|
4
|
+
index + manifest, written last).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from gapit.db_build_ops import Dbtype, perform_build
|
|
13
|
+
from gapit.dispatch import dispatch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def db_build_command(
|
|
17
|
+
name: Annotated[
|
|
18
|
+
str,
|
|
19
|
+
typer.Argument(help="Target database name (created under the datadir)."),
|
|
20
|
+
],
|
|
21
|
+
fasta: Annotated[
|
|
22
|
+
Path,
|
|
23
|
+
typer.Argument(
|
|
24
|
+
help=(
|
|
25
|
+
"Input FASTA: plain, abricate ~~~, or gapit| headers, detected per"
|
|
26
|
+
" record (.gz/.bz2 accepted)."
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
tsv: Annotated[
|
|
31
|
+
Path | None,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--tsv", help="Metadata TSV: header row with gene/accession/function columns."
|
|
34
|
+
),
|
|
35
|
+
] = None,
|
|
36
|
+
dbtype: Annotated[
|
|
37
|
+
Dbtype | None,
|
|
38
|
+
typer.Option("--dbtype", help="Force nucl or prot (default: auto-detect)."),
|
|
39
|
+
] = None,
|
|
40
|
+
datadir: Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--datadir",
|
|
44
|
+
help="Database directory (default: $GAPIT_DATADIR, then ~/.local/share/gapit/db).",
|
|
45
|
+
),
|
|
46
|
+
] = None,
|
|
47
|
+
description: Annotated[
|
|
48
|
+
str,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--description",
|
|
51
|
+
help="Default product for records whose FASTA header has no description text.",
|
|
52
|
+
),
|
|
53
|
+
] = "",
|
|
54
|
+
force: Annotated[
|
|
55
|
+
bool,
|
|
56
|
+
typer.Option("--force", help="Overwrite the database if it already exists."),
|
|
57
|
+
] = False,
|
|
58
|
+
quiet: Annotated[bool, typer.Option("--quiet", help="Silence stderr diagnostics.")] = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Build a custom gapit-native database from a FASTA (+ optional TSV)."""
|
|
61
|
+
|
|
62
|
+
def run() -> None:
|
|
63
|
+
def warn(message: str) -> None:
|
|
64
|
+
if not quiet:
|
|
65
|
+
typer.echo(f"WARNING: {message}", err=True)
|
|
66
|
+
|
|
67
|
+
receipt = perform_build(
|
|
68
|
+
name, fasta, tsv, dbtype, description, datadir, force, warn=warn, quiet=quiet
|
|
69
|
+
)
|
|
70
|
+
typer.echo(receipt.model_dump_json())
|
|
71
|
+
|
|
72
|
+
dispatch(run)
|
gapit/cmd_db_install.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The verified LOCAL-FILE install path: `gapit db install` (from cmd_db.py).
|
|
2
|
+
|
|
3
|
+
``db install`` checksum-verifies and atomically installs BYTES: SOURCE must
|
|
4
|
+
be a path to an existing regular file; no network, no provider IDs, no
|
|
5
|
+
archives, no manifests — streaming SHA256 + atomic copy, kept deliberately
|
|
6
|
+
independent of db.py and the screening pipeline.
|
|
7
|
+
|
|
8
|
+
Split from cmd_db.py in Wave G: cmd_db hit the 250 LOC ceiling when fetch
|
|
9
|
+
gained the bundled-snapshot defaults; this path is self-contained and no
|
|
10
|
+
test imports it directly (everything drives `gapit.cli.app`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from tempfile import NamedTemporaryFile
|
|
18
|
+
from typing import Annotated
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from gapit.dispatch import dispatch
|
|
24
|
+
from gapit.errors import InputError, UsageError
|
|
25
|
+
|
|
26
|
+
_SHA256_SHAPE = re.compile(r"[0-9a-fA-F]{64}")
|
|
27
|
+
_CHUNK_BYTES = 1 << 20
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FetchReceipt(BaseModel, frozen=True):
|
|
31
|
+
"""One-line JSON success receipt printed to stdout (no biological data)."""
|
|
32
|
+
|
|
33
|
+
destination: str
|
|
34
|
+
sha256: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_sha256(raw: str) -> str:
|
|
38
|
+
"""Lowercased digest if it is exactly 64 hex characters, else UsageError."""
|
|
39
|
+
if not _SHA256_SHAPE.fullmatch(raw):
|
|
40
|
+
raise UsageError(
|
|
41
|
+
"--sha256 must be exactly 64 hex characters",
|
|
42
|
+
code="USAGE_ERROR",
|
|
43
|
+
context={"sha256": raw},
|
|
44
|
+
)
|
|
45
|
+
return raw.lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def install_verified(source: Path, target: Path, expected: str) -> FetchReceipt:
|
|
49
|
+
"""Copy source to target with a streaming SHA256 check; atomic on success.
|
|
50
|
+
|
|
51
|
+
Bytes land in a NamedTemporaryFile in the target's parent (same
|
|
52
|
+
filesystem) and are os.replace()d over the target only after the digest
|
|
53
|
+
verifies, so any failure leaves an existing target untouched. The temp
|
|
54
|
+
file is removed on every error path.
|
|
55
|
+
"""
|
|
56
|
+
if not source.is_file():
|
|
57
|
+
raise InputError(
|
|
58
|
+
f"source file not found or unreadable: {source}",
|
|
59
|
+
code="INPUT_NOT_FOUND",
|
|
60
|
+
context={"source": str(source)},
|
|
61
|
+
)
|
|
62
|
+
temp_path: Path | None = None
|
|
63
|
+
try:
|
|
64
|
+
with source.open("rb") as src, NamedTemporaryFile(dir=target.parent, delete=False) as temp:
|
|
65
|
+
temp_path = Path(temp.name)
|
|
66
|
+
hasher = hashlib.sha256()
|
|
67
|
+
while chunk := src.read(_CHUNK_BYTES):
|
|
68
|
+
hasher.update(chunk)
|
|
69
|
+
temp.write(chunk)
|
|
70
|
+
actual = hasher.hexdigest()
|
|
71
|
+
if actual != expected:
|
|
72
|
+
raise InputError(
|
|
73
|
+
f"SHA256 mismatch for {source}",
|
|
74
|
+
code="CHECKSUM_MISMATCH",
|
|
75
|
+
context={
|
|
76
|
+
"source": str(source),
|
|
77
|
+
"expected": expected,
|
|
78
|
+
"actual": actual,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
assert temp_path is not None
|
|
82
|
+
os.replace(temp_path, target)
|
|
83
|
+
except OSError as exc:
|
|
84
|
+
raise InputError(
|
|
85
|
+
f"cannot install {source} to {target}: {exc}",
|
|
86
|
+
code="FILE_IO_ERROR",
|
|
87
|
+
context={"source": str(source), "target": str(target)},
|
|
88
|
+
) from exc
|
|
89
|
+
finally:
|
|
90
|
+
if temp_path is not None:
|
|
91
|
+
temp_path.unlink(missing_ok=True)
|
|
92
|
+
return FetchReceipt(destination=str(target), sha256=expected)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def db_install_command(
|
|
96
|
+
source: Annotated[
|
|
97
|
+
Path,
|
|
98
|
+
typer.Argument(
|
|
99
|
+
help="Local source file path (plain filesystem only; no URLs, no provider IDs)."
|
|
100
|
+
),
|
|
101
|
+
],
|
|
102
|
+
sha256: Annotated[
|
|
103
|
+
str,
|
|
104
|
+
typer.Option("--sha256", help="Expected SHA256 digest of the source (64 hex chars)."),
|
|
105
|
+
],
|
|
106
|
+
output: Annotated[
|
|
107
|
+
Path,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--output", help="Destination path; replaced atomically only after verification."
|
|
110
|
+
),
|
|
111
|
+
],
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Install a local file to --output after verifying its SHA256.
|
|
114
|
+
|
|
115
|
+
VERIFIED LOCAL-FILE INSTALLATION ONLY: this never fetches over the
|
|
116
|
+
network and knows nothing about database providers or sequence content —
|
|
117
|
+
it checksum-verifies and atomically installs bytes. On success a one-line
|
|
118
|
+
JSON receipt (destination, verified digest) is printed to stdout.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def run() -> None:
|
|
122
|
+
expected = _parse_sha256(sha256)
|
|
123
|
+
receipt = install_verified(source, output, expected)
|
|
124
|
+
typer.echo(receipt.model_dump_json())
|
|
125
|
+
|
|
126
|
+
dispatch(run)
|