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,284 @@
1
+ """Command-line interface for gnomad-api-cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from gnomad_api_cache import __version__
12
+ from gnomad_api_cache.query import DEFAULT_DATASET
13
+
14
+ if TYPE_CHECKING:
15
+ from gnomad_api_cache.cache import VariantCache
16
+
17
+ # Output format per lowercased file extension. --format overrides this, and
18
+ # becomes mandatory when the extension is missing or not listed here.
19
+ EXTENSION_FORMATS = {
20
+ ".csv": "csv",
21
+ ".tsv": "tsv",
22
+ ".tab": "tsv",
23
+ ".parquet": "parquet",
24
+ ".pq": "parquet",
25
+ ".json": "json",
26
+ ".jsonl": "jsonl",
27
+ ".ndjson": "jsonl",
28
+ }
29
+
30
+ OUTPUT_FORMATS = ("csv", "tsv", "parquet", "json", "jsonl")
31
+
32
+ INPUT_FORMATS = ("vcf",)
33
+
34
+ # Sentinel for --require-build meaning "accept whatever the VCF declares".
35
+ ANY_BUILD = "any"
36
+
37
+ EXIT_OK = 0
38
+ EXIT_ERROR = 1
39
+ EXIT_USAGE = 2
40
+ EXIT_INTERRUPTED = 130
41
+
42
+
43
+ def infer_format(path: Path) -> str | None:
44
+ """Map an output path to an export format by extension, or None."""
45
+ return EXTENSION_FORMATS.get(path.suffix.lower())
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ """Construct the argument parser.
50
+
51
+ Exposed separately from main() so documentation tooling and tests can
52
+ introspect the interface without running anything.
53
+ """
54
+ parser = argparse.ArgumentParser(
55
+ prog="gnomad-api-cache",
56
+ description=(
57
+ "Fetch gnomAD annotations for the variants in a VCF, cache them "
58
+ "in SQLite, and optionally export a flattened table."
59
+ ),
60
+ epilog=(
61
+ "examples:\n"
62
+ " gnomad-api-cache -i cohort.vcf.gz -c gnomad.sqlite "
63
+ "-o out.parquet\n"
64
+ " gnomad-api-cache -i cohort.vcf.gz -c gnomad.sqlite "
65
+ "-o out.txt -f tsv\n"
66
+ " gnomad-api-cache -i cohort.vcf.gz -c gnomad.sqlite\n"
67
+ "\n"
68
+ "The cache is the durable artifact: re-running against the same "
69
+ "cache re-fetches\nonly what is missing, so exporting a second "
70
+ "format costs no API calls."
71
+ ),
72
+ formatter_class=argparse.RawDescriptionHelpFormatter,
73
+ )
74
+
75
+ parser.add_argument(
76
+ "-i",
77
+ "--input",
78
+ required=True,
79
+ type=Path,
80
+ metavar="PATH",
81
+ help="input variant file (VCF, optionally bgzipped)",
82
+ )
83
+ parser.add_argument(
84
+ "-c",
85
+ "--cache",
86
+ required=True,
87
+ type=Path,
88
+ metavar="PATH",
89
+ help="SQLite cache file; created if it does not exist",
90
+ )
91
+ parser.add_argument(
92
+ "-o",
93
+ "--output",
94
+ type=Path,
95
+ metavar="PATH",
96
+ help=(
97
+ "export destination; omit to populate the cache without "
98
+ "writing a table"
99
+ ),
100
+ )
101
+ parser.add_argument(
102
+ "-f",
103
+ "--format",
104
+ choices=OUTPUT_FORMATS,
105
+ help=(
106
+ "output format (default: inferred from the -o extension; "
107
+ "required when the extension is unrecognised)"
108
+ ),
109
+ )
110
+ parser.add_argument(
111
+ "--input-format",
112
+ choices=INPUT_FORMATS,
113
+ default="vcf",
114
+ help="input format (default: %(default)s)",
115
+ )
116
+
117
+ fetching = parser.add_argument_group("fetching")
118
+ fetching.add_argument(
119
+ "--dataset",
120
+ default=DEFAULT_DATASET,
121
+ metavar="NAME",
122
+ help="gnomAD dataset id (default: %(default)s)",
123
+ )
124
+ fetching.add_argument(
125
+ "--delay",
126
+ type=float,
127
+ default=None,
128
+ metavar="SECONDS",
129
+ help=(
130
+ "seconds between API requests; the public API allows 10 per "
131
+ "minute, so lower this only against a private instance"
132
+ ),
133
+ )
134
+ fetching.add_argument(
135
+ "--require-build",
136
+ default="GRCh38",
137
+ metavar="BUILD",
138
+ help=(
139
+ "reference build the VCF must declare, or 'any' to skip the "
140
+ "check (default: %(default)s)"
141
+ ),
142
+ )
143
+ fetching.add_argument(
144
+ "--no-retry-errors",
145
+ dest="retry_errors",
146
+ action="store_false",
147
+ help="leave previously errored variants alone instead of retrying",
148
+ )
149
+ fetching.add_argument(
150
+ "--retry-not-found",
151
+ action="store_true",
152
+ help=(
153
+ "re-query variants previously absent from gnomAD (useful only "
154
+ "after a dataset release)"
155
+ ),
156
+ )
157
+
158
+ export = parser.add_argument_group("export")
159
+ export.add_argument(
160
+ "--include-populations",
161
+ action="store_true",
162
+ help="add per-ancestry frequency columns to tabular output",
163
+ )
164
+
165
+ parser.add_argument(
166
+ "-q",
167
+ "--quiet",
168
+ action="store_true",
169
+ help="suppress the run summary on stdout",
170
+ )
171
+ parser.add_argument(
172
+ "--version",
173
+ action="version",
174
+ version=f"%(prog)s {__version__}",
175
+ )
176
+ return parser
177
+
178
+
179
+ def _export(
180
+ cache: VariantCache,
181
+ path: Path,
182
+ fmt: str,
183
+ include_populations: bool,
184
+ ) -> int:
185
+ """Dispatch to the exporter for `fmt`. Returns the row/record count."""
186
+ if fmt == "csv":
187
+ return cache.to_csv(path, include_populations=include_populations)
188
+ if fmt == "tsv":
189
+ return cache.to_tsv(path, include_populations=include_populations)
190
+ if fmt == "parquet":
191
+ return cache.to_parquet(path, include_populations=include_populations)
192
+ if fmt == "json":
193
+ return cache.to_json(path)
194
+ if fmt == "jsonl":
195
+ return cache.to_json(path, lines=True)
196
+ raise ValueError(f"unsupported output format: {fmt}")
197
+
198
+
199
+ def main(argv: Sequence[str] | None = None) -> int:
200
+ parser = build_parser()
201
+ args = parser.parse_args(argv)
202
+
203
+ # Resolve the output format before fetching
204
+ output_format: str | None = args.format
205
+ if args.output is not None and output_format is None:
206
+ output_format = infer_format(args.output)
207
+ if output_format is None:
208
+ parser.error(
209
+ f"cannot infer an output format from '{args.output.name}'; "
210
+ f"pass -f/--format with one of: {', '.join(OUTPUT_FORMATS)}"
211
+ )
212
+
213
+ if not args.input.exists():
214
+ print(f"error: input file not found: {args.input}", file=sys.stderr)
215
+ return EXIT_ERROR
216
+
217
+ require_build = (
218
+ None if args.require_build.lower() == ANY_BUILD else args.require_build
219
+ )
220
+
221
+ # Imported here rather than at module scope so --help and --version stay
222
+ # fast and do not require cyvcf2 to be importable.
223
+ from gnomad_api_cache.adapters.vcf_adapter import BuildMismatchError, read_vcf
224
+ from gnomad_api_cache.cache import VariantCache
225
+
226
+ fetch_kwargs = {
227
+ "retry_errors": args.retry_errors,
228
+ "retry_not_found": args.retry_not_found,
229
+ }
230
+ if args.delay is not None:
231
+ fetch_kwargs["delay"] = args.delay
232
+
233
+ try:
234
+ variants = read_vcf(args.input, require_build=require_build)
235
+ if not variants:
236
+ print(
237
+ f"error: no usable variants in {args.input}", file=sys.stderr
238
+ )
239
+ return EXIT_ERROR
240
+
241
+ with VariantCache(args.cache, dataset=args.dataset) as cache:
242
+ summary = cache.fetch(variants, **fetch_kwargs)
243
+ if not args.quiet:
244
+ print(summary)
245
+
246
+ if args.output is not None and output_format is not None:
247
+ args.output.parent.mkdir(parents=True, exist_ok=True)
248
+ count = _export(
249
+ cache,
250
+ args.output,
251
+ output_format,
252
+ args.include_populations,
253
+ )
254
+ if not args.quiet:
255
+ print(
256
+ f"wrote {count} records to {args.output} "
257
+ f"({output_format})"
258
+ )
259
+ except BuildMismatchError as exc:
260
+ print(f"error: {exc}", file=sys.stderr)
261
+ print(
262
+ "hint: pass --require-build any to skip the build check",
263
+ file=sys.stderr,
264
+ )
265
+ return EXIT_ERROR
266
+ except ImportError as exc:
267
+ # Every export dependency ships with the package, so this means a
268
+ # damaged environment rather than a missing optional install.
269
+ print(f"error: {exc}", file=sys.stderr)
270
+ print(
271
+ "hint: the install looks incomplete; try "
272
+ "pip install --force-reinstall gnomad-api-cache",
273
+ file=sys.stderr,
274
+ )
275
+ return EXIT_ERROR
276
+ except (OSError, ValueError) as exc:
277
+ print(f"error: {exc}", file=sys.stderr)
278
+ return EXIT_ERROR
279
+ except KeyboardInterrupt:
280
+ # The cache commits per batch, so an interrupted run keeps its work.
281
+ print("\ninterrupted; cached progress is preserved", file=sys.stderr)
282
+ return EXIT_INTERRUPTED
283
+
284
+ return EXIT_OK
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+
5
+ GNOMAD_API_URL = "https://gnomad.broadinstitute.org/api"
6
+
7
+ def post_gnomad(query: str, variables: dict[str, str]) -> dict:
8
+ """POST a GraphQL query to the gnomAD API and return the JSON response.
9
+
10
+ Raises requests.RequestException on transport errors, or KeyError if the
11
+ response has no 'data' field.
12
+ """
13
+
14
+ response = requests.post(
15
+ GNOMAD_API_URL,
16
+ json={"query": query, "variables": variables},
17
+ timeout=30,
18
+ )
19
+ response.raise_for_status()
20
+ return response.json()
@@ -0,0 +1,319 @@
1
+ """Export cached gnomAD records to Parquet, JSON, or delimited text.
2
+
3
+ Three formats, two scopes (whole cache, or a given list of variant ids):
4
+
5
+ export.to_json(cache, "out.json") # nested, verbatim
6
+ export.to_csv(cache, "out.csv", variant_ids=ids) # flattened, one row each
7
+ export.to_parquet(cache, "out.parquet") # flattened, typed
8
+
9
+ JSON preserves the record exactly as gnomAD returned it. Parquet and CSV/TSV
10
+ are flattened to one row per variant, which drops the nested parts
11
+ (per-population breakdowns unless asked for, histograms, the full transcript
12
+ list) -- use JSON when you need those.
13
+
14
+ Mitochondrial records have a different shape from nuclear ones: no exome or
15
+ genome block, and heteroplasmy-aware counts instead of a single AC. They are
16
+ flattened into the same table with a `kind` column and `mito_`-prefixed
17
+ columns, so a mixed export loses nothing and stays self-describing.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import csv
23
+ import json
24
+ from collections.abc import Iterator, Sequence
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ if TYPE_CHECKING:
29
+ from gnomad_api_cache.cache import VariantCache
30
+
31
+ # Predictors gnomAD v4 exposes; fixed so columns stay stable across exports
32
+ # even when a given variant is missing some.
33
+ IN_SILICO_PREDICTORS = (
34
+ "cadd",
35
+ "revel_max",
36
+ "spliceai_ds_max",
37
+ "pangolin_largest_ds",
38
+ "phylop",
39
+ "sift_max",
40
+ "polyphen_max",
41
+ )
42
+
43
+ # gnomAD's top-level genetic ancestry groups.
44
+ ANCESTRY_GROUPS = (
45
+ "afr", "ami", "amr", "asj", "eas", "fin", "mid", "nfe", "sas", "remaining",
46
+ )
47
+
48
+ # exome and genome share a schema; joint has no `af` field, so it is derived.
49
+ SEQUENCING_TYPES = ("exome", "genome", "joint")
50
+
51
+
52
+ def _join(values: Any) -> str | None:
53
+ """Collapse a list of scalars into one delimited cell, or None if empty."""
54
+ if not values:
55
+ return None
56
+ return ",".join(str(v) for v in values)
57
+
58
+
59
+ def _pick_transcript(record: dict[str, Any]) -> dict[str, Any]:
60
+ """The transcript a flat row should describe.
61
+
62
+ Prefers MANE Select, then Ensembl canonical, then the first entry, matching
63
+ how the gnomAD browser picks the consequence it shows.
64
+ """
65
+ consequences = record.get("transcript_consequences") or []
66
+ for predicate in (
67
+ lambda t: t.get("is_mane_select"),
68
+ lambda t: t.get("is_canonical"),
69
+ ):
70
+ for transcript in consequences:
71
+ if predicate(transcript):
72
+ return transcript
73
+ return consequences[0] if consequences else {}
74
+
75
+
76
+ def _sequencing_columns(
77
+ block: dict[str, Any] | None,
78
+ prefix: str,
79
+ include_populations: bool,
80
+ ) -> dict[str, Any]:
81
+ """Frequency columns for one of exome / genome / joint."""
82
+ block = block or {}
83
+ ac, an = block.get("ac"), block.get("an")
84
+ faf95 = block.get("faf95") or {}
85
+
86
+ columns: dict[str, Any] = {
87
+ f"{prefix}_ac": ac,
88
+ f"{prefix}_an": an,
89
+ # derive af from ac/an because joint has no af field
90
+ f"{prefix}_af": (ac / an) if ac is not None and an else None,
91
+ f"{prefix}_nhomalt": block.get("homozygote_count"),
92
+ f"{prefix}_nhemi": block.get("hemizygote_count"),
93
+ f"{prefix}_filters": _join(block.get("filters")),
94
+ f"{prefix}_faf95": faf95.get("popmax"),
95
+ f"{prefix}_faf95_population": faf95.get("popmax_population"),
96
+ }
97
+
98
+ if include_populations:
99
+ by_id = {p.get("id"): p for p in block.get("populations") or []}
100
+ for group in ANCESTRY_GROUPS:
101
+ population = by_id.get(group) or {}
102
+ p_ac, p_an = population.get("ac"), population.get("an")
103
+ columns[f"{prefix}_ac_{group}"] = p_ac
104
+ columns[f"{prefix}_an_{group}"] = p_an
105
+ columns[f"{prefix}_af_{group}"] = (
106
+ (p_ac / p_an) if p_ac is not None and p_an else None
107
+ )
108
+ columns[f"{prefix}_nhomalt_{group}"] = population.get("homozygote_count")
109
+
110
+ return columns
111
+
112
+
113
+ def _flatten_mitochondrial(record: dict[str, Any]) -> dict[str, Any]:
114
+ """Mito-specific columns. A mito call is homoplasmic or heteroplasmic, so
115
+ there is no single AC to map onto the nuclear columns."""
116
+ return {
117
+ "mito_an": record.get("an"),
118
+ "mito_ac_hom": record.get("ac_hom"),
119
+ "mito_ac_het": record.get("ac_het"),
120
+ "mito_max_heteroplasmy": record.get("max_heteroplasmy"),
121
+ "mito_haplogroup_defining": record.get("haplogroup_defining"),
122
+ "mito_mitotip_score": record.get("mitotip_score"),
123
+ "mito_mitotip_prediction": record.get("mitotip_trna_prediction"),
124
+ "mito_pon_mt_trna_prediction": record.get("pon_mt_trna_prediction"),
125
+ }
126
+
127
+
128
+ def flatten_record(
129
+ variant_id: str,
130
+ record: dict[str, Any] | None,
131
+ include_populations: bool = False,
132
+ ) -> dict[str, Any]:
133
+ """Reduce one cached record to a single flat row."""
134
+ data = record or {}
135
+ # Mito records carry no `chrom` and no `exome` block; the id is the only
136
+ # place the contig appears.
137
+ parts = variant_id.split("-")
138
+ is_mito = parts[0] == "M"
139
+
140
+ row: dict[str, Any] = {
141
+ "variant_id": variant_id,
142
+ "kind": "mitochondrial" if is_mito else "nuclear",
143
+ "in_gnomad": record is not None,
144
+ "chrom": data.get("chrom") or (parts[0] if len(parts) == 4 else None),
145
+ "pos": data.get("pos")
146
+ or (int(parts[1]) if len(parts) == 4 and parts[1].isdigit() else None),
147
+ "ref": data.get("ref") or (parts[2] if len(parts) == 4 else None),
148
+ "alt": data.get("alt") or (parts[3] if len(parts) == 4 else None),
149
+ "rsid": (data.get("rsids") or [None])[0],
150
+ "caid": data.get("caid"),
151
+ "flags": _join(data.get("flags")),
152
+ }
153
+
154
+ row.update(_consequence_columns(_pick_transcript(data)))
155
+ row["n_transcripts"] = len(data.get("transcript_consequences") or [])
156
+
157
+ for prefix in SEQUENCING_TYPES:
158
+ row.update(_sequencing_columns(data.get(prefix), prefix, include_populations))
159
+
160
+ predictors = {
161
+ p.get("id"): p.get("value") for p in data.get("in_silico_predictors") or []
162
+ }
163
+ row.update(
164
+ {f"in_silico_{name}": predictors.get(name) for name in IN_SILICO_PREDICTORS}
165
+ )
166
+
167
+ coverage = data.get("coverage") or {}
168
+ row["exome_mean_coverage"] = (coverage.get("exome") or {}).get("mean")
169
+ row["genome_mean_coverage"] = (coverage.get("genome") or {}).get("mean")
170
+
171
+ row.update(_flatten_mitochondrial(data if is_mito else {}))
172
+ return row
173
+
174
+
175
+ def _consequence_columns(transcript: dict[str, Any]) -> dict[str, Any]:
176
+ return {
177
+ "gene_symbol": transcript.get("gene_symbol"),
178
+ "gene_id": transcript.get("gene_id"),
179
+ "transcript_id": transcript.get("transcript_id"),
180
+ "consequence": transcript.get("major_consequence"),
181
+ "hgvsc": transcript.get("hgvsc"),
182
+ "hgvsp": transcript.get("hgvsp"),
183
+ "lof": transcript.get("lof"),
184
+ "lof_filter": transcript.get("lof_filter"),
185
+ "lof_flags": transcript.get("lof_flags"),
186
+ "polyphen": transcript.get("polyphen_prediction"),
187
+ "sift": transcript.get("sift_prediction"),
188
+ "n_transcripts": 0,
189
+ }
190
+
191
+
192
+ def _select(
193
+ cache: VariantCache,
194
+ variant_ids: Sequence[str] | None,
195
+ ) -> Iterator[tuple[str, dict[str, Any] | None]]:
196
+ """Yield (variant_id, record) for the requested scope.
197
+
198
+ Streams one row at a time so a whole-cache export does not need the entire
199
+ cache resident in memory.
200
+ """
201
+ ids = list(cache) if variant_ids is None else list(variant_ids)
202
+ for variant_id in ids:
203
+ try:
204
+ yield variant_id, cache[variant_id]
205
+ except KeyError:
206
+ # Asked for an id that was never queried: report it as a row with
207
+ # no data rather than omitting it without explanation.
208
+ yield variant_id, None
209
+
210
+
211
+ def iter_rows(
212
+ cache: VariantCache,
213
+ variant_ids: Sequence[str] | None = None,
214
+ include_populations: bool = False,
215
+ ) -> Iterator[dict[str, Any]]:
216
+ """Flat rows for the requested scope, one dict per variant."""
217
+ for variant_id, record in _select(cache, variant_ids):
218
+ yield flatten_record(variant_id, record, include_populations)
219
+
220
+
221
+ def to_json(
222
+ cache: VariantCache,
223
+ path: str | Path,
224
+ variant_ids: Sequence[str] | None = None,
225
+ lines: bool = False,
226
+ indent: int | None = None,
227
+ ) -> int:
228
+ """Write records verbatim, preserving all nesting.
229
+
230
+ Default is one JSON object keyed by variant id. `lines=True` writes JSON
231
+ Lines instead, which streams and is the better choice for a large cache --
232
+ records average ~21 KB, so a 100k-variant dump is a couple of GB.
233
+ """
234
+ count = 0
235
+ with open(path, "w") as handle:
236
+ if lines:
237
+ for variant_id, record in _select(cache, variant_ids):
238
+ handle.write(
239
+ json.dumps({"variant_id": variant_id, "record": record}) + "\n"
240
+ )
241
+ count += 1
242
+ else:
243
+ payload = {vid: rec for vid, rec in _select(cache, variant_ids)}
244
+ json.dump(payload, handle, indent=indent)
245
+ count = len(payload)
246
+ return count
247
+
248
+
249
+ def to_delimited(
250
+ cache: VariantCache,
251
+ path: str | Path,
252
+ variant_ids: Sequence[str] | None = None,
253
+ delimiter: str = ",",
254
+ include_populations: bool = False,
255
+ ) -> int:
256
+ """Write flattened rows as delimited text.
257
+
258
+ Columns are taken from the first row, so every row must share a schema --
259
+ which is why flatten_record always emits the full column set, including for
260
+ variants that are absent from gnomAD.
261
+ """
262
+ rows = iter_rows(cache, variant_ids, include_populations)
263
+ first = next(rows, None)
264
+ if first is None:
265
+ raise ValueError("nothing to export")
266
+
267
+ count = 0
268
+ with open(path, "w", newline="") as handle:
269
+ writer = csv.DictWriter(handle, fieldnames=list(first), delimiter=delimiter)
270
+ writer.writeheader()
271
+ writer.writerow(first)
272
+ count = 1
273
+ for row in rows:
274
+ writer.writerow(row)
275
+ count += 1
276
+ return count
277
+
278
+
279
+ def to_csv(
280
+ cache: VariantCache,
281
+ path: str | Path,
282
+ variant_ids: Sequence[str] | None = None,
283
+ include_populations: bool = False,
284
+ ) -> int:
285
+ return to_delimited(cache, path, variant_ids, ",", include_populations)
286
+
287
+
288
+ def to_tsv(
289
+ cache: VariantCache,
290
+ path: str | Path,
291
+ variant_ids: Sequence[str] | None = None,
292
+ include_populations: bool = False,
293
+ ) -> int:
294
+ return to_delimited(cache, path, variant_ids, "\t", include_populations)
295
+
296
+
297
+ def to_parquet(
298
+ cache: VariantCache,
299
+ path: str | Path,
300
+ variant_ids: Sequence[str] | None = None,
301
+ include_populations: bool = False,
302
+ compression: str = "zstd",
303
+ ) -> int:
304
+ """Write flattened rows as Parquet, ready for pandas/polars/duckdb.
305
+
306
+ pyarrow is imported here rather than at module scope to keep it off the
307
+ import path of callers that only ever export JSON or CSV; loading it
308
+ costs noticeably more than the rest of the package combined.
309
+ """
310
+ import pyarrow
311
+ import pyarrow.parquet
312
+
313
+ rows = list(iter_rows(cache, variant_ids, include_populations))
314
+ if not rows:
315
+ raise ValueError("nothing to export")
316
+
317
+ table = pyarrow.Table.from_pylist(rows)
318
+ pyarrow.parquet.write_table(table, str(path), compression=compression)
319
+ return len(rows)