allelix 2.0.1__tar.gz → 2.1.0__tar.gz
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.
- {allelix-2.0.1 → allelix-2.1.0}/PKG-INFO +13 -1
- {allelix-2.0.1 → allelix-2.1.0}/README.md +12 -0
- allelix-2.1.0/allelix/__init__.py +41 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/base.py +24 -1
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/clinvar.py +14 -4
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/gwas.py +44 -6
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/pharmgkb.py +21 -5
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/snpedia.py +3 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/_helpers.py +20 -3
- allelix-2.1.0/allelix/data/high_value_snps.yaml +136 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/models.py +27 -1
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/base.py +8 -1
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/vcf.py +31 -7
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/_pipeline.py +118 -62
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/diff.py +1 -1
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/json_report.py +6 -2
- allelix-2.1.0/allelix/utils/allele.py +211 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/PKG-INFO +13 -1
- {allelix-2.0.1 → allelix-2.1.0}/pyproject.toml +1 -1
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_end_to_end.py +28 -6
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_models.py +27 -0
- allelix-2.1.0/tests/test_version.py +100 -0
- allelix-2.0.1/allelix/__init__.py +0 -12
- allelix-2.0.1/allelix/data/high_value_snps.yaml +0 -64
- allelix-2.0.1/allelix/utils/allele.py +0 -89
- allelix-2.0.1/tests/test_version.py +0 -52
- {allelix-2.0.1 → allelix-2.1.0}/LICENSE +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/cadd.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/gnomad.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/_options.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/analyze.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/config.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/db.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/focused.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/utility.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/compare.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/config.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/data/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/_versions.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/manager.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/schema.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/exporters/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/exporters/plink.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/twentythreeandme.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/py.typed +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/high_value.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/html.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/methylation.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/terminal.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/utils/__init__.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix/utils/build_detect.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/SOURCES.txt +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/setup.cfg +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_cli.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_cli_helpers.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_compare.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_config.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.0.1 → allelix-2.1.0}/tests/test_registry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
|
|
5
5
|
Author: Allelix
|
|
6
6
|
Maintainer-email: dial481 <dial481@users.noreply.github.com>
|
|
@@ -161,6 +161,18 @@ This is not a disclaimer afterthought. It is a design constraint that affects mo
|
|
|
161
161
|
- Reference databases are downloaded via `allelix db update` and cached locally.
|
|
162
162
|
- Analysis runs offline against local database caches. A brief freshness check runs before analysis by default (skipped with `--no-update`).
|
|
163
163
|
|
|
164
|
+
### Output files contain real annotations of your genome
|
|
165
|
+
|
|
166
|
+
The JSON / HTML / terminal output of `allelix analyze` and its
|
|
167
|
+
focused subcommands contains real annotations against your specific
|
|
168
|
+
variants — drug-response calls, carrier-status flags, hereditary-
|
|
169
|
+
disease findings. Wherever you write them via `--output <path>`,
|
|
170
|
+
that's where they sit until you delete them. Allelix doesn't
|
|
171
|
+
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
172
|
+
other shared location. Treat the files as personal data: read them,
|
|
173
|
+
move them somewhere you control, or delete when you're done. A
|
|
174
|
+
data-lifecycle subcommand is planned for v2.1.
|
|
175
|
+
|
|
164
176
|
## Configuration
|
|
165
177
|
|
|
166
178
|
Allelix stores persistent configuration in `config.toml` (in the data directory, default `~/.local/share/allelix/`). A default config is created on first run.
|
|
@@ -124,6 +124,18 @@ This is not a disclaimer afterthought. It is a design constraint that affects mo
|
|
|
124
124
|
- Reference databases are downloaded via `allelix db update` and cached locally.
|
|
125
125
|
- Analysis runs offline against local database caches. A brief freshness check runs before analysis by default (skipped with `--no-update`).
|
|
126
126
|
|
|
127
|
+
### Output files contain real annotations of your genome
|
|
128
|
+
|
|
129
|
+
The JSON / HTML / terminal output of `allelix analyze` and its
|
|
130
|
+
focused subcommands contains real annotations against your specific
|
|
131
|
+
variants — drug-response calls, carrier-status flags, hereditary-
|
|
132
|
+
disease findings. Wherever you write them via `--output <path>`,
|
|
133
|
+
that's where they sit until you delete them. Allelix doesn't
|
|
134
|
+
auto-clean and won't warn you when you write to `/tmp/` or any
|
|
135
|
+
other shared location. Treat the files as personal data: read them,
|
|
136
|
+
move them somewhere you control, or delete when you're done. A
|
|
137
|
+
data-lifecycle subcommand is planned for v2.1.
|
|
138
|
+
|
|
127
139
|
## Configuration
|
|
128
140
|
|
|
129
141
|
Allelix stores persistent configuration in `config.toml` (in the data directory, default `~/.local/share/allelix/`). A default config is created on first run.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Allelix
|
|
3
|
+
"""Allelix: open-source genotype analysis toolkit."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _read_pyproject_version() -> str | None:
|
|
11
|
+
"""Read the package version from ``pyproject.toml``.
|
|
12
|
+
|
|
13
|
+
GH #34: fall back to ``pyproject.toml`` when run from a bare source
|
|
14
|
+
checkout (no editable install, no installed package metadata). Keeps
|
|
15
|
+
``--version`` and the outbound HTTP User-Agent reporting the real
|
|
16
|
+
version string instead of the ``0.0.0+local`` sentinel that
|
|
17
|
+
misidentifies our traffic to NCBI / EBI / HuggingFace.
|
|
18
|
+
|
|
19
|
+
Returns ``None`` on any failure — the caller falls back to the
|
|
20
|
+
sentinel rather than crashing import.
|
|
21
|
+
"""
|
|
22
|
+
import tomllib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
26
|
+
try:
|
|
27
|
+
with pyproject.open("rb") as fh:
|
|
28
|
+
data = tomllib.load(fh)
|
|
29
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
30
|
+
return None
|
|
31
|
+
project = data.get("project") or {}
|
|
32
|
+
v = project.get("version")
|
|
33
|
+
return v if isinstance(v, str) and v else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
__version__ = version("allelix")
|
|
38
|
+
except PackageNotFoundError:
|
|
39
|
+
# Source checkout without an editable install. Try pyproject.toml
|
|
40
|
+
# before falling back to the sentinel.
|
|
41
|
+
__version__ = _read_pyproject_version() or "0.0.0+local"
|
|
@@ -142,7 +142,30 @@ class Annotator(ABC):
|
|
|
142
142
|
self.data_dir = data_dir
|
|
143
143
|
|
|
144
144
|
def __del__(self) -> None:
|
|
145
|
-
"""
|
|
145
|
+
"""Safety-net resource release on GC. Deliberately retained.
|
|
146
|
+
|
|
147
|
+
GH #36 (audit second pass) flagged ``__del__`` as a Python
|
|
148
|
+
antipattern — GC timing is nondeterministic and raised exceptions
|
|
149
|
+
are silently swallowed. The correct usage pattern is the
|
|
150
|
+
``__enter__`` / ``__exit__`` context manager pair below, wired
|
|
151
|
+
through ``contextlib.ExitStack`` in ``reports/_pipeline.py``.
|
|
152
|
+
|
|
153
|
+
However: removing ``__del__`` exposes residual SQLite connection
|
|
154
|
+
leaks in code paths that construct an annotator outside a
|
|
155
|
+
context manager. ``ResourceWarning`` is elevated to error by
|
|
156
|
+
``pytest`` config, so leaks fail the suite as
|
|
157
|
+
``PytestUnraisableExceptionWarning`` — caught in the v2.0.2
|
|
158
|
+
ship gate when ``__del__`` was first removed. Until every call
|
|
159
|
+
site is verified to use ``with`` / ``ExitStack`` / explicit
|
|
160
|
+
``close()``, this safety net stays. v2.1 task: audit and
|
|
161
|
+
remove.
|
|
162
|
+
|
|
163
|
+
``contextlib.suppress(Exception)`` is deliberate — ``__del__``
|
|
164
|
+
must never raise. The GC timing and shutdown-ordering edges
|
|
165
|
+
are explicitly silenced; this is exactly the
|
|
166
|
+
"if you must keep ``__del__``, make absolutely sure it can
|
|
167
|
+
never raise" mitigation the audit recommended.
|
|
168
|
+
"""
|
|
146
169
|
with contextlib.suppress(Exception):
|
|
147
170
|
self.close()
|
|
148
171
|
|
|
@@ -30,6 +30,7 @@ from allelix.databases.manager import (
|
|
|
30
30
|
verify_file_hash,
|
|
31
31
|
)
|
|
32
32
|
from allelix.models import Annotation
|
|
33
|
+
from allelix.utils.allele import strand_aware_carrier_match
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
35
36
|
from collections.abc import Iterable, Iterator
|
|
@@ -384,7 +385,6 @@ class ClinVarAnnotator(Annotator):
|
|
|
384
385
|
(variant.rsid,),
|
|
385
386
|
).fetchall()
|
|
386
387
|
annotations: list[Annotation] = []
|
|
387
|
-
carrier_alleles = {variant.allele1, variant.allele2}
|
|
388
388
|
user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
|
|
389
389
|
# ADR-0023: report the user's actual diploid call consistently
|
|
390
390
|
# across annotators, not the matched ALT base alone.
|
|
@@ -404,7 +404,14 @@ class ClinVarAnnotator(Annotator):
|
|
|
404
404
|
clinvar_is_indel = len(ref) > 1 or len(alt) > 1
|
|
405
405
|
if clinvar_is_indel and not user_is_multibase:
|
|
406
406
|
continue
|
|
407
|
-
|
|
407
|
+
# ADR-0035 PR 4: strand-aware carrier match. Direct {ref, alt}
|
|
408
|
+
# membership still covers forward-stranded inputs; the
|
|
409
|
+
# complement-fallback path fires only when Variant.ref confirms
|
|
410
|
+
# reverse orientation AND the site isn't palindromic — preserving
|
|
411
|
+
# the v2.0.1 #18 multi-allelic safety invariant.
|
|
412
|
+
if not strand_aware_carrier_match(
|
|
413
|
+
variant.ref, variant.allele1, variant.allele2, ref, alt
|
|
414
|
+
):
|
|
408
415
|
continue
|
|
409
416
|
sig_label = _normalize_clnsig(clnsig) if clnsig else "unknown"
|
|
410
417
|
if not self._include_benign and sig_label in _BENIGN_CLNSIGS:
|
|
@@ -562,7 +569,6 @@ class ClinVarAnnotator(Annotator):
|
|
|
562
569
|
rows = rows_by_build_rsid.get((variant.build, variant.rsid))
|
|
563
570
|
if not rows:
|
|
564
571
|
continue
|
|
565
|
-
carrier_alleles = {variant.allele1, variant.allele2}
|
|
566
572
|
user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
|
|
567
573
|
user_diploid = _user_diploid(variant)
|
|
568
574
|
for row in rows:
|
|
@@ -580,7 +586,11 @@ class ClinVarAnnotator(Annotator):
|
|
|
580
586
|
clinvar_is_indel = len(ref) > 1 or len(alt) > 1
|
|
581
587
|
if clinvar_is_indel and not user_is_multibase:
|
|
582
588
|
continue
|
|
583
|
-
|
|
589
|
+
# ADR-0035 PR 4: strand-aware carrier match. See annotate() for
|
|
590
|
+
# the safety rationale (#18 multi-allelic invariant preserved).
|
|
591
|
+
if not strand_aware_carrier_match(
|
|
592
|
+
variant.ref, variant.allele1, variant.allele2, ref, alt
|
|
593
|
+
):
|
|
584
594
|
continue
|
|
585
595
|
sig_label = _normalize_clnsig(clnsig) if clnsig else "unknown"
|
|
586
596
|
if not self._include_benign and sig_label in _BENIGN_CLNSIGS:
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import contextlib
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
import sqlite3
|
|
10
11
|
import zipfile
|
|
11
12
|
from typing import TYPE_CHECKING, ClassVar
|
|
@@ -56,6 +57,27 @@ logger = logging.getLogger(__name__)
|
|
|
56
57
|
|
|
57
58
|
_BATCH_CHUNK = 500 # SQLite default SQLITE_MAX_VARIABLE_NUMBER is 999
|
|
58
59
|
|
|
60
|
+
# ADR-0035 PR 3: PheCode often arrives embedded in the upstream DISEASE/TRAIT
|
|
61
|
+
# field (e.g. ``"Type 2 diabetes (PheCode 250.2)"``). The annotator extracts it
|
|
62
|
+
# at the boundary so downstream code reads ``Annotation.phecode`` as a
|
|
63
|
+
# structured field instead of regex-parsing prose. The trimmed trait flows into
|
|
64
|
+
# ``Annotation.trait``; ``description`` is rebuilt below from the structured
|
|
65
|
+
# parts so display surfaces still show the PheCode label.
|
|
66
|
+
_PHECODE_SUFFIX_RE = re.compile(r"\s*\(PheCode\s+([\d.]+)\)\s*$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_trait_and_phecode(raw_trait: str) -> tuple[str, str]:
|
|
70
|
+
"""Split an upstream trait into (clean_trait, phecode).
|
|
71
|
+
|
|
72
|
+
PheCode (when present) is returned as a bare numeric string like
|
|
73
|
+
``"250.2"``. Returns ``""`` for phecode when no PheCode suffix is found.
|
|
74
|
+
The clean trait is stripped of trailing whitespace.
|
|
75
|
+
"""
|
|
76
|
+
m = _PHECODE_SUFFIX_RE.search(raw_trait)
|
|
77
|
+
if m is None:
|
|
78
|
+
return raw_trait.strip(), ""
|
|
79
|
+
return raw_trait[: m.start()].rstrip(), m.group(1)
|
|
80
|
+
|
|
59
81
|
|
|
60
82
|
def _magnitude(p_value: float | None, or_beta: float | None) -> float:
|
|
61
83
|
"""Derive magnitude from p-value and optional effect size.
|
|
@@ -288,9 +310,14 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
288
310
|
mag = min(_magnitude(p_value, or_beta), _UNKNOWN_RISK_ALLELE_MAG_CAP)
|
|
289
311
|
risk_note = " (risk allele not specified in study)"
|
|
290
312
|
|
|
313
|
+
clean_trait, phecode = _split_trait_and_phecode(trait)
|
|
314
|
+
phecode_suffix = f" (PheCode {phecode})" if phecode else ""
|
|
291
315
|
p_str = f"p={p_value:.1e}" if p_value is not None else "p=N/A"
|
|
292
316
|
gene_str = gene or "—"
|
|
293
|
-
description =
|
|
317
|
+
description = (
|
|
318
|
+
f"GWAS Catalog: {clean_trait}{phecode_suffix} "
|
|
319
|
+
f"({p_str}, gene: {gene_str}){risk_note}"
|
|
320
|
+
)
|
|
294
321
|
|
|
295
322
|
references: list[str] = []
|
|
296
323
|
if pubmed_id:
|
|
@@ -309,10 +336,13 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
309
336
|
attribution=self.attribution,
|
|
310
337
|
genotype_match=user_diploid,
|
|
311
338
|
references=references,
|
|
312
|
-
condition=
|
|
339
|
+
condition=clean_trait,
|
|
313
340
|
gene=gene or "",
|
|
314
|
-
alt="",
|
|
341
|
+
alt=risk_allele or "",
|
|
315
342
|
is_must_include=variant.rsid in _MUST_INCLUDE_RSIDS,
|
|
343
|
+
trait=clean_trait,
|
|
344
|
+
p_value=p_value,
|
|
345
|
+
phecode=phecode,
|
|
316
346
|
)
|
|
317
347
|
)
|
|
318
348
|
return annotations
|
|
@@ -377,9 +407,14 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
377
407
|
mag = min(_magnitude(p_value, or_beta), _UNKNOWN_RISK_ALLELE_MAG_CAP)
|
|
378
408
|
risk_note = " (risk allele not specified in study)"
|
|
379
409
|
|
|
410
|
+
clean_trait, phecode = _split_trait_and_phecode(trait)
|
|
411
|
+
phecode_suffix = f" (PheCode {phecode})" if phecode else ""
|
|
380
412
|
p_str = f"p={p_value:.1e}" if p_value is not None else "p=N/A"
|
|
381
413
|
gene_str = gene or "—"
|
|
382
|
-
description =
|
|
414
|
+
description = (
|
|
415
|
+
f"GWAS Catalog: {clean_trait}{phecode_suffix} "
|
|
416
|
+
f"({p_str}, gene: {gene_str}){risk_note}"
|
|
417
|
+
)
|
|
383
418
|
|
|
384
419
|
references: list[str] = []
|
|
385
420
|
if pubmed_id:
|
|
@@ -397,10 +432,13 @@ class GWASCatalogAnnotator(Annotator):
|
|
|
397
432
|
attribution=self.attribution,
|
|
398
433
|
genotype_match=user_diploid,
|
|
399
434
|
references=references,
|
|
400
|
-
condition=
|
|
435
|
+
condition=clean_trait,
|
|
401
436
|
gene=gene or "",
|
|
402
|
-
alt="",
|
|
437
|
+
alt=risk_allele or "",
|
|
403
438
|
is_must_include=variant.rsid in _MUST_INCLUDE_RSIDS,
|
|
439
|
+
trait=clean_trait,
|
|
440
|
+
p_value=p_value,
|
|
441
|
+
phecode=phecode,
|
|
404
442
|
)
|
|
405
443
|
|
|
406
444
|
|
|
@@ -30,6 +30,7 @@ from allelix.databases.pharmgkb_loader import (
|
|
|
30
30
|
schema_is_current,
|
|
31
31
|
)
|
|
32
32
|
from allelix.models import Annotation
|
|
33
|
+
from allelix.utils.allele import derive_alt_from_diploid, strand_aware_genotype_match
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
35
36
|
from collections.abc import Callable, Iterable, Iterator
|
|
@@ -260,20 +261,25 @@ class PharmGKBAnnotator(Annotator):
|
|
|
260
261
|
if is_clinvar_homref(variant, self._clinvar_ref_provider):
|
|
261
262
|
return []
|
|
262
263
|
|
|
264
|
+
# ADR-0035 PR 4: fetch all rows for the rsid (typically <5) and apply
|
|
265
|
+
# strand-aware genotype match in Python. Forward-stranded users hit
|
|
266
|
+
# the same set of rows as the v0.7-era ``WHERE genotype = ?`` SQL;
|
|
267
|
+
# reverse-stranded users now also match when Variant.ref confirms
|
|
268
|
+
# orientation AND the source genotype is non-palindromic.
|
|
263
269
|
sql = (
|
|
264
270
|
"SELECT genotype, gene, drugs, phenotype, phenotype_category, "
|
|
265
271
|
"annotation_text, level_of_evidence, score, pgkb_annotation_id "
|
|
266
272
|
"FROM pharmgkb_annotations "
|
|
267
|
-
"WHERE rsid = ? AND
|
|
273
|
+
"WHERE rsid = ? AND is_nonfinding = 0"
|
|
268
274
|
)
|
|
269
|
-
params = (variant.rsid,
|
|
275
|
+
params = (variant.rsid,)
|
|
270
276
|
|
|
271
277
|
rows = self._connection().execute(sql, params).fetchall()
|
|
272
278
|
annotations: list[Annotation] = []
|
|
273
279
|
user_diploid = _user_diploid(variant)
|
|
274
280
|
for row in rows:
|
|
275
281
|
(
|
|
276
|
-
|
|
282
|
+
geno,
|
|
277
283
|
gene,
|
|
278
284
|
drugs,
|
|
279
285
|
phenotype,
|
|
@@ -283,6 +289,10 @@ class PharmGKBAnnotator(Annotator):
|
|
|
283
289
|
_score,
|
|
284
290
|
pgkb_annotation_id,
|
|
285
291
|
) = row
|
|
292
|
+
if not strand_aware_genotype_match(
|
|
293
|
+
variant.allele1, variant.allele2, geno, variant.ref
|
|
294
|
+
):
|
|
295
|
+
continue
|
|
286
296
|
sig_label = level_of_evidence.strip().lower() or "unknown"
|
|
287
297
|
description_parts = [f"ClinPGx: {drugs}"] if drugs else ["ClinPGx"]
|
|
288
298
|
if phenotype:
|
|
@@ -306,6 +316,7 @@ class PharmGKBAnnotator(Annotator):
|
|
|
306
316
|
references=references,
|
|
307
317
|
condition=phenotype or "",
|
|
308
318
|
gene=gene or "",
|
|
319
|
+
alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
|
|
309
320
|
)
|
|
310
321
|
)
|
|
311
322
|
return annotations
|
|
@@ -352,7 +363,7 @@ class PharmGKBAnnotator(Annotator):
|
|
|
352
363
|
for row in cursor:
|
|
353
364
|
rows_by_rsid.setdefault(row[0], []).append(row[1:])
|
|
354
365
|
|
|
355
|
-
for variant,
|
|
366
|
+
for variant, _user_geno in candidates:
|
|
356
367
|
rows = rows_by_rsid.get(variant.rsid)
|
|
357
368
|
if not rows:
|
|
358
369
|
continue
|
|
@@ -369,7 +380,11 @@ class PharmGKBAnnotator(Annotator):
|
|
|
369
380
|
_score,
|
|
370
381
|
pgkb_annotation_id,
|
|
371
382
|
) = row
|
|
372
|
-
|
|
383
|
+
# ADR-0035 PR 4: strand-aware genotype match. See annotate() for
|
|
384
|
+
# the safety rationale (palindrome guard + variant.ref orientation).
|
|
385
|
+
if not strand_aware_genotype_match(
|
|
386
|
+
variant.allele1, variant.allele2, geno, variant.ref
|
|
387
|
+
):
|
|
373
388
|
continue
|
|
374
389
|
sig_label = level_of_evidence.strip().lower() or "unknown"
|
|
375
390
|
description_parts = [f"ClinPGx: {drugs}"] if drugs else ["ClinPGx"]
|
|
@@ -393,6 +408,7 @@ class PharmGKBAnnotator(Annotator):
|
|
|
393
408
|
references=references,
|
|
394
409
|
condition=phenotype or "",
|
|
395
410
|
gene=gene or "",
|
|
411
|
+
alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
|
|
396
412
|
)
|
|
397
413
|
|
|
398
414
|
|
|
@@ -29,6 +29,7 @@ from allelix.databases.snpedia_loader import (
|
|
|
29
29
|
install_prebuilt_cache,
|
|
30
30
|
)
|
|
31
31
|
from allelix.models import Annotation
|
|
32
|
+
from allelix.utils.allele import derive_alt_from_diploid
|
|
32
33
|
|
|
33
34
|
if TYPE_CHECKING:
|
|
34
35
|
from collections.abc import Callable, Iterable, Iterator
|
|
@@ -272,6 +273,7 @@ class SNPediaAnnotator(Annotator):
|
|
|
272
273
|
genotype_match=genotype_match,
|
|
273
274
|
references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
|
|
274
275
|
gene=gene or "",
|
|
276
|
+
alt=derive_alt_from_diploid(variant.ref, allele1, allele2),
|
|
275
277
|
)
|
|
276
278
|
)
|
|
277
279
|
|
|
@@ -356,4 +358,5 @@ class SNPediaAnnotator(Annotator):
|
|
|
356
358
|
genotype_match=genotype_match,
|
|
357
359
|
references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
|
|
358
360
|
gene=gene or "",
|
|
361
|
+
alt=derive_alt_from_diploid(variant.ref, allele1, allele2),
|
|
359
362
|
)
|
|
@@ -336,6 +336,12 @@ def _emit_build_diagnostics(result: object) -> None:
|
|
|
336
336
|
source = "detected"
|
|
337
337
|
elif diag.header_build:
|
|
338
338
|
source = "header (no position confirmation)"
|
|
339
|
+
elif diag.chr_prefix_inferred:
|
|
340
|
+
# GH #38: chr-prefixed contig names ("chr1", "chrX", ...) reliably
|
|
341
|
+
# indicate GRCh38 in modern caller output. We DID detect a build;
|
|
342
|
+
# the banner and the warning should say so instead of reading as
|
|
343
|
+
# a blind default.
|
|
344
|
+
source = "inferred from chr-prefixed contig names"
|
|
339
345
|
else:
|
|
340
346
|
source = "fallback (no known SNPs matched)"
|
|
341
347
|
console.print(
|
|
@@ -349,15 +355,26 @@ def _emit_build_diagnostics(result: object) -> None:
|
|
|
349
355
|
f"This is a real-world data-quality issue — your provider may have "
|
|
350
356
|
f"mislabeled the build (see ADR-0021).[/yellow]"
|
|
351
357
|
)
|
|
358
|
+
elif diag.chr_prefix_inferred:
|
|
359
|
+
# GH #38: positive, accurate message — the inference path
|
|
360
|
+
# actually fired. Still recommend `--build` for users who
|
|
361
|
+
# want to lock in the answer; chr-prefix is a strong signal
|
|
362
|
+
# but UCSC hg19 also uses `chr` prefixes, so the heuristic
|
|
363
|
+
# isn't guaranteed against a hg19-converted file.
|
|
364
|
+
console.print(
|
|
365
|
+
f"[dim]Inferred {diag.effective_build} from chr-prefixed contig "
|
|
366
|
+
f"names (GRCh38 convention). Pass --build grch37 if this file is "
|
|
367
|
+
f"UCSC hg19 with chr-prefixed contigs instead.[/dim]"
|
|
368
|
+
)
|
|
352
369
|
elif not diag.override and diag.detected_build is None and diag.header_build is None:
|
|
353
370
|
# Common shape: VCF from a variant caller where the ID column is `.`
|
|
354
|
-
# and the header has no ##contig assembly tag
|
|
355
|
-
#
|
|
371
|
+
# and the header has no ##contig assembly tag, AND no chr-prefix
|
|
372
|
+
# signal was observed. All three auto-detect paths failed.
|
|
356
373
|
# Loudly recommend an explicit --build because picking the wrong one
|
|
357
374
|
# silently means every annotation lookup uses wrong coordinates.
|
|
358
375
|
console.print(
|
|
359
376
|
f"[yellow]Could not auto-detect genome build (no rsIDs in input, "
|
|
360
|
-
f"no ##contig assembly tag
|
|
377
|
+
f"no ##contig assembly tag, no chr-prefixed contigs). Defaulted to "
|
|
361
378
|
f"{diag.effective_build}. If the file is the other build, pass "
|
|
362
379
|
f"--build grch37 or --build grch38 explicitly — annotation "
|
|
363
380
|
f"coordinates differ between builds and silently using the wrong "
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# High-value SNPs: clinically important variants where a no-call
|
|
2
|
+
# should be explicitly flagged rather than silently omitted.
|
|
3
|
+
#
|
|
4
|
+
# Schema:
|
|
5
|
+
# rsid: dbSNP identifier
|
|
6
|
+
# gene: gene symbol
|
|
7
|
+
# cluster: optional grouping (e.g., "APOE" for the two-SNP APOE haplotype)
|
|
8
|
+
# note: human-readable warning text for no-call reports
|
|
9
|
+
#
|
|
10
|
+
# To add a SNP: append an entry following this format. Entries with the
|
|
11
|
+
# same cluster are grouped in warnings (e.g., "APOE genotype cannot be
|
|
12
|
+
# determined" when either rs429358 or rs7412 is a no-call).
|
|
13
|
+
|
|
14
|
+
- rsid: rs429358
|
|
15
|
+
gene: APOE
|
|
16
|
+
cluster: APOE
|
|
17
|
+
note: Required (with rs7412) to determine APOE genotype
|
|
18
|
+
|
|
19
|
+
- rsid: rs7412
|
|
20
|
+
gene: APOE
|
|
21
|
+
cluster: APOE
|
|
22
|
+
note: Required (with rs429358) to determine APOE genotype
|
|
23
|
+
|
|
24
|
+
- rsid: rs5742904
|
|
25
|
+
gene: APOB
|
|
26
|
+
note: Familial hypercholesterolemia marker (FH)
|
|
27
|
+
|
|
28
|
+
- rsid: rs80357906
|
|
29
|
+
gene: BRCA1
|
|
30
|
+
note: Hereditary breast/ovarian cancer marker
|
|
31
|
+
|
|
32
|
+
- rsid: rs1801133
|
|
33
|
+
gene: MTHFR
|
|
34
|
+
cluster: MTHFR
|
|
35
|
+
note: Methylation pathway (C677T)
|
|
36
|
+
|
|
37
|
+
- rsid: rs1801131
|
|
38
|
+
gene: MTHFR
|
|
39
|
+
cluster: MTHFR
|
|
40
|
+
note: Methylation pathway (A1298C)
|
|
41
|
+
|
|
42
|
+
- rsid: rs4680
|
|
43
|
+
gene: COMT
|
|
44
|
+
note: Catechol-O-methyltransferase activity
|
|
45
|
+
|
|
46
|
+
- rsid: rs1065852
|
|
47
|
+
gene: CYP2D6
|
|
48
|
+
note: Opioid / SSRI metabolism
|
|
49
|
+
|
|
50
|
+
- rsid: rs4244285
|
|
51
|
+
gene: CYP2C19
|
|
52
|
+
note: Clopidogrel, PPIs metabolism
|
|
53
|
+
|
|
54
|
+
- rsid: rs1799853
|
|
55
|
+
gene: CYP2C9
|
|
56
|
+
note: Warfarin metabolism
|
|
57
|
+
|
|
58
|
+
- rsid: rs4149056
|
|
59
|
+
gene: SLCO1B1
|
|
60
|
+
note: Statin myopathy risk
|
|
61
|
+
|
|
62
|
+
- rsid: rs3918290
|
|
63
|
+
gene: DPYD
|
|
64
|
+
note: Fluoropyrimidine toxicity
|
|
65
|
+
|
|
66
|
+
# v2.0.2 additions (GH #7): clinically actionable single-SNP variants
|
|
67
|
+
# verified to be on consumer arrays. Two new clusters: HFE (hereditary
|
|
68
|
+
# hemochromatosis compound-het) and TPMT (thiopurine *3 haplotype).
|
|
69
|
+
|
|
70
|
+
- rsid: rs6025
|
|
71
|
+
gene: F5
|
|
72
|
+
note: Factor V Leiden — hereditary thrombophilia (FDA-cleared GHR variant)
|
|
73
|
+
|
|
74
|
+
- rsid: rs1799963
|
|
75
|
+
gene: F2
|
|
76
|
+
note: Prothrombin G20210A — hereditary thrombophilia
|
|
77
|
+
|
|
78
|
+
- rsid: rs1800562
|
|
79
|
+
gene: HFE
|
|
80
|
+
cluster: HFE
|
|
81
|
+
note: C282Y — hereditary hemochromatosis (compound het with H63D is the clinical form)
|
|
82
|
+
|
|
83
|
+
- rsid: rs1799945
|
|
84
|
+
gene: HFE
|
|
85
|
+
cluster: HFE
|
|
86
|
+
note: H63D — hereditary hemochromatosis (compound het with C282Y is the clinical form)
|
|
87
|
+
|
|
88
|
+
- rsid: rs113993960
|
|
89
|
+
gene: CFTR
|
|
90
|
+
note: F508del — most common CF allele; carrier status for reproductive planning
|
|
91
|
+
|
|
92
|
+
- rsid: rs334
|
|
93
|
+
gene: HBB
|
|
94
|
+
note: Sickle cell (HbS) — most-screened-for variant worldwide; carrier status
|
|
95
|
+
|
|
96
|
+
- rsid: rs80359550
|
|
97
|
+
gene: BRCA2
|
|
98
|
+
note: BRCA2 6174delT — most common Ashkenazi founder mutation (BRCA1 covered by rs80357906)
|
|
99
|
+
|
|
100
|
+
- rsid: rs9923231
|
|
101
|
+
gene: VKORC1
|
|
102
|
+
note: Warfarin dosing (CPIC Level A, pairs with CYP2C9 rs1799853)
|
|
103
|
+
|
|
104
|
+
- rsid: rs1057910
|
|
105
|
+
gene: CYP2C9
|
|
106
|
+
note: CYP2C9*3 — completes warfarin metabolizer profile alongside *2 (rs1799853)
|
|
107
|
+
|
|
108
|
+
- rsid: rs12248560
|
|
109
|
+
gene: CYP2C19
|
|
110
|
+
note: CYP2C19*17 ultrarapid metabolizer — completes clopidogrel profile alongside *2 (rs4244285)
|
|
111
|
+
|
|
112
|
+
- rsid: rs3892097
|
|
113
|
+
gene: CYP2D6
|
|
114
|
+
note: CYP2D6*4 — most common LOF in Europeans (complements rs1065852 *10)
|
|
115
|
+
|
|
116
|
+
- rsid: rs776746
|
|
117
|
+
gene: CYP3A5
|
|
118
|
+
note: CYP3A5*3 — tacrolimus dosing (CPIC Level A)
|
|
119
|
+
|
|
120
|
+
- rsid: rs1142345
|
|
121
|
+
gene: TPMT
|
|
122
|
+
cluster: TPMT
|
|
123
|
+
note: TPMT*3C — thiopurine dosing (CPIC Level A; with rs1800460 resolves *3A/*3B/*3C)
|
|
124
|
+
|
|
125
|
+
- rsid: rs1800460
|
|
126
|
+
gene: TPMT
|
|
127
|
+
cluster: TPMT
|
|
128
|
+
note: TPMT*3B — thiopurine dosing (CPIC Level A; with rs1142345 resolves *3A/*3B/*3C)
|
|
129
|
+
|
|
130
|
+
- rsid: rs116855232
|
|
131
|
+
gene: NUDT15
|
|
132
|
+
note: Thiopurine toxicity (CPIC Level A; critical in East Asian populations, complements TPMT cluster)
|
|
133
|
+
|
|
134
|
+
- rsid: rs34637584
|
|
135
|
+
gene: LRRK2
|
|
136
|
+
note: G2019S — most common monogenic Parkinson's variant
|
|
@@ -31,6 +31,13 @@ class Variant:
|
|
|
31
31
|
allele1: First observed allele. A/T/G/C, multi-base for indels, or "-" for no-call.
|
|
32
32
|
allele2: Second observed allele. Same encoding as allele1.
|
|
33
33
|
build: Reference genome build. "GRCh37" (hg19) or "GRCh38" (hg38).
|
|
34
|
+
ref: Reference allele on the forward strand at this position, when
|
|
35
|
+
known. VCF parsers populate from the REF column; array parsers
|
|
36
|
+
currently leave it None. Required by strand-aware carrier matching
|
|
37
|
+
(ADR-0035) and downstream consumers that need to identify the risk
|
|
38
|
+
allele within the user's pair. None means "cannot resolve" —
|
|
39
|
+
consumers degrade gracefully (return ambiguous, skip strand check,
|
|
40
|
+
etc.) rather than guess.
|
|
34
41
|
"""
|
|
35
42
|
|
|
36
43
|
rsid: str
|
|
@@ -39,9 +46,10 @@ class Variant:
|
|
|
39
46
|
allele1: str
|
|
40
47
|
allele2: str
|
|
41
48
|
build: str = DEFAULT_BUILD
|
|
49
|
+
ref: str | None = None
|
|
42
50
|
|
|
43
51
|
def __post_init__(self) -> None:
|
|
44
|
-
"""Normalize allele case at construction (GH #14).
|
|
52
|
+
"""Normalize allele case at construction (GH #14, ADR-0035).
|
|
45
53
|
|
|
46
54
|
Reference databases (ClinVar, gnomAD, ClinPGx, etc.) all ship
|
|
47
55
|
uppercase alleles, and carrier matching is raw set membership
|
|
@@ -53,11 +61,20 @@ class Variant:
|
|
|
53
61
|
at the model boundary so the invariant is impossible to
|
|
54
62
|
violate downstream. The no-call marker is left as-is;
|
|
55
63
|
multi-base alleles (indels) are uppercased in place.
|
|
64
|
+
|
|
65
|
+
``ref`` (ADR-0035) is a sibling allele field feeding the same
|
|
66
|
+
downstream matching path (PR 4 strand-aware carrier matching
|
|
67
|
+
compares ``ref`` against ``{allele1, allele2}`` and against
|
|
68
|
+
gnomAD's uppercased REF). VCFs derived from soft-masked
|
|
69
|
+
references emit lowercase bases in the REF column, so the
|
|
70
|
+
same normalization applies here for the same reason.
|
|
56
71
|
"""
|
|
57
72
|
if self.allele1 and self.allele1 != NO_CALL_MARKER:
|
|
58
73
|
self.allele1 = self.allele1.upper()
|
|
59
74
|
if self.allele2 and self.allele2 != NO_CALL_MARKER:
|
|
60
75
|
self.allele2 = self.allele2.upper()
|
|
76
|
+
if self.ref is not None and self.ref != NO_CALL_MARKER:
|
|
77
|
+
self.ref = self.ref.upper()
|
|
61
78
|
|
|
62
79
|
@property
|
|
63
80
|
def is_heterozygous(self) -> bool:
|
|
@@ -126,6 +143,15 @@ class Annotation:
|
|
|
126
143
|
am_pathogenicity: float | None = None
|
|
127
144
|
am_class: str = ""
|
|
128
145
|
cadd_phred: float | None = None
|
|
146
|
+
# ADR-0035 cluster manifest (PR 3): structured GWAS fields promoted out of
|
|
147
|
+
# the rendered ``description`` prose. `description` still carries the same
|
|
148
|
+
# rendered text for HTML / terminal display, but downstream code (rollup,
|
|
149
|
+
# diff, future consumers) reads the structured fields directly instead of
|
|
150
|
+
# regex-parsing prose. Populated by the GWAS annotator; empty / None on
|
|
151
|
+
# non-GWAS rows.
|
|
152
|
+
trait: str = ""
|
|
153
|
+
p_value: float | None = None
|
|
154
|
+
phecode: str = ""
|
|
129
155
|
|
|
130
156
|
@property
|
|
131
157
|
def zygosity(self) -> str:
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
from abc import ABC, abstractmethod
|
|
8
|
-
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
|
8
|
+
from typing import TYPE_CHECKING, ClassVar, NotRequired, TypedDict
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
11
|
from collections.abc import Iterator
|
|
@@ -26,11 +26,18 @@ class GenotypeMetadata(TypedDict):
|
|
|
26
26
|
format: Parser name (matches `GenotypeParser.name`).
|
|
27
27
|
sample_id: Vendor sample identifier, or "" if not present in the file.
|
|
28
28
|
build: Reference genome build (e.g., "GRCh37").
|
|
29
|
+
chr_prefix_observed: True when the file uses ``chr``-prefixed contig
|
|
30
|
+
names (``chr1`` / ``chrX``). Optional — only VCF / gVCF parsers
|
|
31
|
+
populate it; consumer-array exports always use bare names and
|
|
32
|
+
don't set it. GH #38: used as a tertiary build-detection signal
|
|
33
|
+
after rsID matching and ``##assembly`` tag checks both fail.
|
|
34
|
+
``chr``-prefixed contigs overwhelmingly indicate GRCh38.
|
|
29
35
|
"""
|
|
30
36
|
|
|
31
37
|
format: str
|
|
32
38
|
sample_id: str
|
|
33
39
|
build: str
|
|
40
|
+
chr_prefix_observed: NotRequired[bool]
|
|
34
41
|
|
|
35
42
|
|
|
36
43
|
class GenotypeParser(ABC):
|