allelix 2.2.0__tar.gz → 2.2.2__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.2.0 → allelix-2.2.2}/PKG-INFO +1 -1
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/clinvar.py +129 -15
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/gnomad.py +96 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/db.py +15 -4
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/utility.py +105 -6
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/manager.py +18 -2
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/schema.py +2 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/models.py +21 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ftdna_famfinder.py +79 -7
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/vcf.py +105 -6
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/_pipeline.py +105 -5
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/html.py +64 -10
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/json_report.py +5 -1
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/terminal.py +13 -3
- {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/PKG-INFO +1 -1
- {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/SOURCES.txt +3 -0
- {allelix-2.2.0 → allelix-2.2.2}/pyproject.toml +1 -1
- allelix-2.2.2/tests/test_changelog_sanitize.py +83 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_cli.py +90 -1
- allelix-2.2.2/tests/test_extract_rsidless_gh128.py +201 -0
- allelix-2.2.2/tests/test_original_rsid_never_serializes.py +296 -0
- {allelix-2.2.0 → allelix-2.2.2}/LICENSE +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/README.md +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/base.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/cadd.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/gwas.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/pharmgkb.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/snpedia.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/_helpers.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/_options.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/analyze.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/config.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/focused.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/compare.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/config.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/data/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/data/high_value_snps.yaml +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/_versions.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/exporters/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/exporters/plink.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/base.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/twentythreeandme.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/py.typed +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/diff.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/high_value.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/methylation.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/utils/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/utils/allele.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix/utils/build_detect.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/setup.cfg +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_cli_helpers.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_compare.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_config.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_end_to_end.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_models.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_registry.py +0 -0
- {allelix-2.2.0 → allelix-2.2.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.2
|
|
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>
|
|
@@ -92,6 +92,101 @@ def _magnitude(clnsig: str) -> float:
|
|
|
92
92
|
return _CLNSIG_MAGNITUDE.get(_normalize_clnsig(clnsig), 5.0)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
# GH #109: ClinVar review-status ranking — lower index = stronger
|
|
96
|
+
# evidence per ClinVar's star-rating documentation. Used to pick the
|
|
97
|
+
# winning row when N SCVs with identical (significance, condition,
|
|
98
|
+
# gene) tuples collapse during per-SCV dedup; the surviving Annotation
|
|
99
|
+
# carries the highest-rated review_status across the group instead of
|
|
100
|
+
# silently dropping provenance.
|
|
101
|
+
#
|
|
102
|
+
# Real cache rows use the comma-and-underscore form
|
|
103
|
+
# (`criteria_provided,_multiple_submitters,_no_conflicts`). The rank
|
|
104
|
+
# helper normalizes via _normalize_clnsig so it's robust to spacing
|
|
105
|
+
# variation. Unknown values fall through to "weakest known."
|
|
106
|
+
_REVIEW_STATUS_RANK: tuple[str, ...] = (
|
|
107
|
+
"practice_guideline",
|
|
108
|
+
"reviewed_by_expert_panel",
|
|
109
|
+
"criteria_provided,_multiple_submitters,_no_conflicts",
|
|
110
|
+
"criteria_provided,_conflicting_classifications",
|
|
111
|
+
"criteria_provided,_conflicting_interpretations",
|
|
112
|
+
"criteria_provided,_single_submitter",
|
|
113
|
+
"no_assertion_criteria_provided",
|
|
114
|
+
"no_assertion_provided",
|
|
115
|
+
"no_classification_provided",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _review_status_rank(status: str) -> int:
|
|
120
|
+
"""Return the rank index for a review_status string.
|
|
121
|
+
|
|
122
|
+
Lower index = stronger evidence. Unknown / unrecognized values get
|
|
123
|
+
the max rank (weakest), so a known SCV always beats an unknown one
|
|
124
|
+
when collapsing.
|
|
125
|
+
"""
|
|
126
|
+
if not status:
|
|
127
|
+
return len(_REVIEW_STATUS_RANK)
|
|
128
|
+
normalized = _normalize_clnsig(status)
|
|
129
|
+
for i, candidate in enumerate(_REVIEW_STATUS_RANK):
|
|
130
|
+
if candidate == normalized:
|
|
131
|
+
return i
|
|
132
|
+
return len(_REVIEW_STATUS_RANK)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _collapse_identical_scvs(annotations: list[Annotation]) -> list[Annotation]:
|
|
136
|
+
"""Collapse identical per-SCV rows from the #42 TSV loader (GH #109).
|
|
137
|
+
|
|
138
|
+
Two Annotations are identical for dedup purposes if they share
|
|
139
|
+
(rsid, significance, condition, gene, alt). The surviving row carries:
|
|
140
|
+
|
|
141
|
+
- the strongest ``review_status`` across the collapsed group
|
|
142
|
+
(per ``_REVIEW_STATUS_RANK``)
|
|
143
|
+
- the union of ``references`` across the collapsed group
|
|
144
|
+
(preserving first-seen order — never silently drops provenance)
|
|
145
|
+
|
|
146
|
+
Distinct (significance, condition) pairs on the same variant are
|
|
147
|
+
preserved as separate rows — that's exactly what #42 was built to
|
|
148
|
+
surface, and collapsing them would be the Frankenstein-pair
|
|
149
|
+
regression #42 retired.
|
|
150
|
+
|
|
151
|
+
GH #109 cross-PR review (Finding 1): ``alt`` is in the key for the
|
|
152
|
+
#18 wrong-allele safety invariant. Two SCVs at a multi-allelic site
|
|
153
|
+
can share (significance, condition, gene) but differ in alt — e.g.
|
|
154
|
+
rs1065852 (G→A drug_response/Codeine_response + G→C drug_response/
|
|
155
|
+
Codeine_response would be a hypothetical). Collapsing across alts
|
|
156
|
+
would (a) drop a distinct-allele annotation and (b) mis-attach the
|
|
157
|
+
surviving row's ``alt`` field to the merged result, breaking the
|
|
158
|
+
downstream exact-(rsid, alt) lookup that gnomAD / AlphaMissense /
|
|
159
|
+
CADD use for allele-specific enrichment. The pre-#109 v2.2.0 code
|
|
160
|
+
emitted one Annotation per cache row, so this case was always
|
|
161
|
+
handled correctly; the post-#109 dedup must preserve that property.
|
|
162
|
+
|
|
163
|
+
Input order is preserved for the kept rows: each group is replaced
|
|
164
|
+
by its winner at the position of the group's first occurrence.
|
|
165
|
+
"""
|
|
166
|
+
from dataclasses import replace
|
|
167
|
+
|
|
168
|
+
groups: dict[tuple[str, str, str, str, str], list[Annotation]] = {}
|
|
169
|
+
for a in annotations:
|
|
170
|
+
key = (a.rsid, a.significance, a.condition, a.gene, a.alt)
|
|
171
|
+
groups.setdefault(key, []).append(a)
|
|
172
|
+
|
|
173
|
+
out: list[Annotation] = []
|
|
174
|
+
for group in groups.values():
|
|
175
|
+
if len(group) == 1:
|
|
176
|
+
out.append(group[0])
|
|
177
|
+
continue
|
|
178
|
+
winner = min(group, key=lambda a: _review_status_rank(a.review_status))
|
|
179
|
+
seen_refs: set[str] = set()
|
|
180
|
+
merged_refs: list[str] = []
|
|
181
|
+
for a in group:
|
|
182
|
+
for ref in a.references:
|
|
183
|
+
if ref not in seen_refs:
|
|
184
|
+
seen_refs.add(ref)
|
|
185
|
+
merged_refs.append(ref)
|
|
186
|
+
out.append(replace(winner, references=merged_refs))
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
95
190
|
class ClinVarAnnotator(Annotator):
|
|
96
191
|
"""Annotates variants with ClinVar's clinical significance classifications.
|
|
97
192
|
|
|
@@ -502,7 +597,11 @@ class ClinVarAnnotator(Annotator):
|
|
|
502
597
|
alt=alt,
|
|
503
598
|
)
|
|
504
599
|
)
|
|
505
|
-
|
|
600
|
+
# GH #109: collapse identical per-SCV rows from the #42 TSV loader.
|
|
601
|
+
# Without this, a variant with N identical Pathogenic submissions
|
|
602
|
+
# emits N identical Annotation objects (visible duplicates on every
|
|
603
|
+
# surface). Distinct (significance, condition) pairs survive.
|
|
604
|
+
return _collapse_identical_scvs(annotations)
|
|
506
605
|
|
|
507
606
|
def bulk_resolve_rsids(self, variants: list[Variant]) -> dict[tuple[str, int, str, str], str]:
|
|
508
607
|
"""Resolve rsIDs by (chromosome, position) for variants with no ID.
|
|
@@ -586,6 +685,12 @@ class ClinVarAnnotator(Annotator):
|
|
|
586
685
|
]
|
|
587
686
|
if len(matches) == 1:
|
|
588
687
|
ref, alt, rsid = matches[0]
|
|
688
|
+
# GH #128: stash the pre-resolution pseudo-rsID
|
|
689
|
+
# for audit / debug before stamping. Debug-only —
|
|
690
|
+
# see Variant.original_rsid docstring; the
|
|
691
|
+
# serialization-guard test asserts this never
|
|
692
|
+
# reaches public output.
|
|
693
|
+
v.original_rsid = v.rsid
|
|
589
694
|
v.rsid = rsid
|
|
590
695
|
resolved[(v.chromosome, v.position, ref, alt)] = rsid
|
|
591
696
|
return resolved
|
|
@@ -636,6 +741,12 @@ class ClinVarAnnotator(Annotator):
|
|
|
636
741
|
continue
|
|
637
742
|
user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
|
|
638
743
|
user_diploid = _user_diploid(variant)
|
|
744
|
+
# GH #109: collect per-variant Annotations and dedup before
|
|
745
|
+
# yielding, mirroring the annotate() collapse. Without this,
|
|
746
|
+
# bulk callers (the analyze pipeline goes through
|
|
747
|
+
# batch_annotate) leak the same per-SCV duplicates the
|
|
748
|
+
# single-variant path used to.
|
|
749
|
+
variant_annotations: list[Annotation] = []
|
|
639
750
|
for row in rows:
|
|
640
751
|
(
|
|
641
752
|
_chrom,
|
|
@@ -665,21 +776,24 @@ class ClinVarAnnotator(Annotator):
|
|
|
665
776
|
f"{clnsig.replace('_', ' ') if clnsig else 'unknown significance'}"
|
|
666
777
|
)
|
|
667
778
|
references = [f"clinvar:allele/{allele_id}"] if allele_id else []
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
779
|
+
variant_annotations.append(
|
|
780
|
+
Annotation(
|
|
781
|
+
source=self.name,
|
|
782
|
+
rsid=variant.rsid,
|
|
783
|
+
significance=f"clinvar_{sig_label}",
|
|
784
|
+
category="clinical",
|
|
785
|
+
magnitude=_magnitude(clnsig),
|
|
786
|
+
description=description,
|
|
787
|
+
attribution=self.attribution,
|
|
788
|
+
genotype_match=user_diploid,
|
|
789
|
+
references=references,
|
|
790
|
+
condition="" if not condition or condition == "." else condition,
|
|
791
|
+
gene=gene or "",
|
|
792
|
+
review_status=review_status or "",
|
|
793
|
+
alt=alt,
|
|
794
|
+
)
|
|
682
795
|
)
|
|
796
|
+
yield from _collapse_identical_scvs(variant_annotations)
|
|
683
797
|
|
|
684
798
|
|
|
685
799
|
def _user_diploid(variant: Variant) -> str:
|
|
@@ -76,6 +76,24 @@ class GnomadAnnotator(Annotator):
|
|
|
76
76
|
f"gnomAD cache not found at {self._db_path}. Run `allelix db update` first."
|
|
77
77
|
)
|
|
78
78
|
self._conn = sqlite3.connect(self._db_path)
|
|
79
|
+
# GH #134: lazy-create the position index on caches built before
|
|
80
|
+
# the index landed (anything <= v2.2.1). The index is required
|
|
81
|
+
# for ``bulk_resolve_rsids_from_positions`` (GH #128) to perform
|
|
82
|
+
# well — without it that method scans the full 57M-row
|
|
83
|
+
# ``gnomad_frequencies`` table per query, which is the root
|
|
84
|
+
# cause of the ~2x analyze slowdown on rsID-less WGS VCFs
|
|
85
|
+
# observed at the v2.2.2 ship gate. ``CREATE INDEX IF NOT
|
|
86
|
+
# EXISTS`` is a no-op once the index is in place (microseconds
|
|
87
|
+
# to check the schema); the one-time build cost on first run
|
|
88
|
+
# against a pre-v2.2.2 cache is a few minutes against the 57M
|
|
89
|
+
# rows but only happens once per cache. Doing this here in
|
|
90
|
+
# ``_connection`` rather than as a schema migration avoids
|
|
91
|
+
# forcing every user to re-download the 2.7 GB compressed
|
|
92
|
+
# cache for what's a transparent index addition.
|
|
93
|
+
self._conn.execute(
|
|
94
|
+
"CREATE INDEX IF NOT EXISTS idx_gnomad_position ON gnomad_frequencies(chrom, pos)"
|
|
95
|
+
)
|
|
96
|
+
self._conn.commit()
|
|
79
97
|
return self._conn
|
|
80
98
|
|
|
81
99
|
def setup(self) -> None:
|
|
@@ -198,6 +216,84 @@ class GnomadAnnotator(Annotator):
|
|
|
198
216
|
result.setdefault(rsid, []).append((chrom, pos, ref, alt))
|
|
199
217
|
return result
|
|
200
218
|
|
|
219
|
+
def bulk_resolve_rsids_from_positions(
|
|
220
|
+
self, positions: set[tuple[str, int]]
|
|
221
|
+
) -> dict[tuple[str, int], list[tuple[str, str, str]]]:
|
|
222
|
+
"""Reverse-lookup rsIDs by ``(chromosome, position)``.
|
|
223
|
+
|
|
224
|
+
Counterpart to :meth:`bulk_resolve_coordinates` for the GH #128
|
|
225
|
+
flow: a VCF variant arriving with an empty ID column (DeepVariant
|
|
226
|
+
gVCFs, GIAB GRCh38 benchmarks, anything that doesn't stamp dbSNP
|
|
227
|
+
IDs) is parsed as a positional pseudo-ID. The ClinVar resolver
|
|
228
|
+
(:meth:`allelix.annotators.clinvar.ClinVarAnnotator.bulk_resolve_rsids`)
|
|
229
|
+
recovers an rsID only for variants in ClinVar's curated subset —
|
|
230
|
+
which excludes most pharmacogenomic, GWAS-only, and SNPedia-only
|
|
231
|
+
rsIDs commonly found in wellness panels. This method fills the
|
|
232
|
+
gap by querying ``gnomad_frequencies`` (keyed on the full dbSNP
|
|
233
|
+
rsID universe with published allele frequencies).
|
|
234
|
+
|
|
235
|
+
Returns ``{(chrom, pos): [(ref, alt, rsid), ...]}``. Multi-allelic
|
|
236
|
+
positions return multiple rows; the caller is responsible for
|
|
237
|
+
applying carrier-rule disambiguation (matching the user's allele
|
|
238
|
+
pair as a subset of ``{ref, alt}`` and abstaining on ties — same
|
|
239
|
+
pattern as the ClinVar resolver).
|
|
240
|
+
|
|
241
|
+
Bare chromosomes are expected on both the input set and the
|
|
242
|
+
``gnomad_frequencies.chrom`` column — Allelix normalizes all
|
|
243
|
+
internal chromosome identifiers to bare form
|
|
244
|
+
(:func:`allelix.parsers._helpers.normalize_chromosome`).
|
|
245
|
+
"""
|
|
246
|
+
if not positions:
|
|
247
|
+
return {}
|
|
248
|
+
conn = self._connection()
|
|
249
|
+
result: dict[tuple[str, int], list[tuple[str, str, str]]] = {}
|
|
250
|
+
# GH #134: issue a single combined SQL statement per chunk
|
|
251
|
+
# using SQLite's row-value ``(chrom, pos) IN ((?, ?), ...)``
|
|
252
|
+
# form instead of grouping by chromosome and issuing one
|
|
253
|
+
# statement per (chrom, chunk) pair. On WGS input each batch
|
|
254
|
+
# touches all ~24 chromosomes; the per-chrom shape was a 24x
|
|
255
|
+
# query multiplier the new ``idx_gnomad_position`` index could
|
|
256
|
+
# not amortize away. The row-value IN form keeps the same
|
|
257
|
+
# carrier-rule contract — both ``chrom`` and ``pos`` must
|
|
258
|
+
# match the input pair simultaneously — and the
|
|
259
|
+
# ``idx_gnomad_position`` covering index on (chrom, pos)
|
|
260
|
+
# serves the lookup directly. ``_BULK_BATCH_SIZE`` here
|
|
261
|
+
# refers to (chrom, pos) PAIRS not single ints, so each
|
|
262
|
+
# chunk binds 2 * batch_size parameters.
|
|
263
|
+
#
|
|
264
|
+
# SQLite parameter-limit awareness: 2 * _BULK_BATCH_SIZE =
|
|
265
|
+
# 1800 bind parameters per chunk. The historical SQLite
|
|
266
|
+
# ``SQLITE_MAX_VARIABLE_NUMBER`` default was 999, raised to
|
|
267
|
+
# 32766 in SQLite 3.32 (2020-05-22). Every Python version
|
|
268
|
+
# Allelix supports (>= 3.11) ships with SQLite >= 3.34, so
|
|
269
|
+
# the 1800-param chunk is well within the modern limit. If a
|
|
270
|
+
# future change bumps ``_BULK_BATCH_SIZE`` toward 16000 or
|
|
271
|
+
# the project drops Python's bundled SQLite for a system
|
|
272
|
+
# ``libsqlite3`` of unknown vintage, re-check this against
|
|
273
|
+
# the runtime ``sqlite3.sqlite_version`` before assuming
|
|
274
|
+
# capacity.
|
|
275
|
+
position_list = list(positions)
|
|
276
|
+
for i in range(0, len(position_list), _BULK_BATCH_SIZE):
|
|
277
|
+
batch = position_list[i : i + _BULK_BATCH_SIZE]
|
|
278
|
+
placeholders = ",".join("(?, ?)" for _ in batch)
|
|
279
|
+
params: list[str | int] = []
|
|
280
|
+
for chrom, pos in batch:
|
|
281
|
+
params.append(chrom)
|
|
282
|
+
params.append(pos)
|
|
283
|
+
rows = conn.execute(
|
|
284
|
+
f"SELECT chrom, pos, ref, alt, rsid FROM gnomad_frequencies"
|
|
285
|
+
f" WHERE (chrom, pos) IN ({placeholders})",
|
|
286
|
+
params,
|
|
287
|
+
).fetchall()
|
|
288
|
+
for c, p, ref, alt, rsid in rows:
|
|
289
|
+
if not rsid:
|
|
290
|
+
continue
|
|
291
|
+
result.setdefault((c, p), []).append((ref, alt, rsid))
|
|
292
|
+
# Deterministic ordering for the carrier-rule pass.
|
|
293
|
+
for rows in result.values():
|
|
294
|
+
rows.sort()
|
|
295
|
+
return result
|
|
296
|
+
|
|
201
297
|
def bulk_lookup_by_alt(self, keys: set[tuple[str, str]]) -> dict[tuple[str, str], float]:
|
|
202
298
|
"""Return ``{(rsid, alt): af}`` for exact allele matches."""
|
|
203
299
|
if not keys:
|
|
@@ -221,16 +221,27 @@ _CADD_PREFIXES: tuple[str, ...] = ("cadd",)
|
|
|
221
221
|
# pre-deletion guard. Matched by prefix so future variants (e.g. multi-
|
|
222
222
|
# build forms like clinvar.GRCh37.sqlite, sidecar files like
|
|
223
223
|
# snpedia.sqlite-bak, the PharmGKB raw zip `clinicalAnnotations.zip`) are
|
|
224
|
-
# all caught.
|
|
225
|
-
#
|
|
226
|
-
#
|
|
224
|
+
# all caught.
|
|
225
|
+
#
|
|
226
|
+
# GH #112 (v2.2.1): `config.toml` is intentionally NOT a marker here.
|
|
227
|
+
# It used to be — on the reasoning that any allelix data dir likely
|
|
228
|
+
# carries one — but `config.toml` is a generic filename (Rust projects,
|
|
229
|
+
# Hugo, Rocket, etc. all use it). A typo'd `--data-dir ~/my-rust-project`
|
|
230
|
+
# slipped past the guard simply because that project's root has a
|
|
231
|
+
# `Cargo.lock` and a `config.toml`. The marker set must be allelix-
|
|
232
|
+
# specific.
|
|
233
|
+
#
|
|
234
|
+
# The fresh-install case — a dir with only config.toml and no cache
|
|
235
|
+
# files — is unaffected: `_iter_cache_entries` excludes config.toml,
|
|
236
|
+
# so `entries` is empty and the `if not entries: ... return` short-
|
|
237
|
+
# circuit in `db_clean` reports "Nothing to clean" before the guard
|
|
238
|
+
# ever runs.
|
|
227
239
|
_KNOWN_ARTIFACT_PREFIXES: frozenset[str] = frozenset(
|
|
228
240
|
{
|
|
229
241
|
"alphamissense",
|
|
230
242
|
"cadd",
|
|
231
243
|
"clinicalAnnotations",
|
|
232
244
|
"clinvar",
|
|
233
|
-
"config.toml",
|
|
234
245
|
"gnomad",
|
|
235
246
|
"gwas",
|
|
236
247
|
"pharmgkb",
|
|
@@ -20,6 +20,7 @@ from allelix.cli._options import (
|
|
|
20
20
|
_SAMPLE_OPT,
|
|
21
21
|
)
|
|
22
22
|
from allelix.databases import resolve_data_dir
|
|
23
|
+
from allelix.models import NO_CALL_MARKER
|
|
23
24
|
|
|
24
25
|
if TYPE_CHECKING:
|
|
25
26
|
from allelix.models import Variant
|
|
@@ -67,7 +68,21 @@ def stats(file_path: Path, fmt: str | None, sample: str | None) -> None:
|
|
|
67
68
|
summary.add_column("Value", style="green")
|
|
68
69
|
summary.add_row("Format", parser.display_name)
|
|
69
70
|
summary.add_row("Sample ID", metadata["sample_id"] or "(unknown)")
|
|
70
|
-
|
|
71
|
+
# ``metadata["build"]`` is blank on every VCF that doesn't declare
|
|
72
|
+
# ``##reference=`` in the header — i.e. most caller outputs
|
|
73
|
+
# (DeepVariant / DRAGEN / GIAB GRCh38 benchmark). A blank cell read
|
|
74
|
+
# as broken on the v2.2.1 battery; ``stats`` deliberately does not
|
|
75
|
+
# run build detection (that's an ``analyze`` job per the protocol),
|
|
76
|
+
# so the honest display when no header build is declared is an em
|
|
77
|
+
# dash plus the chr-prefix hint when available. The user can run
|
|
78
|
+
# ``analyze`` to get the real detected build.
|
|
79
|
+
if metadata["build"]:
|
|
80
|
+
build_display = metadata["build"]
|
|
81
|
+
elif metadata.get("chr_prefix_observed"):
|
|
82
|
+
build_display = "— (chr-prefixed contigs; run `analyze` to confirm)"
|
|
83
|
+
else:
|
|
84
|
+
build_display = "—"
|
|
85
|
+
summary.add_row("Build", build_display)
|
|
71
86
|
summary.add_row("Total SNPs", f"{total:,}")
|
|
72
87
|
summary.add_row("No-calls", f"{no_calls:,} ({_helpers._percent(no_calls, total)})")
|
|
73
88
|
summary.add_row("Heterozygous", f"{het:,} ({_helpers._percent(het, total)})")
|
|
@@ -136,28 +151,90 @@ def extract(
|
|
|
136
151
|
found = _try_tabix_extract(file_path, wanted, sample, data_dir)
|
|
137
152
|
if found is None:
|
|
138
153
|
# Sequential fallback — works for any parser.
|
|
139
|
-
found = _sequential_extract(parser, file_path, wanted)
|
|
154
|
+
found = _sequential_extract(parser, file_path, wanted, data_dir)
|
|
140
155
|
_maybe_print_vcf_index_hint(file_path)
|
|
141
156
|
|
|
142
157
|
_render_extract_table(file_path, wanted, found)
|
|
143
158
|
|
|
144
159
|
|
|
145
160
|
def _sequential_extract(
|
|
146
|
-
parser: GenotypeParser,
|
|
161
|
+
parser: GenotypeParser,
|
|
162
|
+
file_path: Path,
|
|
163
|
+
wanted: set[str],
|
|
164
|
+
data_dir: Path | None = None,
|
|
147
165
|
) -> dict[str, Variant]:
|
|
148
166
|
"""Stream the file once; collect Variants whose rsID is in ``wanted``.
|
|
149
167
|
|
|
150
168
|
Streaming early-exit once all wanted rsIDs are found — for arrays
|
|
151
169
|
that yield <1 M variants this is fast enough on any input.
|
|
170
|
+
|
|
171
|
+
GH #128: for VCF inputs the parser may emit a positional pseudo-ID
|
|
172
|
+
(``chr1:11856378:G:A``) when the row's ID column is empty — every
|
|
173
|
+
modern caller (DeepVariant, DRAGEN, GIAB GRCh38 benchmark) emits
|
|
174
|
+
these. The rsID-only fast path on the iterator misses them. To
|
|
175
|
+
cover the rsID-less VCF case, the function pre-resolves the wanted
|
|
176
|
+
set's coordinates via gnomAD (when ready) and matches variants by
|
|
177
|
+
``(chrom, pos, ref, alt)`` whenever the rsID match misses. The
|
|
178
|
+
``ref + alt-in-alleles`` rule matches the tabix path's correctness
|
|
179
|
+
contract and the resolver's carrier-rule disambiguation.
|
|
152
180
|
"""
|
|
181
|
+
from allelix.annotators.gnomad import GnomadAnnotator
|
|
182
|
+
|
|
153
183
|
counter, stderr_handler, snapshot = _helpers._wire_parser_logging()
|
|
154
184
|
found: dict[str, Variant] = {}
|
|
185
|
+
# Pre-resolve wanted → (chrom, pos, ref, alt) → rsid via gnomAD so
|
|
186
|
+
# the iterator can cover rsID-less VCFs by coord-matching. ``data_dir``
|
|
187
|
+
# is optional only because tests construct mock parsers without a
|
|
188
|
+
# configured cache; production CLI invocations always reach
|
|
189
|
+
# ``resolve_data_dir`` (which defaults to the standard cache
|
|
190
|
+
# location when the user didn't pass ``--data-dir``). The if-test
|
|
191
|
+
# below gates only on gnomAD readiness, not on whether the user
|
|
192
|
+
# explicitly opted in.
|
|
193
|
+
coord_to_rsid: dict[tuple[str, int, str, str], str] = {}
|
|
194
|
+
try:
|
|
195
|
+
resolved_data_dir = resolve_data_dir(data_dir)
|
|
196
|
+
except (OSError, ValueError):
|
|
197
|
+
resolved_data_dir = None
|
|
198
|
+
if resolved_data_dir is not None:
|
|
199
|
+
gnomad = GnomadAnnotator(resolved_data_dir)
|
|
200
|
+
try:
|
|
201
|
+
if gnomad.is_ready():
|
|
202
|
+
coord_map = gnomad.bulk_resolve_coordinates(wanted)
|
|
203
|
+
for rsid, entries in coord_map.items():
|
|
204
|
+
for chrom, pos, ref, alt in entries:
|
|
205
|
+
coord_to_rsid[(chrom, pos, ref, alt)] = rsid
|
|
206
|
+
finally:
|
|
207
|
+
gnomad.close()
|
|
155
208
|
try:
|
|
156
209
|
for variant in parser.parse(file_path):
|
|
157
210
|
if variant.rsid in wanted:
|
|
158
211
|
found[variant.rsid] = variant
|
|
159
212
|
if len(found) == len(wanted):
|
|
160
213
|
break
|
|
214
|
+
continue
|
|
215
|
+
# rsID fast path missed — try coord match for the rsID-less
|
|
216
|
+
# VCF case. Requires ref populated; non-VCF parsers (arrays)
|
|
217
|
+
# don't carry ref but always stamp rsIDs in the file, so
|
|
218
|
+
# they never reach this branch.
|
|
219
|
+
if not coord_to_rsid or variant.ref is None:
|
|
220
|
+
continue
|
|
221
|
+
matched_rsid: str | None = None
|
|
222
|
+
for alt in (variant.allele1, variant.allele2):
|
|
223
|
+
if alt in (NO_CALL_MARKER, variant.ref):
|
|
224
|
+
continue
|
|
225
|
+
key = (variant.chromosome, variant.position, variant.ref, alt)
|
|
226
|
+
candidate = coord_to_rsid.get(key)
|
|
227
|
+
if candidate is None or candidate in found or candidate not in wanted:
|
|
228
|
+
continue
|
|
229
|
+
matched_rsid = candidate
|
|
230
|
+
break
|
|
231
|
+
if matched_rsid is None:
|
|
232
|
+
continue
|
|
233
|
+
variant.original_rsid = variant.rsid
|
|
234
|
+
variant.rsid = matched_rsid
|
|
235
|
+
found[matched_rsid] = variant
|
|
236
|
+
if len(found) == len(wanted):
|
|
237
|
+
break
|
|
161
238
|
finally:
|
|
162
239
|
_helpers._unwire_parser_logging(counter, stderr_handler, snapshot)
|
|
163
240
|
return found
|
|
@@ -247,7 +324,7 @@ def _execute_tabix_extract( # pragma: no cover
|
|
|
247
324
|
for rsid, coords in coord_map.items():
|
|
248
325
|
if rsid in found or not coords:
|
|
249
326
|
continue
|
|
250
|
-
for chrom, pos,
|
|
327
|
+
for chrom, pos, ref, alt in coords:
|
|
251
328
|
# gnomAD chromosomes are bare ('1', 'X'); the VCF might
|
|
252
329
|
# use 'chr1'. Try both.
|
|
253
330
|
for chrom_candidate in (chrom, f"chr{chrom}"):
|
|
@@ -259,9 +336,31 @@ def _execute_tabix_extract( # pragma: no cover
|
|
|
259
336
|
variant = _parse_data_line(row, sample_idx)
|
|
260
337
|
if variant is None:
|
|
261
338
|
continue
|
|
262
|
-
|
|
339
|
+
# GH #128: match the tabix-fetched row to the
|
|
340
|
+
# gnomAD record by (ref, alt) rather than by
|
|
341
|
+
# rsID equality. On rsID-stamped VCFs (e.g. GIAB
|
|
342
|
+
# GRCh37, 23andMe) the parser populates
|
|
343
|
+
# ``variant.rsid`` with the file's ID column and
|
|
344
|
+
# the prior equality check happened to work; on
|
|
345
|
+
# rsID-less VCFs (DeepVariant gVCFs, GIAB GRCh38
|
|
346
|
+
# benchmark, Sequencing.com DRAGEN output)
|
|
347
|
+
# ``variant.rsid`` is a positional pseudo-ID and
|
|
348
|
+
# the equality check silently discarded every
|
|
349
|
+
# tabix-fetched row. Ref+alt match is correct on
|
|
350
|
+
# both — that's what the gnomAD coord was
|
|
351
|
+
# resolved against.
|
|
352
|
+
if variant.ref == ref and alt in (
|
|
353
|
+
variant.allele1,
|
|
354
|
+
variant.allele2,
|
|
355
|
+
):
|
|
263
356
|
# _parse_data_line already normalizes chromosome;
|
|
264
|
-
# no re-normalization needed.
|
|
357
|
+
# no re-normalization needed. Stash + stamp
|
|
358
|
+
# so the displayed table shows the requested
|
|
359
|
+
# rsID rather than the pseudo-ID, with audit
|
|
360
|
+
# preserved per Variant.original_rsid.
|
|
361
|
+
if variant.rsid != rsid:
|
|
362
|
+
variant.original_rsid = variant.rsid
|
|
363
|
+
variant.rsid = rsid
|
|
265
364
|
found[rsid] = variant
|
|
266
365
|
break
|
|
267
366
|
if rsid in found:
|
|
@@ -187,8 +187,10 @@ _SS_CONTRIBUTES = 15 # "ContributesToAggregateClassification"
|
|
|
187
187
|
|
|
188
188
|
# GH #42 follow-up (evaluator defect 5, PR #101): exact set of
|
|
189
189
|
# ClinicalSignificance values the TSV loader filters at ingest.
|
|
190
|
-
# These are
|
|
191
|
-
#
|
|
190
|
+
# These are values that ClinVar uses to mark SCVs with no actionable
|
|
191
|
+
# classification — placeholders ("-", "", "not specified") AND
|
|
192
|
+
# non-classification curatorial categories ("other", "association",
|
|
193
|
+
# "association not found") — AND that aren't in
|
|
192
194
|
# `allelix.annotators.clinvar._CLNSIG_MAGNITUDE` (so without this
|
|
193
195
|
# filter they'd land at the 5.0 default — equal to the analyze
|
|
194
196
|
# display floor — and surface to users as bogus annotations).
|
|
@@ -199,6 +201,13 @@ _SS_CONTRIBUTES = 15 # "ContributesToAggregateClassification"
|
|
|
199
201
|
# would lose real submitter records.
|
|
200
202
|
# - "no classification for the single variant" — same.
|
|
201
203
|
#
|
|
204
|
+
# GH #116 added the three non-classification terms ("other",
|
|
205
|
+
# "association", "association not found"). The per-SCV TSV
|
|
206
|
+
# switch (#42) surfaced these from the underlying submission_summary
|
|
207
|
+
# rows where the old summarized data hid them; they're meaningfully
|
|
208
|
+
# distinct from placeholders (a curator chose them) but equally
|
|
209
|
+
# unactionable as report content.
|
|
210
|
+
#
|
|
202
211
|
# The protocol's §7b significance-sentinel ship-gate scans the
|
|
203
212
|
# same set to verify the loader's commitment against the live cache.
|
|
204
213
|
# Keep both in sync.
|
|
@@ -208,6 +217,13 @@ _CLINVAR_PLACEHOLDER_CLNSIGS: frozenset[str] = frozenset(
|
|
|
208
217
|
"-",
|
|
209
218
|
"not specified",
|
|
210
219
|
"no classification provided",
|
|
220
|
+
# GH #116: non-classification curatorial terms — kept out of
|
|
221
|
+
# _CLNSIG_MAGNITUDE deliberately (they aren't classifications)
|
|
222
|
+
# and out of _BENIGN_CLNSIGS (they aren't benign; #56's repute
|
|
223
|
+
# work must not see them as "good").
|
|
224
|
+
"other",
|
|
225
|
+
"association",
|
|
226
|
+
"association not found",
|
|
211
227
|
}
|
|
212
228
|
)
|
|
213
229
|
|
|
@@ -127,6 +127,8 @@ CREATE TABLE IF NOT EXISTS gnomad_frequencies (
|
|
|
127
127
|
);
|
|
128
128
|
|
|
129
129
|
CREATE INDEX IF NOT EXISTS idx_gnomad_rsid ON gnomad_frequencies(rsid);
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_gnomad_position
|
|
131
|
+
ON gnomad_frequencies(chrom, pos);
|
|
130
132
|
"""
|
|
131
133
|
+ _DATABASE_VERSIONS_TABLE
|
|
132
134
|
)
|
|
@@ -47,6 +47,27 @@ class Variant:
|
|
|
47
47
|
allele2: str
|
|
48
48
|
build: str = DEFAULT_BUILD
|
|
49
49
|
ref: str | None = None
|
|
50
|
+
# GH #128: pre-resolution rsID stashed when a resolver
|
|
51
|
+
# (`ClinVarAnnotator.bulk_resolve_rsids` or
|
|
52
|
+
# `GnomadAnnotator.bulk_resolve_rsids_from_positions`, both invoked
|
|
53
|
+
# in ``_pipeline._flush()``) recovers the real ``rs...`` ID for a
|
|
54
|
+
# variant that arrived without one — e.g. a DeepVariant gVCF row
|
|
55
|
+
# whose ID column is ``.``, parsed as a positional pseudo-ID like
|
|
56
|
+
# ``chr1:11856378:G:A``. The stamp rewrites ``rsid`` to the
|
|
57
|
+
# recovered value so every downstream call site that compares
|
|
58
|
+
# ``v.rsid`` against a requested set (panel coverage, high-value
|
|
59
|
+
# match, extract, PLINK ref/alt, GWAS must-include) works
|
|
60
|
+
# transparently; ``original_rsid`` holds the pre-stamp value
|
|
61
|
+
# purely for audit / debug.
|
|
62
|
+
#
|
|
63
|
+
# **DEBUG-ONLY — do not branch production logic on this field.**
|
|
64
|
+
# Reading ``original_rsid`` outside of logging, debugging, or
|
|
65
|
+
# ``__repr__``-style introspection couples production behavior to
|
|
66
|
+
# provenance metadata and turns this into an unintended API
|
|
67
|
+
# surface. The serialization-guard test in
|
|
68
|
+
# ``tests/test_original_rsid_never_serializes.py`` asserts this
|
|
69
|
+
# field never reaches JSON / HTML / ``--diff`` output.
|
|
70
|
+
original_rsid: str | None = None
|
|
50
71
|
|
|
51
72
|
def __post_init__(self) -> None:
|
|
52
73
|
"""Normalize allele case at construction (GH #14, ADR-0035).
|