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.
- gnomad_api_cache/__init__.py +35 -0
- gnomad_api_cache/__main__.py +8 -0
- gnomad_api_cache/_utils.py +14 -0
- gnomad_api_cache/adapters/__init__.py +0 -0
- gnomad_api_cache/adapters/vcf_adapter.py +147 -0
- gnomad_api_cache/cache.py +328 -0
- gnomad_api_cache/cli.py +284 -0
- gnomad_api_cache/client.py +20 -0
- gnomad_api_cache/export.py +319 -0
- gnomad_api_cache/fetch.py +149 -0
- gnomad_api_cache/keys.py +78 -0
- gnomad_api_cache/query.py +305 -0
- gnomad_api_cache-0.1.0.dist-info/METADATA +115 -0
- gnomad_api_cache-0.1.0.dist-info/RECORD +17 -0
- gnomad_api_cache-0.1.0.dist-info/WHEEL +4 -0
- gnomad_api_cache-0.1.0.dist-info/entry_points.txt +2 -0
- gnomad_api_cache-0.1.0.dist-info/licenses/LICENSE +21 -0
gnomad_api_cache/cli.py
ADDED
|
@@ -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)
|