allelix 2.2.1__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.1 → allelix-2.2.2}/PKG-INFO +1 -1
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/clinvar.py +6 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/gnomad.py +96 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/utility.py +105 -6
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/schema.py +2 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/models.py +21 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/vcf.py +82 -5
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/_pipeline.py +34 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/html.py +35 -8
- {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/PKG-INFO +1 -1
- {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/SOURCES.txt +2 -0
- {allelix-2.2.1 → allelix-2.2.2}/pyproject.toml +1 -1
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_cli.py +43 -0
- 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.1 → allelix-2.2.2}/LICENSE +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/README.md +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/alphamissense.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/base.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/cadd.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/gwas.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/pharmgkb.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/snpedia.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/_helpers.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/_options.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/analyze.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/config.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/db.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/focused.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/compare.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/config.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/data/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/data/high_value_snps.yaml +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/_versions.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/alphamissense_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/cadd_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/cpic_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/gnomad_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/gwas_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/loader_utils.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/manager.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/pharmgkb_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/snpedia_loader.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/snpedia_parser.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/exporters/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/exporters/plink.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/_helpers.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ancestrydna.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/base.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ftdna.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ftdna_famfinder.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ftdna_illumina.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/livingdna.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/myhappygenes.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/myheritage.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/twentythreeandme.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/py.typed +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/diff.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/high_value.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/json_report.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/methylation.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/terminal.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/utils/__init__.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/utils/allele.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix/utils/build_detect.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/dependency_links.txt +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/entry_points.txt +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/requires.txt +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/top_level.txt +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/setup.cfg +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_changelog_sanitize.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_cli_helpers.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_compare.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_config.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_end_to_end.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_mock_data_invariants.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_models.py +0 -0
- {allelix-2.2.1 → allelix-2.2.2}/tests/test_registry.py +0 -0
- {allelix-2.2.1 → 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>
|
|
@@ -685,6 +685,12 @@ class ClinVarAnnotator(Annotator):
|
|
|
685
685
|
]
|
|
686
686
|
if len(matches) == 1:
|
|
687
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
|
|
688
694
|
v.rsid = rsid
|
|
689
695
|
resolved[(v.chromosome, v.position, ref, alt)] = rsid
|
|
690
696
|
return resolved
|
|
@@ -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:
|
|
@@ -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:
|
|
@@ -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).
|
|
@@ -90,6 +90,15 @@ def format_sample_list(samples: list[str], max_shown: int = 10) -> str:
|
|
|
90
90
|
return ", ".join(samples[:max_shown]) + f", ... and {len(samples) - max_shown} more"
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
# GH #126: how many leading non-blank lines ``can_parse`` will scan for
|
|
94
|
+
# ``##fileformat=VCF`` before giving up. 10 covers every real-world
|
|
95
|
+
# preamble shape we've encountered (BOM-on-its-own-line edge cases,
|
|
96
|
+
# tool banners, blank-line prefixes) without making the negative-case
|
|
97
|
+
# cost meaningful — non-VCF inputs reject after reading ~10 short
|
|
98
|
+
# lines (a handful of KB).
|
|
99
|
+
_PREAMBLE_SCAN_LINES = 10
|
|
100
|
+
|
|
101
|
+
|
|
93
102
|
class VcfParser(GenotypeParser):
|
|
94
103
|
"""Streaming parser for VCF 4.x and gVCF files.
|
|
95
104
|
|
|
@@ -133,7 +142,7 @@ class VcfParser(GenotypeParser):
|
|
|
133
142
|
# ── Public API ──────────────────────────────────────────────
|
|
134
143
|
|
|
135
144
|
def can_parse(self, file_path: Path) -> bool:
|
|
136
|
-
"""True if
|
|
145
|
+
"""True if a leading non-blank line is ``##fileformat=VCF...``.
|
|
137
146
|
|
|
138
147
|
GH #121: catches ``UnicodeDecodeError`` and ``EOFError`` (corrupt
|
|
139
148
|
gzip / truncated BGZF) on top of ``OSError`` so format sniffing
|
|
@@ -143,14 +152,45 @@ class VcfParser(GenotypeParser):
|
|
|
143
152
|
binary garbage or a truncated tabix index masquerading as a
|
|
144
153
|
``.vcf.gz`` would otherwise still leak a traceback at the
|
|
145
154
|
``read`` boundary in ``gzip.open``.
|
|
155
|
+
|
|
156
|
+
GH #126: scan multiple leading non-blank lines (capped at
|
|
157
|
+
:data:`_PREAMBLE_SCAN_LINES`) instead of insisting
|
|
158
|
+
``##fileformat=VCF`` appears on line 1. Real-world VCF
|
|
159
|
+
producers occasionally prepend a non-standard line — a
|
|
160
|
+
tool-banner comment, a blank line wrapped by a downstream
|
|
161
|
+
post-processor, an editor's "edited by …" annotation — and
|
|
162
|
+
rejecting the file on first-line-mismatch read as broken
|
|
163
|
+
when the actual format was fine. The scan terminates the
|
|
164
|
+
moment a line starts with ``##fileformat=VCF`` (success) or
|
|
165
|
+
we've consumed the cap without finding it (failure). A line
|
|
166
|
+
starting with ``#CHROM`` (the data-header line) also
|
|
167
|
+
terminates the scan as failure — we're past the metadata
|
|
168
|
+
block and ``##fileformat`` clearly never appeared.
|
|
169
|
+
|
|
170
|
+
The UTF-8 BOM that prompted the original bug report is
|
|
171
|
+
stripped structurally by ``_open_vcf``'s ``utf-8-sig`` codec
|
|
172
|
+
(also GH #126), so the BOM never reaches this loop. The
|
|
173
|
+
multi-line scan complements that by covering the other class
|
|
174
|
+
of preamble quirks the BOM hypothesis doesn't explain.
|
|
146
175
|
"""
|
|
147
176
|
try:
|
|
148
177
|
with _open_vcf(file_path) as handle:
|
|
178
|
+
scanned = 0
|
|
149
179
|
for line in handle:
|
|
150
180
|
stripped = line.strip()
|
|
151
181
|
if not stripped:
|
|
152
182
|
continue
|
|
153
|
-
|
|
183
|
+
if stripped.startswith("##fileformat=VCF"):
|
|
184
|
+
return True
|
|
185
|
+
# ``#CHROM`` is the data-header line; if we hit it
|
|
186
|
+
# before ``##fileformat`` we know the metadata
|
|
187
|
+
# block doesn't contain the declaration and the
|
|
188
|
+
# file isn't a VCF we can claim.
|
|
189
|
+
if stripped.startswith("#CHROM"):
|
|
190
|
+
return False
|
|
191
|
+
scanned += 1
|
|
192
|
+
if scanned >= _PREAMBLE_SCAN_LINES:
|
|
193
|
+
return False
|
|
154
194
|
except (OSError, UnicodeDecodeError, EOFError):
|
|
155
195
|
return False
|
|
156
196
|
return False
|
|
@@ -304,13 +344,27 @@ def _read_header(handle: TextIO) -> _VcfHeader:
|
|
|
304
344
|
|
|
305
345
|
Returns parsed metadata. After this call the handle is positioned
|
|
306
346
|
at the first data line (or EOF).
|
|
347
|
+
|
|
348
|
+
GH #126: tolerates a small preamble of single-``#`` comment lines
|
|
349
|
+
BEFORE the first ``##fileformat`` declaration, symmetric with
|
|
350
|
+
:meth:`VcfParser.can_parse`'s scan. Some upstream tools and
|
|
351
|
+
post-processors prepend a banner (e.g. ``# Generated by …``)
|
|
352
|
+
that's not a VCF metadata line but is also not the data block.
|
|
353
|
+
The scan caps at :data:`_PREAMBLE_SCAN_LINES` non-blank lines —
|
|
354
|
+
if no ``##`` declaration appears within that window we treat the
|
|
355
|
+
first non-comment / non-blank line as the start of data,
|
|
356
|
+
preserving the original behavior on inputs that genuinely lack
|
|
357
|
+
a header.
|
|
307
358
|
"""
|
|
308
359
|
header = _VcfHeader()
|
|
360
|
+
preamble_scanned = 0
|
|
361
|
+
saw_meta = False
|
|
309
362
|
for line in handle:
|
|
310
363
|
stripped = line.rstrip("\n").rstrip("\r")
|
|
311
364
|
if not stripped:
|
|
312
365
|
continue
|
|
313
366
|
if stripped.startswith("##"):
|
|
367
|
+
saw_meta = True
|
|
314
368
|
_absorb_meta_line(stripped, header)
|
|
315
369
|
continue
|
|
316
370
|
if stripped.startswith("#CHROM"):
|
|
@@ -321,6 +375,15 @@ def _read_header(handle: TextIO) -> _VcfHeader:
|
|
|
321
375
|
if len(cols) > 9:
|
|
322
376
|
header.samples = cols[9:]
|
|
323
377
|
return header
|
|
378
|
+
# GH #126: single-``#`` comment lines before any ``##`` metadata
|
|
379
|
+
# are treated as preamble (banner / post-processor annotation)
|
|
380
|
+
# up to the scan cap. Once we've seen at least one ``##`` line
|
|
381
|
+
# the header block is "real" and a stray non-``##`` line is
|
|
382
|
+
# treated as data per the original behavior.
|
|
383
|
+
if not saw_meta and stripped.startswith("#"):
|
|
384
|
+
preamble_scanned += 1
|
|
385
|
+
if preamble_scanned < _PREAMBLE_SCAN_LINES:
|
|
386
|
+
continue
|
|
324
387
|
# Reaching a data line before #CHROM means no column header.
|
|
325
388
|
# Return what we have; samples is empty.
|
|
326
389
|
return header
|
|
@@ -551,7 +614,7 @@ def _lookup_allele(gt_index: str, ref: str, alts: list[str]) -> str | None:
|
|
|
551
614
|
|
|
552
615
|
|
|
553
616
|
def _open_vcf(file_path: Path) -> TextIO:
|
|
554
|
-
"""Open a ``.vcf`` or ``.vcf.gz`` file in text mode.
|
|
617
|
+
r"""Open a ``.vcf`` or ``.vcf.gz`` file in text mode.
|
|
555
618
|
|
|
556
619
|
Sniffs the gzip magic bytes ``1f 8b`` so the parser works on any
|
|
557
620
|
extension (e.g., files named ``foo.txt`` containing valid VCF
|
|
@@ -570,6 +633,20 @@ def _open_vcf(file_path: Path) -> TextIO:
|
|
|
570
633
|
belt-and-braces guard for the case where ``errors="replace"`` is
|
|
571
634
|
ever lifted.
|
|
572
635
|
|
|
636
|
+
GH #126: ``encoding="utf-8-sig"`` (was ``utf-8``). The ``-sig``
|
|
637
|
+
variant transparently strips a leading UTF-8 BOM (``\\xef\\xbb\\xbf``)
|
|
638
|
+
when present and behaves identically to plain ``utf-8`` on
|
|
639
|
+
unprefixed input. Real-world Sequencing.com / DRAGEN VCFs are
|
|
640
|
+
reported to ship with a UTF-8 BOM under some download flows, and
|
|
641
|
+
the BOM defeated ``can_parse``'s ``startswith("##fileformat=VCF")``
|
|
642
|
+
check: ``\\ufeff##fileformat=VCFv4.2`` does not start with
|
|
643
|
+
``##fileformat=VCF``. Switching the codec is zero-risk on
|
|
644
|
+
BOM-less input (the codec falls through to standard utf-8) and
|
|
645
|
+
fixes the BOM case structurally — no per-call lstrip, no
|
|
646
|
+
encoding-detection guess. Same swap on both the gzip and plain
|
|
647
|
+
text paths so the fix applies regardless of how the file was
|
|
648
|
+
delivered.
|
|
649
|
+
|
|
573
650
|
Returns ``typing.TextIO`` rather than the concrete ``io.TextIOBase``
|
|
574
651
|
so mypy-strict callers see the ``__enter__`` / ``__exit__`` protocol
|
|
575
652
|
on the returned handle.
|
|
@@ -577,5 +654,5 @@ def _open_vcf(file_path: Path) -> TextIO:
|
|
|
577
654
|
with open(file_path, "rb") as raw:
|
|
578
655
|
magic = raw.read(2)
|
|
579
656
|
if magic == b"\x1f\x8b":
|
|
580
|
-
return gzip.open(file_path, "rt", encoding="utf-8", errors="replace")
|
|
581
|
-
return open(file_path, encoding="utf-8", errors="replace")
|
|
657
|
+
return gzip.open(file_path, "rt", encoding="utf-8-sig", errors="replace")
|
|
658
|
+
return open(file_path, encoding="utf-8-sig", errors="replace")
|
|
@@ -546,6 +546,40 @@ def run_analysis(
|
|
|
546
546
|
)
|
|
547
547
|
for (chrom, pos, ref, alt), rsid in resolution.items():
|
|
548
548
|
resolved_coords[rsid] = (chrom, pos, ref, alt)
|
|
549
|
+
# GH #128: second-pass resolution against gnomAD for any variant
|
|
550
|
+
# still rsidless after ClinVar's pass. ClinVar carries the
|
|
551
|
+
# clinically-curated subset of dbSNP; gnomAD's frequencies table
|
|
552
|
+
# is keyed on the full dbSNP rsID universe, so pharmacogenomic
|
|
553
|
+
# / GWAS-only / SNPedia-only rsIDs (which never make it into
|
|
554
|
+
# ClinVar) get recovered here. Same carrier-rule disambiguation
|
|
555
|
+
# as the ClinVar resolver: unique consistent (ref, alt) match
|
|
556
|
+
# against the user's allele pair, abstain on ties.
|
|
557
|
+
if gnomad is not None and gnomad.is_ready():
|
|
558
|
+
still_rsidless = [v for v in batch_buf if not v.rsid.startswith("rs")]
|
|
559
|
+
if still_rsidless:
|
|
560
|
+
positions = {
|
|
561
|
+
(v.chromosome, v.position)
|
|
562
|
+
for v in still_rsidless
|
|
563
|
+
if not v.is_no_call and v.chromosome and v.position > 0
|
|
564
|
+
}
|
|
565
|
+
gnomad_rows = gnomad.bulk_resolve_rsids_from_positions(positions)
|
|
566
|
+
for v in still_rsidless:
|
|
567
|
+
rows = gnomad_rows.get((v.chromosome, v.position))
|
|
568
|
+
if not rows:
|
|
569
|
+
continue
|
|
570
|
+
user_alleles = {v.allele1, v.allele2}
|
|
571
|
+
matches = [
|
|
572
|
+
(ref, alt, rsid)
|
|
573
|
+
for ref, alt, rsid in rows
|
|
574
|
+
if ref != alt and user_alleles.issubset({ref, alt})
|
|
575
|
+
]
|
|
576
|
+
if len(matches) == 1:
|
|
577
|
+
ref, alt, rsid = matches[0]
|
|
578
|
+
# Same audit stash as the ClinVar resolver
|
|
579
|
+
# (clinvar.py:bulk_resolve_rsids). Debug-only.
|
|
580
|
+
v.original_rsid = v.rsid
|
|
581
|
+
v.rsid = rsid
|
|
582
|
+
resolved_coords[rsid] = (v.chromosome, v.position, ref, alt)
|
|
549
583
|
# ADR-0035 PR 4: populate Variant.ref for array data via gnomAD's
|
|
550
584
|
# rsid → forward-REF map. VCF inputs already carry ref (PR 1).
|
|
551
585
|
# Annotators downstream (ClinVar / PharmGKB strand-aware carrier
|
|
@@ -489,13 +489,14 @@ def _hv_nocall_banner(warnings: list[str] | None) -> str:
|
|
|
489
489
|
def _panel_coverage_section(
|
|
490
490
|
coverage: PanelCoverage | None,
|
|
491
491
|
panel_genotypes: dict[str, str] | None = None,
|
|
492
|
+
parser_name: str = "",
|
|
492
493
|
) -> str:
|
|
493
|
-
"""GH #75 / #120: render the Panel Coverage section.
|
|
494
|
+
"""GH #75 / #120 / #128: render the Panel Coverage section.
|
|
494
495
|
|
|
495
496
|
Quiet when no panel was supplied. Otherwise show the requested/found
|
|
496
|
-
counts plus a table of rsIDs that weren't
|
|
497
|
-
|
|
498
|
-
|
|
497
|
+
counts plus a table of rsIDs that weren't in the input (state 3)
|
|
498
|
+
and rsIDs that were present but produced no findings (state 2).
|
|
499
|
+
State 1 hits render in the main annotation table as before, so
|
|
499
500
|
this section is purely a "what's NOT in the table" summary.
|
|
500
501
|
|
|
501
502
|
GH #120: when ``panel_genotypes`` is supplied (the rsid → "A/G"
|
|
@@ -505,7 +506,23 @@ def _panel_coverage_section(
|
|
|
505
506
|
half no-calls so the dash is visually obvious even though the v6
|
|
506
507
|
accounting still folds no-calls into ``no_findings`` (the
|
|
507
508
|
dedicated ``no_call`` MECE state is #115's job in v2.3). Rows
|
|
508
|
-
that aren't
|
|
509
|
+
that aren't in the input (state 3) show "—" (em dash).
|
|
510
|
+
|
|
511
|
+
GH #128: state-3 wording is format-aware. Array inputs (23andMe,
|
|
512
|
+
AncestryDNA, FTDNA, etc.) test a fixed set of positions, so
|
|
513
|
+
"not genotyped on this chip/platform" is the accurate framing —
|
|
514
|
+
a missing rsID truly wasn't on the user's chip. VCF / gVCF inputs
|
|
515
|
+
have no chip; an rsID is absent because either (a) the input file
|
|
516
|
+
has no variant call at that position (the sample is reference
|
|
517
|
+
/reference, and gVCFs only emit non-reference calls), or (b) the
|
|
518
|
+
position-based rsID resolver couldn't find a unique match. The
|
|
519
|
+
VCF-side label reflects that — "reference at this position or
|
|
520
|
+
no variant call" — instead of carrying array-format assumptions
|
|
521
|
+
into a context where they're meaningless. The legitimate
|
|
522
|
+
ref/ref case (e.g. rs429358 on an HG002 input, where HG002 has
|
|
523
|
+
T/T and DeepVariant emits no row) reads honestly under the new
|
|
524
|
+
label; under the old label it read as "not on chip" on a
|
|
525
|
+
chip-less input.
|
|
509
526
|
"""
|
|
510
527
|
if coverage is None:
|
|
511
528
|
return ""
|
|
@@ -526,12 +543,20 @@ def _panel_coverage_section(
|
|
|
526
543
|
gt = (panel_genotypes or {}).get(rsid, "—")
|
|
527
544
|
return f"<td><code>{_escape(gt)}</code></td>"
|
|
528
545
|
|
|
546
|
+
# GH #128: VCF / gVCF inputs use a format-aware label; array inputs
|
|
547
|
+
# keep the historical "chip/platform" framing.
|
|
548
|
+
is_vcf = parser_name.lower() in {"vcf", "gvcf", "vcf parser"}
|
|
549
|
+
missing_label = (
|
|
550
|
+
"reference at this position or no variant call"
|
|
551
|
+
if is_vcf
|
|
552
|
+
else "not genotyped on this chip/platform"
|
|
553
|
+
)
|
|
529
554
|
rows = []
|
|
530
555
|
for rsid in missing:
|
|
531
556
|
rows.append(
|
|
532
557
|
f"<tr><td><code>{_escape(rsid)}</code></td>"
|
|
533
558
|
f"{_gt(rsid, in_input=False) if show_gt else ''}"
|
|
534
|
-
"<td>
|
|
559
|
+
f"<td>{missing_label}</td></tr>"
|
|
535
560
|
)
|
|
536
561
|
for rsid in no_findings:
|
|
537
562
|
rows.append(
|
|
@@ -547,10 +572,12 @@ def _panel_coverage_section(
|
|
|
547
572
|
f"<tr><th>rsID</th>{gt_th}<th>Note</th></tr></thead>"
|
|
548
573
|
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
549
574
|
)
|
|
575
|
+
# GH #128: parallel format-aware framing in the summary header.
|
|
576
|
+
missing_summary = "reference or no call" if is_vcf else "not on chip"
|
|
550
577
|
return (
|
|
551
578
|
'<div class="notice"><strong>Panel coverage.</strong> '
|
|
552
579
|
f"{found}/{requested} requested rsIDs genotyped in this input "
|
|
553
|
-
f"({len(missing)}
|
|
580
|
+
f"({len(missing)} {missing_summary}, {len(no_findings)} "
|
|
554
581
|
"genotyped with no findings)."
|
|
555
582
|
f"{table}</div>"
|
|
556
583
|
)
|
|
@@ -1214,7 +1241,7 @@ def render_html(
|
|
|
1214
1241
|
f"{_escape(REGULATORY_NOTICE)}</div>"
|
|
1215
1242
|
f"{build_warn}"
|
|
1216
1243
|
f"{_hv_nocall_banner(high_value_no_calls)}"
|
|
1217
|
-
f"{_panel_coverage_section(result.panel_coverage(filtered), result.panel_genotypes)}"
|
|
1244
|
+
f"{_panel_coverage_section(result.panel_coverage(filtered), result.panel_genotypes, result.parser_name)}" # noqa: E501
|
|
1218
1245
|
f"{_EDUCATION_SECTION}"
|
|
1219
1246
|
f"{_MAGNITUDE_LEGEND}"
|
|
1220
1247
|
f"{diff_banner}"
|
|
@@ -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>
|
|
@@ -76,7 +76,9 @@ tests/test_cli_helpers.py
|
|
|
76
76
|
tests/test_compare.py
|
|
77
77
|
tests/test_config.py
|
|
78
78
|
tests/test_end_to_end.py
|
|
79
|
+
tests/test_extract_rsidless_gh128.py
|
|
79
80
|
tests/test_mock_data_invariants.py
|
|
80
81
|
tests/test_models.py
|
|
82
|
+
tests/test_original_rsid_never_serializes.py
|
|
81
83
|
tests/test_registry.py
|
|
82
84
|
tests/test_version.py
|
|
@@ -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.2"
|
|
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"
|