allelix 2.2.0__tar.gz → 2.2.1__tar.gz

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