allelix 2.2.0__tar.gz → 2.2.1__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.1}/PKG-INFO +1 -1
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/clinvar.py +123 -15
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/db.py +15 -4
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/manager.py +18 -2
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/ftdna_famfinder.py +79 -7
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/vcf.py +26 -4
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/_pipeline.py +71 -5
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/html.py +31 -4
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/json_report.py +5 -1
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/terminal.py +13 -3
- {allelix-2.2.0 → allelix-2.2.1}/allelix.egg-info/PKG-INFO +1 -1
- {allelix-2.2.0 → allelix-2.2.1}/allelix.egg-info/SOURCES.txt +1 -0
- {allelix-2.2.0 → allelix-2.2.1}/pyproject.toml +1 -1
- allelix-2.2.1/tests/test_changelog_sanitize.py +83 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_cli.py +47 -1
- {allelix-2.2.0 → allelix-2.2.1}/LICENSE +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/README.md +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/base.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/cadd.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/gnomad.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/gwas.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/pharmgkb.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/annotators/snpedia.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/_helpers.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/_options.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/analyze.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/config.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/focused.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/cli/utility.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/compare.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/config.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/data/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/data/high_value_snps.yaml +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/_versions.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/schema.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/exporters/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/exporters/plink.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/models.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/base.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/parsers/twentythreeandme.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/py.typed +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/diff.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/high_value.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/reports/methylation.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/utils/__init__.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/utils/allele.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix/utils/build_detect.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/setup.cfg +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_cli_helpers.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_compare.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_config.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_end_to_end.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_models.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/tests/test_registry.py +0 -0
- {allelix-2.2.0 → allelix-2.2.1}/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.1
|
|
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.
|
|
@@ -636,6 +735,12 @@ class ClinVarAnnotator(Annotator):
|
|
|
636
735
|
continue
|
|
637
736
|
user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
|
|
638
737
|
user_diploid = _user_diploid(variant)
|
|
738
|
+
# GH #109: collect per-variant Annotations and dedup before
|
|
739
|
+
# yielding, mirroring the annotate() collapse. Without this,
|
|
740
|
+
# bulk callers (the analyze pipeline goes through
|
|
741
|
+
# batch_annotate) leak the same per-SCV duplicates the
|
|
742
|
+
# single-variant path used to.
|
|
743
|
+
variant_annotations: list[Annotation] = []
|
|
639
744
|
for row in rows:
|
|
640
745
|
(
|
|
641
746
|
_chrom,
|
|
@@ -665,21 +770,24 @@ class ClinVarAnnotator(Annotator):
|
|
|
665
770
|
f"{clnsig.replace('_', ' ') if clnsig else 'unknown significance'}"
|
|
666
771
|
)
|
|
667
772
|
references = [f"clinvar:allele/{allele_id}"] if allele_id else []
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
773
|
+
variant_annotations.append(
|
|
774
|
+
Annotation(
|
|
775
|
+
source=self.name,
|
|
776
|
+
rsid=variant.rsid,
|
|
777
|
+
significance=f"clinvar_{sig_label}",
|
|
778
|
+
category="clinical",
|
|
779
|
+
magnitude=_magnitude(clnsig),
|
|
780
|
+
description=description,
|
|
781
|
+
attribution=self.attribution,
|
|
782
|
+
genotype_match=user_diploid,
|
|
783
|
+
references=references,
|
|
784
|
+
condition="" if not condition or condition == "." else condition,
|
|
785
|
+
gene=gene or "",
|
|
786
|
+
review_status=review_status or "",
|
|
787
|
+
alt=alt,
|
|
788
|
+
)
|
|
682
789
|
)
|
|
790
|
+
yield from _collapse_identical_scvs(variant_annotations)
|
|
683
791
|
|
|
684
792
|
|
|
685
793
|
def _user_diploid(variant: Variant) -> str:
|
|
@@ -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",
|
|
@@ -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
|
|
|
@@ -25,9 +25,26 @@ Specifics:
|
|
|
25
25
|
(case-insensitive); 5 columns vs FTDNA Illumina raw's 4.
|
|
26
26
|
- Separate single-character allele columns (vs. concatenated
|
|
27
27
|
RESULT in the Illumina raw variant).
|
|
28
|
-
- Haploid calls on MT/Y use the single-character pattern
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
- Haploid calls on MT/Y use the single-character pattern: the
|
|
29
|
+
called base in ALLELE1 with EITHER the ALLELE2 column entirely
|
|
30
|
+
absent (4-column line) OR an empty ALLELE2 cell (5-column
|
|
31
|
+
line with trailing tab). Both shapes have been observed in
|
|
32
|
+
real exports. The parser normalizes haploid input to a
|
|
33
|
+
hemizygous-looking Variant where ``allele1 == allele2 ==
|
|
34
|
+
<the called base>`` (consistent with the haploid convention
|
|
35
|
+
in ``_helpers.split_genotype``). GH #113.
|
|
36
|
+
|
|
37
|
+
Chromosome-aware: the haploid shape (4-column line OR
|
|
38
|
+
5-column line with empty ALLELE2) is accepted ONLY when the
|
|
39
|
+
chromosome is MT or Y. FTDNA does not publish haploid calls
|
|
40
|
+
on any other chromosome, so the same shape on an autosome or
|
|
41
|
+
X is almost certainly a truncated diploid that lost an
|
|
42
|
+
allele in transit — promoting it to hemizygous would silently
|
|
43
|
+
double the surviving allele and synthesize a wrong-zygosity
|
|
44
|
+
genotype. The parser warns-and-skips that case instead.
|
|
45
|
+
Tightened against the v2.2.1 cross-PR review on PR #117.
|
|
46
|
+
- No-calls: ``-`` in one or both allele columns (with both
|
|
47
|
+
columns present and ALLELE1 != empty).
|
|
31
48
|
- Build 37 (GRCh37) on every export observed.
|
|
32
49
|
- Detection key: ``famfinder`` substring (case-insensitive) in
|
|
33
50
|
the first ``SNIFF_LINE_LIMIT`` lines AND the canonical 5-column
|
|
@@ -136,11 +153,18 @@ class FTDNAFamFinderParser(GenotypeParser):
|
|
|
136
153
|
continue
|
|
137
154
|
|
|
138
155
|
parts = line.split("\t")
|
|
139
|
-
|
|
156
|
+
# GH #113: accept the 4-column haploid shape (missing
|
|
157
|
+
# ALLELE2 column for hemizygous MT/Y calls) in addition
|
|
158
|
+
# to the canonical 5-column diploid shape. The docstring
|
|
159
|
+
# already claimed haploid support; the impl didn't.
|
|
160
|
+
# Anything other than 4 or 5 columns is genuinely
|
|
161
|
+
# malformed.
|
|
162
|
+
if len(parts) not in (EXPECTED_COLUMNS - 1, EXPECTED_COLUMNS):
|
|
140
163
|
logger.warning(
|
|
141
|
-
"Line %d: expected %d columns, got %d — skipping",
|
|
164
|
+
"Line %d: expected %d columns (or %d for haploid), got %d — skipping",
|
|
142
165
|
lineno,
|
|
143
166
|
EXPECTED_COLUMNS,
|
|
167
|
+
EXPECTED_COLUMNS - 1,
|
|
144
168
|
len(parts),
|
|
145
169
|
)
|
|
146
170
|
continue
|
|
@@ -149,7 +173,55 @@ class FTDNAFamFinderParser(GenotypeParser):
|
|
|
149
173
|
chrom = parts[1].strip()
|
|
150
174
|
pos_str = parts[2].strip()
|
|
151
175
|
allele1 = _normalize_allele(parts[3])
|
|
152
|
-
|
|
176
|
+
# Normalize early — the chromosome-aware haploid guard
|
|
177
|
+
# below compares against the canonical form {"MT", "Y"}.
|
|
178
|
+
normalized_chrom = normalize_chromosome(chrom)
|
|
179
|
+
# Haploid: either the 4-column shape (no ALLELE2 column)
|
|
180
|
+
# OR the 5-column shape with an empty ALLELE2 cell. Both
|
|
181
|
+
# are real in FTDNA's MT/Y output. A genuine hemizygous
|
|
182
|
+
# call is represented as allele1 == allele2 (the
|
|
183
|
+
# convention shared by 23andMe / _helpers.split_genotype),
|
|
184
|
+
# NOT allele2 = NO_CALL_MARKER (which would render as a
|
|
185
|
+
# half no-call and surface the GH #113 reporter's
|
|
186
|
+
# "lab-spec MT/Y vanishes" behavior).
|
|
187
|
+
#
|
|
188
|
+
# GH #113 cross-PR review (#117 review): only accept the
|
|
189
|
+
# haploid shape on MT and Y — the only chromosomes FTDNA
|
|
190
|
+
# publishes haploid calls on. A 4-column line (or
|
|
191
|
+
# 5-column-empty-ALLELE2) on an autosome or X is almost
|
|
192
|
+
# certainly a truncated diploid that lost an allele in
|
|
193
|
+
# transit; promoting it to hemizygous would silently
|
|
194
|
+
# double the surviving allele. Warn-skip instead. This
|
|
195
|
+
# closes the trade-off the v2.3 follow-up was opened
|
|
196
|
+
# for; no real MT/Y haploid call is lost (the canonical
|
|
197
|
+
# shape on those chromosomes is unchanged).
|
|
198
|
+
if len(parts) == EXPECTED_COLUMNS - 1:
|
|
199
|
+
if normalized_chrom not in {"MT", "Y"}:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Line %d: %d-column haploid line on non-haploid "
|
|
202
|
+
"chromosome %r — almost certainly a truncated "
|
|
203
|
+
"diploid; skipping",
|
|
204
|
+
lineno,
|
|
205
|
+
len(parts),
|
|
206
|
+
chrom,
|
|
207
|
+
)
|
|
208
|
+
continue
|
|
209
|
+
allele2 = allele1
|
|
210
|
+
else:
|
|
211
|
+
raw_allele2 = parts[4].strip()
|
|
212
|
+
if raw_allele2 == "":
|
|
213
|
+
if normalized_chrom not in {"MT", "Y"}:
|
|
214
|
+
logger.warning(
|
|
215
|
+
"Line %d: empty ALLELE2 on non-haploid "
|
|
216
|
+
"chromosome %r — almost certainly a "
|
|
217
|
+
"truncated diploid; skipping",
|
|
218
|
+
lineno,
|
|
219
|
+
chrom,
|
|
220
|
+
)
|
|
221
|
+
continue
|
|
222
|
+
allele2 = allele1
|
|
223
|
+
else:
|
|
224
|
+
allele2 = _normalize_allele(parts[4])
|
|
153
225
|
|
|
154
226
|
try:
|
|
155
227
|
position = int(pos_str)
|
|
@@ -159,7 +231,7 @@ class FTDNAFamFinderParser(GenotypeParser):
|
|
|
159
231
|
|
|
160
232
|
yield Variant(
|
|
161
233
|
rsid=rsid,
|
|
162
|
-
chromosome=
|
|
234
|
+
chromosome=normalized_chrom,
|
|
163
235
|
position=position,
|
|
164
236
|
allele1=allele1,
|
|
165
237
|
allele2=allele2,
|
|
@@ -133,7 +133,17 @@ class VcfParser(GenotypeParser):
|
|
|
133
133
|
# ── Public API ──────────────────────────────────────────────
|
|
134
134
|
|
|
135
135
|
def can_parse(self, file_path: Path) -> bool:
|
|
136
|
-
"""True if the first non-blank line is ``##fileformat=VCF...``.
|
|
136
|
+
"""True if the first non-blank line is ``##fileformat=VCF...``.
|
|
137
|
+
|
|
138
|
+
GH #121: catches ``UnicodeDecodeError`` and ``EOFError`` (corrupt
|
|
139
|
+
gzip / truncated BGZF) on top of ``OSError`` so format sniffing
|
|
140
|
+
never raises through into ``detect_parser``. A real-world
|
|
141
|
+
``.vcf.gz`` shouldn't reach the decode path's replacement
|
|
142
|
+
substitution (``_open_vcf`` uses ``errors="replace"``), but
|
|
143
|
+
binary garbage or a truncated tabix index masquerading as a
|
|
144
|
+
``.vcf.gz`` would otherwise still leak a traceback at the
|
|
145
|
+
``read`` boundary in ``gzip.open``.
|
|
146
|
+
"""
|
|
137
147
|
try:
|
|
138
148
|
with _open_vcf(file_path) as handle:
|
|
139
149
|
for line in handle:
|
|
@@ -141,7 +151,7 @@ class VcfParser(GenotypeParser):
|
|
|
141
151
|
if not stripped:
|
|
142
152
|
continue
|
|
143
153
|
return stripped.startswith("##fileformat=VCF")
|
|
144
|
-
except OSError:
|
|
154
|
+
except (OSError, UnicodeDecodeError, EOFError):
|
|
145
155
|
return False
|
|
146
156
|
return False
|
|
147
157
|
|
|
@@ -548,6 +558,18 @@ def _open_vcf(file_path: Path) -> TextIO:
|
|
|
548
558
|
gzipped content still parse). The caller is responsible for closing
|
|
549
559
|
the returned handle (it's a context manager).
|
|
550
560
|
|
|
561
|
+
GH #121: ``errors="replace"`` on the text decode. Real-world VCFs
|
|
562
|
+
routinely carry non-UTF-8 bytes in tool headers — a ``©`` (latin-1
|
|
563
|
+
``0xa9``) in a ``##CL=`` line is extremely common (GATK / DeepVariant
|
|
564
|
+
/ bcftools all do it). A strict decode would raise ``UnicodeDecodeError``
|
|
565
|
+
mid-iteration, leaking a Python traceback through ``can_parse`` and
|
|
566
|
+
``parse``. Replacement renders the bad byte as U+FFFD inside the
|
|
567
|
+
header block — VCF data lines are pure ASCII tab-separated values
|
|
568
|
+
and never reach the substitution path, so genotype fidelity is
|
|
569
|
+
preserved. ``can_parse`` also catches ``UnicodeDecodeError`` as a
|
|
570
|
+
belt-and-braces guard for the case where ``errors="replace"`` is
|
|
571
|
+
ever lifted.
|
|
572
|
+
|
|
551
573
|
Returns ``typing.TextIO`` rather than the concrete ``io.TextIOBase``
|
|
552
574
|
so mypy-strict callers see the ``__enter__`` / ``__exit__`` protocol
|
|
553
575
|
on the returned handle.
|
|
@@ -555,5 +577,5 @@ def _open_vcf(file_path: Path) -> TextIO:
|
|
|
555
577
|
with open(file_path, "rb") as raw:
|
|
556
578
|
magic = raw.read(2)
|
|
557
579
|
if magic == b"\x1f\x8b":
|
|
558
|
-
return gzip.open(file_path, "rt", encoding="utf-8")
|
|
559
|
-
return open(file_path, encoding="utf-8")
|
|
580
|
+
return gzip.open(file_path, "rt", encoding="utf-8", errors="replace")
|
|
581
|
+
return open(file_path, encoding="utf-8", errors="replace")
|
|
@@ -138,6 +138,14 @@ class AnalysisResult:
|
|
|
138
138
|
panel_rsids: frozenset[str] | None = None
|
|
139
139
|
genotyped_panel_rsids: frozenset[str] | None = None
|
|
140
140
|
panel_annotated_rsids: frozenset[str] | None = None
|
|
141
|
+
# GH #120: per-rsID genotype string (e.g. "C/T", "A/-", "-/-")
|
|
142
|
+
# for each panel rsID present in the input file. Populated only
|
|
143
|
+
# when --filter-file supplied a panel. None on default analyze
|
|
144
|
+
# runs (zero behavior change on the default path). The HTML
|
|
145
|
+
# Panel coverage section renders this in a GT column; JSON shape
|
|
146
|
+
# stays v6 untouched — display-only addition. The genotype-aware
|
|
147
|
+
# MECE redesign that lands this in JSON + terminal is #115 (v2.3).
|
|
148
|
+
panel_genotypes: dict[str, str] | None = None
|
|
141
149
|
# GH #90: variants reaching the annotator phase with ref=None.
|
|
142
150
|
# Used by the CLI to surface the strand-aware-matching-inactive
|
|
143
151
|
# info line once per run for array inputs.
|
|
@@ -148,7 +156,9 @@ class AnalysisResult:
|
|
|
148
156
|
# warning until #62's dbSNP resolution lands.
|
|
149
157
|
rsidless_variant_count: int = 0
|
|
150
158
|
|
|
151
|
-
def panel_coverage(
|
|
159
|
+
def panel_coverage(
|
|
160
|
+
self, filtered_annotations: list[Annotation] | None = None
|
|
161
|
+
) -> PanelCoverage | None:
|
|
152
162
|
"""Return panel-coverage info when a rsid panel was supplied.
|
|
153
163
|
|
|
154
164
|
States, per GH #75:
|
|
@@ -175,15 +185,56 @@ class AnalysisResult:
|
|
|
175
185
|
chip presence, not call success. High-value no-calls are
|
|
176
186
|
separately surfaced by the ``hv_warnings`` path in the CLI;
|
|
177
187
|
readers should not interpret ``found`` as "called."
|
|
188
|
+
|
|
189
|
+
GH #106 (v2.2.1 patch): when ``filtered_annotations`` is
|
|
190
|
+
supplied — the post-magnitude-filter list each renderer
|
|
191
|
+
already produces for the main annotation table — the
|
|
192
|
+
"annotated" set is derived from THAT list, not from
|
|
193
|
+
``panel_annotated_rsids`` (which is populated from the
|
|
194
|
+
unfiltered annotation set in ``run_analysis``). Without
|
|
195
|
+
this, panel rsIDs whose only annotations score below the
|
|
196
|
+
analyze display floor land in ``found`` but render nowhere
|
|
197
|
+
— they're counted as "found" then filtered out of the
|
|
198
|
+
annotation table, then NOT folded into ``no_findings``
|
|
199
|
+
(because the pipeline thinks they had annotations). Pure
|
|
200
|
+
limbo, a flat lie in ``found``. Passing the filtered list
|
|
201
|
+
in makes "annotated" equal "actually rendered" and the
|
|
202
|
+
below-floor variants drop into ``no_findings`` honestly.
|
|
203
|
+
|
|
204
|
+
The redesign (genotype-aware MECE 4-state with `no_call`
|
|
205
|
+
distinct from `typed_no_annotation`, schema 6→7) lands in
|
|
206
|
+
v2.3 (#115).
|
|
178
207
|
"""
|
|
179
208
|
if self.panel_rsids is None:
|
|
180
209
|
return None
|
|
181
210
|
genotyped = self.genotyped_panel_rsids or frozenset()
|
|
182
|
-
|
|
183
|
-
|
|
211
|
+
panel_rsids = self.panel_rsids
|
|
212
|
+
if filtered_annotations is not None:
|
|
213
|
+
# GH #106 patch: "annotated" is the set of panel rsIDs that
|
|
214
|
+
# actually got rendered — derived from the post-filter
|
|
215
|
+
# annotation list the renderer passes in. Sub-floor-only
|
|
216
|
+
# panel rsids drop out of `annotated`, so they fall into
|
|
217
|
+
# `no_findings` instead of limbo. The `found` field still
|
|
218
|
+
# means "in input file" (genotyped count) — same v6
|
|
219
|
+
# contract — only `no_findings` widens to be honest.
|
|
220
|
+
annotated: frozenset[str] = frozenset(
|
|
221
|
+
a.rsid for a in filtered_annotations if a.rsid in panel_rsids
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
# Back-compat path for callers that don't thread the
|
|
225
|
+
# filtered list. `annotated` stays the raw pre-filter set,
|
|
226
|
+
# reproducing the v6.0 behavior (sub-floor panel rsids
|
|
227
|
+
# land in `found` and vanish from every rendered surface —
|
|
228
|
+
# the limbo bug the #106 patch retires when the filtered
|
|
229
|
+
# list IS supplied).
|
|
230
|
+
annotated = self.panel_annotated_rsids or frozenset()
|
|
231
|
+
missing = sorted(panel_rsids - genotyped)
|
|
232
|
+
# Below-floor panel rsids land here on the filtered path: in
|
|
233
|
+
# genotyped but not in the post-filter annotated set. They had
|
|
234
|
+
# annotations in the cache but none survived the display floor.
|
|
184
235
|
no_findings = sorted(genotyped - annotated)
|
|
185
236
|
return {
|
|
186
|
-
"requested": len(
|
|
237
|
+
"requested": len(panel_rsids),
|
|
187
238
|
"found": len(genotyped),
|
|
188
239
|
"missing": missing,
|
|
189
240
|
"no_findings": no_findings,
|
|
@@ -442,6 +493,11 @@ def run_analysis(
|
|
|
442
493
|
# so a 600k-variant input contributes ~panel-size memory, not
|
|
443
494
|
# 600k rsids.
|
|
444
495
|
genotyped_panel: set[str] = set()
|
|
496
|
+
# GH #120: per-rsID genotype for panel members. First-occurrence
|
|
497
|
+
# wins (a 23andMe-style chip emits one row per rsID anyway; multi-
|
|
498
|
+
# row input formats keep the earliest reading). HTML-only surface
|
|
499
|
+
# in v2.2.1; #115 carries the JSON + terminal addition in v2.3.
|
|
500
|
+
panel_genotypes_map: dict[str, str] = {}
|
|
445
501
|
# GH #90 / #91: per-run diagnostic totals for ref-None and
|
|
446
502
|
# rsidless variants reaching the annotator phase. Used by the CLI
|
|
447
503
|
# to emit the "strand-aware inactive" info line (#90) and the
|
|
@@ -513,7 +569,14 @@ def run_analysis(
|
|
|
513
569
|
if hv_set:
|
|
514
570
|
hv_variants.extend(v for v in batch_buf if v.rsid in hv_set)
|
|
515
571
|
if panel_rsids:
|
|
516
|
-
|
|
572
|
+
for v in batch_buf:
|
|
573
|
+
if v.rsid in panel_rsids:
|
|
574
|
+
genotyped_panel.add(v.rsid)
|
|
575
|
+
# First-occurrence wins; renders as "A/G",
|
|
576
|
+
# "C/C", "A/-", "-/-" honestly — uses the
|
|
577
|
+
# same Variant.genotype property the rest of
|
|
578
|
+
# the codebase uses.
|
|
579
|
+
panel_genotypes_map.setdefault(v.rsid, v.genotype)
|
|
517
580
|
# GH #90 / #91: per-batch diagnostic counters. Tallied AFTER
|
|
518
581
|
# the ref-population and resolver passes so the counts
|
|
519
582
|
# reflect the final state annotators see — not the raw input.
|
|
@@ -629,10 +692,12 @@ def run_analysis(
|
|
|
629
692
|
result_panel = frozenset(panel_rsids)
|
|
630
693
|
result_genotyped = frozenset(genotyped_panel)
|
|
631
694
|
result_annotated = frozenset(a.rsid for a in annotations if a.rsid in panel_rsids)
|
|
695
|
+
result_genotypes: dict[str, str] | None = dict(panel_genotypes_map)
|
|
632
696
|
else:
|
|
633
697
|
result_panel = None
|
|
634
698
|
result_genotyped = None
|
|
635
699
|
result_annotated = None
|
|
700
|
+
result_genotypes = None
|
|
636
701
|
|
|
637
702
|
return AnalysisResult(
|
|
638
703
|
file_path=file_path,
|
|
@@ -649,6 +714,7 @@ def run_analysis(
|
|
|
649
714
|
panel_rsids=result_panel,
|
|
650
715
|
genotyped_panel_rsids=result_genotyped,
|
|
651
716
|
panel_annotated_rsids=result_annotated,
|
|
717
|
+
panel_genotypes=result_genotypes,
|
|
652
718
|
no_ref_variant_count=no_ref_total,
|
|
653
719
|
rsidless_variant_count=rsidless_total,
|
|
654
720
|
)
|
|
@@ -486,14 +486,26 @@ def _hv_nocall_banner(warnings: list[str] | None) -> str:
|
|
|
486
486
|
)
|
|
487
487
|
|
|
488
488
|
|
|
489
|
-
def _panel_coverage_section(
|
|
490
|
-
|
|
489
|
+
def _panel_coverage_section(
|
|
490
|
+
coverage: PanelCoverage | None,
|
|
491
|
+
panel_genotypes: dict[str, str] | None = None,
|
|
492
|
+
) -> str:
|
|
493
|
+
"""GH #75 / #120: render the Panel Coverage section.
|
|
491
494
|
|
|
492
495
|
Quiet when no panel was supplied. Otherwise show the requested/found
|
|
493
496
|
counts plus a table of rsIDs that weren't on the user's chip (state
|
|
494
497
|
3) and rsIDs that were on the chip but produced no findings (state
|
|
495
498
|
2). State 1 hits render in the main annotation table as before, so
|
|
496
499
|
this section is purely a "what's NOT in the table" summary.
|
|
500
|
+
|
|
501
|
+
GH #120: when ``panel_genotypes`` is supplied (the rsid → "A/G"
|
|
502
|
+
map collected during run_analysis), a GT column is added between
|
|
503
|
+
rsID and Note. Genotyped rsIDs (state 2 + no-call) render their
|
|
504
|
+
literal call — including "-/-" for full no-calls and "A/-" for
|
|
505
|
+
half no-calls so the dash is visually obvious even though the v6
|
|
506
|
+
accounting still folds no-calls into ``no_findings`` (the
|
|
507
|
+
dedicated ``no_call`` MECE state is #115's job in v2.3). Rows
|
|
508
|
+
that aren't on the chip (state 3) show "—" (em dash).
|
|
497
509
|
"""
|
|
498
510
|
if coverage is None:
|
|
499
511
|
return ""
|
|
@@ -501,23 +513,38 @@ def _panel_coverage_section(coverage: PanelCoverage | None) -> str:
|
|
|
501
513
|
found = int(coverage["found"])
|
|
502
514
|
missing = list(coverage["missing"])
|
|
503
515
|
no_findings = list(coverage["no_findings"])
|
|
516
|
+
show_gt = panel_genotypes is not None
|
|
517
|
+
|
|
518
|
+
def _gt(rsid: str, in_input: bool) -> str:
|
|
519
|
+
"""Render the GT column cell (GH #120).
|
|
520
|
+
|
|
521
|
+
State-3 rows (not_on_chip) show em dash; state-2 rows show
|
|
522
|
+
the literal Variant.genotype (e.g. "A/G", "-/-", "A/-").
|
|
523
|
+
"""
|
|
524
|
+
if not in_input:
|
|
525
|
+
return "<td>—</td>"
|
|
526
|
+
gt = (panel_genotypes or {}).get(rsid, "—")
|
|
527
|
+
return f"<td><code>{_escape(gt)}</code></td>"
|
|
504
528
|
|
|
505
529
|
rows = []
|
|
506
530
|
for rsid in missing:
|
|
507
531
|
rows.append(
|
|
508
532
|
f"<tr><td><code>{_escape(rsid)}</code></td>"
|
|
533
|
+
f"{_gt(rsid, in_input=False) if show_gt else ''}"
|
|
509
534
|
"<td>not genotyped on this chip/platform</td></tr>"
|
|
510
535
|
)
|
|
511
536
|
for rsid in no_findings:
|
|
512
537
|
rows.append(
|
|
513
538
|
f"<tr><td><code>{_escape(rsid)}</code></td>"
|
|
539
|
+
f"{_gt(rsid, in_input=True) if show_gt else ''}"
|
|
514
540
|
"<td>genotyped, no annotations matched</td></tr>"
|
|
515
541
|
)
|
|
516
542
|
table = ""
|
|
517
543
|
if rows:
|
|
544
|
+
gt_th = "<th>GT</th>" if show_gt else ""
|
|
518
545
|
table = (
|
|
519
546
|
"<table class='panel-coverage'><thead>"
|
|
520
|
-
"<tr><th>rsID</th
|
|
547
|
+
f"<tr><th>rsID</th>{gt_th}<th>Note</th></tr></thead>"
|
|
521
548
|
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
522
549
|
)
|
|
523
550
|
return (
|
|
@@ -1187,7 +1214,7 @@ def render_html(
|
|
|
1187
1214
|
f"{_escape(REGULATORY_NOTICE)}</div>"
|
|
1188
1215
|
f"{build_warn}"
|
|
1189
1216
|
f"{_hv_nocall_banner(high_value_no_calls)}"
|
|
1190
|
-
f"{_panel_coverage_section(result.panel_coverage())}"
|
|
1217
|
+
f"{_panel_coverage_section(result.panel_coverage(filtered), result.panel_genotypes)}"
|
|
1191
1218
|
f"{_EDUCATION_SECTION}"
|
|
1192
1219
|
f"{_MAGNITUDE_LEGEND}"
|
|
1193
1220
|
f"{diff_banner}"
|
|
@@ -157,7 +157,11 @@ def render_json(
|
|
|
157
157
|
# object distinguishes "not on chip" from "homozygous reference"
|
|
158
158
|
# from "had a finding". `missing` is state 3, `no_findings` is
|
|
159
159
|
# state 2, both lists are sorted for deterministic diffs.
|
|
160
|
-
|
|
160
|
+
# GH #106 (v2.2.1 patch): thread the post-filter annotation list so
|
|
161
|
+
# `panel_coverage` derives "annotated" from what actually got
|
|
162
|
+
# rendered. Without this, sub-floor panel rsids vanished from every
|
|
163
|
+
# rendered surface while still being counted as "found".
|
|
164
|
+
panel_coverage = result.panel_coverage(filtered)
|
|
161
165
|
if panel_coverage is not None:
|
|
162
166
|
payload["panel_coverage"] = panel_coverage
|
|
163
167
|
|
|
@@ -60,7 +60,7 @@ def render_terminal(
|
|
|
60
60
|
)
|
|
61
61
|
filtered = rollup_gwas_duplicates(filtered)
|
|
62
62
|
_print_table(filtered, console)
|
|
63
|
-
_print_panel_coverage_warning(result, console)
|
|
63
|
+
_print_panel_coverage_warning(result, console, filtered)
|
|
64
64
|
_print_regulatory_notice(console)
|
|
65
65
|
return len(filtered)
|
|
66
66
|
|
|
@@ -78,14 +78,24 @@ def _print_regulatory_notice(console: Console) -> None:
|
|
|
78
78
|
console.print(f"\n[dim italic]{REGULATORY_NOTICE}[/dim italic]")
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
def _print_panel_coverage_warning(
|
|
81
|
+
def _print_panel_coverage_warning(
|
|
82
|
+
result: AnalysisResult,
|
|
83
|
+
console: Console,
|
|
84
|
+
filtered_annotations: list[Annotation],
|
|
85
|
+
) -> None:
|
|
82
86
|
"""GH #75: surface panel rsIDs that weren't in the user's input file.
|
|
83
87
|
|
|
84
88
|
"Not on your chip" is critically different from "homozygous reference"
|
|
85
89
|
— making this invisible was the original audit complaint. Quiet when
|
|
86
90
|
no panel was supplied, or when every panel rsID was genotyped.
|
|
91
|
+
|
|
92
|
+
GH #106: the post-filter annotation list is threaded through so
|
|
93
|
+
`panel_coverage()` derives "annotated" from what was actually rendered,
|
|
94
|
+
not from the unfiltered set. Without this thread, panel rsids whose
|
|
95
|
+
only annotations were below the magnitude floor showed in "found" but
|
|
96
|
+
not in any rendered surface (the 9-of-20 limbo bug).
|
|
87
97
|
"""
|
|
88
|
-
coverage = result.panel_coverage()
|
|
98
|
+
coverage = result.panel_coverage(filtered_annotations)
|
|
89
99
|
if coverage is None:
|
|
90
100
|
return
|
|
91
101
|
missing = coverage["missing"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: allelix
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
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>
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "allelix"
|
|
7
|
-
version = "2.2.
|
|
7
|
+
version = "2.2.1"
|
|
8
8
|
description = "Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Allelix
|
|
3
|
+
"""Release-gate sanitize guard for the public CHANGELOG.
|
|
4
|
+
|
|
5
|
+
GH #118: CHANGELOG.md ships verbatim from the private allelix-dev
|
|
6
|
+
repo to the public allelix repo and PyPI. Bare `#NN` references
|
|
7
|
+
auto-link on GitHub against the surrounding repo's numbering — on
|
|
8
|
+
the public repo they ALL 404, because the issues/PRs only exist on
|
|
9
|
+
the private tracker. Same hazard for an `### Internal` section,
|
|
10
|
+
which is forensic provenance no public user needs.
|
|
11
|
+
|
|
12
|
+
These two assertions are the structural guard the issue requires:
|
|
13
|
+
- any bare issue/PR reference (#NN) fails the gate
|
|
14
|
+
- any `### Internal` heading fails the gate
|
|
15
|
+
|
|
16
|
+
The guard runs on every fast-tier pytest pass, so a PR that
|
|
17
|
+
re-introduces either form gets caught at PR time, not at tag time.
|
|
18
|
+
|
|
19
|
+
If you genuinely need internal traceability for an entry, put the
|
|
20
|
+
issue number in the git commit body (the private dev branch keeps
|
|
21
|
+
the squash commit + the private-only release commit body — both
|
|
22
|
+
retain forensic context for anyone tracing history). Public users
|
|
23
|
+
read the CHANGELOG; everything in there must stand on its own.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
_CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
34
|
+
_ISSUE_REF_PATTERN = re.compile(r"#\d+\b")
|
|
35
|
+
_INTERNAL_HEADING_PATTERN = re.compile(r"^###\s+Internal\b", re.MULTILINE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="module")
|
|
39
|
+
def changelog_text() -> str:
|
|
40
|
+
return _CHANGELOG.read_text(encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_changelog_has_no_issue_or_pr_references(changelog_text: str) -> None:
|
|
44
|
+
"""GH #118: every `#NN` token in CHANGELOG.md auto-links to the
|
|
45
|
+
surrounding repo on GitHub. Private-repo issue/PR numbers don't
|
|
46
|
+
exist on the public repo, so every shipped `#NN` is a dead link.
|
|
47
|
+
|
|
48
|
+
If you need to credit an issue, restructure the prose so it
|
|
49
|
+
stands without the tracker number, and put the reference in the
|
|
50
|
+
git commit body (forensic context the squash commit retains).
|
|
51
|
+
"""
|
|
52
|
+
matches = _ISSUE_REF_PATTERN.findall(changelog_text)
|
|
53
|
+
assert not matches, (
|
|
54
|
+
f"CHANGELOG.md contains {len(matches)} bare issue/PR reference(s) "
|
|
55
|
+
f"that would become dead links on the public repo: "
|
|
56
|
+
f"{sorted(set(matches))[:10]}{'…' if len(set(matches)) > 10 else ''}. "
|
|
57
|
+
f"Strip them (forensic context lives in the git commit body, not "
|
|
58
|
+
f"in the user-facing CHANGELOG)."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_changelog_has_no_internal_section(changelog_text: str) -> None:
|
|
63
|
+
"""GH #118: an `### Internal` heading carries forensic provenance
|
|
64
|
+
(architectural notes, plumbing details, fixture changes) that no
|
|
65
|
+
public user needs. The squash commit on main + the private
|
|
66
|
+
release commit body both retain this context for anyone tracing
|
|
67
|
+
history.
|
|
68
|
+
"""
|
|
69
|
+
matches = _INTERNAL_HEADING_PATTERN.findall(changelog_text)
|
|
70
|
+
assert not matches, (
|
|
71
|
+
"CHANGELOG.md contains an '### Internal' heading. Move that "
|
|
72
|
+
"content into the squash commit body on main (the load-bearing "
|
|
73
|
+
"forensic surface) and drop the heading from the CHANGELOG."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_changelog_keep_a_changelog_header_intact(changelog_text: str) -> None:
|
|
78
|
+
"""Light sanity check that the file structure isn't accidentally
|
|
79
|
+
truncated by an over-aggressive sanitize pass — the Keep a
|
|
80
|
+
Changelog header should always be present at the top."""
|
|
81
|
+
first_lines = "\n".join(changelog_text.splitlines()[:3])
|
|
82
|
+
assert "# Changelog" in first_lines
|
|
83
|
+
assert "Keep a Changelog" in changelog_text
|
|
@@ -533,7 +533,14 @@ class TestDbCommands:
|
|
|
533
533
|
):
|
|
534
534
|
info = get_database_info(db_path, clinvar_record_name(build))
|
|
535
535
|
assert info is not None, f"{build} cache missing version row"
|
|
536
|
-
|
|
536
|
+
# GH #111 grew the fixture set: 13 → 20 (added 3 identical-SCV
|
|
537
|
+
# rows for rs999000111, 2 conflicting-SCV rows for rs999000222,
|
|
538
|
+
# 1 sub-floor row for rs999000333, 1 unmapped-CLNSIG row for
|
|
539
|
+
# rs999000444). #109 cross-PR review (Finding 1) added 2 more
|
|
540
|
+
# rows for rs999000555 (multi-allelic distinct-alt dedup
|
|
541
|
+
# guard) → 22 total. Cache row count stays at 22 even after
|
|
542
|
+
# #109's dedup — dedup is annotator-side, not loader-side.
|
|
543
|
+
assert info["record_count"] == 22
|
|
537
544
|
assert info["version"] == "20260101"
|
|
538
545
|
|
|
539
546
|
pharmgkb_info = get_database_info(pharmgkb_sqlite, "pharmgkb")
|
|
@@ -1047,6 +1054,18 @@ class TestDbCommands:
|
|
|
1047
1054
|
import os
|
|
1048
1055
|
import stat
|
|
1049
1056
|
|
|
1057
|
+
# GH #110: chmod 0 is bypassed by root — root sees a "readonly"
|
|
1058
|
+
# dir as writable (DAC override) and the test silently passes
|
|
1059
|
+
# for the wrong reason. CI runners and dev containers commonly
|
|
1060
|
+
# run as root; skip there with a clear message so the test
|
|
1061
|
+
# isn't a false-green on those environments. Hermetic
|
|
1062
|
+
# local-dev runs (non-root) keep the real assertion.
|
|
1063
|
+
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
1064
|
+
pytest.skip(
|
|
1065
|
+
"chmod-readonly is bypassed under root (DAC override); "
|
|
1066
|
+
"test is non-hermetic in that environment. Run as a "
|
|
1067
|
+
"non-root user to exercise the writability check."
|
|
1068
|
+
)
|
|
1050
1069
|
# Make the dir non-writable
|
|
1051
1070
|
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IXUSR)
|
|
1052
1071
|
try:
|
|
@@ -1211,6 +1230,33 @@ class TestDbCommands:
|
|
|
1211
1230
|
stderr_flat = " ".join(result.stderr.split())
|
|
1212
1231
|
assert "doesn't look like an allelix cache" in stderr_flat
|
|
1213
1232
|
|
|
1233
|
+
def test_db_clean_refuses_dir_with_only_generic_config_toml(self, tmp_path: Path):
|
|
1234
|
+
"""GH #112: a `config.toml` alongside non-allelix content (the
|
|
1235
|
+
Rust / Hugo / Rocket / etc. project shape) must NOT pass the
|
|
1236
|
+
guard. Pre-#112 the guard accepted any dir with a config.toml
|
|
1237
|
+
— so a typo'd `--data-dir ~/my-rust-project` could trigger
|
|
1238
|
+
`rm -rf` against a Cargo project root.
|
|
1239
|
+
|
|
1240
|
+
Pinned: a generic config.toml + any non-allelix file →
|
|
1241
|
+
guard refuses with the "doesn't look like an allelix cache"
|
|
1242
|
+
error and exit code 1, regardless of what's in config.toml.
|
|
1243
|
+
"""
|
|
1244
|
+
# A Rust-shaped config.toml (no allelix-specific sections).
|
|
1245
|
+
(tmp_path / "config.toml").write_text("[package]\nname = 'not-allelix'\n")
|
|
1246
|
+
(tmp_path / "Cargo.lock").write_text("# cargo lockfile\n")
|
|
1247
|
+
(tmp_path / "src").mkdir()
|
|
1248
|
+
(tmp_path / "src" / "main.rs").write_text("fn main() {}\n")
|
|
1249
|
+
|
|
1250
|
+
runner = CliRunner()
|
|
1251
|
+
result = runner.invoke(main, ["db", "clean", "--data-dir", str(tmp_path), "--yes"])
|
|
1252
|
+
assert result.exit_code == 1
|
|
1253
|
+
stderr_flat = " ".join(result.stderr.split())
|
|
1254
|
+
assert "doesn't look like an allelix cache" in stderr_flat
|
|
1255
|
+
# And the Cargo project survives the encounter unharmed.
|
|
1256
|
+
assert (tmp_path / "config.toml").exists()
|
|
1257
|
+
assert (tmp_path / "Cargo.lock").exists()
|
|
1258
|
+
assert (tmp_path / "src" / "main.rs").exists()
|
|
1259
|
+
|
|
1214
1260
|
|
|
1215
1261
|
class TestAnalyzeOutputDispatch:
|
|
1216
1262
|
def test_analyze_writes_json(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|