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/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