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.
Files changed (86) hide show
  1. {allelix-2.2.1 → allelix-2.2.2}/PKG-INFO +1 -1
  2. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/clinvar.py +6 -0
  3. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/gnomad.py +96 -0
  4. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/utility.py +105 -6
  5. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/schema.py +2 -0
  6. {allelix-2.2.1 → allelix-2.2.2}/allelix/models.py +21 -0
  7. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/vcf.py +82 -5
  8. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/_pipeline.py +34 -0
  9. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/html.py +35 -8
  10. {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/PKG-INFO +1 -1
  11. {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/SOURCES.txt +2 -0
  12. {allelix-2.2.1 → allelix-2.2.2}/pyproject.toml +1 -1
  13. {allelix-2.2.1 → allelix-2.2.2}/tests/test_cli.py +43 -0
  14. allelix-2.2.2/tests/test_extract_rsidless_gh128.py +201 -0
  15. allelix-2.2.2/tests/test_original_rsid_never_serializes.py +296 -0
  16. {allelix-2.2.1 → allelix-2.2.2}/LICENSE +0 -0
  17. {allelix-2.2.1 → allelix-2.2.2}/README.md +0 -0
  18. {allelix-2.2.1 → allelix-2.2.2}/allelix/__init__.py +0 -0
  19. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/__init__.py +0 -0
  20. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/alphamissense.py +0 -0
  21. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/base.py +0 -0
  22. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/cadd.py +0 -0
  23. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/gwas.py +0 -0
  24. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/pharmgkb.py +0 -0
  25. {allelix-2.2.1 → allelix-2.2.2}/allelix/annotators/snpedia.py +0 -0
  26. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/__init__.py +0 -0
  27. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/_helpers.py +0 -0
  28. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/_options.py +0 -0
  29. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/analyze.py +0 -0
  30. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/config.py +0 -0
  31. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/db.py +0 -0
  32. {allelix-2.2.1 → allelix-2.2.2}/allelix/cli/focused.py +0 -0
  33. {allelix-2.2.1 → allelix-2.2.2}/allelix/compare.py +0 -0
  34. {allelix-2.2.1 → allelix-2.2.2}/allelix/config.py +0 -0
  35. {allelix-2.2.1 → allelix-2.2.2}/allelix/data/__init__.py +0 -0
  36. {allelix-2.2.1 → allelix-2.2.2}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
  37. {allelix-2.2.1 → allelix-2.2.2}/allelix/data/high_value_snps.yaml +0 -0
  38. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/__init__.py +0 -0
  39. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/_versions.py +0 -0
  40. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/alphamissense_loader.py +0 -0
  41. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/cadd_loader.py +0 -0
  42. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/cpic_loader.py +0 -0
  43. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/gnomad_loader.py +0 -0
  44. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/gwas_loader.py +0 -0
  45. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/loader_utils.py +0 -0
  46. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/manager.py +0 -0
  47. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/pharmgkb_loader.py +0 -0
  48. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/snpedia_loader.py +0 -0
  49. {allelix-2.2.1 → allelix-2.2.2}/allelix/databases/snpedia_parser.py +0 -0
  50. {allelix-2.2.1 → allelix-2.2.2}/allelix/exporters/__init__.py +0 -0
  51. {allelix-2.2.1 → allelix-2.2.2}/allelix/exporters/plink.py +0 -0
  52. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/__init__.py +0 -0
  53. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/_helpers.py +0 -0
  54. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ancestrydna.py +0 -0
  55. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/base.py +0 -0
  56. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ftdna.py +0 -0
  57. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ftdna_famfinder.py +0 -0
  58. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/ftdna_illumina.py +0 -0
  59. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/livingdna.py +0 -0
  60. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/myhappygenes.py +0 -0
  61. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/myheritage.py +0 -0
  62. {allelix-2.2.1 → allelix-2.2.2}/allelix/parsers/twentythreeandme.py +0 -0
  63. {allelix-2.2.1 → allelix-2.2.2}/allelix/py.typed +0 -0
  64. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/__init__.py +0 -0
  65. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/diff.py +0 -0
  66. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/high_value.py +0 -0
  67. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/json_report.py +0 -0
  68. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/methylation.py +0 -0
  69. {allelix-2.2.1 → allelix-2.2.2}/allelix/reports/terminal.py +0 -0
  70. {allelix-2.2.1 → allelix-2.2.2}/allelix/utils/__init__.py +0 -0
  71. {allelix-2.2.1 → allelix-2.2.2}/allelix/utils/allele.py +0 -0
  72. {allelix-2.2.1 → allelix-2.2.2}/allelix/utils/build_detect.py +0 -0
  73. {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/dependency_links.txt +0 -0
  74. {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/entry_points.txt +0 -0
  75. {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/requires.txt +0 -0
  76. {allelix-2.2.1 → allelix-2.2.2}/allelix.egg-info/top_level.txt +0 -0
  77. {allelix-2.2.1 → allelix-2.2.2}/setup.cfg +0 -0
  78. {allelix-2.2.1 → allelix-2.2.2}/tests/test_changelog_sanitize.py +0 -0
  79. {allelix-2.2.1 → allelix-2.2.2}/tests/test_cli_helpers.py +0 -0
  80. {allelix-2.2.1 → allelix-2.2.2}/tests/test_compare.py +0 -0
  81. {allelix-2.2.1 → allelix-2.2.2}/tests/test_config.py +0 -0
  82. {allelix-2.2.1 → allelix-2.2.2}/tests/test_end_to_end.py +0 -0
  83. {allelix-2.2.1 → allelix-2.2.2}/tests/test_mock_data_invariants.py +0 -0
  84. {allelix-2.2.1 → allelix-2.2.2}/tests/test_models.py +0 -0
  85. {allelix-2.2.1 → allelix-2.2.2}/tests/test_registry.py +0 -0
  86. {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.1
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
- summary.add_row("Build", metadata["build"])
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, file_path: Path, wanted: set[str]
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, _ref, _alt in coords:
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
- if variant.rsid == rsid:
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 the first non-blank line is ``##fileformat=VCF...``.
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
- return stripped.startswith("##fileformat=VCF")
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 on the user's chip (state
497
- 3) and rsIDs that were on the chip but produced no findings (state
498
- 2). State 1 hits render in the main annotation table as before, so
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 on the chip (state 3) show "—" (em dash).
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>not genotyped on this chip/platform</td></tr>"
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)} not on chip, {len(no_findings)} "
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.1
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.1"
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"