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 +9 -0
- viralfetch/__init__.py +3 -0
- viralfetch/accessions.py +78 -0
- viralfetch/assets/logo.png +0 -0
- viralfetch/cache.py +106 -0
- viralfetch/cli.py +406 -0
- viralfetch/compare.py +69 -0
- viralfetch/config.py +88 -0
- viralfetch/data/VMR_MSL41.v1.20260320.tsv +19272 -0
- viralfetch/ictv.py +421 -0
- viralfetch/models.py +122 -0
- viralfetch/ncbi.py +490 -0
- viralfetch/queries.py +299 -0
- viralfetch/render/__init__.py +19 -0
- viralfetch/render/json_.py +175 -0
- viralfetch/render/rich_.py +305 -0
- viralfetch/sequences.py +208 -0
- viralfetch/vmr.py +203 -0
- viralfetch-0.1.0.dist-info/METADATA +488 -0
- viralfetch-0.1.0.dist-info/RECORD +23 -0
- viralfetch-0.1.0.dist-info/WHEEL +4 -0
- viralfetch-0.1.0.dist-info/entry_points.txt +2 -0
- viralfetch-0.1.0.dist-info/licenses/LICENSE +21 -0
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
viralfetch/accessions.py
ADDED
|
@@ -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
|
+
)
|