gnomad-api-cache 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,35 @@
1
+ """Fetch and cache gnomAD annotations for VCF variants.
2
+
3
+ from gnomad_api_cache import VariantCache, read_vcf
4
+
5
+ with VariantCache("gnomad.sqlite") as cache:
6
+ print(cache.fetch_vcf("cohort.vcf.gz"))
7
+ cache.to_parquet("annotations.parquet")
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from gnomad_api_cache.adapters.vcf_adapter import (
13
+ BuildMismatchError,
14
+ iter_variant_keys,
15
+ read_vcf,
16
+ )
17
+ from gnomad_api_cache.cache import VariantCache
18
+ from gnomad_api_cache.fetch import FetchSummary, fetch_gnomad
19
+ from gnomad_api_cache.keys import InvalidVariantError, VariantKey
20
+ from gnomad_api_cache.query import DEFAULT_DATASET, QUERY_VERSION
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "DEFAULT_DATASET",
26
+ "QUERY_VERSION",
27
+ "BuildMismatchError",
28
+ "FetchSummary",
29
+ "InvalidVariantError",
30
+ "VariantCache",
31
+ "VariantKey",
32
+ "fetch_gnomad",
33
+ "iter_variant_keys",
34
+ "read_vcf",
35
+ ]
@@ -0,0 +1,8 @@
1
+ """Enables `python -m gnomad_api_cache`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gnomad_api_cache.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator, Sequence
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+
7
+
8
+ def _now() -> str:
9
+ return datetime.now(UTC).isoformat(timespec="seconds")
10
+
11
+
12
+ def _chunked(items: Sequence[Any], size: int) -> Iterator[Sequence[Any]]:
13
+ for i in range(0, len(items), size):
14
+ yield items[i : i + size]
File without changes
@@ -0,0 +1,147 @@
1
+ """Read a VCF into VariantKey objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import Counter
7
+ from collections.abc import Callable, Iterator
8
+ from pathlib import Path
9
+
10
+ from cyvcf2 import VCF, Variant
11
+
12
+ from gnomad_api_cache.keys import (
13
+ CANONICAL_CHROMS,
14
+ InvalidVariantError,
15
+ VariantKey,
16
+ normalize_chrom,
17
+ )
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # Use chr1 to detect build
22
+ CHR1_LENGTH_BY_BUILD = {248956422: "GRCh38", 249250621: "GRCh37"}
23
+
24
+
25
+ class BuildMismatchError(RuntimeError):
26
+ """Raised when a VCF is not on the build the target gnomAD dataset uses."""
27
+
28
+
29
+ def detect_build(vcf: VCF) -> str | None:
30
+ """Return "GRCh38", "GRCh37", or None if the header doesn't say."""
31
+ lengths: dict[str, int] = dict(zip(vcf.seqnames, vcf.seqlens))
32
+ chr1 = lengths.get("chr1") or lengths.get("1")
33
+ if chr1 is None:
34
+ return None
35
+ return CHR1_LENGTH_BY_BUILD.get(chr1)
36
+
37
+
38
+ def is_normalized(vcf: VCF) -> bool:
39
+ """True if the header shows bcftools norm was run."""
40
+ return any(
41
+ line.startswith("##bcftools_norm") for line in vcf.raw_header.split("\n")
42
+ )
43
+
44
+
45
+ def _skip_reason(alt: str, chrom: str) -> str | None:
46
+ """Return why this allele can't become a gnomAD ID, or None if it can."""
47
+ if alt.startswith("<"):
48
+ return "symbolic" # <DEL>, <NON_REF>, <*>
49
+ if "[" in alt or "]" in alt:
50
+ return "breakend"
51
+ if alt == "*":
52
+ return "spanning_deletion"
53
+ if normalize_chrom(chrom) not in CANONICAL_CHROMS:
54
+ return "non_canonical_contig" # *_random, chrUn_*, HLA-*
55
+ return None
56
+
57
+
58
+ def iter_variant_keys(
59
+ path: str | Path,
60
+ *,
61
+ require_build: str | None = "GRCh38",
62
+ warn_unnormalized: bool = True,
63
+ filter_function: Callable[[Variant], bool] | None = None,
64
+ ) -> Iterator[VariantKey]:
65
+ """Yield one VariantKey per ALT allele of every record in `path`."""
66
+ vcf = VCF(str(path))
67
+
68
+ build = detect_build(vcf)
69
+ if require_build is not None:
70
+ if build is None:
71
+ log.warning(
72
+ "%s: could not determine build from header; assuming %s",
73
+ path,
74
+ require_build,
75
+ )
76
+ elif build != require_build:
77
+ raise BuildMismatchError(
78
+ f"{path}: VCF is {build}, expected {require_build}. "
79
+ + "Lift over first, or target a matching gnomAD dataset."
80
+ )
81
+
82
+ if warn_unnormalized and not is_normalized(vcf):
83
+ log.warning(
84
+ "%s: no ##bcftools_norm header. Unnormalized indels will silently "
85
+ + "miss in gnomAD. Run: bcftools norm -f <ref.fa> -m -any",
86
+ path,
87
+ )
88
+
89
+ skipped: Counter[str] = Counter()
90
+ yielded = 0
91
+
92
+ # main loop
93
+ for record in vcf:
94
+ if filter_function is not None and not filter_function(record):
95
+ continue
96
+
97
+ for alt in record.ALT: # empty list when ALT is "."
98
+ reason = _skip_reason(alt, record.CHROM)
99
+ if reason is not None:
100
+ skipped[reason] += 1
101
+ continue
102
+ try:
103
+ key = VariantKey.from_parts(record.CHROM, record.POS, record.REF, alt)
104
+ except InvalidVariantError as e:
105
+ skipped["invalid"] += 1
106
+ log.debug(
107
+ "skipping %s:%s %s>%s -- %s",
108
+ record.CHROM,
109
+ record.POS,
110
+ record.REF,
111
+ alt,
112
+ e,
113
+ )
114
+ continue
115
+ yielded += 1
116
+ yield key
117
+
118
+ vcf.close()
119
+
120
+ if skipped:
121
+ log.info(
122
+ "%s: %d keys, %d alleles skipped (%s)",
123
+ path,
124
+ yielded,
125
+ sum(skipped.values()),
126
+ ", ".join(f"{k}={v}" for k, v in sorted(skipped.items())),
127
+ )
128
+ else:
129
+ log.info("%s: %d keys", path, yielded)
130
+
131
+
132
+ def read_vcf(
133
+ path: str | Path,
134
+ *,
135
+ require_build: str | None = "GRCh38",
136
+ warn_unnormalized: bool = True,
137
+ filter_function: Callable[[Variant], bool] | None = None,
138
+ ) -> list[VariantKey]:
139
+ """Eager convenience wrapper around iter_variant_keys."""
140
+ return list(
141
+ iter_variant_keys(
142
+ path,
143
+ require_build=require_build,
144
+ warn_unnormalized=warn_unnormalized,
145
+ filter_function=filter_function
146
+ )
147
+ )
@@ -0,0 +1,328 @@
1
+ """SQLite-backed cache of gnomAD records.
2
+
3
+ Stores each API response verbatim as compressed JSON, keyed by variant id and
4
+ dataset.
5
+
6
+ Reads behave like a dict:
7
+
8
+ with VariantCache("gnomad.sqlite") as cache:
9
+ record = cache["1-55039974-G-T"] # -> dict, or None if not in gnomAD
10
+ "1-55039974-G-T" in cache # -> have we ever asked?
11
+ len(cache)
12
+
13
+ Three columns carry the bookkeeping the fetch loop depends on:
14
+
15
+ status 'found' | 'not_found' | 'error'. Caching a miss is what stops
16
+ absent variants from being re-queried on every run; 'error' is
17
+ kept distinct so transient failures can be retried without
18
+ being mistaken for real absences.
19
+ query_version which version of the GraphQL selection produced this row, so a
20
+ widened query re-fetches only the rows that predate it.
21
+ fetched_at provenance, and a basis for staleness policies later.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sqlite3
28
+ import zlib
29
+ from collections.abc import Callable, Iterator, Mapping, Sequence
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING, Any, Self
32
+ from cyvcf2 import Variant
33
+
34
+ if TYPE_CHECKING:
35
+ from gnomad_api_cache.fetch import FetchSummary
36
+
37
+ from gnomad_api_cache._utils import _chunked, _now
38
+ from gnomad_api_cache.keys import VariantKey
39
+ from gnomad_api_cache.query import DEFAULT_DATASET, QUERY_VERSION
40
+
41
+ # zlib level 6 measured ~8.1x on real gnomAD records (21.4 KB -> 2.6 KB mean)
42
+ # at ~0.3 ms/record. Level 9 buys 2% more for 2x the time.
43
+ COMPRESSION_LEVEL = 6
44
+
45
+ # Stay under SQLite's bound-parameter limit when building "IN (?, ?, ...)".
46
+ _SQL_CHUNK = 900
47
+
48
+ STATUS_FOUND = "found"
49
+ STATUS_NOT_FOUND = "not_found"
50
+ STATUS_ERROR = "error"
51
+
52
+ _SCHEMA = """
53
+ CREATE TABLE IF NOT EXISTS variants (
54
+ variant_id TEXT NOT NULL,
55
+ dataset TEXT NOT NULL,
56
+ status TEXT NOT NULL,
57
+ data BLOB,
58
+ fetched_at TEXT NOT NULL,
59
+ query_version INTEGER NOT NULL,
60
+ PRIMARY KEY (variant_id, dataset)
61
+ );
62
+
63
+ CREATE TABLE IF NOT EXISTS meta (
64
+ key TEXT PRIMARY KEY,
65
+ value TEXT NOT NULL
66
+ );
67
+ """
68
+
69
+
70
+ class VariantCache(Mapping[str, "dict[str, Any] | None"]):
71
+ def __init__(
72
+ self,
73
+ path: str | Path,
74
+ dataset: str = DEFAULT_DATASET,
75
+ query_version: int = QUERY_VERSION,
76
+ ) -> None:
77
+ self.path = str(path)
78
+ self.dataset = dataset
79
+ self.query_version = query_version
80
+ self.db = sqlite3.connect(self.path)
81
+ # WAL lets readers work while a fetch is writing. synchronous=NORMAL is
82
+ # safe under WAL for this use: a crash can lose the last commit, which
83
+ # costs at most one batch that the next run simply re-fetches.
84
+ self.db.execute("PRAGMA journal_mode=WAL")
85
+ self.db.execute("PRAGMA synchronous=NORMAL")
86
+ self.db.executescript(_SCHEMA)
87
+ self.db.commit()
88
+
89
+ # --- lifecycle --------------------------------------------------------
90
+
91
+ def close(self) -> None:
92
+ self.db.commit()
93
+ self.db.close()
94
+
95
+ def __enter__(self) -> Self:
96
+ return self
97
+
98
+ def __exit__(self, *exc: object) -> None:
99
+ self.close()
100
+
101
+ def commit(self) -> None:
102
+ self.db.commit()
103
+
104
+ # --- what the fetch loop calls ---------------------------------------
105
+
106
+ def needs_query(
107
+ self,
108
+ keys: Sequence[VariantKey],
109
+ retry_errors: bool = True,
110
+ retry_not_found: bool = False,
111
+ ) -> list[VariantKey]:
112
+ """Return the subset of `keys` that must be fetched from gnomAD.
113
+
114
+ A key is skipped only when a row exists AND it was written by the
115
+ current query_version AND its status is a real answer. Anything older
116
+ or missing comes back for re-fetch.
117
+
118
+ The two retry flags cover the two ways a key can be in the cache
119
+ without usable data, which want opposite defaults:
120
+
121
+ retry_errors we never got an answer (network, bad response).
122
+ Usually transient, so retried by default -- leaving
123
+ these out would let a run report itself complete
124
+ while carrying silent holes.
125
+ retry_not_found gnomAD answered "no such variant". A real answer, so
126
+ skipped by default; set True to re-check after a
127
+ gnomAD release adds variants.
128
+
129
+ Input order is preserved and duplicates are dropped, so the caller can
130
+ pass a raw VCF-derived list straight in.
131
+ """
132
+ # Deduplicate up front
133
+ unique: dict[str, VariantKey] = {}
134
+ for key in keys:
135
+ unique.setdefault(key.id, key)
136
+
137
+ fresh: set[str] = set()
138
+ ids = list(unique)
139
+ for chunk in _chunked(ids, _SQL_CHUNK):
140
+ placeholders = ",".join("?" * len(chunk))
141
+ rows = self.db.execute(
142
+ f"SELECT variant_id, status FROM variants "
143
+ f"WHERE dataset = ? AND query_version >= ? "
144
+ f"AND variant_id IN ({placeholders})",
145
+ (self.dataset, self.query_version, *chunk),
146
+ ).fetchall()
147
+ for variant_id, status in rows:
148
+ if retry_errors and status == STATUS_ERROR:
149
+ continue
150
+ if retry_not_found and status == STATUS_NOT_FOUND:
151
+ continue
152
+ fresh.add(variant_id)
153
+
154
+ return [key for vid, key in unique.items() if vid not in fresh]
155
+
156
+ def put(
157
+ self,
158
+ key: VariantKey | str,
159
+ record: dict[str, Any] | None,
160
+ status: str | None = None,
161
+ ) -> None:
162
+ """Stage one record. Call commit() (or put_many) to persist.
163
+
164
+ `status` defaults to 'found' when a record is given and 'not_found'
165
+ when it is None.
166
+ """
167
+ variant_id = key.id if isinstance(key, VariantKey) else key
168
+ if status is None:
169
+ status = STATUS_FOUND if record is not None else STATUS_NOT_FOUND
170
+ blob = (
171
+ zlib.compress(json.dumps(record).encode(), COMPRESSION_LEVEL)
172
+ if record is not None
173
+ else None
174
+ )
175
+ self.db.execute(
176
+ "INSERT OR REPLACE INTO variants "
177
+ "(variant_id, dataset, status, data, fetched_at, query_version) "
178
+ "VALUES (?, ?, ?, ?, ?, ?)",
179
+ (variant_id, self.dataset, status, blob, _now(), self.query_version),
180
+ )
181
+
182
+ def put_many(
183
+ self,
184
+ records: Mapping[str, dict[str, Any] | None],
185
+ status: str | None = None,
186
+ ) -> None:
187
+ """Stage and commit a whole batch."""
188
+ for variant_id, record in records.items():
189
+ self.put(variant_id, record, status)
190
+ self.db.commit()
191
+
192
+ def mark_errors(self, keys: Sequence[VariantKey]) -> None:
193
+ """Record a failed batch so a later run can retry just those keys."""
194
+ for key in keys:
195
+ self.put(key, None, STATUS_ERROR)
196
+ self.db.commit()
197
+
198
+ # --- dict-style reads -------------------------------------------------
199
+
200
+ def __getitem__(self, variant_id: str) -> dict[str, Any] | None:
201
+ row = self.db.execute(
202
+ "SELECT status, data FROM variants WHERE variant_id = ? AND dataset = ?",
203
+ (variant_id, self.dataset),
204
+ ).fetchone()
205
+ if row is None:
206
+ raise KeyError(variant_id)
207
+ status, blob = row
208
+ if status != STATUS_FOUND or blob is None:
209
+ return None
210
+ return json.loads(zlib.decompress(blob))
211
+
212
+ def __iter__(self) -> Iterator[str]:
213
+ for (variant_id,) in self.db.execute(
214
+ "SELECT variant_id FROM variants WHERE dataset = ?", (self.dataset,)
215
+ ):
216
+ yield variant_id
217
+
218
+ def __len__(self) -> int:
219
+ row = self.db.execute(
220
+ "SELECT count(*) FROM variants WHERE dataset = ?", (self.dataset,)
221
+ ).fetchone()
222
+ return int(row[0])
223
+
224
+ def many(self, variant_ids: Sequence[str]) -> dict[str, dict[str, Any] | None]:
225
+ """Fetch many records in one round trip. Absent ids are simply omitted."""
226
+ out: dict[str, dict[str, Any] | None] = {}
227
+ for chunk in _chunked(list(variant_ids), _SQL_CHUNK):
228
+ placeholders = ",".join("?" * len(chunk))
229
+ for variant_id, status, blob in self.db.execute(
230
+ f"SELECT variant_id, status, data FROM variants "
231
+ f"WHERE dataset = ? AND variant_id IN ({placeholders})",
232
+ (self.dataset, *chunk),
233
+ ):
234
+ out[variant_id] = (
235
+ json.loads(zlib.decompress(blob))
236
+ if status == STATUS_FOUND and blob is not None
237
+ else None
238
+ )
239
+ return out
240
+
241
+ # --- export ----------------------------------------------------------
242
+ #
243
+ # Thin delegations to gnomad_api_cache.export, which holds the flattening
244
+ # logic. Imported lazily so cache.py has no import-time dependency on the
245
+ # export module (or, through it, on pyarrow).
246
+
247
+ def to_json(
248
+ self,
249
+ path: str | Path,
250
+ variant_ids: Sequence[str] | None = None,
251
+ lines: bool = False,
252
+ indent: int | None = None,
253
+ ) -> int:
254
+ """Write records verbatim as JSON. See export.to_json."""
255
+ from gnomad_api_cache import export
256
+
257
+ return export.to_json(self, path, variant_ids, lines, indent)
258
+
259
+ def to_csv(
260
+ self,
261
+ path: str | Path,
262
+ variant_ids: Sequence[str] | None = None,
263
+ include_populations: bool = False,
264
+ ) -> int:
265
+ """Write flattened rows as CSV. See export.to_csv."""
266
+ from gnomad_api_cache import export
267
+
268
+ return export.to_csv(self, path, variant_ids, include_populations)
269
+
270
+ def to_tsv(
271
+ self,
272
+ path: str | Path,
273
+ variant_ids: Sequence[str] | None = None,
274
+ include_populations: bool = False,
275
+ ) -> int:
276
+ """Write flattened rows as TSV. See export.to_tsv."""
277
+ from gnomad_api_cache import export
278
+
279
+ return export.to_tsv(self, path, variant_ids, include_populations)
280
+
281
+ def to_parquet(
282
+ self,
283
+ path: str | Path,
284
+ variant_ids: Sequence[str] | None = None,
285
+ include_populations: bool = False,
286
+ ) -> int:
287
+ """Write flattened rows as Parquet. See export.to_parquet."""
288
+ from gnomad_api_cache import export
289
+
290
+ return export.to_parquet(self, path, variant_ids, include_populations)
291
+
292
+ def status_counts(self) -> dict[str, int]:
293
+ """Row counts by status -- a one-line health check on the cache."""
294
+ return {
295
+ status: count
296
+ for status, count in self.db.execute(
297
+ "SELECT status, count(*) FROM variants WHERE dataset = ? "
298
+ "GROUP BY status",
299
+ (self.dataset,),
300
+ )
301
+ }
302
+
303
+ def fetch(self, variants: list[VariantKey], **kwargs) -> FetchSummary:
304
+ """Fetch missing records into this cache. See fetch.fetch_into."""
305
+ from gnomad_api_cache import fetch as _fetch
306
+
307
+ return _fetch.fetch_into(self, variants, **kwargs)
308
+
309
+ def fetch_vcf(
310
+ self,
311
+ vcf_path: str | Path,
312
+ retry_errors: bool = True,
313
+ retry_not_found: bool = False,
314
+ filter_function: Callable[[Variant], bool] | None = None,
315
+ ) -> FetchSummary:
316
+ """Read a VCF and fetch missing records into this cache."""
317
+ from gnomad_api_cache import fetch as _fetch
318
+ from gnomad_api_cache.adapters import vcf_adapter
319
+
320
+ return _fetch.fetch_into(
321
+ self,
322
+ vcf_adapter.read_vcf(
323
+ vcf_path,
324
+ filter_function=filter_function
325
+ ),
326
+ retry_errors=retry_errors,
327
+ retry_not_found=retry_not_found,
328
+ )