allelix 2.4.1__tar.gz → 2.4.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 (81) hide show
  1. {allelix-2.4.1/allelix.egg-info → allelix-2.4.2}/PKG-INFO +1 -1
  2. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/__init__.py +6 -1
  3. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/_resolution.py +13 -6
  4. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/base.py +27 -0
  5. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/clinvar.py +102 -20
  6. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/pharmgkb.py +26 -4
  7. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/rsid_identity.py +2 -1
  8. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/snpedia.py +24 -12
  9. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/_versions.py +7 -1
  10. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/manager.py +54 -6
  11. {allelix-2.4.1 → allelix-2.4.2}/allelix/models.py +11 -2
  12. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/_pipeline.py +7 -3
  13. {allelix-2.4.1 → allelix-2.4.2}/allelix/utils/allele.py +3 -2
  14. {allelix-2.4.1 → allelix-2.4.2/allelix.egg-info}/PKG-INFO +1 -1
  15. {allelix-2.4.1 → allelix-2.4.2}/pyproject.toml +1 -1
  16. {allelix-2.4.1 → allelix-2.4.2}/LICENSE +0 -0
  17. {allelix-2.4.1 → allelix-2.4.2}/MANIFEST.in +0 -0
  18. {allelix-2.4.1 → allelix-2.4.2}/README.md +0 -0
  19. {allelix-2.4.1 → allelix-2.4.2}/allelix/__init__.py +0 -0
  20. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/alphamissense.py +0 -0
  21. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/cadd.py +0 -0
  22. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/gnomad.py +0 -0
  23. {allelix-2.4.1 → allelix-2.4.2}/allelix/annotators/gwas.py +0 -0
  24. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/__init__.py +0 -0
  25. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/_helpers.py +0 -0
  26. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/_options.py +0 -0
  27. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/analyze.py +0 -0
  28. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/config.py +0 -0
  29. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/db.py +0 -0
  30. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/focused.py +0 -0
  31. {allelix-2.4.1 → allelix-2.4.2}/allelix/cli/utility.py +0 -0
  32. {allelix-2.4.1 → allelix-2.4.2}/allelix/compare.py +0 -0
  33. {allelix-2.4.1 → allelix-2.4.2}/allelix/config.py +0 -0
  34. {allelix-2.4.1 → allelix-2.4.2}/allelix/data/__init__.py +0 -0
  35. {allelix-2.4.1 → allelix-2.4.2}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
  36. {allelix-2.4.1 → allelix-2.4.2}/allelix/data/high_value_snps.yaml +0 -0
  37. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/__init__.py +0 -0
  38. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/alphamissense_loader.py +0 -0
  39. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/cadd_loader.py +0 -0
  40. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/cpic_loader.py +0 -0
  41. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/gnomad_loader.py +0 -0
  42. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/gwas_loader.py +0 -0
  43. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/loader_utils.py +0 -0
  44. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/pharmgkb_loader.py +0 -0
  45. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/rsid_identity_loader.py +0 -0
  46. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/schema.py +0 -0
  47. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/snpedia_loader.py +0 -0
  48. {allelix-2.4.1 → allelix-2.4.2}/allelix/databases/snpedia_parser.py +0 -0
  49. {allelix-2.4.1 → allelix-2.4.2}/allelix/exporters/__init__.py +0 -0
  50. {allelix-2.4.1 → allelix-2.4.2}/allelix/exporters/plink.py +0 -0
  51. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/__init__.py +0 -0
  52. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/_helpers.py +0 -0
  53. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/ancestrydna.py +0 -0
  54. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/base.py +0 -0
  55. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/ftdna.py +0 -0
  56. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/ftdna_famfinder.py +0 -0
  57. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/ftdna_illumina.py +0 -0
  58. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/livingdna.py +0 -0
  59. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/myhappygenes.py +0 -0
  60. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/myheritage.py +0 -0
  61. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/twentythreeandme.py +0 -0
  62. {allelix-2.4.1 → allelix-2.4.2}/allelix/parsers/vcf.py +0 -0
  63. {allelix-2.4.1 → allelix-2.4.2}/allelix/py.typed +0 -0
  64. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/__init__.py +0 -0
  65. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/build_text.py +0 -0
  66. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/diff.py +0 -0
  67. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/high_value.py +0 -0
  68. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/html.py +0 -0
  69. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/json_report.py +0 -0
  70. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/methylation.py +0 -0
  71. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/terminal.py +0 -0
  72. {allelix-2.4.1 → allelix-2.4.2}/allelix/reports/vcf.py +0 -0
  73. {allelix-2.4.1 → allelix-2.4.2}/allelix/utils/__init__.py +0 -0
  74. {allelix-2.4.1 → allelix-2.4.2}/allelix/utils/build_detect.py +0 -0
  75. {allelix-2.4.1 → allelix-2.4.2}/allelix/utils/plausibility.py +0 -0
  76. {allelix-2.4.1 → allelix-2.4.2}/allelix.egg-info/SOURCES.txt +0 -0
  77. {allelix-2.4.1 → allelix-2.4.2}/allelix.egg-info/dependency_links.txt +0 -0
  78. {allelix-2.4.1 → allelix-2.4.2}/allelix.egg-info/entry_points.txt +0 -0
  79. {allelix-2.4.1 → allelix-2.4.2}/allelix.egg-info/requires.txt +0 -0
  80. {allelix-2.4.1 → allelix-2.4.2}/allelix.egg-info/top_level.txt +0 -0
  81. {allelix-2.4.1 → allelix-2.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.4.1
3
+ Version: 2.4.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>
@@ -54,9 +54,14 @@ def get_annotators(
54
54
  data_dir,
55
55
  clinvar_ref_provider=clinvar.reference_for,
56
56
  palindromic_provider=rsid_identity.palindromic_rsids,
57
+ normal_allele_provider=clinvar.normal_allele_for,
57
58
  )
58
59
  gwas = GWASCatalogAnnotator(data_dir, filter_traits=gwas_filter_traits)
59
- snpedia = SNPediaAnnotator(data_dir, clinvar_ref_provider=clinvar.reference_for)
60
+ snpedia = SNPediaAnnotator(
61
+ data_dir,
62
+ clinvar_ref_provider=clinvar.reference_for,
63
+ normal_allele_provider=clinvar.normal_allele_for,
64
+ )
60
65
  gnomad = GnomadAnnotator(data_dir)
61
66
  alphamissense = AlphaMissenseAnnotator(data_dir)
62
67
  cadd = CaddAnnotator(data_dir, full_mode=cadd_full)
@@ -129,11 +129,18 @@ def resolve_rsidless(
129
129
 
130
130
 
131
131
  def _populate_ref_from_identity(identity: RsidIdentityAnnotator, variants: list[Variant]) -> None:
132
- """Set ``Variant.ref`` from the identity table's REF at the variant's locus.
132
+ """Set ``Variant.ref`` from the identity table's REF for the call's own rsID.
133
133
 
134
- Only for calls that carry an rsID, and only when the locus has a single
135
- REF of the call's shape: single-base for single-base calls (an indel
136
- row's anchor REF can share the position), any length otherwise.
134
+ Only for calls that carry an rsID, only from the identity rows of that
135
+ rsID at the variant's locus, and only when those rows give a single REF
136
+ of the call's shape: single-base for single-base calls (an indel row's
137
+ anchor REF can share the position), any length otherwise.
138
+
139
+ A call whose rsID the identity table does not hold (a withdrawn or
140
+ merged ID) gets no REF, even when another rsID's rows sit at the same
141
+ position: nothing confirms that the other rsID is the same variant.
142
+ Such a call keeps ``ref`` None, and ClinVar matches it by locus only
143
+ when ClinVar holds its rsID.
137
144
 
138
145
  A call without an rsID (array vendor probes such as 23andMe
139
146
  ``i5000123``) never gets a REF from position alone. The REF at a locus
@@ -154,9 +161,9 @@ def _populate_ref_from_identity(identity: RsidIdentityAnnotator, variants: list[
154
161
  ]
155
162
  for build in {v.build for v in pending}:
156
163
  group = [v for v in pending if v.build == build]
157
- refs_at = identity.refs_at_loci(build, {variant_locus(v) for v in group})
164
+ rows_at = identity.alleles_at_loci(build, {variant_locus(v) for v in group})
158
165
  for v in group:
159
- refs = refs_at.get(variant_locus(v), set())
166
+ refs = {ref for ref, _alt, rsid in rows_at.get(variant_locus(v), []) if rsid == v.rsid}
160
167
  if len(v.allele1) <= 1 and len(v.allele2) <= 1:
161
168
  refs = {r for r in refs if len(r) == 1}
162
169
  if len(refs) == 1:
@@ -10,6 +10,7 @@ from enum import Enum, auto
10
10
  from typing import TYPE_CHECKING, ClassVar
11
11
 
12
12
  from allelix.parsers._helpers import normalize_chromosome
13
+ from allelix.utils.allele import derive_alt_from_diploid
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from collections.abc import Callable, Iterable, Iterator
@@ -95,6 +96,32 @@ def is_clinvar_homref(
95
96
  return ref is not None and len(ref) == 1 and variant.allele1 == ref and variant.allele2 == ref
96
97
 
97
98
 
99
+ def carried_alt(
100
+ variant: Variant,
101
+ allele1: str,
102
+ allele2: str,
103
+ normal_allele_provider: Callable[[str, str], str | None] | None,
104
+ ) -> tuple[str, bool]:
105
+ """The non-normal allele of a matched genotype, for per-genotype sources.
106
+
107
+ Returns ``(alt, reference_carries_alt)``. The alt is derived against the
108
+ normal allele, which is ``variant.ref`` except where the build's
109
+ reference carries a ClinVar variant allele: there
110
+ ``normal_allele_provider`` (``ClinVarAnnotator.normal_allele_for``) gives
111
+ the normal allele from the other build (C at F5 rs6025 on GRCh37, whose
112
+ reference is the Leiden T). The alt is then the same on both builds,
113
+ and it can equal ``variant.ref``, which the Annotation is told through
114
+ ``reference_carries_alt``.
115
+ """
116
+ normal = variant.ref
117
+ if normal is not None and normal_allele_provider is not None and variant.rsid.startswith("rs"):
118
+ other = normal_allele_provider(variant.rsid, variant.build)
119
+ if other is not None and len(other) == 1 and other != normal:
120
+ normal = other
121
+ alt = derive_alt_from_diploid(normal, allele1, allele2)
122
+ return alt, bool(alt) and alt == variant.ref
123
+
124
+
98
125
  class Annotator(ABC):
99
126
  """Base class for all reference database annotators.
100
127
 
@@ -36,6 +36,7 @@ from allelix.utils.allele import (
36
36
  resolve_rsid_at_locus,
37
37
  strand_aware_carrier_match,
38
38
  )
39
+ from allelix.utils.build_detect import BUILD_GRCH37, BUILD_GRCH38
39
40
 
40
41
  if TYPE_CHECKING:
41
42
  from collections.abc import Iterable, Iterator
@@ -248,6 +249,10 @@ class ClinVarAnnotator(Annotator):
248
249
  # consults this as its primary non-finding filter. Built lazily
249
250
  # on first lookup per build.
250
251
  self._ref_lookups: dict[str, dict[str, str]] = {}
252
+ # Per build, the rsIDs where ``reference_for`` gives the normal
253
+ # allele from the other build (the build's reference carries a
254
+ # ClinVar variant allele), with that allele.
255
+ self._normal_alleles: dict[str, dict[str, str]] = {}
251
256
 
252
257
  def _connection(self, build: str) -> sqlite3.Connection | None:
253
258
  """Return a lazy connection to the per-build cache, or None if missing.
@@ -383,6 +388,15 @@ class ClinVarAnnotator(Annotator):
383
388
  return False
384
389
  return True
385
390
 
391
+ def _cache_is_current(self, build: str) -> bool:
392
+ """True when ``build``'s cache carries the current interpreter-version tag."""
393
+ if build not in self._db_paths:
394
+ return False
395
+ info = get_database_info(self._db_paths[build], clinvar_record_name(build))
396
+ return info is not None and info.get("local_version_tag") == (
397
+ f"iv:{CLINVAR_INTERPRETER_VERSION}"
398
+ )
399
+
386
400
  def version(self) -> str | None:
387
401
  """Composite version string across managed builds.
388
402
 
@@ -413,6 +427,7 @@ class ClinVarAnnotator(Annotator):
413
427
  conn.close()
414
428
  self._conns.clear()
415
429
  self._ref_lookups.clear()
430
+ self._normal_alleles.clear()
416
431
 
417
432
  def reference_for(self, rsid: str, build: str) -> str | None:
418
433
  """Return ClinVar's single-base REF allele for `rsid` in `build`, or None.
@@ -426,6 +441,15 @@ class ClinVarAnnotator(Annotator):
426
441
  skipped — array-based parsers can't call indels, so a multi-base
427
442
  REF can't validly suppress a single-base genotype.
428
443
 
444
+ Where this build's reference carries a ClinVar variant allele (a
445
+ REF == ALT row, F5 c.1601G>A on GRCh37), a homozygous-reference
446
+ call is the variant genotype. There the normal allele is returned
447
+ instead: the REF of the same ClinVar variant on the other build,
448
+ when that row is a single-base REF != ALT (C for F5), so the same
449
+ call is a non-finding on both builds. Without such a row (only one
450
+ build's cache installed, or REF == ALT on both builds) it returns
451
+ None and nothing is suppressed.
452
+
429
453
  Returns None when ClinVar has no data for the rsid in this build
430
454
  (or has only indel REFs). Callers fall through to secondary tiers.
431
455
  """
@@ -449,17 +473,72 @@ class ClinVarAnnotator(Annotator):
449
473
  # so peak memory is one row + the dict, not the entire result set
450
474
  # twice.
451
475
  cursor = conn.execute(
452
- "SELECT DISTINCT rsid, ref FROM clinvar_variants WHERE length(ref) = 1"
476
+ "SELECT DISTINCT rsid, ref, alt, allele_id FROM clinvar_variants WHERE length(ref) = 1"
453
477
  )
454
478
  out: dict[str, str] = {}
455
- for rsid, ref in cursor:
479
+ # rsID -> VariationIDs whose row is REF == ALT: this build's
480
+ # reference carries their variant allele.
481
+ reference_carried: dict[str, set[int]] = {}
482
+ for rsid, ref, alt, variation_id in cursor:
483
+ if ref == alt:
484
+ reference_carried.setdefault(rsid, set()).add(variation_id)
456
485
  # If a rsid has multiple single-base REFs (shouldn't happen at
457
486
  # one position but defending against future data shapes), keep
458
487
  # the first.
459
488
  if rsid not in out:
460
489
  out[rsid] = ref
490
+ # Only a current cache gives REF == ALT its one meaning; an older
491
+ # one still holds normal-allele records written REF == ALT, and
492
+ # keeps the locus REF (the 2.4.1 behaviour) until `db update`.
493
+ normal: dict[str, str] = {}
494
+ if reference_carried and self._cache_is_current(build):
495
+ normal = self._normal_alleles_on_other_build(build, reference_carried)
496
+ for rsid in reference_carried:
497
+ if rsid in normal:
498
+ out[rsid] = normal[rsid]
499
+ else:
500
+ out.pop(rsid, None)
501
+ self._normal_alleles[build] = normal
461
502
  return out
462
503
 
504
+ def normal_allele_for(self, rsid: str, build: str) -> str | None:
505
+ """The normal allele at `rsid` where `build`'s reference carries a variant, or None.
506
+
507
+ Set only where ``reference_for`` returns the other build's REF of
508
+ the same ClinVar variant (C at F5 rs6025 on GRCh37). ClinPGx and
509
+ SNPedia derive their ``alt`` against it there; everywhere else
510
+ they keep ``Variant.ref``, even where ClinVar's REF differs from it.
511
+ """
512
+ self.reference_for(rsid, build)
513
+ return self._normal_alleles.get(build, {}).get(rsid)
514
+
515
+ def _normal_alleles_on_other_build(
516
+ self, build: str, reference_carried: dict[str, set[int]]
517
+ ) -> dict[str, str]:
518
+ """The normal allele at each reference-carried rsID, from the other build.
519
+
520
+ For each rsID, the single-base REF of the same VariationIDs on the
521
+ other build where that row's REF differs from its ALT. An rsID with
522
+ no such row, or more than one candidate REF, is left out.
523
+ """
524
+ other = BUILD_GRCH38 if build == BUILD_GRCH37 else BUILD_GRCH37
525
+ conn = self._connection(other)
526
+ if conn is None or not self._cache_is_current(other):
527
+ return {}
528
+ rsids = sorted(reference_carried)
529
+ candidates: dict[str, set[str]] = {}
530
+ for start in range(0, len(rsids), _BATCH_CHUNK):
531
+ chunk = rsids[start : start + _BATCH_CHUNK]
532
+ placeholders = ",".join("?" * len(chunk))
533
+ for rsid, ref, alt, variation_id in conn.execute(
534
+ f"SELECT DISTINCT rsid, ref, alt, allele_id FROM clinvar_variants "
535
+ f"WHERE rsid IN ({placeholders})",
536
+ chunk,
537
+ ):
538
+ if variation_id in reference_carried[rsid] and len(ref) == 1 and ref != alt:
539
+ candidates.setdefault(rsid, set()).add(ref)
540
+ return {rsid: refs.pop() for rsid, refs in candidates.items() if len(refs) == 1}
541
+
463
542
  def fetch_remote_signal(self) -> str | None:
464
543
  r"""Freshness signal derived from the per-SCV submission_summary md5.
465
544
 
@@ -691,9 +770,13 @@ class ClinVarAnnotator(Annotator):
691
770
 
692
771
  A row annotates only when all of these hold:
693
772
 
694
- - REF != ALT. ClinVar's reference-allele records (HGVS ``=``,
695
- e.g. ``c.1601=``) carry REF == ALT; they describe the reference
696
- allele, never a variant the user carries.
773
+ - REF == ALT means this build's reference carries the variant
774
+ allele (F5 c.1601G>A is T>T on GRCh37, whose reference base is
775
+ the Leiden allele). The loader keeps no other REF == ALT rows:
776
+ it drops normal-allele records (HGVS ``c.N=``) written REF ==
777
+ ALT and REF == ALT rows that are not SNVs (cache ``iv:5``). Such
778
+ a row annotates a user who carries its ALT, counting copies of
779
+ it, like any other row.
697
780
  - The row's REF agrees with ``variant.ref`` when the input supplies
698
781
  one (directly, or as the complement for a reverse-stranded
699
782
  input). Together with the locus this identifies the variant by
@@ -705,26 +788,26 @@ class ClinVarAnnotator(Annotator):
705
788
 
706
789
  Two guards decide whether the locus may be matched at all:
707
790
 
708
- - A call with no reference allele and no rsID (array vendor probes
709
- such as 23andMe ``i5000123``) is never locus-matched: nothing
710
- confirms that the row at its position describes the same
711
- variant. The pipeline never infers a REF for such a call from
712
- position (the identity table fills REF only for rsID-carrying
713
- calls), so a no-rsID call with a REF is a VCF record whose REF
714
- came from the file, and the REF check applies to it.
791
+ - A call with no reference allele is locus-matched only when
792
+ ClinVar holds its rsID on this build (``rsid_loci``, every
793
+ position ClinVar records for that rsID). That excludes array
794
+ vendor probes without an rsID (23andMe ``i5000123``) and array
795
+ calls whose rsID neither ClinVar nor the identity table holds (a
796
+ withdrawn or merged ID): nothing confirms that the row at the
797
+ position describes the same variant. The pipeline fills REF only
798
+ from the identity rows of the call's own rsID, so a call with a
799
+ REF is either one of those or a VCF record whose REF came from
800
+ the file, and the REF check applies to it.
715
801
  - When the input carries an rsID that ClinVar places elsewhere
716
- (``rsid_loci``, every position ClinVar records for that rsID on
717
- this build) and not at the input's locus, the rows at the locus
718
- belong to a different variant and are not reported under the
719
- input's rsID. An rsID ClinVar does not hold at all (a retired or
720
- merged ID) still matches by locus.
802
+ and not at the input's locus, the rows at the locus belong to a
803
+ different variant and are not reported under the input's rsID.
721
804
 
722
805
  The Annotation's rsID is the input's when it has one, otherwise
723
806
  the matching row's. identical per-SCV rows collapse.
724
807
  """
725
808
  if variant.is_no_call:
726
809
  return []
727
- if variant.ref is None and not variant.rsid.startswith("rs"):
810
+ if variant.ref is None and not rsid_loci:
728
811
  return []
729
812
  if rsid_loci and variant_locus(variant) not in rsid_loci:
730
813
  return []
@@ -746,8 +829,6 @@ class ClinVarAnnotator(Annotator):
746
829
  allele_id,
747
830
  row_rsid,
748
831
  ) = row
749
- if ref == alt:
750
- continue
751
832
  if variant.ref is not None and variant.ref not in (ref, complement(ref)):
752
833
  continue
753
834
  clinvar_is_indel = len(ref) > 1 or len(alt) > 1
@@ -785,6 +866,7 @@ class ClinVarAnnotator(Annotator):
785
866
  variant.allele1, variant.allele2, alt
786
867
  ),
787
868
  locus=variant_locus(variant),
869
+ reference_carries_alt=ref == alt,
788
870
  )
789
871
  )
790
872
  return _collapse_identical_scvs(annotations)
@@ -10,7 +10,13 @@ import sqlite3
10
10
  import urllib.error
11
11
  from typing import TYPE_CHECKING, ClassVar
12
12
 
13
- from allelix.annotators.base import Annotator, LicenseDescriptor, is_clinvar_homref, variant_locus
13
+ from allelix.annotators.base import (
14
+ Annotator,
15
+ LicenseDescriptor,
16
+ carried_alt,
17
+ is_clinvar_homref,
18
+ variant_locus,
19
+ )
14
20
  from allelix.databases._versions import PHARMGKB_INTERPRETER_VERSION
15
21
  from allelix.databases.cpic_loader import (
16
22
  fetch_cpic_allele_functions,
@@ -30,7 +36,7 @@ from allelix.databases.pharmgkb_loader import (
30
36
  schema_is_current,
31
37
  )
32
38
  from allelix.models import Annotation
33
- from allelix.utils.allele import derive_alt_from_diploid, strand_aware_genotype_match
39
+ from allelix.utils.allele import strand_aware_genotype_match
34
40
 
35
41
  if TYPE_CHECKING:
36
42
  from collections.abc import Callable, Iterable, Iterator
@@ -86,6 +92,7 @@ class PharmGKBAnnotator(Annotator):
86
92
  data_dir: Path,
87
93
  clinvar_ref_provider: Callable[[str, str], str | None] | None = None,
88
94
  palindromic_provider: Callable[[set[str]], set[str]] | None = None,
95
+ normal_allele_provider: Callable[[str, str], str | None] | None = None,
89
96
  ) -> None:
90
97
  """Resolve the ClinPGx SQLite cache path within `data_dir`.
91
98
 
@@ -100,12 +107,19 @@ class PharmGKBAnnotator(Annotator):
100
107
  matching is skipped there. In production it's wired to
101
108
  `RsidIdentityAnnotator.palindromic_rsids`. None keeps the per-genotype
102
109
  palindrome guard only.
110
+
111
+ `normal_allele_provider` gives the normal allele where the build's
112
+ reference carries a ClinVar variant allele, for deriving `alt`
113
+ (`base.carried_alt`). In production it's wired to
114
+ `ClinVarAnnotator.normal_allele_for`. None derives `alt` against
115
+ `Variant.ref` everywhere.
103
116
  """
104
117
  super().__init__(data_dir)
105
118
  self._db_path = data_dir / PHARMGKB_DB_FILENAME
106
119
  self._conn: sqlite3.Connection | None = None
107
120
  self._clinvar_ref_provider = clinvar_ref_provider
108
121
  self._palindromic_provider = palindromic_provider
122
+ self._normal_allele_provider = normal_allele_provider
109
123
 
110
124
  def _connection(self) -> sqlite3.Connection:
111
125
  if self._conn is None:
@@ -319,6 +333,9 @@ class PharmGKBAnnotator(Annotator):
319
333
  references = (
320
334
  [f"pharmgkb:annotation/{pgkb_annotation_id}"] if pgkb_annotation_id else []
321
335
  )
336
+ alt, reference_carries_alt = carried_alt(
337
+ variant, variant.allele1, variant.allele2, self._normal_allele_provider
338
+ )
322
339
  annotations.append(
323
340
  Annotation(
324
341
  source=self.name,
@@ -332,8 +349,9 @@ class PharmGKBAnnotator(Annotator):
332
349
  references=references,
333
350
  condition=phenotype or "",
334
351
  gene=gene or "",
335
- alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
352
+ alt=alt,
336
353
  locus_ref=variant.ref,
354
+ reference_carries_alt=reference_carries_alt,
337
355
  carried_alleles=(variant.allele1, variant.allele2),
338
356
  locus=variant_locus(variant),
339
357
  )
@@ -422,6 +440,9 @@ class PharmGKBAnnotator(Annotator):
422
440
  references = (
423
441
  [f"pharmgkb:annotation/{pgkb_annotation_id}"] if pgkb_annotation_id else []
424
442
  )
443
+ alt, reference_carries_alt = carried_alt(
444
+ variant, variant.allele1, variant.allele2, self._normal_allele_provider
445
+ )
425
446
  yield Annotation(
426
447
  source=self.name,
427
448
  rsid=variant.rsid,
@@ -434,8 +455,9 @@ class PharmGKBAnnotator(Annotator):
434
455
  references=references,
435
456
  condition=phenotype or "",
436
457
  gene=gene or "",
437
- alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
458
+ alt=alt,
438
459
  locus_ref=variant.ref,
460
+ reference_carries_alt=reference_carries_alt,
439
461
  carried_alleles=(variant.allele1, variant.allele2),
440
462
  locus=variant_locus(variant),
441
463
  )
@@ -5,7 +5,8 @@
5
5
  Not a clinical source: it never produces Annotations. The pipeline uses it
6
6
  for three things, all keyed by locus on the input's own build:
7
7
 
8
- 1. ``Variant.ref`` for array data on GRCh37 and GRCh38 (``refs_at_loci``).
8
+ 1. ``Variant.ref`` for array data on GRCh37 and GRCh38, from the rows of
9
+ the call's own rsID at its locus (``alleles_at_loci``).
9
10
  2. rsID recovery for rsID-less VCF records on both builds
10
11
  (``alleles_at_loci`` feeding ``resolve_rsid_at_locus``).
11
12
  3. Translating a GRCh37 ``(chrom, pos, ref, alt)`` to its GRCh38 key so the
@@ -18,7 +18,13 @@ import logging
18
18
  import sqlite3
19
19
  from typing import TYPE_CHECKING, ClassVar
20
20
 
21
- from allelix.annotators.base import Annotator, LicenseDescriptor, is_clinvar_homref, variant_locus
21
+ from allelix.annotators.base import (
22
+ Annotator,
23
+ LicenseDescriptor,
24
+ carried_alt,
25
+ is_clinvar_homref,
26
+ variant_locus,
27
+ )
22
28
  from allelix.databases.manager import (
23
29
  download,
24
30
  verify_file_hash,
@@ -29,7 +35,7 @@ from allelix.databases.snpedia_loader import (
29
35
  install_prebuilt_cache,
30
36
  )
31
37
  from allelix.models import Annotation
32
- from allelix.utils.allele import complement, derive_alt_from_diploid
38
+ from allelix.utils.allele import complement
33
39
 
34
40
  if TYPE_CHECKING:
35
41
  from collections.abc import Callable, Iterable, Iterator
@@ -125,6 +131,7 @@ class SNPediaAnnotator(Annotator):
125
131
  self,
126
132
  data_dir: Path,
127
133
  clinvar_ref_provider: Callable[[str, str], str | None] | None = None,
134
+ normal_allele_provider: Callable[[str, str], str | None] | None = None,
128
135
  ) -> None:
129
136
  """Initialize with path to the data directory.
130
137
 
@@ -132,11 +139,16 @@ class SNPediaAnnotator(Annotator):
132
139
  callable used by the ADR-0023 hom-ref check. In production it is
133
140
  wired to ``ClinVarAnnotator.reference_for``. ``None`` disables the
134
141
  check (tests, standalone use).
142
+
143
+ ``normal_allele_provider`` gives the normal allele where the build's
144
+ reference carries a ClinVar variant allele, for deriving ``alt``
145
+ (``base.carried_alt``); wired to ``ClinVarAnnotator.normal_allele_for``.
135
146
  """
136
147
  super().__init__(data_dir)
137
148
  self._db_path = data_dir / SNPEDIA_DB_FILENAME
138
149
  self._conn: sqlite3.Connection | None = None
139
150
  self._clinvar_ref_provider = clinvar_ref_provider
151
+ self._normal_allele_provider = normal_allele_provider
140
152
 
141
153
  def _connection(self) -> sqlite3.Connection:
142
154
  if self._conn is None:
@@ -314,6 +326,9 @@ class SNPediaAnnotator(Annotator):
314
326
 
315
327
  description = f"SNPedia: {summary}"
316
328
  forward_allele1, forward_allele2 = forward_genotype
329
+ alt, reference_carries_alt = carried_alt(
330
+ variant, forward_allele1, forward_allele2, self._normal_allele_provider
331
+ )
317
332
  genotype_match = f"{forward_allele1}{forward_allele2}"
318
333
 
319
334
  annotations.append(
@@ -328,12 +343,9 @@ class SNPediaAnnotator(Annotator):
328
343
  genotype_match=genotype_match,
329
344
  references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
330
345
  gene=gene or "",
331
- alt=derive_alt_from_diploid(
332
- variant.ref,
333
- forward_allele1,
334
- forward_allele2,
335
- ),
346
+ alt=alt,
336
347
  locus_ref=variant.ref,
348
+ reference_carries_alt=reference_carries_alt,
337
349
  carried_alleles=(forward_allele1, forward_allele2),
338
350
  locus=variant_locus(variant),
339
351
  )
@@ -418,6 +430,9 @@ class SNPediaAnnotator(Annotator):
418
430
  category = _REPUTE_CATEGORY.get(repute_lower, "trait")
419
431
  description = f"SNPedia: {summary}"
420
432
  forward_allele1, forward_allele2 = forward_genotype
433
+ alt, reference_carries_alt = carried_alt(
434
+ variant, forward_allele1, forward_allele2, self._normal_allele_provider
435
+ )
421
436
  genotype_match = f"{forward_allele1}{forward_allele2}"
422
437
  yield Annotation(
423
438
  source=self.name,
@@ -430,12 +445,9 @@ class SNPediaAnnotator(Annotator):
430
445
  genotype_match=genotype_match,
431
446
  references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
432
447
  gene=gene or "",
433
- alt=derive_alt_from_diploid(
434
- variant.ref,
435
- forward_allele1,
436
- forward_allele2,
437
- ),
448
+ alt=alt,
438
449
  locus_ref=variant.ref,
450
+ reference_carries_alt=reference_carries_alt,
439
451
  carried_alleles=(forward_allele1, forward_allele2),
440
452
  locus=variant_locus(variant),
441
453
  )
@@ -15,7 +15,13 @@ reject stale caches without forcing a full re-download.
15
15
  # ("-", "not specified", "no classification provided", etc.) in the cache.
16
16
  # Without this bump, existing iv:3 caches stay poisoned — is_ready() would
17
17
  # treat them as current and the fix would only reach fresh / --force builds.
18
- CLINVAR_INTERPRETER_VERSION = 4
18
+ # iv:4→iv:5: the loader classifies records by their HGVS name (2.4.2).
19
+ # Normal-allele records (c.N=) lose their REF == ALT rows and their
20
+ # Pathogenic / Likely pathogenic rows, and REF == ALT non-SNV rows are
21
+ # dropped, so a REF == ALT row means this build's reference carries the
22
+ # variant allele. An iv:4 cache still holds the old rows and must not be
23
+ # read with the new rule.
24
+ CLINVAR_INTERPRETER_VERSION = 5
19
25
  # Also bump for a change to how CPIC allele-function data is loaded: a bump
20
26
  # makes existing ClinPGx caches not ready, and `allelix db update` rebuilds
21
27
  # them, fetching the CPIC table again, so the change reaches them.
@@ -9,6 +9,7 @@ import gzip
9
9
  import hashlib
10
10
  import logging
11
11
  import os
12
+ import re
12
13
  import sqlite3
13
14
  import urllib.request
14
15
  from pathlib import Path
@@ -167,6 +168,8 @@ def verify_file_hash(path: Path, algorithm: str, expected_hex: str) -> None:
167
168
  # Both files use `\t` as field separator and `#` as comment-line prefix.
168
169
 
169
170
  # variant_summary.txt.gz — 43 columns. We only need a handful.
171
+ _VS_TYPE = 1 # "Type": "single nucleotide variant", "Deletion", ...
172
+ _VS_NAME = 2 # "Name": HGVS, e.g. "NM_000130.5(F5):c.1601G>A (p.Arg534Gln)"
170
173
  _VS_RS = 9 # "RS# (dbSNP)" — number only, no `rs` prefix
171
174
  _VS_GENE_SYMBOL = 4 # "GeneSymbol"
172
175
  _VS_REVIEW_STATUS = 24 # "ReviewStatus" — aggregate
@@ -227,6 +230,33 @@ _CLINVAR_PLACEHOLDER_CLNSIGS: frozenset[str] = frozenset(
227
230
  )
228
231
 
229
232
 
233
+ # Per-SCV classifications dropped from normal-allele records whose VCF
234
+ # alleles differ on the build (see ``describes_reference_allele``).
235
+ _NORMAL_ALLELE_DROPPED_CLNSIGS: frozenset[str] = frozenset(
236
+ {
237
+ "pathogenic",
238
+ "likely pathogenic",
239
+ "pathogenic/likely pathogenic",
240
+ "pathogenic, low penetrance",
241
+ "likely pathogenic, low penetrance",
242
+ }
243
+ )
244
+
245
+ _HGVS_ALLELE = re.compile(r"\b[cgmn]\.([^\s(]+)")
246
+
247
+
248
+ def describes_reference_allele(name: str) -> bool:
249
+ """Return True when a ClinVar record's HGVS name describes the reference sequence.
250
+
251
+ ClinVar writes the normal (reference-sequence) allele as ``c.N=``, for
252
+ example ``NM_000130.5(F5):c.1601=``. Only the nucleotide description
253
+ counts: a synonymous protein change such as ``c.123A>G (p.Leu41=)``
254
+ describes a real variant.
255
+ """
256
+ match = _HGVS_ALLELE.search(name)
257
+ return match is not None and match.group(1).endswith("=")
258
+
259
+
230
260
  def _open_tsv(path: Path) -> Iterator[list[str]]:
231
261
  """Stream a (possibly-gzipped) tab-delimited TSV, skipping comment lines."""
232
262
  opener: object = gzip.open if path.suffix == ".gz" else open
@@ -345,7 +375,8 @@ def iter_clinvar_tsv_records(
345
375
  " ref TEXT, "
346
376
  " alt TEXT, "
347
377
  " gene TEXT, "
348
- " review_status TEXT"
378
+ " review_status TEXT, "
379
+ " normal_allele INTEGER"
349
380
  ");"
350
381
  )
351
382
  batch: list[tuple[object, ...]] = []
@@ -377,6 +408,16 @@ def iter_clinvar_tsv_records(
377
408
  variation_id = int(cols[_VS_VARIATION_ID].strip())
378
409
  except (ValueError, IndexError):
379
410
  continue
411
+ normal_allele = describes_reference_allele(cols[_VS_NAME])
412
+ if ref == alt and (
413
+ normal_allele or cols[_VS_TYPE].strip() != "single nucleotide variant"
414
+ ):
415
+ # REF == ALT has one meaning left in the cache: this
416
+ # build's reference carries the variant allele (F5
417
+ # c.1601G>A on GRCh37). A normal-allele record with
418
+ # REF == ALT describes the reference itself, and a
419
+ # non-SNV with REF == ALT has no alleles on this build.
420
+ continue
380
421
  batch.append(
381
422
  (
382
423
  variation_id,
@@ -387,17 +428,18 @@ def iter_clinvar_tsv_records(
387
428
  alt,
388
429
  cols[_VS_GENE_SYMBOL].strip(),
389
430
  cols[_VS_REVIEW_STATUS].strip(),
431
+ int(normal_allele),
390
432
  )
391
433
  )
392
434
  if len(batch) >= INSERT_BATCH_SIZE:
393
435
  tmp_conn.executemany(
394
- "INSERT OR IGNORE INTO vs VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
436
+ "INSERT OR IGNORE INTO vs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
395
437
  batch,
396
438
  )
397
439
  batch.clear()
398
440
  if batch:
399
441
  tmp_conn.executemany(
400
- "INSERT OR IGNORE INTO vs VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
442
+ "INSERT OR IGNORE INTO vs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
401
443
  batch,
402
444
  )
403
445
  tmp_conn.commit()
@@ -415,14 +457,20 @@ def iter_clinvar_tsv_records(
415
457
  except (ValueError, IndexError):
416
458
  continue
417
459
  row = cursor.execute(
418
- "SELECT rsid, chromosome, position, ref, alt, gene, review_status "
419
- "FROM vs WHERE variation_id = ?",
460
+ "SELECT rsid, chromosome, position, ref, alt, gene, review_status, "
461
+ "normal_allele FROM vs WHERE variation_id = ?",
420
462
  (variation_id,),
421
463
  ).fetchone()
422
464
  if row is None:
423
465
  continue
424
- rsid, chromosome, position, ref, alt, gene, review_status_agg = row
466
+ rsid, chromosome, position, ref, alt, gene, review_status_agg, normal = row
425
467
  clinical_significance = cols[_SS_CLIN_SIG].strip()
468
+ if normal and clinical_significance.lower() in _NORMAL_ALLELE_DROPPED_CLNSIGS:
469
+ # A normal-allele record names the reference sequence;
470
+ # carrying it is not a pathogenic finding (F5 c.1601=,
471
+ # written T>C on GRCh37). Its other classifications
472
+ # stay as they are.
473
+ continue
426
474
  # ClinVar's submission_summary
427
475
  # carries several placeholder values that mean "no clinical
428
476
  # significance recorded." A placeholder that ISN'T in
@@ -211,6 +211,12 @@ class Annotation:
211
211
  # analysis build, chromosome in Allelix's bare form. Lets enrichment
212
212
  # key every annotation by position rather than by rsID (ADR-0040).
213
213
  locus: tuple[str, int] | None = field(default=None, compare=False, repr=False)
214
+ # ``reference_carries_alt``: ``alt`` is the reference base because this
215
+ # build's reference sequence carries the variant allele (F5 c.1601G>A
216
+ # on GRCh37): a ClinVar row written REF == ALT, or a ClinPGx / SNPedia
217
+ # genotype whose alt is derived against the normal allele from the
218
+ # other build. Exempts the annotation from invariant 2 only.
219
+ reference_carries_alt: bool = field(default=False, compare=False, repr=False)
214
220
 
215
221
  def __post_init__(self) -> None:
216
222
  """Enforce the allele-identity invariants.
@@ -222,7 +228,9 @@ class Annotation:
222
228
  (``ALT_IS_NON_REFERENCE_SOURCES``), ``alt`` is never the
223
229
  reference base. GWAS is exempt: its ``alt`` is the study's
224
230
  effect (risk) allele, which is often the reference allele.
225
- Checked when ``locus_ref`` is known.
231
+ Checked when ``locus_ref`` is known. An annotation whose alt is
232
+ the reference base because the build's reference carries the
233
+ variant allele is exempt (``reference_carries_alt``).
226
234
 
227
235
  Annotators filter offending source rows before construction; a
228
236
  violation here is a programming error, not a data condition.
@@ -237,6 +245,7 @@ class Annotation:
237
245
  and self.alt
238
246
  and self.locus_ref is not None
239
247
  and self.alt == self.locus_ref
248
+ and not self.reference_carries_alt
240
249
  ):
241
250
  raise AnnotationInvariantError(
242
251
  f"{self.source} annotation for {self.rsid}: alt {self.alt!r} is the "
@@ -267,7 +276,7 @@ class Annotation:
267
276
  # new public-output path, you're re-introducing the bug — add the field
268
277
  # here and use ``annotation_to_public_dict`` instead.
269
278
  _INTERNAL_ANNOTATION_FIELDS: frozenset[str] = frozenset(
270
- {"is_must_include", "locus_ref", "carried_alleles", "locus"}
279
+ {"is_must_include", "locus_ref", "carried_alleles", "locus", "reference_carries_alt"}
271
280
  )
272
281
 
273
282
 
@@ -498,7 +498,10 @@ def _enrichment_allele(a: Annotation) -> str | None:
498
498
  The annotation's ``alt``; when it has none (a GWAS row with no reported
499
499
  risk allele), the user's single carried non-reference allele. None when
500
500
  the locus or its REF is unknown, when there is no such allele or more
501
- than one, or when the allele is the reference.
501
+ than one, or when the allele is the reference, unless the annotation
502
+ says the build's reference carries the variant allele
503
+ (``reference_carries_alt``, F5 c.1601G>A on GRCh37): that allele is
504
+ the variant, and its GRCh38 key is the real variant there.
502
505
  """
503
506
  if a.locus is None or a.locus_ref is None:
504
507
  return None
@@ -508,7 +511,7 @@ def _enrichment_allele(a: Annotation) -> str | None:
508
511
  if len(carried) != 1:
509
512
  return None
510
513
  allele = carried.pop()
511
- if allele == a.locus_ref:
514
+ if allele == a.locus_ref and not a.reference_carries_alt:
512
515
  return None
513
516
  return allele
514
517
 
@@ -544,7 +547,8 @@ def _enrichment_keys(
544
547
  GWAS row with no reported risk allele) uses the user's single carried
545
548
  non-reference allele, as the rsID-less position fallback always did,
546
549
  and gets no key when there is none or more than one. An allele equal
547
- to the reference has no variant to look up. GRCh38 keys are used as
550
+ to the reference has no variant to look up, except where the build's
551
+ reference carries the variant allele (``reference_carries_alt``). GRCh38 keys are used as
548
552
  they are; GRCh37 keys go through the identity table; any other build
549
553
  gets no keys.
550
554
  """
@@ -162,8 +162,9 @@ def resolve_rsid_at_locus(
162
162
  """Pick the rsID for an rsID-less call from the ``(ref, alt, rsid)`` rows at its locus.
163
163
 
164
164
  Shared by every position resolver (ClinVar, gnomAD) so they apply one
165
- rule. A row is consistent with the call when it is not a
166
- reference-allele record (REF != ALT), its REF equals ``variant_ref``
165
+ rule. A row is consistent with the call when its REF differs from its
166
+ ALT (a REF == ALT row names the reference base itself, which no
167
+ non-reference call explains), its REF equals ``variant_ref``
167
168
  when that is known, and the user carries its ALT. The call resolves
168
169
  when those rows:
169
170
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.4.1
3
+ Version: 2.4.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>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "allelix"
7
- version = "2.4.1"
7
+ version = "2.4.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"
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