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/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)