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

Commit 4aa89f5

Browse files
committed
v1.3.0: gnomAD exome frequency cache
Add population allele frequency enrichment from gnomAD v4.1 exomes (~16M variants). Pre-built SQLite cache distributed via HuggingFace, downloaded during db update. Composite PK (chrom, pos, ref, alt) preserves multi-allelic sites for future AlphaMissense/CADD joins. Also: CPIC fallback for PharmGKB, graceful individual annotator failure handling in db update, Pop. Freq column in all report formats, --no-gnomad flag, ODbL attribution, CI workflow, streaming VCF build script with local file support.
1 parent 422df17 commit 4aa89f5

44 files changed

Lines changed: 2664 additions & 389 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ name: CI
33
on:
44
push:
55
branches: [main]
6+
tags: ["v*"]
67
pull_request:
78
branches: [main]
89

@@ -39,3 +40,29 @@ jobs:
3940

4041
- name: Pytest
4142
run: pytest
43+
44+
version-tag-guard:
45+
if: startsWith(github.ref, 'refs/tags/v')
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@v4
49+
50+
- name: Set up Python
51+
uses: actions/setup-python@v5
52+
with:
53+
python-version: "3.12"
54+
55+
- name: Assert tag matches pyproject.toml version
56+
run: |
57+
TAG="${GITHUB_REF#refs/tags/}"
58+
VERSION=$(python3 -c "
59+
import tomllib, pathlib
60+
d = tomllib.loads(pathlib.Path('pyproject.toml').read_text())
61+
print(d['project']['version'])
62+
")
63+
EXPECTED="v${VERSION}"
64+
if [ "$TAG" != "$EXPECTED" ]; then
65+
echo "::error::Tag '$TAG' does not match pyproject.toml version '$VERSION' (expected '$EXPECTED')"
66+
exit 1
67+
fi
68+
echo "Tag '$TAG' matches pyproject.toml version '$VERSION'."

‎.gitignore‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Claude / AI tooling
22
CLAUDE.md
3+
GITHUB_WORKFLOW.md
34
.claude/
45

56
# Virtual environments
@@ -94,7 +95,11 @@ desktop.ini
9495
*.pem
9596
*.key
9697

97-
# Test data library (committed; only databases/ stays gitignored)
98+
# Test data library (committed fixtures; downloads stay gitignored)
9899
test_data/databases/
99100
test_data/baselines_local/
101+
test_data/gwas_catalog*
102+
test_data/gwas-catalog*
103+
test_data/data/
104+
test_data.tar.gz
100105

‎.pre-commit-config.yaml‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ repos:
2626
pass_filenames: false
2727
stages: [pre-commit]
2828

29-
- id: pytest
30-
name: pytest (fast suite, skips @slow real-data tests)
31-
entry: pytest -m "not slow"
32-
language: system
33-
pass_filenames: false
34-
always_run: true
35-
stages: [pre-push]
36-
3729
- id: version-tag-match
3830
name: version-tag-match (pyproject.toml == pushed tag)
3931
entry: scripts/check_version_tag.sh

‎CHANGELOG.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@
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+
## [1.3.0]
6+
7+
### Added
8+
- **gnomAD population allele frequencies (R-6).** New `GnomadAnnotator`
9+
enriches report annotations with population frequency context from
10+
gnomAD v4.1 exomes (~16M rsIDs). Pre-built cache downloaded from
11+
HuggingFace via `db update`. Frequency column appears in terminal,
12+
HTML, and JSON reports when gnomAD data is available. `--no-gnomad`
13+
flag on `analyze`, `methylation`, `pharmacogenomics`, and `db update`
14+
to skip.
15+
- **CPIC fallback for PharmGKB (R-5).** `db update` succeeds when the
16+
CPIC API is unreachable — PharmGKB downloads complete and the
17+
non-finding filter degrades gracefully. Signal carries
18+
`cpic:unavailable` so recovery auto-triggers a refresh.
19+
- `scripts/build_gnomad_cache.py` — streaming VCF build script for
20+
the gnomAD frequency cache. Downloads ~120GB over HTTPS, never saves
21+
VCFs to disk, outputs ~1GB SQLite.
22+
- `scripts/extract_array_manifest.py` — extracts rsID superset from
23+
genotype files for filtered gnomAD cache builds.
24+
- gnomAD ODbL v1.0 attribution in HTML and JSON reports.
25+
- JSON report `schema_version` bumped to `"2"` (added `allele_frequency`
26+
field on annotations). Diff engine accepts both v1 and v2 baselines.
27+
- `db update` now handles individual annotator failures gracefully —
28+
prints error and continues to remaining annotators instead of aborting.
29+
30+
### Fixed
31+
- Offline claim in README and ADR-0012 corrected: analysis runs offline
32+
by default with an opt-out freshness check (`--no-update`), not
33+
opt-in network access (#10).
34+
- `.gitignore` updated for GWAS Catalog test data (#12).
35+
- `scripts/fetch_testdata.sh` downloads GWAS Catalog associations
36+
from EBI FTP (#12).
37+
38+
### Changed
39+
- Pre-push hook reduced to version-tag check only; pytest removed
40+
(CI runs the full suite on every PR, pre-push pytest caused SSH timeouts).
41+
- CI version-tag guard job added to `.github/workflows/ci.yml` (#11).
42+
543
## [1.2.0] — 2026-06-07
644

745
### Fixed
@@ -1172,8 +1210,8 @@ All notable changes are documented here. Format follows [Keep a Changelog](https
11721210
installed via `pre-commit install --hook-type pre-commit --hook-type pre-push`:
11731211
- **pre-commit** runs `ruff check` + `ruff format --check` so a commit
11741212
that doesn't lint or format clean is blocked.
1175-
- **pre-push** runs `pytest`, which enforces the 92% coverage floor and
1176-
the strict warning filters; a push with failing tests is blocked.
1213+
- **pre-push** runs the version-tag check only (fast, no test suite).
1214+
The full test suite runs in CI on every pull request.
11771215
- Annotator test fixture now closes connections on teardown (round-4 N-1).
11781216
- `pytest.PytestUnraisableExceptionWarning` is escalated to error in
11791217
`filterwarnings`, so future fixtures without teardown fail CI loudly.

‎CONTRIBUTING.md‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ pip install -e ".[dev]"
1414
pre-commit install --hook-type pre-commit --hook-type pre-push
1515
```
1616

17-
Run the test suite (excludes slow tests that download databases):
17+
Run the test suite:
1818

1919
```bash
20-
pytest -m "not slow"
20+
pytest
2121
```
2222

23+
Tests marked `@slow` require `test_data/gwas_catalog.zip` (fetched by
24+
`scripts/fetch_testdata.sh`). CI runs the full suite including slow tests;
25+
locally they are skipped when the fixture is absent.
26+
2327
Lint and format:
2428

2529
```bash
@@ -353,9 +357,16 @@ ruff check allelix/annotators/mydb.py tests/annotators/test_mydb.py
353357
- The `data/` directory at project root is the local database cache.
354358
It is gitignored and populated by `allelix db update`.
355359

360+
## Hooks and CI
361+
362+
Pre-commit hooks (ruff check, ruff format) run on every commit. The
363+
pre-push hook runs only the version-tag check (fast, no test suite).
364+
The full test suite runs in CI on every pull request — there is no
365+
pre-push pytest gate.
366+
356367
## Pull Request Checklist
357368

358-
- [ ] Tests pass: `pytest -m "not slow"`
369+
- [ ] Tests pass: `pytest`
359370
- [ ] Lint clean: `ruff check .`
360371
- [ ] Format clean: `ruff format --check .`
361372
- [ ] License header on new files

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ This is not a disclaimer afterthought. It is a design constraint that affects mo
108108

109109
- No data leaves your machine. No telemetry. No uploads. No analytics.
110110
- Reference databases are downloaded via `allelix db update` and cached locally.
111-
- Analysis runs entirely offline.
111+
- Analysis runs offline against local database caches. A brief freshness check runs before analysis by default (skipped with `--no-update`).
112112

113113
## Data Sources & Licensing
114114

@@ -121,6 +121,7 @@ Allelix source code is licensed under the **GNU Affero General Public License v3
121121
| PharmGKB | pharmgkb.org | CC BY-SA 4.0 | Attribution required |
122122
| CPIC | cpicpgx.org | CC BY-SA 4.0 | Attribution required. Per-allele function data fetched from `api.cpicpgx.org` at `db update` time; used internally for the PharmGKB non-finding filter (ADR-0020), not surfaced as its own annotator. |
123123
| SNPedia | snpedia.com | CC BY-NC-SA 3.0 US | Attribution required, **non-commercial only**. Use `--exclude-snpedia` to omit. |
124+
| gnomAD | gnomad.broadinstitute.org | ODbL v1.0 | Attribution required. Population allele frequencies for context; not a clinical annotator. Use `--no-gnomad` to omit. |
124125

125126
**Commercial users:** SNPedia content is non-commercial. Pass `--exclude-snpedia` to any analysis command, or skip the `scripts/scrape_snpedia.py` step entirely — either way, `analyze` runs using all other databases and omits SNPedia annotations automatically.
126127

‎allelix/annotators/__init__.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from allelix.annotators.base import Annotator
1010
from allelix.annotators.clinvar import CLINVAR_SUPPORTED_BUILDS, ClinVarAnnotator
11+
from allelix.annotators.gnomad import GnomadAnnotator
1112
from allelix.annotators.gwas import GWASCatalogAnnotator
1213
from allelix.annotators.pharmgkb import PharmGKBAnnotator
1314
from allelix.annotators.snpedia import SNPediaAnnotator
@@ -44,13 +45,15 @@ def get_annotators(
4445
pharmgkb = PharmGKBAnnotator(data_dir, clinvar_ref_provider=clinvar.reference_for)
4546
gwas = GWASCatalogAnnotator(data_dir, filter_traits=gwas_filter_traits)
4647
snpedia = SNPediaAnnotator(data_dir, clinvar_ref_provider=clinvar.reference_for)
47-
return [clinvar, pharmgkb, gwas, snpedia]
48+
gnomad = GnomadAnnotator(data_dir)
49+
return [clinvar, pharmgkb, gwas, snpedia, gnomad]
4850

4951

5052
__all__ = [
5153
"Annotator",
5254
"ClinVarAnnotator",
5355
"GWASCatalogAnnotator",
56+
"GnomadAnnotator",
5457
"PharmGKBAnnotator",
5558
"SNPediaAnnotator",
5659
"get_annotators",

‎allelix/annotators/base.py‎

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

55
from __future__ import annotations
66

7+
import contextlib
78
from abc import ABC, abstractmethod
89
from typing import TYPE_CHECKING, ClassVar
910

@@ -56,6 +57,11 @@ def __init__(self, data_dir: Path) -> None:
5657
"""Bind the annotator to a data directory (created elsewhere)."""
5758
self.data_dir = data_dir
5859

60+
def __del__(self) -> None:
61+
"""Release resources on GC to prevent ResourceWarning."""
62+
with contextlib.suppress(Exception):
63+
self.close()
64+
5965
def __enter__(self) -> Annotator:
6066
"""Return self for `with` usage."""
6167
return self

‎allelix/annotators/gnomad.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 dial481
3+
"""gnomAD population frequency enrichment.
4+
5+
gnomAD is not a clinical annotator — it does not produce Annotation
6+
objects. It enriches existing annotations with population allele
7+
frequency context. The pipeline calls ``bulk_lookup()`` after all
8+
annotators have run, and stamps each annotation's ``allele_frequency``
9+
field.
10+
11+
License: ODbL v1.0 (Open Database License). We extract only rsID +
12+
allele frequencies (no SpliceAI or other restrictively licensed fields).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
import sqlite3
19+
from typing import TYPE_CHECKING, ClassVar
20+
21+
from allelix.annotators.base import Annotator
22+
from allelix.databases.gnomad_loader import (
23+
GNOMAD_CACHE_URL,
24+
GNOMAD_DB_FILENAME,
25+
install_prebuilt_cache,
26+
)
27+
from allelix.databases.manager import download, get_database_info, head_request_headers
28+
29+
if TYPE_CHECKING:
30+
from pathlib import Path
31+
32+
from allelix.models import Annotation, Variant
33+
34+
logger = logging.getLogger(__name__)
35+
36+
_BULK_BATCH_SIZE = 900
37+
38+
39+
class GnomadAnnotator(Annotator):
40+
"""Population frequency enrichment from gnomAD.
41+
42+
Subclasses Annotator for ``db update`` / ``db status`` / ``is_ready()``
43+
integration. ``annotate()`` always returns ``[]`` — gnomAD does not
44+
participate in the per-variant annotation loop.
45+
"""
46+
47+
name: ClassVar[str] = "gnomad"
48+
display_name: ClassVar[str] = "gnomAD"
49+
attribution: ClassVar[str] = "gnomAD"
50+
requires_download: ClassVar[bool] = True
51+
52+
def __init__(self, data_dir: Path) -> None:
53+
"""Bind to the data directory."""
54+
super().__init__(data_dir)
55+
self._db_path = data_dir / GNOMAD_DB_FILENAME
56+
self._conn: sqlite3.Connection | None = None
57+
58+
def _connection(self) -> sqlite3.Connection:
59+
if self._conn is None:
60+
self._conn = sqlite3.connect(self._db_path)
61+
return self._conn
62+
63+
def setup(self) -> None:
64+
"""Download the pre-built gnomAD exome frequency cache from HuggingFace."""
65+
signal = self.fetch_remote_signal()
66+
gz_path = self.data_dir / "gnomad_exome_frequencies.sqlite.gz"
67+
download(GNOMAD_CACHE_URL, gz_path)
68+
install_prebuilt_cache(
69+
gz_path,
70+
self._db_path,
71+
source_url=GNOMAD_CACHE_URL,
72+
remote_signal=signal,
73+
)
74+
try:
75+
gz_path.unlink()
76+
except OSError:
77+
logger.warning("Could not remove staged file at %s", gz_path)
78+
79+
def is_ready(self) -> bool:
80+
"""True when the gnomAD SQLite cache exists and is queryable."""
81+
return get_database_info(self._db_path, "gnomad") is not None
82+
83+
def version(self) -> str | None:
84+
"""Return the cached database version, or None."""
85+
info = get_database_info(self._db_path, "gnomad")
86+
return info["version"] if info else None
87+
88+
def record_count(self) -> int | None:
89+
"""Return the number of rsIDs in the cache, or None."""
90+
info = get_database_info(self._db_path, "gnomad")
91+
return info["record_count"] if info else None
92+
93+
def close(self) -> None:
94+
"""Close the SQLite connection if open."""
95+
if self._conn is not None:
96+
self._conn.close()
97+
self._conn = None
98+
99+
def fetch_remote_signal(self) -> str | None:
100+
"""Probe the HuggingFace asset URL for ETag or Last-Modified."""
101+
headers = head_request_headers(GNOMAD_CACHE_URL)
102+
if headers is None:
103+
return None
104+
etag = headers.get("ETag") or headers.get("Etag")
105+
last_modified = headers.get("Last-Modified") or headers.get("Last-modified")
106+
if etag:
107+
return f"etag:{etag.strip()}"
108+
if last_modified:
109+
return f"lm:{last_modified.strip()}"
110+
return None
111+
112+
def cached_remote_signal(self) -> str | None:
113+
"""Return the remote signal stored at last successful download."""
114+
info = get_database_info(self._db_path, "gnomad")
115+
if not info or not info["remote_signal"]:
116+
return None
117+
return info["remote_signal"]
118+
119+
def annotate(self, variant: Variant) -> list[Annotation]:
120+
"""Not used — gnomAD enriches, does not annotate. Always returns []."""
121+
return []
122+
123+
def lookup(self, rsid: str) -> float | None:
124+
"""Return global allele frequency for a single rsID, or None."""
125+
conn = self._connection()
126+
row = conn.execute(
127+
"SELECT MAX(af) FROM gnomad_frequencies WHERE rsid = ?", (rsid,)
128+
).fetchone()
129+
return row[0] if row else None
130+
131+
def bulk_lookup(self, rsids: set[str]) -> dict[str, float]:
132+
"""Return ``{rsid: af}`` for all rsIDs found in the cache.
133+
134+
Batches into chunks of 900 to stay within SQLite's variable limit.
135+
"""
136+
if not rsids:
137+
return {}
138+
conn = self._connection()
139+
result: dict[str, float] = {}
140+
rsid_list = list(rsids)
141+
for i in range(0, len(rsid_list), _BULK_BATCH_SIZE):
142+
batch = rsid_list[i : i + _BULK_BATCH_SIZE]
143+
placeholders = ",".join("?" * len(batch))
144+
rows = conn.execute(
145+
f"SELECT rsid, MAX(af) FROM gnomad_frequencies"
146+
f" WHERE rsid IN ({placeholders}) GROUP BY rsid",
147+
batch,
148+
).fetchall()
149+
for rsid, af in rows:
150+
if af is not None:
151+
result[rsid] = af
152+
return result

0 commit comments

Comments
 (0)