nileshhanotia commited on
Commit
9d2f5a3
·
verified ·
1 Parent(s): 9b953c3

Upload vep_handler.py

Browse files
Files changed (1) hide show
  1. vep_handler.py +417 -0
vep_handler.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ vep_handler.py — PeVe v1.4
3
+ ===========================
4
+ Fixes:
5
+ 1. Genome build normalisation (GRCh38 enforced, "chr" prefix stripped)
6
+ 2. Correct Ensembl REST endpoint and headers
7
+ 3. Full HTTP debug logging to Space stdout
8
+ 4. Retry with back-off
9
+ 5. annotation_available flag surfaced in every return value
10
+ 6. AF lookup independent of VEP success
11
+ 7. Test block: python vep_handler.py
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ import traceback
18
+ import urllib.error
19
+ import urllib.request
20
+ from dataclasses import dataclass, field
21
+ from typing import Optional
22
+
23
+ # ── Constants ─────────────────────────────────────────────────────────────────
24
+ _ENSEMBL_REST = "https://rest.ensembl.org"
25
+ _GNOMAD_API = "https://gnomad.broadinstitute.org/api"
26
+ _GENOME_BUILD = "GRCh38" # enforced throughout
27
+ _REQUEST_TIMEOUT = 20 # seconds
28
+ _MAX_RETRIES = 3
29
+ _RETRY_DELAY = 2 # seconds, doubled each retry
30
+
31
+ # ── Return types ──────────────────────────────────────────────────────────────
32
+
33
+ @dataclass
34
+ class VEPResult:
35
+ consequence: str = "unknown"
36
+ impact: str = "MODIFIER"
37
+ gene: str = ""
38
+ transcript: str = ""
39
+ all_consequences: list = field(default_factory=lambda: ["unknown"])
40
+ annotation_available: bool = False
41
+ error_message: Optional[str] = None
42
+ raw_response: Optional[dict] = None
43
+
44
+
45
+ @dataclass
46
+ class AFResult:
47
+ state: str = "AF_UNKNOWN"
48
+ global_af: Optional[float] = None
49
+ population_afs: dict = field(default_factory=dict)
50
+ is_rare: Optional[bool] = None
51
+ founder_variant_flag: bool = False
52
+ annotation_available: bool = False
53
+ error_message: Optional[str] = None
54
+
55
+
56
+ # ══════════════════════════════════════════════════════════════════════════════
57
+ # Helpers
58
+ # ══════════════════════════════════════════════════════════════════════════════
59
+
60
+ def _normalise_chrom(chrom: str) -> str:
61
+ """Strip 'chr' prefix, uppercase. '17' and 'chr17' → '17'."""
62
+ return str(chrom).strip().upper().lstrip("CHR")
63
+
64
+
65
+ def _http_get(url: str, label: str) -> Optional[dict | str]:
66
+ """
67
+ GET with retry + back-off.
68
+ Returns parsed JSON dict, raw string, or None on failure.
69
+ Logs full request/response to stdout (visible in HF Space logs).
70
+ """
71
+ headers = {
72
+ "Content-Type": "application/json",
73
+ "Accept": "application/json",
74
+ }
75
+ delay = _RETRY_DELAY
76
+ last_err = ""
77
+
78
+ for attempt in range(1, _MAX_RETRIES + 1):
79
+ print(f"[VEP] {label} → GET {url} (attempt {attempt}/{_MAX_RETRIES})")
80
+ try:
81
+ req = urllib.request.Request(url, headers=headers)
82
+ with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
83
+ status = resp.status
84
+ body = resp.read().decode("utf-8")
85
+ print(f"[VEP] {label} ← HTTP {status} ({len(body)} bytes)")
86
+
87
+ # Try JSON parse; fall back to raw string
88
+ try:
89
+ return json.loads(body)
90
+ except json.JSONDecodeError:
91
+ return body
92
+
93
+ except urllib.error.HTTPError as e:
94
+ last_err = f"HTTP {e.code}: {e.reason}"
95
+ body_preview = ""
96
+ try:
97
+ body_preview = e.read().decode()[:300]
98
+ except Exception:
99
+ pass
100
+ print(f"[VEP] {label} attempt {attempt} HTTPError: {last_err}")
101
+ print(f"[VEP] body: {body_preview}")
102
+
103
+ except urllib.error.URLError as e:
104
+ last_err = f"URLError: {e.reason}"
105
+ print(f"[VEP] {label} attempt {attempt} URLError: {last_err}")
106
+
107
+ except Exception:
108
+ last_err = traceback.format_exc()
109
+ print(f"[VEP] {label} attempt {attempt} Exception:\n{last_err}")
110
+
111
+ if attempt < _MAX_RETRIES:
112
+ print(f"[VEP] {label} retrying in {delay}s …")
113
+ time.sleep(delay)
114
+ delay *= 2
115
+
116
+ print(f"[VEP] {label} FAILED after {_MAX_RETRIES} attempts: {last_err}")
117
+ return None
118
+
119
+
120
+ # ══════════════════════════════════════════════════════════════════════════════
121
+ # VEP Annotation
122
+ # ══════════════════════════════════════════════════════════════════════════════
123
+
124
+ def fetch_vep(chrom: str, pos: int, ref: str, alt: str) -> VEPResult:
125
+ """
126
+ Query Ensembl REST VEP (GRCh38).
127
+ Endpoint: /vep/human/region/{chrom}:{pos}-{pos}/{alt}
128
+
129
+ Debug info printed to stdout on every call.
130
+ """
131
+ chrom_norm = _normalise_chrom(chrom)
132
+
133
+ # Ensembl VEP REST requires no "chr" prefix for human GRCh38
134
+ url = (
135
+ f"{_ENSEMBL_REST}/vep/human/region/"
136
+ f"{chrom_norm}:{pos}-{pos}/{alt}"
137
+ f"?content-type=application/json"
138
+ f"&canonical=1"
139
+ f"&pick=1"
140
+ f"&hgvs=1"
141
+ f"&LoF=1"
142
+ )
143
+
144
+ print(f"[VEP] Querying VEP | build={_GENOME_BUILD} | "
145
+ f"coord={chrom_norm}:{pos} {ref}>{alt}")
146
+ print(f"[VEP] URL: {url}")
147
+
148
+ data = _http_get(url, f"VEP {chrom_norm}:{pos}")
149
+
150
+ if data is None:
151
+ return VEPResult(
152
+ annotation_available=False,
153
+ error_message="HTTP request failed — see logs above",
154
+ )
155
+
156
+ if not isinstance(data, list) or len(data) == 0:
157
+ msg = f"Unexpected VEP response type={type(data).__name__}: {str(data)[:200]}"
158
+ print(f"[VEP] ✗ {msg}")
159
+ return VEPResult(annotation_available=False, error_message=msg)
160
+
161
+ entry = data[0]
162
+
163
+ # Check for Ensembl error object
164
+ if "error" in entry:
165
+ msg = f"Ensembl VEP error: {entry['error']}"
166
+ print(f"[VEP] ✗ {msg}")
167
+ return VEPResult(annotation_available=False, error_message=msg)
168
+
169
+ tcs = entry.get("transcript_consequences") or []
170
+ if not tcs:
171
+ # Try intergenic
172
+ ics = entry.get("intergenic_consequences") or [{}]
173
+ tc = ics[0]
174
+ print(f"[VEP] ⚠ No transcript consequences — variant may be intergenic")
175
+ else:
176
+ tc = tcs[0]
177
+
178
+ result = VEPResult(
179
+ consequence = tc.get("consequence_terms", ["unknown"])[0],
180
+ impact = tc.get("impact", "MODIFIER"),
181
+ gene = tc.get("gene_symbol", ""),
182
+ transcript = tc.get("transcript_id", ""),
183
+ all_consequences = [
184
+ t.get("consequence_terms", ["unknown"])[0] for t in tcs
185
+ ] or ["unknown"],
186
+ annotation_available = True,
187
+ raw_response = entry,
188
+ )
189
+
190
+ print(f"[VEP] ✓ gene={result.gene} consequence={result.consequence} "
191
+ f"impact={result.impact} tx={result.transcript}")
192
+ return result
193
+
194
+
195
+ # ══════════════════════════════════════════════════════════════════════════════
196
+ # Allele Frequency (gnomAD v4 GraphQL)
197
+ # ══════════════════════════════════════════════════════════════════════════════
198
+
199
+ _GNOMAD_QUERY = """
200
+ query VariantAF($variantId: String!, $dataset: DatasetId!) {
201
+ variant(variantId: $variantId, dataset: $dataset) {
202
+ variant_id
203
+ genome {
204
+ af
205
+ populations {
206
+ id
207
+ af
208
+ }
209
+ }
210
+ }
211
+ }
212
+ """
213
+
214
+ _POP_FOUNDER = {"asj", "fin"} # populations with founder-effect risk
215
+ _RARE_THRESHOLD = 0.001 # AF < 0.1%
216
+
217
+
218
+ def fetch_af(
219
+ chrom: str,
220
+ pos: int,
221
+ ref: str,
222
+ alt: str,
223
+ ancestry: Optional[str] = None,
224
+ ) -> AFResult:
225
+ """
226
+ Query gnomAD v4 for global + population AF.
227
+ variant_id format: 17-43092176-G-T (no 'chr' prefix)
228
+ """
229
+ chrom_norm = _normalise_chrom(chrom)
230
+ variant_id = f"{chrom_norm}-{pos}-{ref}-{alt}"
231
+ dataset = "gnomad_r4"
232
+
233
+ print(f"[AF] Querying gnomAD | variant_id={variant_id}")
234
+
235
+ query_body = json.dumps({
236
+ "query": _GNOMAD_QUERY,
237
+ "variables": {"variantId": variant_id, "dataset": dataset},
238
+ }).encode("utf-8")
239
+
240
+ try:
241
+ req = urllib.request.Request(
242
+ _GNOMAD_API,
243
+ data=query_body,
244
+ headers={
245
+ "Content-Type": "application/json",
246
+ "Accept": "application/json",
247
+ },
248
+ method="POST",
249
+ )
250
+ with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
251
+ status = resp.status
252
+ body = resp.read().decode("utf-8")
253
+ print(f"[AF] gnomAD ← HTTP {status} ({len(body)} bytes)")
254
+ data = json.loads(body)
255
+
256
+ except Exception:
257
+ tb = traceback.format_exc()
258
+ print(f"[AF] gnomAD request failed:\n{tb}")
259
+ return AFResult(
260
+ state="AF_UNKNOWN",
261
+ annotation_available=False,
262
+ error_message=tb,
263
+ )
264
+
265
+ variant_data = (
266
+ (data.get("data") or {}).get("variant") or {}
267
+ )
268
+ genome_data = variant_data.get("genome") or {}
269
+
270
+ if not genome_data:
271
+ print(f"[AF] ⚠ No genome AF data for {variant_id} in {dataset}")
272
+ # Try v2 fallback
273
+ return _fetch_af_gnomad_v2(chrom_norm, pos, ref, alt, ancestry)
274
+
275
+ global_af = genome_data.get("af")
276
+ pops_raw = genome_data.get("populations") or []
277
+ pop_afs = {p["id"]: p["af"] for p in pops_raw if p.get("af") is not None}
278
+
279
+ # Ancestry-specific AF
280
+ anc_af = None
281
+ if ancestry:
282
+ anc_key = ancestry.lower()
283
+ for k, v in pop_afs.items():
284
+ if anc_key in k.lower():
285
+ anc_af = v
286
+ break
287
+
288
+ effective_af = anc_af if anc_af is not None else global_af
289
+
290
+ if effective_af is None:
291
+ print(f"[AF] ⚠ AF is null for {variant_id}")
292
+ return AFResult(
293
+ state="AF_UNKNOWN",
294
+ population_afs=pop_afs,
295
+ annotation_available=True,
296
+ error_message="AF field is null — variant may be absent from gnomAD",
297
+ )
298
+
299
+ is_rare = effective_af < _RARE_THRESHOLD
300
+ founder_flag = any(
301
+ pop_afs.get(p, 0) > effective_af * 5
302
+ for p in _POP_FOUNDER
303
+ if p in pop_afs
304
+ )
305
+
306
+ state = "AF_RARE" if is_rare else "AF_COMMON"
307
+ print(f"[AF] ✓ global_af={global_af:.6f} "
308
+ f"effective={effective_af:.6f} "
309
+ f"rare={is_rare} founder={founder_flag}")
310
+
311
+ return AFResult(
312
+ state = state,
313
+ global_af = float(global_af),
314
+ population_afs = pop_afs,
315
+ is_rare = is_rare,
316
+ founder_variant_flag = founder_flag,
317
+ annotation_available = True,
318
+ )
319
+
320
+
321
+ def _fetch_af_gnomad_v2(
322
+ chrom: str, pos: int, ref: str, alt: str,
323
+ ancestry: Optional[str]
324
+ ) -> AFResult:
325
+ """Fallback to gnomAD v2.1.1 (GRCh37 liftover via 38 API)."""
326
+ variant_id = f"{chrom}-{pos}-{ref}-{alt}"
327
+ dataset = "gnomad_r2_1"
328
+ print(f"[AF] Trying gnomAD v2 fallback | variant_id={variant_id}")
329
+
330
+ query_body = json.dumps({
331
+ "query": _GNOMAD_QUERY,
332
+ "variables": {"variantId": variant_id, "dataset": dataset},
333
+ }).encode("utf-8")
334
+
335
+ try:
336
+ req = urllib.request.Request(
337
+ _GNOMAD_API,
338
+ data=query_body,
339
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
340
+ method="POST",
341
+ )
342
+ with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
343
+ data = json.loads(resp.read().decode("utf-8"))
344
+
345
+ genome_data = ((data.get("data") or {}).get("variant") or {}).get("genome") or {}
346
+ global_af = genome_data.get("af")
347
+
348
+ if global_af is not None:
349
+ is_rare = float(global_af) < _RARE_THRESHOLD
350
+ print(f"[AF] ✓ gnomAD v2 fallback: global_af={global_af:.6f}")
351
+ return AFResult(
352
+ state = "AF_RARE" if is_rare else "AF_COMMON",
353
+ global_af = float(global_af),
354
+ is_rare = is_rare,
355
+ annotation_available=True,
356
+ )
357
+ except Exception:
358
+ print(f"[AF] gnomAD v2 fallback failed:\n{traceback.format_exc()}")
359
+
360
+ return AFResult(
361
+ state="AF_UNKNOWN",
362
+ annotation_available=False,
363
+ error_message="Both gnomAD v4 and v2 lookups failed",
364
+ )
365
+
366
+
367
+ # ── Compat shims for existing app.py / decision_engine calls ──────────────────
368
+
369
+ def format_af_display(af_result: AFResult) -> str:
370
+ if af_result.global_af is None:
371
+ return "Not found in gnomAD"
372
+ return f"{af_result.global_af:.6f}"
373
+
374
+
375
+ # ══════════════════════════════════════════════════════════════════════════════
376
+ # Test block
377
+ # ══════════════════════════════════════════════════════════════════════════════
378
+
379
+ def _test():
380
+ print("=" * 60)
381
+ print("TEST: chr17:43092176 G>T (BRCA1 known pathogenic)")
382
+ print("=" * 60)
383
+
384
+ vep = fetch_vep("17", 43092176, "G", "T")
385
+ print(f"\nVEP result:")
386
+ print(f" annotation_available : {vep.annotation_available}")
387
+ print(f" gene : {vep.gene}")
388
+ print(f" consequence : {vep.consequence}")
389
+ print(f" impact : {vep.impact}")
390
+ print(f" transcript : {vep.transcript}")
391
+ print(f" error_message : {vep.error_message}")
392
+
393
+ print()
394
+
395
+ af = fetch_af("17", 43092176, "G", "T", ancestry="nfe")
396
+ print(f"AF result:")
397
+ print(f" annotation_available : {af.annotation_available}")
398
+ print(f" state : {af.state}")
399
+ print(f" global_af : {af.global_af}")
400
+ print(f" is_rare : {af.is_rare}")
401
+ print(f" founder_flag : {af.founder_variant_flag}")
402
+ print(f" error_message : {af.error_message}")
403
+
404
+ print()
405
+ print("EXPECTED:")
406
+ print(" gene = BRCA1")
407
+ print(" consequence = missense_variant (or similar)")
408
+ print(" global_af = very small float (rare)")
409
+
410
+ assert vep.annotation_available, "VEP annotation_available should be True"
411
+ assert vep.gene == "BRCA1", f"Expected BRCA1, got '{vep.gene}'"
412
+ assert vep.consequence != "unknown", "consequence should not be 'unknown'"
413
+ print("\n✓ All assertions passed")
414
+
415
+
416
+ if __name__ == "__main__":
417
+ _test()