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 ADDED
@@ -0,0 +1,6 @@
1
+ """VCFART -- VCF ARTificial: saturation-mutagenesis VCF generator for MAVE / SGE / DMS.
2
+
3
+ 뿌뿡 (ppu-ppung) -- give it a transcript and a range, it blows out every variant.
4
+ """
5
+
6
+ __version__ = "1.0.1"
vcfart/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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