viralfetch 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.
viralfetch/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ # Local build/runtime artifacts inside the package tree.
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+
6
+ # IMPORTANT: the embedded VMR is the local source of truth and MUST be
7
+ # tracked. Keep it explicitly un-ignored in case a broad rule ever matches it.
8
+ !data/
9
+ !data/*.tsv
viralfetch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """viralfetch — query and download viral taxonomy, metadata and sequences."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,78 @@
1
+ """Parser for the VMR "Virus GENBANK accession" free-text field.
2
+
3
+ The column is not a clean identifier. Observed shapes (VMR MSL41):
4
+
5
+ NC_045512 -> simple
6
+ RNA1: NC_003615; RNA2: NC_003616 -> segmented, labelled
7
+ DNA-A: X15656; DNA-B: X15657 -> geminivirus
8
+ KT732816; KT732815; KT732817 -> multiple, unlabelled
9
+ AE006468 (2844298.2877981) -> with coordinate annotation
10
+ AB012345 (partial) -> with textual annotation
11
+ CAJDJZ010000002 -> WGS accession (long prefix)
12
+
13
+ The parser normalises every field into ``list[Accession]`` — one entry per
14
+ accession, which is what lets the rest of the tool work at accession
15
+ granularity (essential for segmented viruses, where each segment is a
16
+ separate GenBank record).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ from .models import Accession
24
+
25
+ # A GenBank / RefSeq / WGS accession token. Covers:
26
+ # - classic: X15656, AB012345, EU623082
27
+ # - RefSeq: NC_045512, AC_123456
28
+ # - WGS: CAJDJZ010000002 (up to 6 letters + long digit run)
29
+ # Optionally versioned with a trailing ".N".
30
+ _ACCESSION_RE = re.compile(r"^[A-Za-z]{1,6}_?\d{5,}(?:\.\d+)?$")
31
+
32
+ # Parenthetical annotations to strip before extracting the token, e.g.
33
+ # "(2844298.2877981)" or "(partial)".
34
+ _PAREN_RE = re.compile(r"\([^)]*\)")
35
+
36
+
37
+ def parse_accessions(raw: str | None) -> list[Accession]:
38
+ """Parse a raw VMR accession field into a list of :class:`Accession`.
39
+
40
+ Unparseable chunks are dropped silently here; callers that need a
41
+ quality signal should compare ``len(parse_accessions(raw))`` against the
42
+ presence of a non-empty ``raw`` (see :func:`count_unparsed`).
43
+ """
44
+ if not raw:
45
+ return []
46
+
47
+ out: list[Accession] = []
48
+ for chunk in raw.split(";"):
49
+ chunk = chunk.strip()
50
+ if not chunk:
51
+ continue
52
+
53
+ segment: str | None = None
54
+ # A leading "LABEL:" marks a segment. Labels are arbitrary free text
55
+ # ("RNA1", "DNA-A", "S", "partial", ...); split on the first colon.
56
+ if ":" in chunk:
57
+ label, _, rest = chunk.partition(":")
58
+ segment = label.strip() or None
59
+ chunk = rest.strip()
60
+
61
+ # Drop coordinate / textual annotations before tokenising, then trim
62
+ # trailing punctuation (VMR occasionally has a dangling "." with no
63
+ # version, e.g. "PP173676.").
64
+ token = _PAREN_RE.sub("", chunk).strip().rstrip(".")
65
+ if not token:
66
+ continue
67
+
68
+ if _ACCESSION_RE.match(token):
69
+ out.append(Accession(accession=token, segment=segment))
70
+
71
+ return out
72
+
73
+
74
+ def is_parseable(raw: str | None) -> bool:
75
+ """True if a non-empty field yielded at least one accession."""
76
+ if not (raw and raw.strip()):
77
+ return False
78
+ return len(parse_accessions(raw)) > 0
Binary file
viralfetch/cache.py ADDED
@@ -0,0 +1,106 @@
1
+ """On-disk cache with a simple TTL.
2
+
3
+ Two namespaces (SPEC section 3):
4
+
5
+ - ``SEQS`` — sequences and accession metadata. Accessions are immutable, so
6
+ these entries are cached **permanently** (no TTL).
7
+ - ``TEXTS`` — ICTV chapter text. Cached with a **30-day TTL**.
8
+
9
+ The store is intentionally dumb: one file per entry, keyed by a hash of the
10
+ logical key; expiry is decided from the file mtime. No "smart" invalidation
11
+ (SPEC section 10).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import time
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ SEQS = "seqs"
22
+ TEXTS = "texts"
23
+
24
+ TEXT_TTL = 30 * 24 * 60 * 60 # 30 days, in seconds
25
+
26
+
27
+ def _hash(key: str) -> str:
28
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()
29
+
30
+
31
+ @dataclass
32
+ class NamespaceInfo:
33
+ namespace: str
34
+ entries: int
35
+ bytes: int
36
+
37
+
38
+ class Cache:
39
+ """A file-backed key/value cache. Disable with ``enabled=False``."""
40
+
41
+ def __init__(self, base_dir: Path, enabled: bool = True):
42
+ self.base_dir = Path(base_dir)
43
+ self.enabled = enabled
44
+
45
+ def _path(self, namespace: str, key: str) -> Path:
46
+ return self.base_dir / namespace / _hash(key)
47
+
48
+ def get(self, namespace: str, key: str, ttl: int | None = None) -> str | None:
49
+ """Return cached text, or ``None`` if absent, expired, or disabled."""
50
+ if not self.enabled:
51
+ return None
52
+ path = self._path(namespace, key)
53
+ if not path.is_file():
54
+ return None
55
+ if ttl is not None and (time.time() - path.stat().st_mtime) > ttl:
56
+ return None
57
+ try:
58
+ return path.read_text(encoding="utf-8")
59
+ except OSError:
60
+ return None
61
+
62
+ def set(self, namespace: str, key: str, value: str) -> None:
63
+ """Store ``value`` under ``key``. No-op when disabled."""
64
+ if not self.enabled:
65
+ return
66
+ path = self._path(namespace, key)
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ path.write_text(value, encoding="utf-8")
69
+
70
+ def clear(self, *, texts: bool = False, seqs: bool = False) -> int:
71
+ """Remove cached entries. With neither flag, clear everything.
72
+
73
+ Returns the number of entries removed.
74
+ """
75
+ both = not texts and not seqs
76
+ targets = []
77
+ if texts or both:
78
+ targets.append(TEXTS)
79
+ if seqs or both:
80
+ targets.append(SEQS)
81
+
82
+ removed = 0
83
+ for ns in targets:
84
+ ns_dir = self.base_dir / ns
85
+ if not ns_dir.is_dir():
86
+ continue
87
+ for entry in ns_dir.iterdir():
88
+ if entry.is_file():
89
+ entry.unlink()
90
+ removed += 1
91
+ return removed
92
+
93
+ def info(self) -> list[NamespaceInfo]:
94
+ """Per-namespace entry counts and total bytes."""
95
+ out: list[NamespaceInfo] = []
96
+ for ns in (SEQS, TEXTS):
97
+ ns_dir = self.base_dir / ns
98
+ entries = 0
99
+ total = 0
100
+ if ns_dir.is_dir():
101
+ for entry in ns_dir.iterdir():
102
+ if entry.is_file():
103
+ entries += 1
104
+ total += entry.stat().st_size
105
+ out.append(NamespaceInfo(namespace=ns, entries=entries, bytes=total))
106
+ return out
viralfetch/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ """Command-line interface.
2
+
3
+ Thin by design (SPEC section 4): commands resolve config, call a service in
4
+ :mod:`viralfetch.queries`, and hand the resulting view dataclass to the
5
+ :mod:`viralfetch.render` layer. No business logic or printing lives here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ import typer
13
+
14
+ from . import compare
15
+ from . import config as config_mod
16
+ from . import ictv
17
+ from . import queries
18
+ from . import render
19
+ from . import sequences
20
+ from .cache import Cache
21
+ from .ictv import ICTVClient
22
+ from .ncbi import NCBIClient, NCBIError
23
+ from .vmr import VMR_FILENAME, load
24
+
25
+ LARGE_DOWNLOAD = 500 # accessions above which a fetch asks for confirmation
26
+
27
+
28
+ def _make_client(cfg: config_mod.Config, out) -> NCBIClient:
29
+ """Build an NCBI client or exit(3) with a helpful message if no email."""
30
+ try:
31
+ return NCBIClient(cfg, cache=Cache(config_mod.CACHE_DIR, enabled=not cfg.no_cache))
32
+ except config_mod.ConfigError as exc:
33
+ out.error(str(exc))
34
+ raise typer.Exit(3)
35
+
36
+
37
+ def _make_ictv_client(cfg: config_mod.Config, out) -> ICTVClient:
38
+ """Build an ICTV client or exit(3) if no email (needed for the User-Agent)."""
39
+ try:
40
+ return ICTVClient(cfg, cache=Cache(config_mod.CACHE_DIR, enabled=not cfg.no_cache))
41
+ except config_mod.ConfigError as exc:
42
+ out.error(str(exc))
43
+ raise typer.Exit(3)
44
+
45
+
46
+ def _complete_taxon(incomplete: str) -> list[str]:
47
+ """Shell-completion source: taxon names starting with the typed prefix."""
48
+ try:
49
+ vmr = load()
50
+ except Exception:
51
+ return []
52
+ needle = incomplete.casefold()
53
+ names = sorted({t.name for t in vmr.taxa.values()}, key=str.casefold)
54
+ return [n for n in names if n.casefold().startswith(needle)][:40]
55
+
56
+
57
+ def _mask(key: str | None) -> str:
58
+ """Mask an API key for display, revealing only the last four characters."""
59
+ if not key:
60
+ return "(not set)"
61
+ return ("…" + key[-4:]) if len(key) > 4 else "****"
62
+
63
+ HELP = """Query and download viral taxonomy, metadata and sequences.
64
+
65
+ Combines the ICTV VMR (local, embedded), NCBI E-utilities (remote) and the
66
+ ICTV Report (remote). The VMR is the local index; everything else is fetched
67
+ on demand and cached.
68
+
69
+ Global options below apply to every command and go [bold]before[/] it, e.g.
70
+ [cyan]viralfetch --json tax Coronaviridae[/]. Each command has its own arguments
71
+ and options — run [bold]viralfetch COMMAND --help[/] to see them in their own boxes.
72
+ """
73
+
74
+ # Help-panel titles group each command's options into their own boxes.
75
+ _FORMAT = "Output format"
76
+ _SELECT = "Molecule selection"
77
+ _TARGET = "Target & output"
78
+
79
+ # Command-group panels split the main --help listing into two sections.
80
+ _PANEL_QUERY = "Query & retrieval"
81
+ _PANEL_CONFIG = "Configuration & maintenance"
82
+
83
+ app = typer.Typer(
84
+ name="viralfetch",
85
+ help=HELP,
86
+ no_args_is_help=True,
87
+ add_completion=True,
88
+ rich_markup_mode="rich",
89
+ )
90
+
91
+
92
+ @app.callback()
93
+ def main(
94
+ ctx: typer.Context,
95
+ json_out: bool = typer.Option(False, "--json", help="Emit pure JSON on stdout (for jq)."),
96
+ no_cache: bool = typer.Option(False, "--no-cache", help="Force refetch, ignore cache."),
97
+ verbose: bool = typer.Option(False, "--verbose", help="Verbose diagnostics on stderr."),
98
+ email: str = typer.Option(None, "--email", help="NCBI email (overrides $NCBI_EMAIL)."),
99
+ api_key: str = typer.Option(None, "--api-key", help="NCBI API key (overrides $NCBI_API_KEY)."),
100
+ ) -> None:
101
+ """Resolve global configuration and stash it on the context."""
102
+ ctx.obj = config_mod.resolve(
103
+ email=email,
104
+ api_key=api_key,
105
+ fmt="json" if json_out else "rich",
106
+ verbose=verbose,
107
+ no_cache=no_cache,
108
+ )
109
+
110
+
111
+ @app.command(rich_help_panel=_PANEL_QUERY)
112
+ def tax(
113
+ ctx: typer.Context,
114
+ name: str = typer.Argument(..., help="Taxon name (any rank).", autocompletion=_complete_taxon),
115
+ compare_ncbi: bool = typer.Option(
116
+ False, "--compare-ncbi", help="Show the ICTV lineage beside NCBI's, highlighting divergences."
117
+ ),
118
+ ) -> None:
119
+ """Show the full ICTV lineage of a taxon (realm -> species).
120
+
121
+ With --compare-ncbi, fetch the NCBI taxonomy lineage for a representative
122
+ accession and render both side by side. Divergences are expected and are
123
+ the product of the command — NCBI commonly lags ICTV.
124
+ """
125
+ cfg: config_mod.Config = ctx.obj
126
+ out = render.get(cfg.format)
127
+ vmr = load()
128
+
129
+ if compare_ncbi:
130
+ try:
131
+ client = _make_client(cfg, out)
132
+ result = compare.compare_ncbi(vmr, client, name)
133
+ except queries.TaxonNotFound as exc:
134
+ out.not_found(exc.name, exc.suggestions)
135
+ raise typer.Exit(1)
136
+ except (compare.NoRepresentativeAccession, compare.NoNcbiTaxid) as exc:
137
+ out.error(str(exc))
138
+ raise typer.Exit(4)
139
+ except NCBIError as exc:
140
+ out.error(f"NCBI request failed: {exc}")
141
+ raise typer.Exit(4)
142
+ out.compare(result)
143
+ return
144
+
145
+ try:
146
+ view = queries.tax(vmr, name)
147
+ except queries.TaxonNotFound as exc:
148
+ out.not_found(exc.name, exc.suggestions)
149
+ raise typer.Exit(1)
150
+ out.tax(view)
151
+
152
+
153
+ @app.command(rich_help_panel=_PANEL_QUERY)
154
+ def members(
155
+ ctx: typer.Context,
156
+ taxon: str = typer.Argument(..., help="Parent taxon name.", autocompletion=_complete_taxon),
157
+ rank: str = typer.Option(None, "--rank", help="Restrict to a rank below the parent (e.g. genus)."),
158
+ count: bool = typer.Option(False, "--count", help="Show aggregated counts only."),
159
+ tree: bool = typer.Option(False, "--tree", help="List the full descendant subtree as a hierarchy."),
160
+ ) -> None:
161
+ """List child taxa of a taxon at any rank below it (local, no network).
162
+
163
+ With --tree, render the entire descendant hierarchy (subfamily -> genus ->
164
+ species) rooted at the taxon. Without flags, show a per-rank breakdown.
165
+ """
166
+ cfg: config_mod.Config = ctx.obj
167
+ out = render.get(cfg.format)
168
+ vmr = load()
169
+ try:
170
+ if tree:
171
+ out.members_tree(queries.members_tree(vmr, taxon))
172
+ return
173
+ view = queries.members(vmr, taxon, rank=rank, count=count)
174
+ except queries.TaxonNotFound as exc:
175
+ out.not_found(exc.name, exc.suggestions)
176
+ raise typer.Exit(1)
177
+ except queries.InvalidRank as exc:
178
+ out.error(
179
+ f"Rank {exc.rank!r} is not below {exc.taxon.rank} {exc.taxon.name!r}. "
180
+ f"Valid ranks: {', '.join(exc.valid)}."
181
+ )
182
+ raise typer.Exit(2)
183
+ out.members(view)
184
+
185
+
186
+ @app.command(rich_help_panel=_PANEL_QUERY)
187
+ def seq(
188
+ ctx: typer.Context,
189
+ species: str = typer.Argument(None, help="Species name (VMR). Omit when using --taxon.", autocompletion=_complete_taxon),
190
+ taxon: str = typer.Option(None, "--taxon", help="Operate on a whole taxon (any rank) instead of one species.", rich_help_panel=_TARGET, autocompletion=_complete_taxon),
191
+ meta: bool = typer.Option(False, "--meta", help="Metadata via esummary (default).", rich_help_panel=_FORMAT),
192
+ fasta: bool = typer.Option(False, "--fasta", help="FASTA sequences via efetch.", rich_help_panel=_FORMAT),
193
+ gb: bool = typer.Option(False, "--gb", help="Full GenBank records via efetch.", rich_help_panel=_FORMAT),
194
+ moltype: str = typer.Option(None, "--moltype", help="Filter nuccore results by moltype (e.g. ssRNA); matches ss-RNA etc.", rich_help_panel=_SELECT),
195
+ biomol: str = typer.Option(None, "--biomol", help="Filter nuccore results by biomol (e.g. genomic, mRNA, cRNA).", rich_help_panel=_SELECT),
196
+ protein: bool = typer.Option(False, "--protein", help="Fetch PROTEINS via elink (nuccore->protein). Not a nuccore filter.", rich_help_panel=_SELECT),
197
+ output: str = typer.Option(None, "-o", "--output", help="Write records to a file instead of stdout.", rich_help_panel=_TARGET),
198
+ yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt for large downloads.", rich_help_panel=_TARGET),
199
+ ) -> None:
200
+ """Fetch NCBI sequence data for a species or a whole taxon (accessions come
201
+ from the VMR). Output formats are mutually exclusive; --meta is the default.
202
+
203
+ Note: --moltype/--biomol filter nuccore records locally, while --protein is
204
+ a separate path (elink nuccore->protein), not a nuccore filter.
205
+ """
206
+ cfg: config_mod.Config = ctx.obj
207
+ out = render.get(cfg.format)
208
+
209
+ if (species is None) == (taxon is None):
210
+ out.error("Provide exactly one of a species argument or --taxon.")
211
+ raise typer.Exit(2)
212
+
213
+ chosen = [name for name, on in (("meta", meta), ("fasta", fasta), ("gb", gb)) if on]
214
+ if len(chosen) > 1:
215
+ out.error("Choose only one of --meta, --fasta, --gb.")
216
+ raise typer.Exit(2)
217
+ mode = chosen[0] if chosen else "meta"
218
+
219
+ if protein and (moltype or biomol):
220
+ out.warn("--moltype/--biomol are nuccore fields and are ignored with --protein.")
221
+
222
+ vmr = load()
223
+ try:
224
+ # Taxon --meta with no record-level need => cheap local aggregate.
225
+ if taxon is not None and mode == "meta" and not protein:
226
+ out.seq_aggregate(sequences.taxon_aggregate(vmr, taxon))
227
+ return
228
+
229
+ name, accessions = sequences.resolve_accessions(vmr, species=species, taxon=taxon)
230
+ client = _make_client(cfg, out)
231
+
232
+ if protein:
233
+ if mode == "meta":
234
+ out.seq_meta(name, sequences.protein_meta(client, accessions))
235
+ else:
236
+ rettype = "fasta" if mode == "fasta" else "gb"
237
+ _confirm_or_exit(client.protein_uids_for(accessions), yes, cfg, out)
238
+ out.seq_records(name, sequences.protein_records(client, accessions, rettype), output)
239
+ return
240
+
241
+ if mode == "meta":
242
+ result = sequences.meta(client, accessions, moltype=moltype, biomol=biomol)
243
+ out.seq_meta(name, result)
244
+ else:
245
+ rettype = "fasta" if mode == "fasta" else "gb"
246
+ _confirm_or_exit(accessions, yes, cfg, out)
247
+ result = sequences.records(client, accessions, rettype, moltype=moltype, biomol=biomol)
248
+ out.seq_records(name, result, output)
249
+ except queries.TaxonNotFound as exc:
250
+ out.not_found(exc.name, exc.suggestions)
251
+ raise typer.Exit(1)
252
+ except sequences.NotASpecies as exc:
253
+ out.error(f"{exc.name!r} is a {exc.rank}, not a species. Use --taxon to fetch a whole taxon.")
254
+ raise typer.Exit(2)
255
+ except NCBIError as exc:
256
+ out.error(f"NCBI request failed: {exc}")
257
+ raise typer.Exit(4)
258
+
259
+
260
+ @app.command(rich_help_panel=_PANEL_QUERY)
261
+ def text(
262
+ ctx: typer.Context,
263
+ name: str = typer.Argument(..., help="Family name (ICTV Report chapter).", autocompletion=_complete_taxon),
264
+ section: str = typer.Option(None, "--section", help="Show only a section by heading (e.g. summary)."),
265
+ raw: bool = typer.Option(False, "--raw", help="Emit raw Markdown to stdout (for redirecting to a file)."),
266
+ ) -> None:
267
+ """Fetch an ICTV Report chapter and render it (headings, tables, italics).
268
+
269
+ The original page URL and the chapter's references/attribution are shown at
270
+ the top, and the content is CC BY 4.0. Images are omitted.
271
+ """
272
+ cfg: config_mod.Config = ctx.obj
273
+ out = render.get(cfg.format)
274
+ client = _make_ictv_client(cfg, out)
275
+
276
+ # The ICTV Report is organised by family: map a genus/species/etc. to its
277
+ # family chapter. A name unknown to the VMR is tried verbatim, and its
278
+ # suggestions are kept in case that fetch 404s.
279
+ vmr = load()
280
+ suggestions: list[str] = []
281
+ try:
282
+ target, note = queries.report_target(vmr, name)
283
+ except queries.TaxonNotFound as exc:
284
+ target, note, suggestions = name, None, exc.suggestions
285
+ if note:
286
+ out.warn(note) # to stderr, so --raw/--json stdout stays clean
287
+
288
+ try:
289
+ chapter = client.fetch_chapter(target)
290
+ markdown = ictv.section_markdown(chapter, section) if section else chapter.markdown
291
+ except ictv.ChapterNotFound as exc:
292
+ if suggestions:
293
+ out.not_found(name, suggestions)
294
+ else:
295
+ out.error(f"No ICTV Report chapter found for {name!r} (tried {exc.url}).")
296
+ raise typer.Exit(1)
297
+ except ictv.SectionNotFound as exc:
298
+ out.error(f"Section {exc.section!r} not found. Available: {', '.join(exc.available)}.")
299
+ raise typer.Exit(2)
300
+ except ictv.ChapterParseError as exc:
301
+ out.error(f"Could not parse the ICTV chapter: {exc}")
302
+ raise typer.Exit(4)
303
+ except ictv.ICTVError as exc:
304
+ out.error(f"ICTV request failed: {exc}")
305
+ raise typer.Exit(4)
306
+
307
+ if raw and cfg.format != "json":
308
+ out.text_raw(markdown)
309
+ else:
310
+ out.text(chapter, markdown)
311
+
312
+
313
+ def _confirm_or_exit(items: list[str], yes: bool, cfg: config_mod.Config, out) -> None:
314
+ """Guard large downloads. Above the threshold, require an explicit yes."""
315
+ n = len(items)
316
+ if n <= LARGE_DOWNLOAD or yes:
317
+ return
318
+ # Never block on a prompt in JSON mode or a non-interactive stdin.
319
+ if cfg.format == "json" or not sys.stdin.isatty():
320
+ out.error(f"{n} records to download (> {LARGE_DOWNLOAD}). Re-run with --yes to proceed.")
321
+ raise typer.Exit(2)
322
+ if not typer.confirm(f"About to download {n} records. Continue?"):
323
+ raise typer.Exit(0)
324
+
325
+
326
+ @app.command(rich_help_panel=_PANEL_CONFIG)
327
+ def diagnose(ctx: typer.Context) -> None:
328
+ """Report VMR accession-parser quality (rows that yielded zero accessions).
329
+
330
+ The empty/unparsed counts are the parser's quality indicator (SPEC section
331
+ 6); a spike means the free-text accession field grew an unhandled shape.
332
+ """
333
+ cfg: config_mod.Config = ctx.obj
334
+ out = render.get(cfg.format)
335
+ out.diagnose(queries.diagnostics(load()))
336
+
337
+
338
+ @app.command(rich_help_panel=_PANEL_CONFIG)
339
+ def update(ctx: typer.Context) -> None:
340
+ """Check whether a newer VMR is published on ictv.global/vmr.
341
+
342
+ The VMR ships embedded in the package; this only reports whether a newer
343
+ release exists (and where to download it), it does not replace the file.
344
+ """
345
+ cfg: config_mod.Config = ctx.obj
346
+ out = render.get(cfg.format)
347
+ client = _make_ictv_client(cfg, out)
348
+ try:
349
+ status = client.check_vmr_update(VMR_FILENAME)
350
+ except ictv.ICTVError as exc:
351
+ out.error(f"ICTV request failed: {exc}")
352
+ raise typer.Exit(4)
353
+ out.update_status(status)
354
+
355
+
356
+ @app.command(rich_help_panel=_PANEL_CONFIG)
357
+ def config(
358
+ ctx: typer.Context,
359
+ store_ncbi_email: str = typer.Option(None, "--store-ncbi-email", help="Persist an NCBI email to the config file."),
360
+ store_ncbi_apikey: str = typer.Option(None, "--store-ncbi-apikey", help="Persist an NCBI API key to the config file."),
361
+ ) -> None:
362
+ """Show the effective NCBI email/API key (masked) and cache/config paths.
363
+
364
+ With --store-ncbi-email / --store-ncbi-apikey, persist those values first.
365
+ """
366
+ cfg: config_mod.Config = ctx.obj
367
+ out = render.get(cfg.format)
368
+ if store_ncbi_email is not None or store_ncbi_apikey is not None:
369
+ config_mod.store(email=store_ncbi_email, api_key=store_ncbi_apikey)
370
+ cfg = config_mod.resolve(fmt=cfg.format) # refresh from env + file
371
+ out.config_view({
372
+ "email": cfg.email,
373
+ "api_key": _mask(cfg.api_key),
374
+ "cache_dir": str(config_mod.CACHE_DIR),
375
+ "config_file": str(config_mod.CONFIG_FILE),
376
+ "config_file_exists": config_mod.CONFIG_FILE.is_file(),
377
+ })
378
+
379
+
380
+ cache_app = typer.Typer(help="Inspect or clear the on-disk cache.")
381
+ app.add_typer(cache_app, name="cache", rich_help_panel=_PANEL_CONFIG)
382
+
383
+
384
+ @cache_app.command("info")
385
+ def cache_info_cmd(ctx: typer.Context) -> None:
386
+ """Show per-namespace entry counts and total size."""
387
+ cfg: config_mod.Config = ctx.obj
388
+ out = render.get(cfg.format)
389
+ out.cache_info(Cache(config_mod.CACHE_DIR).info())
390
+
391
+
392
+ @cache_app.command("clear")
393
+ def cache_clear_cmd(
394
+ ctx: typer.Context,
395
+ texts: bool = typer.Option(False, "--texts", help="Clear only ICTV chapter text (30-day TTL namespace)."),
396
+ seqs: bool = typer.Option(False, "--seqs", help="Clear only sequences/metadata (permanent namespace)."),
397
+ ) -> None:
398
+ """Remove cached entries. With neither flag, clear everything."""
399
+ cfg: config_mod.Config = ctx.obj
400
+ out = render.get(cfg.format)
401
+ removed = Cache(config_mod.CACHE_DIR).clear(texts=texts, seqs=seqs)
402
+ out.cache_cleared(removed, texts=texts, seqs=seqs)
403
+
404
+
405
+ if __name__ == "__main__": # pragma: no cover
406
+ app()
viralfetch/compare.py ADDED
@@ -0,0 +1,69 @@
1
+ """ICTV-vs-NCBI lineage comparison (SPEC section 5.2).
2
+
3
+ Flow: take the local ICTV lineage from the VMR, find a representative accession
4
+ for the taxon, elink nuccore -> taxonomy for the NCBI taxid, efetch the NCBI
5
+ lineage, and return both side by side. Divergences are the *product* of this
6
+ command, not an error — NCBI commonly lags ICTV.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from .models import RANKS
14
+ from .ncbi import NcbiLineage, NCBIClient
15
+ from .queries import TaxonNotFound, descendant_isolates
16
+ from .vmr import VMR
17
+
18
+
19
+ class NoRepresentativeAccession(Exception):
20
+ """Raised when a taxon has no accession to anchor the NCBI lookup."""
21
+
22
+ def __init__(self, name: str):
23
+ self.name = name
24
+ super().__init__(f"no accession available for {name!r}")
25
+
26
+
27
+ class NoNcbiTaxid(Exception):
28
+ """Raised when NCBI has no taxonomy link for the representative accession."""
29
+
30
+ def __init__(self, accession: str):
31
+ self.accession = accession
32
+ super().__init__(f"NCBI returned no taxid for {accession!r}")
33
+
34
+
35
+ @dataclass
36
+ class LineageComparison:
37
+ taxon: str
38
+ representative_accession: str
39
+ ictv: list[tuple[str, str]] = field(default_factory=list) # (rank, name)
40
+ ncbi: NcbiLineage | None = None
41
+
42
+
43
+ def _representative_accession(vmr: VMR, name: str) -> str:
44
+ _taxon, isolates = descendant_isolates(vmr, name)
45
+ # Prefer an exemplar isolate's first accession; fall back to any.
46
+ for iso in sorted(isolates, key=lambda i: not i.exemplar):
47
+ if iso.accessions:
48
+ return iso.accessions[0].accession
49
+ raise NoRepresentativeAccession(name)
50
+
51
+
52
+ def compare_ncbi(vmr: VMR, ncbi: NCBIClient, name: str) -> LineageComparison:
53
+ taxon = vmr.find(name)
54
+ if taxon is None:
55
+ raise TaxonNotFound(name, vmr.suggest(name))
56
+
57
+ accession = _representative_accession(vmr, name)
58
+ taxid = ncbi.taxid_for_accession(accession)
59
+ if not taxid:
60
+ raise NoNcbiTaxid(accession)
61
+
62
+ ncbi_lineage = ncbi.efetch_taxonomy(taxid)
63
+ ictv = [(rank, taxon.lineage[rank]) for rank in RANKS if rank in taxon.lineage]
64
+ return LineageComparison(
65
+ taxon=taxon.name,
66
+ representative_accession=accession,
67
+ ictv=ictv,
68
+ ncbi=ncbi_lineage,
69
+ )