Skip to content
This repository was archived by the owner on Jun 15, 2026. It is now read-only.

Commit 422df17

Browse files
committed
v1.2.0: GRCh36 fallback fix, database auto-refresh, version-tag guard
- GRCh36 safety guard: non-confident GRCh36 detection no longer falls back to GRCh37. Covers both the end-of-stream flush path and the buffer-limit path (large FTDNA files with sparse probe SNPs). ClinVar is correctly skipped for GRCh36 data. - Database auto-refresh: the analysis pipeline checks database ages before running. Databases older than 7 days with a changed remote signal are refreshed automatically. --no-update to skip. Network failures warn and continue. - Version-tag drift guard: pre-push hook asserts pushed v* tags match pyproject.toml version. - Corpas exome VCF attribution with paper DOI and CC-BY/CC0 license. - pyproject.toml version corrected (was 1.1.0 on v1.1.1 release). - Changelog dates corrected to match actual release timestamps.
1 parent 26c2b1e commit 422df17

10 files changed

Lines changed: 376 additions & 16 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ repos:
3333
pass_filenames: false
3434
always_run: true
3535
stages: [pre-push]
36+
37+
- id: version-tag-match
38+
name: version-tag-match (pyproject.toml == pushed tag)
39+
entry: scripts/check_version_tag.sh
40+
language: script
41+
pass_filenames: false
42+
always_run: true
43+
stages: [pre-push]

‎CHANGELOG.md‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,39 @@
22

33
All notable changes are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5-
## [Unreleased]
5+
## [1.2.0] — 2026-06-07
66

7-
## [1.1.1] — 2026-06-07
7+
### Fixed
8+
- `pyproject.toml` version corrected to match release (was `1.1.0` on
9+
the v1.1.1 release).
10+
- **GRCh36 fallback bug.** Non-confident GRCh36 detection (e.g., 3/4
11+
probe SNPs matched) was falling back to GRCh37 as the effective build,
12+
silently bypassing the ClinVar safety guard and annotating GRCh36
13+
positions against GRCh37 coordinates. Fixed in both the end-of-stream
14+
`flush()` path AND the buffer-limit path (large files where probe SNPs
15+
appear past the 100K-variant buffer cap). The pipeline now uses GRCh36
16+
as the effective build whenever detection points to GRCh36, even
17+
non-confidently or with a single probe SNP match.
18+
19+
### Added
20+
- **Auto-refresh stale databases.** `analyze`, `methylation`, and
21+
`pharmacogenomics` now check database file ages before running. If any
22+
database is older than 7 days and the remote signal (MD5/ETag) has
23+
changed, the database is refreshed automatically. If the network is
24+
unreachable, a warning is printed and analysis continues with the stale
25+
cache. SNPedia is excluded (no remote download). Use `--no-update` to
26+
skip the freshness check entirely.
27+
- Corpas family exome VCF attribution in `test_data/edge_cases/README.md`
28+
with paper DOI (Corpas et al., *BMC Genomics* 2015,
29+
doi:10.1186/s12864-015-1973-7). Licensing table in `test_data/README.md`
30+
updated. Every genotype fixture in the repo now has documented
31+
provenance and license.
32+
- **Version-tag drift guard.** Pre-push hook
33+
(`scripts/check_version_tag.sh`) asserts any pushed `v*` tag matches
34+
the version in `pyproject.toml`. Prevents the class of bug where a
35+
release ships with a stale version string.
36+
37+
## [1.1.1] — 2026-06-06
838

939
### Changed
1040
- Relocated real genotype test data (`test_data/real/` and
@@ -1202,7 +1232,7 @@ All notable changes are documented here. Format follows [Keep a Changelog](https
12021232
- GitHub Actions CI matrix on Python 3.11 and 3.12.
12031233

12041234

1205-
[Unreleased]: https://github.com/dial481/allelix/compare/v1.1.1...HEAD
1235+
[1.2.0]: https://github.com/dial481/allelix/compare/v1.1.1...v1.2.0
12061236
[1.1.1]: https://github.com/dial481/allelix/compare/v1.1.0...v1.1.1
12071237
[1.1.0]: https://github.com/dial481/allelix/compare/v1.0.0...v1.1.0
12081238
[1.0.0]: https://github.com/dial481/allelix/releases/tag/v1.0.0

‎allelix/cli.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import logging
88
import sys
9+
import time
910
from pathlib import Path
1011
from typing import TYPE_CHECKING, NamedTuple
1112

@@ -128,6 +129,50 @@ def _ready_annotators(
128129
return resolved, ready, not_ready
129130

130131

132+
_STALENESS_SECONDS = 7 * 24 * 60 * 60 # 7 days
133+
134+
135+
def _maybe_refresh_databases(data_dir: Path) -> None:
136+
"""Check database mtimes; refresh any that are stale and have a changed remote signal.
137+
138+
Only runs for annotators that download data (SNPedia excluded).
139+
If the network is unreachable, warns and continues with stale caches.
140+
"""
141+
now = time.time()
142+
annotators = get_annotators(data_dir)
143+
for annotator in annotators:
144+
with annotator:
145+
if not annotator.requires_download or not annotator.is_ready():
146+
continue
147+
db_files = list(data_dir.glob(f"{annotator.name}*sqlite*"))
148+
if not db_files:
149+
continue
150+
newest_mtime = max(f.stat().st_mtime for f in db_files)
151+
age = now - newest_mtime
152+
if age <= _STALENESS_SECONDS:
153+
continue
154+
155+
remote = annotator.fetch_remote_signal()
156+
if remote is None:
157+
age_days = int(age / 86400)
158+
console.print(
159+
f"[yellow]{annotator.display_name} database is {age_days} days old. "
160+
"Run `allelix db update` when online.[/yellow]"
161+
)
162+
continue
163+
164+
cached = annotator.cached_remote_signal()
165+
if cached == remote:
166+
continue
167+
168+
console.print(f"[bold]Updating {annotator.display_name}…[/bold]")
169+
_run_setup(annotator)
170+
console.print(
171+
f"[green]✓ {annotator.display_name} updated[/green] "
172+
f"(version {annotator.version() or '(unknown)'})"
173+
)
174+
175+
131176
def _format_from_path(output: Path, override: str | None) -> str:
132177
if override:
133178
return override.lower()
@@ -158,7 +203,11 @@ def _run_analysis_command(
158203
exclude_sources: frozenset[str] | None = None,
159204
gwas_all: bool = False,
160205
diff_path: Path | None = None,
206+
no_update: bool = False,
161207
) -> None:
208+
resolved = resolve_data_dir(data_dir)
209+
if not no_update:
210+
_maybe_refresh_databases(resolved)
162211
parser = _resolve_parser(file_path, fmt)
163212
_, ready, not_ready = _ready_annotators(
164213
data_dir, include_benign=include_benign, gwas_filter_traits=not gwas_all
@@ -458,6 +507,12 @@ def stats(file_path: Path, fmt: str | None) -> None:
458507
"Not a monitoring tool — use for version-to-version validation."
459508
),
460509
)
510+
_NO_UPDATE_OPT = click.option(
511+
"--no-update",
512+
is_flag=True,
513+
default=False,
514+
help="Skip the pre-analysis database freshness check.",
515+
)
461516
_BUILD_OPT = click.option(
462517
"--build",
463518
type=click.Choice(["grch37", "grch38", "auto"], case_sensitive=False),
@@ -551,6 +606,7 @@ def _emit_build_diagnostics(result: object) -> None:
551606
@_GWAS_ALL_OPT
552607
@_EXCLUDE_SNPEDIA_OPT
553608
@_DIFF_OPT
609+
@_NO_UPDATE_OPT
554610
def analyze(
555611
file_path: Path,
556612
fmt: str | None,
@@ -566,6 +622,7 @@ def analyze(
566622
gwas_all: bool,
567623
exclude_snpedia: bool,
568624
diff_path: Path | None,
625+
no_update: bool,
569626
) -> None:
570627
"""Annotate a genotype file against all ready reference databases."""
571628
_run_analysis_command(
@@ -584,6 +641,7 @@ def analyze(
584641
exclude_sources=frozenset({"snpedia"}) if exclude_snpedia else None,
585642
gwas_all=gwas_all,
586643
diff_path=diff_path,
644+
no_update=no_update,
587645
)
588646

589647

@@ -738,6 +796,7 @@ def compare(file1: Path, file2: Path, fmt1: str | None, fmt2: str | None) -> Non
738796
@_GWAS_ALL_OPT
739797
@_EXCLUDE_SNPEDIA_OPT
740798
@_DIFF_OPT
799+
@_NO_UPDATE_OPT
741800
def methylation(
742801
file_path: Path,
743802
fmt: str | None,
@@ -753,6 +812,7 @@ def methylation(
753812
gwas_all: bool,
754813
exclude_snpedia: bool,
755814
diff_path: Path | None,
815+
no_update: bool,
756816
) -> None:
757817
"""Methylation-pathway-focused report (MTHFR, MTR, MTRR, COMT, CBS, …)."""
758818
excluded: set[str] = set()
@@ -776,6 +836,7 @@ def methylation(
776836
exclude_sources=frozenset(excluded) if excluded else None,
777837
gwas_all=gwas_all,
778838
diff_path=diff_path,
839+
no_update=no_update,
779840
)
780841

781842

@@ -794,6 +855,7 @@ def methylation(
794855
@_GWAS_ALL_OPT
795856
@_EXCLUDE_SNPEDIA_OPT
796857
@_DIFF_OPT
858+
@_NO_UPDATE_OPT
797859
def pharmacogenomics(
798860
file_path: Path,
799861
fmt: str | None,
@@ -809,6 +871,7 @@ def pharmacogenomics(
809871
gwas_all: bool,
810872
exclude_snpedia: bool,
811873
diff_path: Path | None,
874+
no_update: bool,
812875
) -> None:
813876
"""Pharmacogenomics-focused report (annotations from PharmGKB-style sources)."""
814877
excluded: set[str] = set()
@@ -832,6 +895,7 @@ def pharmacogenomics(
832895
exclude_sources=frozenset(excluded) if excluded else None,
833896
gwas_all=gwas_all,
834897
diff_path=diff_path,
898+
no_update=no_update,
835899
)
836900

837901

‎allelix/reports/_pipeline.py‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import TYPE_CHECKING
2020

2121
from allelix.utils.build_detect import (
22+
BUILD_GRCH36,
2223
BUILD_GRCH37,
2324
BUILD_GRCH38,
2425
KNOWN_SNP_POSITIONS,
@@ -325,9 +326,17 @@ def feed(self, variant: Variant) -> tuple[bool, list[Variant]]:
325326
self._buffer.clear()
326327
return True, batch
327328
if len(self._buffer) >= _DETECTION_BUFFER_LIMIT:
328-
# Stream is unusually large with no known SNPs hit; fall back so we
329-
# don't OOM. Detection failed → use header build, then GRCh37.
330-
self.effective = self.header_build or BUILD_GRCH37
329+
# Buffer full before detection converged. Run partial detection
330+
# so the GRCh36 safety guard can fire (same logic as flush()).
331+
result = detect_build(self._buffer)
332+
if result.build is not None:
333+
self.detected = result.build
334+
self.matched_count = result.matched
335+
self.inspected_count = result.inspected
336+
if result.build == BUILD_GRCH36:
337+
self.effective = BUILD_GRCH36
338+
else:
339+
self.effective = self.header_build or BUILD_GRCH37
331340
batch = [replace(v, build=self.effective) for v in self._buffer]
332341
self._buffer.clear()
333342
return True, batch
@@ -343,10 +352,15 @@ def flush(self) -> list[Variant]:
343352
self.detected = result.build
344353
self.effective = result.build
345354
else:
346-
# Fall back to header → default. Surface partial counts.
347-
self.effective = self.header_build or BUILD_GRCH37
348355
if result.build is not None:
349356
self.detected = result.build
357+
# GRCh36 must fail safe: there is no GRCh36 ClinVar cache,
358+
# so falling back to GRCh37 would silently query wrong
359+
# coordinates and bypass the GRCh36 safety guard.
360+
if result.build == BUILD_GRCH36:
361+
self.effective = BUILD_GRCH36
362+
else:
363+
self.effective = self.header_build or BUILD_GRCH37
350364
self.matched_count = result.matched
351365
self.inspected_count = result.inspected
352366
out = [replace(v, build=self.effective) for v in self._buffer]

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "allelix"
7-
version = "1.1.0"
7+
version = "1.2.0"
88
description = "Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first."
99
readme = "README.md"
1010
requires-python = ">=3.11"

‎scripts/check_version_tag.sh‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
# Pre-push guard: if a version tag (v*) is being pushed, assert it
3+
# matches pyproject.toml. Prevents the 1.1.0-on-v1.1.1 class of bug.
4+
#
5+
# Pre-push hooks receive lines on stdin:
6+
# <local ref> <local sha> <remote ref> <remote sha>
7+
8+
pyproject_version=$(python3 -c "
9+
import tomllib, pathlib
10+
d = tomllib.loads(pathlib.Path('pyproject.toml').read_text())
11+
print(d['project']['version'])
12+
")
13+
14+
while read -r local_ref local_sha remote_ref remote_sha; do
15+
tag="${local_ref#refs/tags/}"
16+
case "$tag" in
17+
v*)
18+
expected="v${pyproject_version}"
19+
if [ "$tag" != "$expected" ]; then
20+
echo "ERROR: pushing tag '$tag' but pyproject.toml says version '${pyproject_version}' (expected tag '$expected')" >&2
21+
exit 1
22+
fi
23+
;;
24+
esac
25+
done
26+
27+
exit 0

‎test_data/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ formats.
5555

5656
| Source | License | Notes |
5757
|---|---|---|
58-
| openSNP genotype files (real/, transcoded/, plus the synthetic source for baselines) | [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) | Public domain. |
58+
| openSNP genotype files (real/, transcoded/, edge_cases/, plus the synthetic source for baselines) | [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) | Public domain. |
59+
| Corpas family exome VCF (edge_cases/unsupported_23andme_exome_vcf.txt) | CC-BY / CC0 (see [Corpas et al. 2015](https://doi.org/10.1186/s12864-015-1973-7)) | Deliberately public-released by subjects. Attributed to satisfy CC-BY. |
5960
| ClinVar (in databases/) | NCBI public domain | Restore via `allelix db update`. |
6061
| GWAS Catalog (in databases/) | NCBI/EBI public domain | Same. |
6162
| PharmGKB (in databases/) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | Attribution required if reports are redistributed. |

‎test_data/edge_cases/README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,24 @@ what it tests, and the expected parser behavior.
5959
- **Expected behavior:** `allelix stats` returns `Error: No parser recognized`.
6060

6161
### `unsupported_23andme_exome_vcf.txt`
62-
- **Source:** openSNP user1, file 3302. Provenance: CC0.
63-
- **What it is:** 23andMe whole-exome VCF (a research-only deliverable,
64-
distinct from 23andMe's standard chip export). VCF format, not supported
65-
by Allelix v1.x (VCF is on the v2 roadmap).
62+
- **Source:** Corpas family "Corpasome" release; dataset via figshare,
63+
project tag "Corpasome". Originally deposited on openSNP as user1,
64+
file 3302.
65+
- **Citation:** Corpas M et al., "Crowdsourced direct-to-consumer genomic
66+
analysis of a family quartet," *BMC Genomics* 2015.
67+
doi:10.1186/s12864-015-1973-7
68+
- **License:** Released CC-BY by the family (2013); described as CC0 in
69+
the 2015 paper. Attributed here to satisfy CC-BY.
70+
- **What it is:** Whole-exome variant calls (~115k variants) from a
71+
GATK HaplotypeCaller pipeline. VCF format, not supported by Allelix
72+
v1.x (VCF is on the v2 roadmap).
6673
- **What it tests:** Unsupported format rejected cleanly even when the
6774
filename suggests it might be 23andMe.
6875
- **Expected behavior:** `allelix stats` returns `Error: No parser recognized`.
76+
- **Note:** This is the only named-individual dataset in the repo. The
77+
Corpas family deliberately released their genomic data to the public.
78+
Replacement with a synthetic or fully anonymous exome VCF is tracked
79+
as a follow-up.
6980

7081
### `mhg_grch38_with_grch37_header.txt`
7182
- **Source:** Synthetic MHG (Tempus) fixture. Hand-built from the project's

0 commit comments

Comments
 (0)