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.
Files changed (64) hide show
  1. gapit/__init__.py +3 -0
  2. gapit/blast.py +239 -0
  3. gapit/cli.py +128 -0
  4. gapit/cmd_db.py +113 -0
  5. gapit/cmd_db_build.py +72 -0
  6. gapit/cmd_db_install.py +126 -0
  7. gapit/cmd_db_outdated.py +51 -0
  8. gapit/cmd_db_search.py +66 -0
  9. gapit/cmd_screen.py +214 -0
  10. gapit/cmd_summary.py +65 -0
  11. gapit/config.py +51 -0
  12. gapit/data/snapshots/card.tar.gz +0 -0
  13. gapit/data/snapshots/vfdb.tar.gz +0 -0
  14. gapit/db.py +226 -0
  15. gapit/db_build_ops.py +210 -0
  16. gapit/db_ops.py +128 -0
  17. gapit/db_query_ops.py +252 -0
  18. gapit/dbbuild.py +207 -0
  19. gapit/dbcodec.py +117 -0
  20. gapit/dispatch.py +31 -0
  21. gapit/errors.py +87 -0
  22. gapit/fasta.py +123 -0
  23. gapit/formats/__init__.py +1 -0
  24. gapit/formats/json.py +309 -0
  25. gapit/formats/md.py +190 -0
  26. gapit/formats/schemas.py +30 -0
  27. gapit/formats/summary.py +103 -0
  28. gapit/formats/tsv.py +45 -0
  29. gapit/hits.py +107 -0
  30. gapit/mcp.py +158 -0
  31. gapit/mcp_schemas.py +123 -0
  32. gapit/mcp_tools.py +289 -0
  33. gapit/minimap.py +20 -0
  34. gapit/minimap2_run.py +114 -0
  35. gapit/paf.py +115 -0
  36. gapit/proctools.py +24 -0
  37. gapit/providers/__init__.py +39 -0
  38. gapit/providers/argannot.py +94 -0
  39. gapit/providers/bacmet2.py +59 -0
  40. gapit/providers/card.py +150 -0
  41. gapit/providers/common.py +245 -0
  42. gapit/providers/ecoh.py +63 -0
  43. gapit/providers/ecoli_vf.py +74 -0
  44. gapit/providers/megares.py +71 -0
  45. gapit/providers/ncbi.py +103 -0
  46. gapit/providers/plasmidfinder.py +69 -0
  47. gapit/providers/resfinder.py +123 -0
  48. gapit/providers/snapshots.py +119 -0
  49. gapit/providers/upec_expec_vf.py +85 -0
  50. gapit/providers/vfdb.py +92 -0
  51. gapit/providers/victors.py +109 -0
  52. gapit/py.typed +0 -0
  53. gapit/reads.py +221 -0
  54. gapit/records.py +152 -0
  55. gapit/report.py +25 -0
  56. gapit/screening.py +145 -0
  57. gapit/screening_reads.py +255 -0
  58. gapit/seqconvert.py +203 -0
  59. gapit/summary.py +151 -0
  60. gapit-0.2.2.dist-info/METADATA +183 -0
  61. gapit-0.2.2.dist-info/RECORD +64 -0
  62. gapit-0.2.2.dist-info/WHEEL +4 -0
  63. gapit-0.2.2.dist-info/entry_points.txt +3 -0
  64. gapit-0.2.2.dist-info/licenses/LICENSE +21 -0
gapit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gapit — agent-first Python reimplementation of abricate."""
2
+
3
+ __version__ = "0.2.2"
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)
@@ -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)