gmlst 0.1.0__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 (104) hide show
  1. gmlst/__init__.py +3 -0
  2. gmlst/__main__.py +6 -0
  3. gmlst/aligners/__init__.py +44 -0
  4. gmlst/aligners/base.py +181 -0
  5. gmlst/aligners/blastn.py +287 -0
  6. gmlst/aligners/kma.py +165 -0
  7. gmlst/aligners/minimap2.py +1369 -0
  8. gmlst/aligners/nucmer.py +221 -0
  9. gmlst/calling/__init__.py +6 -0
  10. gmlst/calling/allele.py +185 -0
  11. gmlst/calling/chew_policy.py +286 -0
  12. gmlst/calling/confidence.py +53 -0
  13. gmlst/calling/st_lookup.py +199 -0
  14. gmlst/cli.py +43 -0
  15. gmlst/commands/__init__.py +31 -0
  16. gmlst/commands/common.py +138 -0
  17. gmlst/commands/scheme.py +1329 -0
  18. gmlst/commands/typing.py +1336 -0
  19. gmlst/commands/typing_output.py +84 -0
  20. gmlst/commands/typing_runner.py +128 -0
  21. gmlst/commands/typing_runtime.py +54 -0
  22. gmlst/commands/typing_scheme.py +70 -0
  23. gmlst/commands/utils.py +1006 -0
  24. gmlst/core/__init__.py +230 -0
  25. gmlst/core/adapters_cds.py +49 -0
  26. gmlst/core/adapters_exact_hash.py +148 -0
  27. gmlst/core/adapters_index_prefilter.py +157 -0
  28. gmlst/core/adapters_refinement.py +251 -0
  29. gmlst/core/cds.py +125 -0
  30. gmlst/core/config.py +65 -0
  31. gmlst/core/exact_hash.py +440 -0
  32. gmlst/core/indexing.py +206 -0
  33. gmlst/core/pipeline.py +690 -0
  34. gmlst/core/prefilter.py +215 -0
  35. gmlst/core/ranking.py +67 -0
  36. gmlst/core/refinement.py +534 -0
  37. gmlst/core/sequences.py +98 -0
  38. gmlst/core/types.py +70 -0
  39. gmlst/core_config.py +199 -0
  40. gmlst/data/__init__.py +0 -0
  41. gmlst/data/blocked_schemes.json +10 -0
  42. gmlst/data/catalogs/__init__.py +0 -0
  43. gmlst/data/catalogs/cgmlst.json +368 -0
  44. gmlst/data/catalogs/enterobase.json +261 -0
  45. gmlst/data/catalogs/pasteur.json +776 -0
  46. gmlst/data/catalogs/pubmlst.json +2792 -0
  47. gmlst/data/organism_mapping.json +18 -0
  48. gmlst/database/__init__.py +6 -0
  49. gmlst/database/atomic.py +17 -0
  50. gmlst/database/cache.py +576 -0
  51. gmlst/database/download.py +527 -0
  52. gmlst/database/providers/__init__.py +63 -0
  53. gmlst/database/providers/base.py +126 -0
  54. gmlst/database/providers/bigsdb.py +767 -0
  55. gmlst/database/providers/cgmlst.py +277 -0
  56. gmlst/database/providers/cgmlst_schemes.py +286 -0
  57. gmlst/database/providers/enterobase.py +509 -0
  58. gmlst/database/schema.py +159 -0
  59. gmlst/fasta_io.py +55 -0
  60. gmlst/kmer_prefilter.py +91 -0
  61. gmlst/metadata_io.py +23 -0
  62. gmlst/novel/__init__.py +12 -0
  63. gmlst/novel/reader.py +188 -0
  64. gmlst/novel/service.py +170 -0
  65. gmlst/novel/writer.py +226 -0
  66. gmlst/readers/__init__.py +7 -0
  67. gmlst/readers/fasta.py +69 -0
  68. gmlst/readers/fastq.py +67 -0
  69. gmlst/readers/sample.py +177 -0
  70. gmlst/schemefree/__init__.py +53 -0
  71. gmlst/schemefree/assembly_engine.py +96 -0
  72. gmlst/schemefree/cluster_engine.py +124 -0
  73. gmlst/schemefree/config.py +197 -0
  74. gmlst/schemefree/gene_predictor.py +286 -0
  75. gmlst/schemefree/hasher.py +463 -0
  76. gmlst/schemefree/io_handler.py +91 -0
  77. gmlst/schemefree/typing_engine.py +428 -0
  78. gmlst/utils.py +173 -0
  79. gmlst/visual/__init__.py +6 -0
  80. gmlst/visual/app.py +606 -0
  81. gmlst/visual/cli.py +995 -0
  82. gmlst/visual/mst.py +335 -0
  83. gmlst/visual/mst_edmonds.py +527 -0
  84. gmlst/visual/mst_grapetree.py +543 -0
  85. gmlst/visual/mst_shared.py +527 -0
  86. gmlst/web/README.md +33 -0
  87. gmlst/web/frontend/index.html +15 -0
  88. gmlst/web/frontend/package-lock.json +1591 -0
  89. gmlst/web/frontend/package.json +20 -0
  90. gmlst/web/frontend/src/App.vue +4176 -0
  91. gmlst/web/frontend/src/main.js +5 -0
  92. gmlst/web/frontend/src/style.css +1364 -0
  93. gmlst/web/frontend/src/visualSelection.js +264 -0
  94. gmlst/web/frontend/src/visualSelection.test.js +304 -0
  95. gmlst/web/frontend/vite.config.js +32 -0
  96. gmlst/web/static/visual/dist/app.css +1 -0
  97. gmlst/web/static/visual/dist/app.js +6 -0
  98. gmlst/web/static/visual/dist/index.html +16 -0
  99. gmlst/web/templates/visual/index.html +16 -0
  100. gmlst-0.1.0.dist-info/METADATA +396 -0
  101. gmlst-0.1.0.dist-info/RECORD +104 -0
  102. gmlst-0.1.0.dist-info/WHEEL +4 -0
  103. gmlst-0.1.0.dist-info/entry_points.txt +2 -0
  104. gmlst-0.1.0.dist-info/licenses/LICENSE +21 -0
gmlst/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """gmlst – Fast MLST, cgMLST/wgMLST typing via multiple alignment backends."""
2
+
3
+ __version__ = "0.1.0"
gmlst/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m gmlst``."""
2
+
3
+ from gmlst.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,44 @@
1
+ """Alignment backend registry."""
2
+
3
+ from gmlst.aligners.base import Aligner, AlignmentResult, AlleleMatch
4
+ from gmlst.aligners.blastn import BlastnAligner
5
+ from gmlst.aligners.kma import KmaAligner
6
+ from gmlst.aligners.minimap2 import Minimap2Aligner
7
+ from gmlst.aligners.nucmer import NucmerAligner
8
+
9
+ _REGISTRY: dict[str, type[Aligner]] = {
10
+ "blastn": BlastnAligner,
11
+ "kma": KmaAligner,
12
+ "minimap2": Minimap2Aligner,
13
+ "nucmer": NucmerAligner,
14
+ }
15
+
16
+ AVAILABLE_BACKENDS: list[str] = list(_REGISTRY.keys())
17
+
18
+
19
+ def get_aligner(name: str, **kwargs) -> Aligner:
20
+ """Return an instantiated aligner by backend name.
21
+
22
+ Parameters
23
+ ----------
24
+ name:
25
+ Backend name (blastn, kma, minimap2, nucmer).
26
+ **kwargs:
27
+ Additional arguments passed to aligner constructor.
28
+ For blastn: threads (int, default=1)
29
+ """
30
+ name = name.lower()
31
+ if name not in _REGISTRY:
32
+ raise ValueError(
33
+ f"Unknown backend '{name}'. Available: {', '.join(AVAILABLE_BACKENDS)}"
34
+ )
35
+ return _REGISTRY[name](**kwargs)
36
+
37
+
38
+ __all__ = [
39
+ "Aligner",
40
+ "AlleleMatch",
41
+ "AlignmentResult",
42
+ "AVAILABLE_BACKENDS",
43
+ "get_aligner",
44
+ ]
gmlst/aligners/base.py ADDED
@@ -0,0 +1,181 @@
1
+ """Core types and Aligner Protocol shared by all backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Literal, Protocol, runtime_checkable
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Data classes
12
+ # ---------------------------------------------------------------------------
13
+
14
+ CallType = Literal["exact", "closest", "novel", "partial", "missing"]
15
+
16
+
17
+ @dataclass
18
+ class AlleleMatch:
19
+ """Normalised alignment hit for one allele against one locus.
20
+
21
+ All backends must convert their native output into this structure so that
22
+ downstream calling logic is backend-agnostic.
23
+ """
24
+
25
+ locus: str
26
+ """Gene name, e.g. ``"abcZ"``."""
27
+
28
+ allele_id: str
29
+ """Allele number as a string, e.g. ``"42"``."""
30
+
31
+ identity: float
32
+ """Percent identity (0–100)."""
33
+
34
+ coverage: float
35
+ """Fraction of the allele sequence covered by the alignment (0.0–1.0)."""
36
+
37
+ strand: str = "+"
38
+ """Alignment strand: ``"+"`` or ``"-"``."""
39
+
40
+ score: float = 0.0
41
+ """Backend-specific score, normalised to 0–100 where possible."""
42
+
43
+ depth: float | None = None
44
+ """Mean read depth across the allele (FASTQ inputs only)."""
45
+
46
+ alignment_length: int = 0
47
+ """Length of the alignment block in bases."""
48
+
49
+ sequence: str | None = None
50
+ """Extracted sequence for novel alleles (optional)."""
51
+ copy_count: int = 1
52
+ """Approximate number of distinct genomic occurrences for this allele."""
53
+ query_contig: str | None = None
54
+ """Contig/chromosome identifier for FASTA assembly mappings."""
55
+ query_contig_length: int | None = None
56
+ """Contig/chromosome length when available."""
57
+ query_start: int | None = None
58
+ """0-based start coordinate on the query contig/chromosome."""
59
+ query_end: int | None = None
60
+ """0-based end coordinate on the query contig/chromosome."""
61
+ allele_length: int | None = None
62
+ """Allele/template full length when available."""
63
+ allele_start: int | None = None
64
+ """0-based aligned start on allele/template."""
65
+ allele_end: int | None = None
66
+ """0-based aligned end on allele/template."""
67
+ """Length of the alignment block in bases."""
68
+
69
+ @property
70
+ def call_type(self) -> CallType:
71
+ """Classify this match using tseemann-compatible thresholds."""
72
+ if self.identity == 100.0 and self.coverage >= 1.0:
73
+ return "exact"
74
+ if self.identity >= 95.0 and self.coverage >= 0.95:
75
+ return "closest"
76
+ if self.coverage >= 0.95:
77
+ # Good coverage but low identity → likely a novel allele
78
+ return "novel"
79
+ if self.coverage > 0.0:
80
+ return "partial"
81
+ return "missing"
82
+
83
+ @property
84
+ def is_exact(self) -> bool:
85
+ return self.identity == 100.0 and self.coverage >= 1.0
86
+
87
+
88
+ @dataclass
89
+ class AlignmentResult:
90
+ """All allele matches produced by one backend for one sample."""
91
+
92
+ sample_id: str
93
+ matches: list[AlleleMatch] = field(default_factory=list)
94
+ failed_loci: list[str] = field(default_factory=list)
95
+ backend: str = ""
96
+ runtime_seconds: float = 0.0
97
+ _matches_by_locus: dict[str, list[AlleleMatch]] | None = field(
98
+ default=None,
99
+ init=False,
100
+ repr=False,
101
+ )
102
+
103
+ def matches_for(self, locus: str) -> list[AlleleMatch]:
104
+ """Return all hits for a single locus, sorted best-first."""
105
+ if self._matches_by_locus is None:
106
+ grouped: dict[str, list[AlleleMatch]] = defaultdict(list)
107
+ for match in self.matches:
108
+ grouped[match.locus].append(match)
109
+ for hits in grouped.values():
110
+ hits.sort(
111
+ key=lambda m: (m.identity, m.coverage, m.depth or 0.0),
112
+ reverse=True,
113
+ )
114
+ self._matches_by_locus = dict(grouped)
115
+ return self._matches_by_locus.get(locus, [])
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Aligner Protocol
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ @runtime_checkable
124
+ class Aligner(Protocol):
125
+ """Interface every alignment backend must satisfy.
126
+
127
+ Backends are stateless; per-run state lives in ``index_dir``.
128
+ """
129
+
130
+ @property
131
+ def name(self) -> str:
132
+ """Short identifier, e.g. ``"blastn"``."""
133
+ ...
134
+
135
+ @property
136
+ def supports_fastq(self) -> bool:
137
+ """Whether this backend can handle raw FASTQ reads."""
138
+ ...
139
+
140
+ def check_dependencies(self) -> None:
141
+ """Raise ``RuntimeError`` if required external tools are missing."""
142
+ ...
143
+
144
+ def index(self, allele_fastas: list[Path], index_dir: Path) -> Path:
145
+ """Build a backend-specific index from allele FASTA files.
146
+
147
+ Parameters
148
+ ----------
149
+ allele_fastas:
150
+ One FASTA file per locus (e.g. ``arcC.tfa``).
151
+ index_dir:
152
+ Directory in which to write index artifacts.
153
+
154
+ Returns
155
+ -------
156
+ Path
157
+ The index path (file or directory) to pass back to :meth:`align`.
158
+ """
159
+ ...
160
+
161
+ def align(
162
+ self,
163
+ sample: Path | tuple[Path, Path],
164
+ index_path: Path,
165
+ loci: list[str],
166
+ input_type: Literal["fasta", "fastq"],
167
+ ) -> AlignmentResult:
168
+ """Run alignment and return normalised results.
169
+
170
+ Parameters
171
+ ----------
172
+ sample:
173
+ Path to query file, or paired FASTQ paths ``(R1, R2)``.
174
+ index_path:
175
+ Path returned by :meth:`index`.
176
+ loci:
177
+ Gene names expected in the scheme (used to detect missing loci).
178
+ input_type:
179
+ ``"fasta"`` for assembled genomes, ``"fastq"`` for raw reads.
180
+ """
181
+ ...
@@ -0,0 +1,287 @@
1
+ """BLASTN alignment backend.
2
+
3
+ Strategy
4
+ --------
5
+ FASTA input (assembled genome):
6
+ Query = allele sequences (all loci concatenated)
7
+ Subject = genome contigs → makeblastdb on genome
8
+ This matches tseemann/mlst behaviour exactly.
9
+
10
+ FASTQ input:
11
+ Not supported — BLASTN cannot handle raw reads.
12
+ Use minimap2 or kma backends instead.
13
+
14
+ Output format
15
+ -------------
16
+ We use BLAST tabular output format 6 with the following fields::
17
+
18
+ qseqid sseqid pident length qlen slen qstart qend sstart send evalue bitscore
19
+
20
+ The query sequence id encodes locus and allele, e.g. ``arcC_1``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Literal
29
+
30
+ from gmlst.aligners.base import AlignmentResult, AlleleMatch
31
+ from gmlst.utils import require_tool, run_cmd, temp_dir
32
+
33
+ logger = logging.getLogger("gmlst.aligners.blastn")
34
+
35
+ # Tabular fields we request from BLAST
36
+ _OUTFMT = (
37
+ "6 qseqid sseqid pident length qlen qstart qend sstart send evalue bitscore sseq"
38
+ )
39
+
40
+
41
+ class BlastnAligner:
42
+ """MLST aligner using NCBI BLASTN."""
43
+
44
+ def __init__(
45
+ self, threads: int = 1, count_same_copy: bool = False, **kwargs
46
+ ) -> None:
47
+ self.threads = threads
48
+ self.count_same_copy = count_same_copy
49
+
50
+ @property
51
+ def name(self) -> str:
52
+ return "blastn"
53
+
54
+ @property
55
+ def supports_fastq(self) -> bool:
56
+ return False
57
+
58
+ def check_dependencies(self) -> None:
59
+ require_tool("blastn")
60
+ require_tool("makeblastdb")
61
+
62
+ # ------------------------------------------------------------------
63
+ # Indexing
64
+ # ------------------------------------------------------------------
65
+
66
+ def index(self, allele_fastas: list[Path], index_dir: Path) -> Path:
67
+ """Concatenate all allele FASTAs and build a BLAST nucleotide database.
68
+
69
+ Returns
70
+ -------
71
+ Path
72
+ The BLAST database prefix (e.g. ``index_dir / "alleles"``).
73
+ """
74
+ require_tool("makeblastdb")
75
+ index_dir.mkdir(parents=True, exist_ok=True)
76
+
77
+ # Merged FASTA path
78
+ merged = index_dir / "alleles.fasta"
79
+ with merged.open("wb") as out:
80
+ for fasta in sorted(allele_fastas):
81
+ with fasta.open("rb") as f:
82
+ import shutil
83
+
84
+ shutil.copyfileobj(f, out)
85
+ merged = index_dir / "alleles.fasta"
86
+ with merged.open("w") as out:
87
+ for fasta in sorted(allele_fastas):
88
+ out.write(fasta.read_text())
89
+
90
+ db_prefix = index_dir / "alleles"
91
+
92
+ # Build BLAST db only if stale
93
+ nhr = db_prefix.with_suffix(".nhr")
94
+ if not nhr.exists() or nhr.stat().st_mtime < merged.stat().st_mtime:
95
+ logger.info("Building BLAST database at %s …", db_prefix)
96
+ run_cmd(
97
+ [
98
+ "makeblastdb",
99
+ "-in",
100
+ str(merged),
101
+ "-dbtype",
102
+ "nucl",
103
+ "-out",
104
+ str(db_prefix),
105
+ "-title",
106
+ "gmlst_alleles",
107
+ ]
108
+ )
109
+ return db_prefix
110
+
111
+ # ------------------------------------------------------------------
112
+ # Alignment
113
+ # ------------------------------------------------------------------
114
+
115
+ def align(
116
+ self,
117
+ sample: Path,
118
+ index_path: Path,
119
+ loci: list[str],
120
+ input_type: Literal["fasta", "fastq"],
121
+ ) -> AlignmentResult:
122
+ """Run BLASTN: allele sequences → assembled genome.
123
+
124
+ For FASTA inputs the allele DB is used as **query** and the genome
125
+ as **subject** (``-db`` flag points to the allele index, genome is
126
+ passed with ``-query``).
127
+
128
+ Wait — tseemann does it the other way: genome is the db, alleles are
129
+ queries. We follow the same convention for compatibility:
130
+
131
+ blastn -query alleles.fasta -db genome -outfmt 6 …
132
+
133
+ This avoids building a db per sample (samples are queries would need
134
+ per-sample dbs). Instead we build one allele db and query each
135
+ sample against it.
136
+
137
+ Actually the most efficient approach for assembled genomes is:
138
+ -query <allele_fasta> -subject <genome> (no db needed per run)
139
+ Or build a db from the genome once per sample (tseemann does this).
140
+
141
+ We use ``-query <alleles> -subject <genome>`` for simplicity — no
142
+ per-sample database build required.
143
+ """
144
+ if input_type == "fastq":
145
+ raise ValueError(
146
+ "BlastnAligner does not support FASTQ input. "
147
+ "Use minimap2 or kma backend."
148
+ )
149
+
150
+ sample_id = sample.stem.split(".")[0]
151
+ t0 = time.perf_counter()
152
+
153
+ # The index_path is the allele db prefix; we query alleles against genome
154
+ alleles_fasta = index_path.parent / "alleles.fasta"
155
+
156
+ with temp_dir("gmlst_blastn_") as tmp:
157
+ out_file = tmp / "hits.tsv"
158
+ run_cmd(
159
+ [
160
+ "blastn",
161
+ "-query",
162
+ str(alleles_fasta),
163
+ "-subject",
164
+ str(sample),
165
+ "-outfmt",
166
+ _OUTFMT,
167
+ "-out",
168
+ str(out_file),
169
+ "-perc_identity",
170
+ "80",
171
+ "-dust",
172
+ "no",
173
+ "-num_threads",
174
+ str(self.threads),
175
+ ],
176
+ )
177
+ matches = _parse_blast_output(
178
+ out_file,
179
+ loci,
180
+ count_same_copy=self.count_same_copy,
181
+ )
182
+
183
+ runtime = time.perf_counter() - t0
184
+ called_loci = {m.locus for m in matches}
185
+ failed = [loc for loc in loci if loc not in called_loci]
186
+
187
+ return AlignmentResult(
188
+ sample_id=sample_id,
189
+ matches=matches,
190
+ failed_loci=failed,
191
+ backend=self.name,
192
+ runtime_seconds=runtime,
193
+ )
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Output parsing
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def _parse_blast_output(
202
+ path: Path,
203
+ loci: list[str],
204
+ *,
205
+ count_same_copy: bool = False,
206
+ ) -> list[AlleleMatch]:
207
+ """Parse BLAST tabular output and return :class:`AlleleMatch` objects.
208
+
209
+ Fields (format 6 custom)::
210
+
211
+ qseqid sseqid pident length qlen qstart qend sstart send evalue bitscore
212
+
213
+ The query id encodes locus and allele as ``<locus>_<allele_id>``.
214
+ We take the **best hit per (locus, allele_id)** pair — highest identity,
215
+ then longest alignment.
216
+ """
217
+ loci_set = set(loci)
218
+ # best[(locus, allele_id)] = AlleleMatch
219
+ best: dict[tuple[str, str], AlleleMatch] = {}
220
+ copies: dict[tuple[str, str], set[tuple[str, str, str]]] = {}
221
+
222
+ if not path.exists():
223
+ return []
224
+
225
+ with path.open() as fh:
226
+ for line in fh:
227
+ line = line.strip()
228
+ if not line or line.startswith("#"):
229
+ continue
230
+ parts = line.split("\t")
231
+ if len(parts) < 11:
232
+ continue
233
+
234
+ qseqid = parts[0]
235
+ pident = float(parts[2])
236
+ aln_len = int(parts[3])
237
+ qlen = int(parts[4])
238
+
239
+ # Parse locus + allele from query id (e.g. "arcC_1")
240
+ locus, allele_id = _split_allele_id(qseqid)
241
+ if locus not in loci_set:
242
+ continue
243
+
244
+ coverage = aln_len / qlen if qlen > 0 else 0.0
245
+ key = (locus, allele_id)
246
+ if count_same_copy:
247
+ sseqid = parts[1]
248
+ sstart = parts[7]
249
+ send = parts[8]
250
+ start, end = (sstart, send) if sstart <= send else (send, sstart)
251
+ copies.setdefault(key, set()).add((sseqid, start, end))
252
+
253
+ existing = best.get(key)
254
+ if existing is None or (
255
+ pident > existing.identity
256
+ or (pident == existing.identity and coverage > existing.coverage)
257
+ ):
258
+ # Extract subject sequence if available (for novel alleles)
259
+ sequence = parts[11] if len(parts) > 11 else None
260
+
261
+ best[key] = AlleleMatch(
262
+ locus=locus,
263
+ allele_id=allele_id,
264
+ identity=pident,
265
+ coverage=coverage,
266
+ alignment_length=aln_len,
267
+ score=float(parts[10]), # bitscore
268
+ sequence=sequence,
269
+ )
270
+
271
+ if count_same_copy:
272
+ for key, match in best.items():
273
+ match.copy_count = max(1, len(copies.get(key, set())))
274
+
275
+ return list(best.values())
276
+
277
+
278
+ def _split_allele_id(qseqid: str) -> tuple[str, str]:
279
+ """Split ``arcC_1`` → ``("arcC", "1")``.
280
+
281
+ Handles both ``_`` and ``-`` separators.
282
+ """
283
+ for sep in ("_", "-"):
284
+ if sep in qseqid:
285
+ locus, allele_id = qseqid.rsplit(sep, 1)
286
+ return locus, allele_id
287
+ return qseqid, "1"
gmlst/aligners/kma.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import shutil
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from gmlst.aligners.base import AlignmentResult, AlleleMatch
11
+ from gmlst.readers.sample import SampleInput
12
+ from gmlst.utils import temp_dir
13
+
14
+
15
+ class KmaAligner:
16
+ def __init__(self, threads: int = 1, **kwargs) -> None:
17
+ self.threads = threads
18
+ self.fastq_mem_mode = bool(kwargs.get("fastq_mem_mode", False))
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ return "kma"
23
+
24
+ @property
25
+ def supports_fastq(self) -> bool:
26
+ return True
27
+
28
+ def check_dependencies(self) -> None:
29
+ if shutil.which("kma") is None:
30
+ raise RuntimeError(
31
+ "kma backend requires KMA binary. Install by source build: "
32
+ "git clone https://github.com/genomicepidemiology/kma.git "
33
+ "&& cd kma && make"
34
+ )
35
+
36
+ def index(self, allele_fastas: list[Path], index_dir: Path) -> Path:
37
+ index_dir.mkdir(parents=True, exist_ok=True)
38
+ merged = index_dir / "alleles.fasta"
39
+ db_prefix = index_dir / "kma_db"
40
+
41
+ with merged.open("w") as out:
42
+ for fasta in sorted(allele_fastas):
43
+ opener = gzip.open if fasta.suffix == ".gz" else open
44
+ with opener(fasta, "rt") as fh: # type: ignore[call-overload]
45
+ out.write(fh.read())
46
+
47
+ subprocess.run(
48
+ [
49
+ "kma",
50
+ "index",
51
+ "-i",
52
+ str(merged),
53
+ "-o",
54
+ str(db_prefix),
55
+ ],
56
+ check=True,
57
+ capture_output=True,
58
+ text=True,
59
+ )
60
+ return index_dir
61
+
62
+ def align(
63
+ self,
64
+ sample: Path | tuple[Path, Path],
65
+ index_path: Path,
66
+ loci: list[str],
67
+ input_type: Literal["fasta", "fastq"],
68
+ ) -> AlignmentResult:
69
+ sample_path = sample[0] if isinstance(sample, tuple) else sample
70
+ sample_id = SampleInput.from_path(sample_path).sample_id
71
+ t0 = time.perf_counter()
72
+
73
+ db_prefix = index_path / "kma_db"
74
+ with temp_dir("gmlst_kma_") as tmp_dir:
75
+ out_prefix = tmp_dir / "kma_out"
76
+ cmd = [
77
+ "kma",
78
+ "-o",
79
+ str(out_prefix),
80
+ "-t_db",
81
+ str(db_prefix),
82
+ "-t",
83
+ str(max(1, self.threads)),
84
+ ]
85
+ if isinstance(sample, tuple):
86
+ cmd.extend(["-ipe", str(sample[0]), str(sample[1])])
87
+ else:
88
+ cmd.extend(["-i", str(sample)])
89
+ if input_type == "fasta":
90
+ cmd.append("-asm")
91
+ else:
92
+ cmd.append("-ill")
93
+ if self.fastq_mem_mode:
94
+ cmd.append("-mem_mode")
95
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
96
+
97
+ res_file = out_prefix.with_suffix(".res")
98
+ matches = _parse_kma_res(res_file, loci, input_type)
99
+
100
+ runtime = time.perf_counter() - t0
101
+ called_loci = {m.locus for m in matches}
102
+ failed = [loc for loc in loci if loc not in called_loci]
103
+ return AlignmentResult(
104
+ sample_id=sample_id,
105
+ matches=matches,
106
+ failed_loci=failed,
107
+ backend=self.name,
108
+ runtime_seconds=runtime,
109
+ )
110
+
111
+
112
+ def _parse_kma_res(
113
+ path: Path,
114
+ loci: list[str],
115
+ input_type: Literal["fasta", "fastq"],
116
+ ) -> list[AlleleMatch]:
117
+ loci_set = set(loci)
118
+ results: list[AlleleMatch] = []
119
+ with path.open() as fh:
120
+ header: dict[str, int] | None = None
121
+ for line in fh:
122
+ line = line.rstrip("\n")
123
+ if not line:
124
+ continue
125
+ if line.startswith("#"):
126
+ cols = line.lstrip("#").split("\t")
127
+ header = {name: idx for idx, name in enumerate(cols)}
128
+ continue
129
+ if header is None:
130
+ continue
131
+
132
+ cols = line.split("\t")
133
+ template = cols[header.get("Template", 0)]
134
+ locus, allele_id = _split_template(template)
135
+ if locus not in loci_set:
136
+ continue
137
+
138
+ identity = float(cols[header.get("Template_Identity", 4)])
139
+ coverage = float(cols[header.get("Template_Coverage", 5)]) / 100.0
140
+ score = float(cols[header.get("Score", 1)])
141
+ raw_depth = float(cols[header.get("Depth", 8)])
142
+ depth = raw_depth if input_type == "fastq" else None
143
+ template_len = int(float(cols[header.get("Template_length", 3)]))
144
+
145
+ results.append(
146
+ AlleleMatch(
147
+ locus=locus,
148
+ allele_id=allele_id,
149
+ identity=identity,
150
+ coverage=coverage,
151
+ score=score,
152
+ depth=depth,
153
+ alignment_length=template_len,
154
+ )
155
+ )
156
+
157
+ return results
158
+
159
+
160
+ def _split_template(template: str) -> tuple[str, str]:
161
+ if "_" in template:
162
+ return tuple(template.rsplit("_", 1)) # type: ignore[return-value]
163
+ if "-" in template:
164
+ return tuple(template.rsplit("-", 1)) # type: ignore[return-value]
165
+ return (template, "")