vcfart 1.0.1__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.
- vcfart/__init__.py +6 -0
- vcfart/__main__.py +4 -0
- vcfart/annotation.py +398 -0
- vcfart/cli.py +429 -0
- vcfart/fetch.py +55 -0
- vcfart/hgvs.py +296 -0
- vcfart/reference.py +174 -0
- vcfart/regions.py +108 -0
- vcfart/sequtils.py +104 -0
- vcfart/utils.py +67 -0
- vcfart/variants.py +187 -0
- vcfart/writers.py +98 -0
- vcfart-1.0.1.dist-info/METADATA +235 -0
- vcfart-1.0.1.dist-info/RECORD +18 -0
- vcfart-1.0.1.dist-info/WHEEL +5 -0
- vcfart-1.0.1.dist-info/entry_points.txt +2 -0
- vcfart-1.0.1.dist-info/licenses/LICENSE +21 -0
- vcfart-1.0.1.dist-info/top_level.txt +1 -0
vcfart/__init__.py
ADDED
vcfart/__main__.py
ADDED
vcfart/annotation.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Transcript models and HGVS c./n. <-> genomic coordinate conversion.
|
|
2
|
+
|
|
3
|
+
Supported annotation inputs
|
|
4
|
+
---------------------------
|
|
5
|
+
* UCSC genePred tables: ``refGene.txt(.gz)``, ``ncbiRefSeq.txt(.gz)``,
|
|
6
|
+
``ncbiRefSeqSelect.txt(.gz)`` -- with or without the leading ``bin`` column.
|
|
7
|
+
ANNOVAR ``humandb/hg38_refGene.txt`` / ``hg38_refGeneWithVer.txt`` /
|
|
8
|
+
``hg19_refGene.txt`` are the same format and work as-is.
|
|
9
|
+
* GTF: UCSC ``hg38.ncbiRefSeq.gtf.gz``, GENCODE / Ensembl GTF (ENST IDs).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import gzip
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from .sequtils import revcomp
|
|
16
|
+
from .utils import warn
|
|
17
|
+
|
|
18
|
+
_ACC_RE = re.compile(r"^(?P<acc>[A-Za-z]+_?\d+)(?:\.(?P<ver>\d+))?(?:_(?P<dup>\d+))?$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def split_accession(name):
|
|
22
|
+
m = _ACC_RE.match(name)
|
|
23
|
+
if m:
|
|
24
|
+
return m.group("acc"), m.group("ver")
|
|
25
|
+
head, dot, tail = name.rpartition(".")
|
|
26
|
+
if dot and tail.isdigit():
|
|
27
|
+
return head, tail
|
|
28
|
+
return name, None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Transcript:
|
|
32
|
+
"""A spliced transcript on the genome.
|
|
33
|
+
|
|
34
|
+
Coordinates: genomic positions are 1-based inclusive. ``n`` is the
|
|
35
|
+
1-based position along the mature transcript (exons only, 5'->3').
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, name, chrom, strand, exons, cds_start=None, cds_end=None,
|
|
39
|
+
gene="", source=""):
|
|
40
|
+
if strand not in "+-" or not exons:
|
|
41
|
+
raise ValueError(f"bad transcript {name}")
|
|
42
|
+
self.name = name
|
|
43
|
+
self.gene = gene or name
|
|
44
|
+
self.chrom = chrom
|
|
45
|
+
self.strand = strand
|
|
46
|
+
self.plus = strand == "+"
|
|
47
|
+
self.source = source
|
|
48
|
+
self.exons_g = sorted(exons)
|
|
49
|
+
self.tx_start = self.exons_g[0][0]
|
|
50
|
+
self.tx_end = self.exons_g[-1][1]
|
|
51
|
+
self.exons = self.exons_g if self.plus else list(reversed(self.exons_g))
|
|
52
|
+
self.exon_n = []
|
|
53
|
+
n = 1
|
|
54
|
+
for s, e in self.exons:
|
|
55
|
+
self.exon_n.append((n, n + (e - s)))
|
|
56
|
+
n += e - s + 1
|
|
57
|
+
self.length = n - 1
|
|
58
|
+
self.coding = cds_start is not None and cds_end is not None and cds_end >= cds_start
|
|
59
|
+
self.cds_start = cds_start if self.coding else None
|
|
60
|
+
self.cds_end = cds_end if self.coding else None
|
|
61
|
+
if self.coding:
|
|
62
|
+
first = cds_start if self.plus else cds_end
|
|
63
|
+
last = cds_end if self.plus else cds_start
|
|
64
|
+
self.cds_start_n = self._exonic_n(first)
|
|
65
|
+
self.cds_end_n = self._exonic_n(last)
|
|
66
|
+
if self.cds_start_n is None or self.cds_end_n is None:
|
|
67
|
+
raise ValueError(f"{name}: CDS boundary not inside an exon")
|
|
68
|
+
self.cds_len = self.cds_end_n - self.cds_start_n + 1
|
|
69
|
+
self._tx_seq = None
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------- helpers
|
|
72
|
+
@property
|
|
73
|
+
def prefix(self):
|
|
74
|
+
return "c." if self.coding else "n."
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def n_exons(self):
|
|
78
|
+
return len(self.exons)
|
|
79
|
+
|
|
80
|
+
def _exonic_n(self, g):
|
|
81
|
+
for i, (s, e) in enumerate(self.exons):
|
|
82
|
+
if s <= g <= e:
|
|
83
|
+
n0 = self.exon_n[i][0]
|
|
84
|
+
return n0 + (g - s if self.plus else e - g)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def g_to_n(self, g):
|
|
88
|
+
"""genomic -> (n, offset). offset != 0 only for intronic positions.
|
|
89
|
+
|
|
90
|
+
Upstream of the transcript gives n <= 0, downstream gives n > length.
|
|
91
|
+
"""
|
|
92
|
+
n = self._exonic_n(g)
|
|
93
|
+
if n is not None:
|
|
94
|
+
return n, 0
|
|
95
|
+
if self.plus:
|
|
96
|
+
if g < self.tx_start:
|
|
97
|
+
return 1 - (self.tx_start - g), 0
|
|
98
|
+
if g > self.tx_end:
|
|
99
|
+
return self.length + (g - self.tx_end), 0
|
|
100
|
+
else:
|
|
101
|
+
if g > self.tx_end:
|
|
102
|
+
return 1 - (g - self.tx_end), 0
|
|
103
|
+
if g < self.tx_start:
|
|
104
|
+
return self.length + (self.tx_start - g), 0
|
|
105
|
+
for k in range(len(self.exons) - 1):
|
|
106
|
+
s1, e1 = self.exons[k]
|
|
107
|
+
s2, e2 = self.exons[k + 1]
|
|
108
|
+
if self.plus and e1 < g < s2:
|
|
109
|
+
d5, d3 = g - e1, s2 - g
|
|
110
|
+
elif not self.plus and e2 < g < s1:
|
|
111
|
+
d5, d3 = s1 - g, g - e2
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
if d5 <= d3:
|
|
115
|
+
return self.exon_n[k][1], d5
|
|
116
|
+
return self.exon_n[k + 1][0], -d3
|
|
117
|
+
raise ValueError(f"cannot place {g} on {self.name}")
|
|
118
|
+
|
|
119
|
+
def n_to_g(self, n, offset=0):
|
|
120
|
+
if 1 <= n <= self.length:
|
|
121
|
+
for i, (n0, n1) in enumerate(self.exon_n):
|
|
122
|
+
if n0 <= n <= n1:
|
|
123
|
+
s, e = self.exons[i]
|
|
124
|
+
g = s + (n - n0) if self.plus else e - (n - n0)
|
|
125
|
+
break
|
|
126
|
+
elif n < 1:
|
|
127
|
+
g = self.tx_start - (1 - n) if self.plus else self.tx_end + (1 - n)
|
|
128
|
+
else:
|
|
129
|
+
g = self.tx_end + (n - self.length) if self.plus else self.tx_start - (n - self.length)
|
|
130
|
+
return g + offset if self.plus else g - offset
|
|
131
|
+
|
|
132
|
+
def n_to_pos(self, n):
|
|
133
|
+
"""transcript position -> c./n. numbering string (no prefix, no offset)."""
|
|
134
|
+
if self.coding:
|
|
135
|
+
if n < self.cds_start_n:
|
|
136
|
+
return f"-{self.cds_start_n - n}"
|
|
137
|
+
if n <= self.cds_end_n:
|
|
138
|
+
return str(n - self.cds_start_n + 1)
|
|
139
|
+
return f"*{n - self.cds_end_n}"
|
|
140
|
+
if n < 1:
|
|
141
|
+
return f"-{1 - n}"
|
|
142
|
+
if n <= self.length:
|
|
143
|
+
return str(n)
|
|
144
|
+
return f"*{n - self.length}"
|
|
145
|
+
|
|
146
|
+
def pos(self, g):
|
|
147
|
+
"""genomic position -> HGVS position string, e.g. '5075-1', '*12', '-30+2'."""
|
|
148
|
+
n, off = self.g_to_n(g)
|
|
149
|
+
s = self.n_to_pos(n)
|
|
150
|
+
if off > 0:
|
|
151
|
+
s += f"+{off}"
|
|
152
|
+
elif off < 0:
|
|
153
|
+
s += str(off)
|
|
154
|
+
return s
|
|
155
|
+
|
|
156
|
+
_POS_RE = re.compile(r"^(?:([cngr])\.)?([-*]?)(\d+)(?:([+-])(\d+))?$")
|
|
157
|
+
|
|
158
|
+
def parse_pos(self, text):
|
|
159
|
+
"""HGVS position ('c.5075-1', '-30', '*5+1', 'n.12') -> genomic position."""
|
|
160
|
+
t = text.strip().replace(" ", "")
|
|
161
|
+
m = self._POS_RE.match(t)
|
|
162
|
+
if not m:
|
|
163
|
+
raise ValueError(f"cannot parse position '{text}'")
|
|
164
|
+
kind, sign, num, osign, onum = m.groups()
|
|
165
|
+
num = int(num)
|
|
166
|
+
if kind == "c" and not self.coding:
|
|
167
|
+
raise ValueError(f"{self.name} is non-coding; use n. positions")
|
|
168
|
+
if kind == "g":
|
|
169
|
+
raise ValueError("use g.START_END for genomic ranges")
|
|
170
|
+
if self.coding and kind != "n":
|
|
171
|
+
if sign == "" and num == 0:
|
|
172
|
+
raise ValueError("c.0 does not exist (c.-1 is followed by c.1)")
|
|
173
|
+
if sign == "-":
|
|
174
|
+
n = self.cds_start_n - num
|
|
175
|
+
elif sign == "*":
|
|
176
|
+
n = self.cds_end_n + num
|
|
177
|
+
else:
|
|
178
|
+
n = self.cds_start_n + num - 1
|
|
179
|
+
else:
|
|
180
|
+
if sign == "-":
|
|
181
|
+
n = 1 - num
|
|
182
|
+
elif sign == "*":
|
|
183
|
+
n = self.length + num
|
|
184
|
+
else:
|
|
185
|
+
n = num
|
|
186
|
+
off = 0
|
|
187
|
+
if osign:
|
|
188
|
+
off = int(onum) * (1 if osign == "+" else -1)
|
|
189
|
+
ok = any((off > 0 and n == n1) or (off < 0 and n == n0)
|
|
190
|
+
for n0, n1 in self.exon_n)
|
|
191
|
+
if not ok:
|
|
192
|
+
warn(f"'{text}': intronic offset is not anchored on an exon boundary "
|
|
193
|
+
"(non-standard HGVS) -- computed anyway")
|
|
194
|
+
return self.n_to_g(n, off)
|
|
195
|
+
|
|
196
|
+
def region(self, g):
|
|
197
|
+
"""('exon', k) / ('intron', k) / ('upstream', 0) / ('downstream', 0); k is 1-based."""
|
|
198
|
+
for i, (s, e) in enumerate(self.exons):
|
|
199
|
+
if s <= g <= e:
|
|
200
|
+
return "exon", i + 1
|
|
201
|
+
if self.plus:
|
|
202
|
+
if g < self.tx_start:
|
|
203
|
+
return "upstream", 0
|
|
204
|
+
if g > self.tx_end:
|
|
205
|
+
return "downstream", 0
|
|
206
|
+
else:
|
|
207
|
+
if g > self.tx_end:
|
|
208
|
+
return "upstream", 0
|
|
209
|
+
if g < self.tx_start:
|
|
210
|
+
return "downstream", 0
|
|
211
|
+
for k in range(len(self.exons) - 1):
|
|
212
|
+
lo = min(self.exons[k][1], self.exons[k + 1][1])
|
|
213
|
+
hi = max(self.exons[k][0], self.exons[k + 1][0])
|
|
214
|
+
if lo < g < hi:
|
|
215
|
+
return "intron", k + 1
|
|
216
|
+
return "intergenic", 0
|
|
217
|
+
|
|
218
|
+
def region_label(self, g):
|
|
219
|
+
kind, k = self.region(g)
|
|
220
|
+
return f"{kind}{k}" if k else kind
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------- sequence
|
|
223
|
+
def transcript_seq(self, ref):
|
|
224
|
+
if self._tx_seq is None:
|
|
225
|
+
parts = [ref.fetch(self.chrom, s, e) for s, e in self.exons]
|
|
226
|
+
if self.plus:
|
|
227
|
+
self._tx_seq = "".join(parts)
|
|
228
|
+
else:
|
|
229
|
+
self._tx_seq = "".join(revcomp(p) for p in parts)
|
|
230
|
+
return self._tx_seq
|
|
231
|
+
|
|
232
|
+
def cds_seq(self, ref):
|
|
233
|
+
if not self.coding:
|
|
234
|
+
return ""
|
|
235
|
+
return self.transcript_seq(ref)[self.cds_start_n - 1:self.cds_end_n]
|
|
236
|
+
|
|
237
|
+
def describe(self):
|
|
238
|
+
span = f"{self.chrom}:{self.tx_start}-{self.tx_end}"
|
|
239
|
+
kind = f"coding, CDS {self.cds_len} bp" if self.coding else "non-coding"
|
|
240
|
+
return (f"{self.name} ({self.gene}) {span} strand {self.strand}, "
|
|
241
|
+
f"{self.n_exons} exons, {self.length} nt, {kind}")
|
|
242
|
+
|
|
243
|
+
def exon_table(self):
|
|
244
|
+
rows = []
|
|
245
|
+
for i, ((s, e), (n0, n1)) in enumerate(zip(self.exons, self.exon_n)):
|
|
246
|
+
first = self.n_to_pos(n0)
|
|
247
|
+
last = self.n_to_pos(n1)
|
|
248
|
+
rows.append((i + 1, s, e, e - s + 1, f"{self.prefix}{first}", f"{self.prefix}{last}"))
|
|
249
|
+
return rows
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ====================================================================== parsing
|
|
253
|
+
def _open(path):
|
|
254
|
+
if str(path).endswith(".gz"):
|
|
255
|
+
return gzip.open(path, "rt")
|
|
256
|
+
return open(path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _name_matches(name, acc, ver):
|
|
260
|
+
a, v = split_accession(name)
|
|
261
|
+
if a != acc:
|
|
262
|
+
return False
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_genepred(path, query):
|
|
267
|
+
"""Return transcripts whose name (accession) or gene symbol matches ``query``."""
|
|
268
|
+
acc, ver = split_accession(query)
|
|
269
|
+
hits = []
|
|
270
|
+
with _open(path) as fh:
|
|
271
|
+
for line in fh:
|
|
272
|
+
if acc not in line:
|
|
273
|
+
continue
|
|
274
|
+
f = line.rstrip("\n").split("\t")
|
|
275
|
+
if len(f) < 10:
|
|
276
|
+
continue
|
|
277
|
+
o = 1 if (f[0].isdigit() and len(f) > 3 and f[3] in ("+", "-")) else 0
|
|
278
|
+
name, chrom, strand = f[o], f[o + 1], f[o + 2]
|
|
279
|
+
gene = f[o + 11] if len(f) > o + 11 else ""
|
|
280
|
+
if not (_name_matches(name, acc, ver) or gene == query):
|
|
281
|
+
continue
|
|
282
|
+
starts = [int(x) for x in f[o + 8].strip(",").split(",") if x]
|
|
283
|
+
ends = [int(x) for x in f[o + 9].strip(",").split(",") if x]
|
|
284
|
+
exons = [(s + 1, e) for s, e in zip(starts, ends)]
|
|
285
|
+
cs, ce = int(f[o + 5]), int(f[o + 6])
|
|
286
|
+
cds = (cs + 1, ce) if ce > cs else (None, None)
|
|
287
|
+
hits.append(Transcript(name, chrom, strand, exons, cds[0], cds[1], gene,
|
|
288
|
+
source=str(path)))
|
|
289
|
+
return hits
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
_ATTR_RE = re.compile(r'(\S+)\s+"([^"]*)"')
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def load_gtf(path, query):
|
|
296
|
+
acc, ver = split_accession(query)
|
|
297
|
+
recs = {}
|
|
298
|
+
with _open(path) as fh:
|
|
299
|
+
for line in fh:
|
|
300
|
+
if line.startswith("#") or acc not in line:
|
|
301
|
+
continue
|
|
302
|
+
f = line.rstrip("\n").split("\t")
|
|
303
|
+
if len(f) < 9:
|
|
304
|
+
continue
|
|
305
|
+
feat = f[2]
|
|
306
|
+
if feat not in ("exon", "CDS", "start_codon", "stop_codon"):
|
|
307
|
+
continue
|
|
308
|
+
attrs = dict(_ATTR_RE.findall(f[8]))
|
|
309
|
+
tid = attrs.get("transcript_id", "")
|
|
310
|
+
gene = attrs.get("gene_name") or attrs.get("gene_id", "")
|
|
311
|
+
if not (_name_matches(tid, acc, ver) or gene == query):
|
|
312
|
+
continue
|
|
313
|
+
key = (tid, f[0], f[6])
|
|
314
|
+
r = recs.setdefault(key, {"exons": [], "cds": [], "gene": gene})
|
|
315
|
+
s, e = int(f[3]), int(f[4])
|
|
316
|
+
if feat == "exon":
|
|
317
|
+
r["exons"].append((s, e))
|
|
318
|
+
else:
|
|
319
|
+
r["cds"].append((s, e))
|
|
320
|
+
hits = []
|
|
321
|
+
for (tid, chrom, strand), r in recs.items():
|
|
322
|
+
if not r["exons"]:
|
|
323
|
+
continue
|
|
324
|
+
cds = (min(s for s, _ in r["cds"]), max(e for _, e in r["cds"])) if r["cds"] else (None, None)
|
|
325
|
+
hits.append(Transcript(tid, chrom, strand, sorted(set(r["exons"])), cds[0], cds[1],
|
|
326
|
+
r["gene"], source=str(path)))
|
|
327
|
+
return hits
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def load_transcripts(path, query):
|
|
331
|
+
p = str(path).lower()
|
|
332
|
+
if p.endswith((".gtf", ".gtf.gz", ".gff", ".gff.gz")):
|
|
333
|
+
return load_gtf(path, query)
|
|
334
|
+
return load_genepred(path, query)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def choose_transcript(hits, query, ref=None, chrom=None):
|
|
338
|
+
"""Pick one transcript from candidate hits; returns (Transcript, notes)."""
|
|
339
|
+
acc, ver = split_accession(query)
|
|
340
|
+
notes = []
|
|
341
|
+
if not hits:
|
|
342
|
+
raise LookupError(f"'{query}' not found in the annotation file")
|
|
343
|
+
|
|
344
|
+
by_acc = [t for t in hits if split_accession(t.name)[0] == acc]
|
|
345
|
+
if not by_acc:
|
|
346
|
+
accs = sorted({split_accession(t.name)[0] for t in hits})
|
|
347
|
+
if len(accs) > 1:
|
|
348
|
+
raise LookupError(
|
|
349
|
+
f"'{query}' is a gene symbol with several transcripts; choose one with -t: "
|
|
350
|
+
+ ", ".join(sorted({t.name for t in hits})[:40]))
|
|
351
|
+
notes.append(f"gene symbol {query} -> only transcript {hits[0].name}")
|
|
352
|
+
acc, ver = accs[0], None
|
|
353
|
+
else:
|
|
354
|
+
hits = by_acc
|
|
355
|
+
|
|
356
|
+
def _v(t):
|
|
357
|
+
return split_accession(t.name)[1]
|
|
358
|
+
|
|
359
|
+
exact = [t for t in hits if ver is not None and _v(t) == ver]
|
|
360
|
+
if exact:
|
|
361
|
+
hits = exact
|
|
362
|
+
else:
|
|
363
|
+
vers = sorted({_v(t) for t in hits}, key=lambda v: int(v or 0))
|
|
364
|
+
if ver is not None:
|
|
365
|
+
if vers == [None]:
|
|
366
|
+
notes.append(f"annotation has no version numbers; assuming {acc} == {query}")
|
|
367
|
+
else:
|
|
368
|
+
have = "/".join(str(v) for v in vers if v)
|
|
369
|
+
notes.append(f"{query} not in annotation (has {acc}.{have}); "
|
|
370
|
+
f"using {acc}.{vers[-1]}")
|
|
371
|
+
top = vers[-1]
|
|
372
|
+
hits = [t for t in hits if _v(t) == top]
|
|
373
|
+
|
|
374
|
+
if chrom:
|
|
375
|
+
hits = [t for t in hits if t.chrom == chrom or t.chrom.replace("chr", "") == chrom.replace("chr", "")]
|
|
376
|
+
if not hits:
|
|
377
|
+
raise LookupError(f"{query} not annotated on {chrom}")
|
|
378
|
+
|
|
379
|
+
primary = [t for t in hits if "_" not in t.chrom]
|
|
380
|
+
if primary and len(primary) < len(hits):
|
|
381
|
+
notes.append("ignored copies on alt/fix/random contigs: "
|
|
382
|
+
+ ", ".join(sorted({t.chrom for t in hits if '_' in t.chrom})))
|
|
383
|
+
hits = primary
|
|
384
|
+
|
|
385
|
+
if ref is not None:
|
|
386
|
+
present = [t for t in hits if ref.resolve(t.chrom)]
|
|
387
|
+
if not present:
|
|
388
|
+
raise LookupError(f"contig {hits[0].chrom} of {query} is not in the FASTA")
|
|
389
|
+
hits = present
|
|
390
|
+
|
|
391
|
+
uniq = {}
|
|
392
|
+
for t in hits:
|
|
393
|
+
uniq.setdefault((t.chrom, t.strand, tuple(t.exons_g), t.cds_start, t.cds_end), t)
|
|
394
|
+
hits = list(uniq.values())
|
|
395
|
+
if len(hits) > 1:
|
|
396
|
+
locs = ", ".join(f"{t.name}@{t.chrom}:{t.tx_start}-{t.tx_end}" for t in hits)
|
|
397
|
+
raise LookupError(f"{query} maps to multiple loci ({locs}); pick one with --chrom")
|
|
398
|
+
return hits[0], notes
|