allelign 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.
- allelign/__init__.py +69 -0
- allelign/__main__.py +5 -0
- allelign/_common.py +95 -0
- allelign/_numba.py +22 -0
- allelign/align.py +1252 -0
- allelign/alleles.py +90 -0
- allelign/build.py +57 -0
- allelign/catalog.py +88 -0
- allelign/cli.py +323 -0
- allelign/download.py +525 -0
- allelign/genotypes.py +241 -0
- allelign/ldcheck.py +771 -0
- allelign/qc.py +638 -0
- allelign/status.py +130 -0
- allelign/sumstats.py +1207 -0
- allelign/variants.py +155 -0
- allelign/warning_markers.py +15 -0
- allelign/weights.py +614 -0
- allelign-0.1.0.dist-info/METADATA +175 -0
- allelign-0.1.0.dist-info/RECORD +24 -0
- allelign-0.1.0.dist-info/WHEEL +5 -0
- allelign-0.1.0.dist-info/entry_points.txt +2 -0
- allelign-0.1.0.dist-info/licenses/LICENSE +21 -0
- allelign-0.1.0.dist-info/top_level.txt +1 -0
allelign/download.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Resolve and download public GWAS summary statistics and PGS scores.
|
|
2
|
+
|
|
3
|
+
A *source reference* names one published file. Resolving it answers "which
|
|
4
|
+
URL, which genome build, and what is known about the study", and
|
|
5
|
+
:func:`download_ref` fetches it into a local file with a provenance sidecar.
|
|
6
|
+
The dialect rules that parse the result are :mod:`allelign.sumstats`' (the
|
|
7
|
+
providers here publish columns it already maps), and PGS scores are read by
|
|
8
|
+
:func:`allelign.read_scoring_file`.
|
|
9
|
+
|
|
10
|
+
Supported references (case-insensitive; ``GCST``/``PGS`` accessions bare):
|
|
11
|
+
|
|
12
|
+
============== ==============================================================
|
|
13
|
+
reference file
|
|
14
|
+
============== ==============================================================
|
|
15
|
+
``GCST91000728`` GWAS Catalog harmonised summary statistics
|
|
16
|
+
``FINNGEN:R11:I9_AF`` one FinnGen release endpoint (GRCh38)
|
|
17
|
+
``COVIDHGI:R7:C2`` one COVID-19 HGI release-7 analysis (GRCh37)
|
|
18
|
+
``GBMI:ASTHMA:EUR`` one GBMI endpoint in one ancestry stratum (GRCh38)
|
|
19
|
+
``EQTL:QTS000001:QTD000001:ENSG00000008128`` one eQTL Catalogue molecular
|
|
20
|
+
trait, row-filtered at download (GRCh38)
|
|
21
|
+
``PGS000337`` one PGS Catalog score, harmonised if published (build
|
|
22
|
+
preference GRCh37, or ``build=``)
|
|
23
|
+
============== ==============================================================
|
|
24
|
+
|
|
25
|
+
GBMI's stratum is required rather than defaulted to the multi-ancestry
|
|
26
|
+
meta: a single-ancestry LD reference cannot represent it. The same rule
|
|
27
|
+
keeps the eQTL Catalogue trait mandatory: a dataset's bulk file holds every
|
|
28
|
+
trait it tested.
|
|
29
|
+
|
|
30
|
+
Network access uses :data:`USER_AGENT` over ``urllib`` only; the opener is
|
|
31
|
+
injectable so the suite runs offline.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import gzip
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import tempfile
|
|
42
|
+
import urllib.error
|
|
43
|
+
import urllib.request
|
|
44
|
+
from contextlib import contextmanager
|
|
45
|
+
from datetime import datetime, timezone
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
from . import __version__
|
|
49
|
+
from .sumstats import COLUMN_ALIASES
|
|
50
|
+
|
|
51
|
+
__all__ = ["parse_ref", "resolve_ref", "download_ref", "read_keep_ids"]
|
|
52
|
+
|
|
53
|
+
GWAS_FTP = "https://ftp.ebi.ac.uk/pub/databases/gwas"
|
|
54
|
+
SUMSTATS_FTP = f"{GWAS_FTP}/summary_statistics"
|
|
55
|
+
HARMONISED_LIST = f"{SUMSTATS_FTP}/harmonised_list.txt"
|
|
56
|
+
GWAS_STUDY_REST = "https://www.ebi.ac.uk/gwas/rest/api/v2/studies/{accession}"
|
|
57
|
+
GWAS_PUBLICATION_REST = ("https://www.ebi.ac.uk/gwas/rest/api/v2/"
|
|
58
|
+
"publications/{pmid}")
|
|
59
|
+
PGS_SCORE_REST = "https://www.pgscatalog.org/rest/score/{pgs_id}"
|
|
60
|
+
|
|
61
|
+
FINNGEN_HOST = "https://storage.googleapis.com"
|
|
62
|
+
FINNGEN_BUCKET = "finngen-public-data-{release}"
|
|
63
|
+
FINNGEN_PATH = "summary_stats/finngen_{release}_{endpoint}.gz"
|
|
64
|
+
# The bucket name lowercases the release; the object keeps it as parsed.
|
|
65
|
+
FINNGEN_REF = re.compile(r"^FINNGEN:(R\d{1,2}):([A-Z0-9][A-Z0-9_]{0,39})$")
|
|
66
|
+
|
|
67
|
+
COVIDHGI_HOST = "https://storage.googleapis.com"
|
|
68
|
+
COVIDHGI_PATH = ("covid19-hg-public/freeze_7/results/20220403/main/"
|
|
69
|
+
"sumstats/COVID19_HGI_{analysis}_ALL_leave_23andme_"
|
|
70
|
+
"20220403_GRCh37.tsv.gz")
|
|
71
|
+
COVIDHGI_REF = re.compile(r"^COVIDHGI:(R7):(A2|B1|B2|C2)$")
|
|
72
|
+
|
|
73
|
+
GBMI_HOST = "https://gbmi-sumstats.s3.amazonaws.com"
|
|
74
|
+
GBMI_RELEASE = "052021"
|
|
75
|
+
# Endpoint code as it appears in the object name, keyed by the uppercased
|
|
76
|
+
# ref form because the path is mixed case.
|
|
77
|
+
GBMI_ENDPOINTS = {
|
|
78
|
+
"AAA": "AAA", "ACAPP": "AcApp", "ASTHMA": "Asthma", "COPD": "COPD",
|
|
79
|
+
"GOUT": "Gout", "HCM": "HCM", "HF": "HF", "IPF": "IPF",
|
|
80
|
+
"POAG": "POAG", "STROKE": "Stroke", "THC": "ThC", "VTE": "VTE",
|
|
81
|
+
}
|
|
82
|
+
# Ancestry stratum -> path token ("" for the multi-ancestry meta).
|
|
83
|
+
GBMI_STRATA = {"ALL": "", "AFR": "afr_", "AMR": "amr_", "EAS": "eas_",
|
|
84
|
+
"EUR": "eur_", "SAS": "sas_"}
|
|
85
|
+
GBMI_REF = re.compile(
|
|
86
|
+
r"^GBMI:(" + "|".join(sorted(GBMI_ENDPOINTS)) + r"):("
|
|
87
|
+
+ "|".join(sorted(GBMI_STRATA)) + r")$")
|
|
88
|
+
|
|
89
|
+
EQTL_HOST = "https://ftp.ebi.ac.uk"
|
|
90
|
+
EQTL_PATH = ("pub/databases/spot/eQTL/sumstats/{study}/{dataset}/"
|
|
91
|
+
"{dataset}.all.tsv.gz")
|
|
92
|
+
EQTL_REF = re.compile(
|
|
93
|
+
r"^EQTL:(QTS\d{6}):(QTD\d{6}):([A-Z0-9][A-Z0-9._-]{0,63})$")
|
|
94
|
+
EQTL_ROW_FILTER_COLUMN = "molecular_trait_id"
|
|
95
|
+
|
|
96
|
+
GCST_REF = re.compile(r"^GCST\d{3,}$")
|
|
97
|
+
PGS_REF = re.compile(r"^PGS\d{6}$")
|
|
98
|
+
|
|
99
|
+
#: Reported to every HTTP service, so hosts can tell a tool from a browser.
|
|
100
|
+
USER_AGENT = f"allelign/{__version__} (+https://github.com/bvilhjal/allelign)"
|
|
101
|
+
|
|
102
|
+
#: Some providers carry several comma-joined identifiers per row (FinnGen's
|
|
103
|
+
#: ``rsids``); a joined cell matches a keep set when any part does.
|
|
104
|
+
ID_SEPARATOR = ","
|
|
105
|
+
_ID_COLUMNS = frozenset(COLUMN_ALIASES["id"])
|
|
106
|
+
|
|
107
|
+
TIMEOUT = 60 # index, HEAD and metadata requests
|
|
108
|
+
STREAM_TIMEOUT = 300 # data transfers
|
|
109
|
+
CHUNK = 1 << 20
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _open(url, method="GET", timeout=TIMEOUT):
|
|
113
|
+
"""Open ``url`` with the allelign user agent.
|
|
114
|
+
|
|
115
|
+
The suite replaces this module-level hook with a fake response source,
|
|
116
|
+
which keeps every test offline.
|
|
117
|
+
"""
|
|
118
|
+
request = urllib.request.Request(url, method=method,
|
|
119
|
+
headers={"User-Agent": USER_AGENT})
|
|
120
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _request_failed(exc, label):
|
|
124
|
+
if isinstance(exc, urllib.error.HTTPError) and exc.code == 404:
|
|
125
|
+
return f"{label}: file not found (404)"
|
|
126
|
+
return f"{label}: request failed ({exc})"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _head(url, label, opener):
|
|
130
|
+
try:
|
|
131
|
+
with opener(url, method="HEAD") as response:
|
|
132
|
+
return {"remote_bytes": int(
|
|
133
|
+
response.headers.get("Content-Length", 0) or 0)}
|
|
134
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
|
|
135
|
+
raise ValueError(_request_failed(exc, label)) from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _fetch_bytes(url, label, opener, timeout=TIMEOUT):
|
|
139
|
+
try:
|
|
140
|
+
with opener(url, timeout=timeout) as response:
|
|
141
|
+
return response.read()
|
|
142
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
|
|
143
|
+
raise ValueError(_request_failed(exc, label)) from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fetch_json(url, label, opener):
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(_fetch_bytes(url, label, opener).decode("utf-8"))
|
|
149
|
+
except ValueError:
|
|
150
|
+
raise ValueError(f"{label}: invalid JSON from {url}") from None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def parse_ref(ref):
|
|
154
|
+
"""``{provider: ..., ...}`` for one source reference, or ``None``.
|
|
155
|
+
|
|
156
|
+
The reference is uppercased first; ``provider`` is one of ``gwas``,
|
|
157
|
+
``finngen``, ``covidhgi``, ``gbmi``, ``eqtl`` and ``pgs``.
|
|
158
|
+
"""
|
|
159
|
+
text = (ref or "").strip().upper()
|
|
160
|
+
match = GCST_REF.match(text)
|
|
161
|
+
if match:
|
|
162
|
+
return {"provider": "gwas", "accession": text}
|
|
163
|
+
match = FINNGEN_REF.match(text)
|
|
164
|
+
if match:
|
|
165
|
+
return {"provider": "finngen", "release": match.group(1),
|
|
166
|
+
"endpoint": match.group(2)}
|
|
167
|
+
match = COVIDHGI_REF.match(text)
|
|
168
|
+
if match:
|
|
169
|
+
return {"provider": "covidhgi", "analysis": match.group(2)}
|
|
170
|
+
match = GBMI_REF.match(text)
|
|
171
|
+
if match:
|
|
172
|
+
return {"provider": "gbmi", "endpoint": match.group(1),
|
|
173
|
+
"stratum": match.group(2)}
|
|
174
|
+
match = EQTL_REF.match(text)
|
|
175
|
+
if match:
|
|
176
|
+
return {"provider": "eqtl", "study": match.group(1),
|
|
177
|
+
"dataset": match.group(2), "trait": match.group(3)}
|
|
178
|
+
match = PGS_REF.match(text)
|
|
179
|
+
if match:
|
|
180
|
+
return {"provider": "pgs", "pgs_id": text}
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def finngen_url(release, endpoint):
|
|
185
|
+
"""Download URL for one FinnGen release endpoint (bucket lowercased)."""
|
|
186
|
+
return (f"{FINNGEN_HOST}/"
|
|
187
|
+
f"{FINNGEN_BUCKET.format(release=release.lower())}/"
|
|
188
|
+
f"{FINNGEN_PATH.format(release=release, endpoint=endpoint)}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def covidhgi_url(analysis):
|
|
192
|
+
"""Download URL for one COVID-19 HGI release-7 analysis (GRCh37)."""
|
|
193
|
+
return f"{COVIDHGI_HOST}/{COVIDHGI_PATH.format(analysis=analysis)}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def gbmi_url(endpoint, stratum):
|
|
197
|
+
"""Download URL for one GBMI endpoint in one ancestry stratum.
|
|
198
|
+
|
|
199
|
+
Both components are looked up rather than interpolated: the endpoint is
|
|
200
|
+
mixed case in the path and the stratum carries its own trailing
|
|
201
|
+
underscore (empty for the multi-ancestry meta).
|
|
202
|
+
"""
|
|
203
|
+
return (f"{GBMI_HOST}/{GBMI_ENDPOINTS[endpoint]}_Bothsex_"
|
|
204
|
+
f"{GBMI_STRATA[stratum]}inv_var_meta_GBMI_{GBMI_RELEASE}"
|
|
205
|
+
"_nbbkgt1.txt.gz")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def eqtl_url(study, dataset):
|
|
209
|
+
"""Download URL for one eQTL Catalogue dataset's bulk file (GRCh38)."""
|
|
210
|
+
return f"{EQTL_HOST}/{EQTL_PATH.format(study=study, dataset=dataset)}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _harmonised_index(opener):
|
|
214
|
+
"""``{accession: ftp path}`` from the Catalog's harmonised index."""
|
|
215
|
+
try:
|
|
216
|
+
text = _fetch_bytes(HARMONISED_LIST, "the harmonised index", opener,
|
|
217
|
+
STREAM_TIMEOUT).decode("utf-8", "replace")
|
|
218
|
+
except ValueError as exc:
|
|
219
|
+
raise ValueError(f"GCST: catalog index lookup failed ({exc})") from exc
|
|
220
|
+
out = {}
|
|
221
|
+
for line in text.splitlines():
|
|
222
|
+
found = re.search(r"/(GCST\d+)/harmonised/(\S+\.h\.tsv\.gz)",
|
|
223
|
+
line.strip())
|
|
224
|
+
if found:
|
|
225
|
+
out[found.group(1)] = line.strip().lstrip("./")
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _gwas_study_metadata(accession, opener):
|
|
230
|
+
"""Trait, sample and publication fields from the Catalog's study API."""
|
|
231
|
+
payload = _fetch_json(GWAS_STUDY_REST.format(accession=accession),
|
|
232
|
+
accession, opener)
|
|
233
|
+
if (not isinstance(payload, dict)
|
|
234
|
+
or payload.get("accession_id") != accession):
|
|
235
|
+
raise ValueError(f"{accession}: invalid study metadata from the "
|
|
236
|
+
"GWAS Catalog")
|
|
237
|
+
sample = payload.get("initial_sample_size") or ""
|
|
238
|
+
trait = payload.get("disease_trait") or accession
|
|
239
|
+
if not isinstance(sample, str) or not isinstance(trait, str):
|
|
240
|
+
raise ValueError(f"{accession}: invalid study metadata from the "
|
|
241
|
+
"GWAS Catalog")
|
|
242
|
+
meta = {"accession": accession, "trait": trait.strip(),
|
|
243
|
+
"sample": sample.strip(),
|
|
244
|
+
"pmid": str(payload.get("pubmed_id") or "")}
|
|
245
|
+
# The publication title is decoration; an outage there must not block a
|
|
246
|
+
# download whose study metadata is already in hand.
|
|
247
|
+
if meta["pmid"]:
|
|
248
|
+
try:
|
|
249
|
+
pub = _fetch_json(
|
|
250
|
+
GWAS_PUBLICATION_REST.format(pmid=meta["pmid"]),
|
|
251
|
+
accession, opener)
|
|
252
|
+
if isinstance(pub, dict) and isinstance(pub.get("title"), str):
|
|
253
|
+
meta["title"] = pub["title"].strip()
|
|
254
|
+
except ValueError:
|
|
255
|
+
pass
|
|
256
|
+
return meta
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _resolve_gwas(accession, opener):
|
|
260
|
+
index = _harmonised_index(opener)
|
|
261
|
+
relative = index.get(accession)
|
|
262
|
+
if relative is None:
|
|
263
|
+
raise ValueError(f"{accession}: no harmonised summary-statistics "
|
|
264
|
+
"file in the GWAS Catalog")
|
|
265
|
+
url = f"{SUMSTATS_FTP}/{relative}"
|
|
266
|
+
plan = {"ref": accession, "provider": "gwas", "label": accession,
|
|
267
|
+
"url": url, "genome_build": None, "trait": accession,
|
|
268
|
+
"note": "harmonised set; the declared build is in the file "
|
|
269
|
+
"header"}
|
|
270
|
+
plan.update(_head(url, accession, opener))
|
|
271
|
+
# The study record decorates the sidecar; the indexed file is the
|
|
272
|
+
# ground truth for whether the download exists.
|
|
273
|
+
try:
|
|
274
|
+
plan.update(_gwas_study_metadata(accession, opener))
|
|
275
|
+
if plan.get("trait") and plan["trait"] != accession:
|
|
276
|
+
plan["label"] = f"{accession} ({plan['trait']})"
|
|
277
|
+
except ValueError:
|
|
278
|
+
pass
|
|
279
|
+
return plan
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resolve_pgs(pgs_id, build, opener):
|
|
283
|
+
payload = _fetch_json(PGS_SCORE_REST.format(pgs_id=pgs_id), pgs_id, opener)
|
|
284
|
+
if not isinstance(payload, dict):
|
|
285
|
+
raise ValueError(f"{pgs_id}: invalid score metadata from the "
|
|
286
|
+
"PGS Catalog")
|
|
287
|
+
plain = payload.get("ftp_scoring_file")
|
|
288
|
+
urls = {}
|
|
289
|
+
for name, entry in (payload.get("ftp_harmonized_scoring_files")
|
|
290
|
+
or {}).items():
|
|
291
|
+
if isinstance(entry, dict) and isinstance(entry.get("positions"), str):
|
|
292
|
+
urls[name] = entry["positions"]
|
|
293
|
+
choice = build or "GRCh37"
|
|
294
|
+
order = [choice, "GRCh37" if choice != "GRCh37" else "GRCh38"]
|
|
295
|
+
genome_build = next((b for b in order if urls.get(b)), None)
|
|
296
|
+
url = urls[genome_build] if genome_build else plain
|
|
297
|
+
if not isinstance(url, str) or not url:
|
|
298
|
+
raise ValueError(f"{pgs_id}: no scoring file published")
|
|
299
|
+
plan = {"ref": pgs_id, "provider": "pgs", "label": pgs_id, "url": url,
|
|
300
|
+
"urls": urls or None, "genome_build": genome_build,
|
|
301
|
+
"trait": payload.get("trait_reported") or "",
|
|
302
|
+
"note": ("harmonised scoring file" if genome_build else
|
|
303
|
+
"submitted scoring file (no harmonised positions)")}
|
|
304
|
+
if plan["trait"]:
|
|
305
|
+
plan["label"] = f"{pgs_id} ({plan['trait']})"
|
|
306
|
+
plan.update(_head(url, pgs_id, opener))
|
|
307
|
+
return plan
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def resolve_ref(ref, *, build=None, opener=None):
|
|
311
|
+
"""Everything a download needs about one source reference.
|
|
312
|
+
|
|
313
|
+
Returns a plan dict with ``ref, provider, label, url, genome_build,
|
|
314
|
+
trait, remote_bytes`` and provider-specific extras. ``build`` only
|
|
315
|
+
selects among a PGS Catalog score's harmonised builds. Raises
|
|
316
|
+
``ValueError`` with a user-readable message for a malformed reference,
|
|
317
|
+
an unknown study, or a dead URL (checked with a HEAD request, so a
|
|
318
|
+
download announced by ``--dry-run`` does not fail half way through).
|
|
319
|
+
"""
|
|
320
|
+
if opener is None:
|
|
321
|
+
opener = _open
|
|
322
|
+
parsed = parse_ref(ref)
|
|
323
|
+
if parsed is None:
|
|
324
|
+
raise ValueError(f"{(ref or '').strip()!r}: expected a reference like "
|
|
325
|
+
"GCST91000728, FINNGEN:R11:I9_AF, COVIDHGI:R7:C2, "
|
|
326
|
+
"GBMI:ASTHMA:EUR, "
|
|
327
|
+
"EQTL:QTS000001:QTD000001:ENSG00000008128 or "
|
|
328
|
+
"PGS000337")
|
|
329
|
+
provider = parsed["provider"]
|
|
330
|
+
if provider == "gwas":
|
|
331
|
+
return _resolve_gwas(parsed["accession"], opener)
|
|
332
|
+
if provider == "pgs":
|
|
333
|
+
return _resolve_pgs(parsed["pgs_id"], build, opener)
|
|
334
|
+
if provider == "finngen":
|
|
335
|
+
label = f"FinnGen {parsed['release']} {parsed['endpoint']}"
|
|
336
|
+
url = finngen_url(parsed["release"], parsed["endpoint"])
|
|
337
|
+
plan = {"ref": ref.strip().upper(), "provider": "finngen",
|
|
338
|
+
"label": label, "url": url, "genome_build": "GRCh38",
|
|
339
|
+
"trait": label,
|
|
340
|
+
"note": "beta is per ALT; af_alt is the effect-allele "
|
|
341
|
+
"frequency; rsids may be comma-joined"}
|
|
342
|
+
elif provider == "covidhgi":
|
|
343
|
+
label = f"COVID-HGI R7 {parsed['analysis']}"
|
|
344
|
+
url = covidhgi_url(parsed["analysis"])
|
|
345
|
+
plan = {"ref": ref.strip().upper(), "provider": "covidhgi",
|
|
346
|
+
"label": label, "url": url, "genome_build": "GRCh37",
|
|
347
|
+
"trait": label,
|
|
348
|
+
"note": "all_inv_var_meta_effective is the per-variant "
|
|
349
|
+
"effective N; the ALL-population file"}
|
|
350
|
+
elif provider == "gbmi":
|
|
351
|
+
label = f"GBMI {parsed['endpoint']} {parsed['stratum']} " \
|
|
352
|
+
f"({GBMI_RELEASE})"
|
|
353
|
+
url = gbmi_url(parsed["endpoint"], parsed["stratum"])
|
|
354
|
+
plan = {"ref": ref.strip().upper(), "provider": "gbmi",
|
|
355
|
+
"label": label, "url": url, "genome_build": "GRCh38",
|
|
356
|
+
"trait": label,
|
|
357
|
+
"note": "beta is per ALT; per-variant N_case and N_ctrl "
|
|
358
|
+
"columns, no single N"}
|
|
359
|
+
else:
|
|
360
|
+
label = f"eQTL Catalogue {parsed['dataset']} {parsed['trait']}"
|
|
361
|
+
url = eqtl_url(parsed["study"], parsed["dataset"])
|
|
362
|
+
plan = {"ref": ref.strip().upper(), "provider": "eqtl",
|
|
363
|
+
"label": label, "url": url, "genome_build": "GRCh38",
|
|
364
|
+
"trait": label,
|
|
365
|
+
"row_filter": (EQTL_ROW_FILTER_COLUMN, parsed["trait"]),
|
|
366
|
+
"note": "rows filtered to the molecular trait at download; "
|
|
367
|
+
"no sample-size column (supply N)"}
|
|
368
|
+
plan.update(_head(url, label, opener))
|
|
369
|
+
return plan
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@contextmanager
|
|
373
|
+
def _atomic_binary(path):
|
|
374
|
+
"""Stream bytes so only a complete file is ever published at ``path``."""
|
|
375
|
+
path = Path(path)
|
|
376
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
377
|
+
try:
|
|
378
|
+
with os.fdopen(fd, "wb") as fh:
|
|
379
|
+
yield fh
|
|
380
|
+
fh.flush()
|
|
381
|
+
os.fsync(fh.fileno())
|
|
382
|
+
os.replace(temporary, path)
|
|
383
|
+
finally:
|
|
384
|
+
if os.path.exists(temporary):
|
|
385
|
+
os.unlink(temporary)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _text_lines(response, url):
|
|
389
|
+
"""Iterate decoded lines of a plain or gzip remote file."""
|
|
390
|
+
source = gzip.GzipFile(fileobj=response) if url.endswith(".gz") \
|
|
391
|
+
else response
|
|
392
|
+
for raw in source:
|
|
393
|
+
yield raw.decode("utf-8", "replace").rstrip("\r\n")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _sha256_of(path):
|
|
397
|
+
digest = hashlib.sha256()
|
|
398
|
+
with open(path, "rb") as fh:
|
|
399
|
+
while chunk := fh.read(CHUNK):
|
|
400
|
+
digest.update(chunk)
|
|
401
|
+
return digest.hexdigest()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def download_ref(ref, dest, *, build=None, keep_ids=None, meta=True,
|
|
405
|
+
opener=None):
|
|
406
|
+
"""Download one source reference into ``dest`` (atomic, streamed).
|
|
407
|
+
|
|
408
|
+
``keep_ids`` restricts the output to rows whose id column matches (a
|
|
409
|
+
joined multi-rsID cell matches when any part does); the eQTL
|
|
410
|
+
Catalogue's trait filter always applies. Filtering needs a text pass,
|
|
411
|
+
so a gzip remote is decompressed and the output follows ``dest``'s own
|
|
412
|
+
suffix. With ``meta`` (the default) a provenance sidecar
|
|
413
|
+
``<dest>.meta.json`` records the URL, provider, trait, SHA-256 and
|
|
414
|
+
filter counts. Returns the metadata dict.
|
|
415
|
+
"""
|
|
416
|
+
if opener is None:
|
|
417
|
+
opener = _open
|
|
418
|
+
plan = resolve_ref(ref, build=build, opener=opener)
|
|
419
|
+
dest = Path(dest)
|
|
420
|
+
url = plan["url"]
|
|
421
|
+
label = plan["label"]
|
|
422
|
+
filtering = keep_ids is not None or plan.get("row_filter") is not None
|
|
423
|
+
counts = {}
|
|
424
|
+
try:
|
|
425
|
+
response = opener(url, timeout=STREAM_TIMEOUT)
|
|
426
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
|
|
427
|
+
raise ValueError(_request_failed(exc, label)) from exc
|
|
428
|
+
try:
|
|
429
|
+
with response, _atomic_binary(dest) as out:
|
|
430
|
+
if not filtering:
|
|
431
|
+
while chunk := response.read(CHUNK):
|
|
432
|
+
out.write(chunk)
|
|
433
|
+
else:
|
|
434
|
+
lines = _text_lines(response, url)
|
|
435
|
+
try:
|
|
436
|
+
header = next(lines)
|
|
437
|
+
except StopIteration:
|
|
438
|
+
raise ValueError(f"{label}: the remote file is "
|
|
439
|
+
"empty") from None
|
|
440
|
+
writer = None
|
|
441
|
+
if dest.name.endswith(".gz"):
|
|
442
|
+
writer = gzip.GzipFile(filename="", mode="wb", fileobj=out,
|
|
443
|
+
compresslevel=1, mtime=0)
|
|
444
|
+
try:
|
|
445
|
+
counts = _write_filtered(lines, header, writer or out,
|
|
446
|
+
keep_ids, plan.get("row_filter"))
|
|
447
|
+
finally:
|
|
448
|
+
if writer is not None:
|
|
449
|
+
writer.close()
|
|
450
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError,
|
|
451
|
+
EOFError) as exc:
|
|
452
|
+
raise ValueError(f"{label}: download failed ({exc})") from exc
|
|
453
|
+
record = {"allelign": __version__, "ref": plan["ref"],
|
|
454
|
+
"provider": plan["provider"], "label": label, "url": url,
|
|
455
|
+
"genome_build": plan.get("genome_build"),
|
|
456
|
+
"trait": plan.get("trait") or None,
|
|
457
|
+
"remote_bytes": plan.get("remote_bytes"),
|
|
458
|
+
"sha256": _sha256_of(dest),
|
|
459
|
+
"n_bytes_written": dest.stat().st_size,
|
|
460
|
+
"fetched_at": datetime.now(timezone.utc).isoformat()}
|
|
461
|
+
record.update(counts)
|
|
462
|
+
if keep_ids is not None:
|
|
463
|
+
record["keep_ids"] = {"n_ids": len(keep_ids)}
|
|
464
|
+
if meta:
|
|
465
|
+
sidecar = dest.with_name(dest.name + ".meta.json")
|
|
466
|
+
with _atomic_binary(sidecar) as fh:
|
|
467
|
+
payload = json.dumps(record, indent=2, sort_keys=True,
|
|
468
|
+
default=str)
|
|
469
|
+
fh.write(payload.encode("utf-8"))
|
|
470
|
+
fh.write(b"\n")
|
|
471
|
+
return record
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _write_filtered(lines, header, out, keep_ids, row_filter):
|
|
475
|
+
"""Write ``header`` plus the rows passing both filters; return counts."""
|
|
476
|
+
columns = [c.strip().lower() for c in header.split("\t")]
|
|
477
|
+
id_index = next((i for i, c in enumerate(columns)
|
|
478
|
+
if c in _ID_COLUMNS), None)
|
|
479
|
+
if keep_ids is not None and id_index is None:
|
|
480
|
+
raise ValueError("keep-ids: the remote file has no id column "
|
|
481
|
+
f"among {columns}")
|
|
482
|
+
filter_index = filter_value = None
|
|
483
|
+
if row_filter is not None:
|
|
484
|
+
name, value = row_filter
|
|
485
|
+
try:
|
|
486
|
+
filter_index = columns.index(name)
|
|
487
|
+
except ValueError:
|
|
488
|
+
raise ValueError(f"no {name} column in the remote file; cannot "
|
|
489
|
+
"select the molecular trait") from None
|
|
490
|
+
filter_value = value.lower()
|
|
491
|
+
out.write(header.encode("utf-8"))
|
|
492
|
+
out.write(b"\n")
|
|
493
|
+
n_input = n_kept = 0
|
|
494
|
+
for line in lines:
|
|
495
|
+
if not line:
|
|
496
|
+
continue
|
|
497
|
+
n_input += 1
|
|
498
|
+
cells = line.split("\t")
|
|
499
|
+
if keep_ids is not None:
|
|
500
|
+
parts = cells[id_index].split(ID_SEPARATOR)
|
|
501
|
+
if not any(part.strip() in keep_ids for part in parts):
|
|
502
|
+
continue
|
|
503
|
+
if filter_index is not None and \
|
|
504
|
+
cells[filter_index].strip().lower() != filter_value:
|
|
505
|
+
continue
|
|
506
|
+
n_kept += 1
|
|
507
|
+
out.write(line.encode("utf-8"))
|
|
508
|
+
out.write(b"\n")
|
|
509
|
+
return {"n_input": n_input, "n_kept": n_kept,
|
|
510
|
+
"n_dropped": n_input - n_kept}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def read_keep_ids(path):
|
|
514
|
+
"""A keep set from a one-id-per-line file, a ``.bim`` or a ``.pvar``."""
|
|
515
|
+
from .variants import read_variants
|
|
516
|
+
path = str(path)
|
|
517
|
+
if path.endswith((".bim", ".pvar")):
|
|
518
|
+
return {str(i) for i in read_variants(path).id}
|
|
519
|
+
out = set()
|
|
520
|
+
with open(path, encoding="utf-8") as fh:
|
|
521
|
+
for line in fh:
|
|
522
|
+
token = line.split("#", 1)[0].strip()
|
|
523
|
+
if token:
|
|
524
|
+
out.add(token)
|
|
525
|
+
return out
|