• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openvax / pyensembl / 25749118295

12 May 2026 04:52PM UTC coverage: 84.129% (+0.6%) from 83.547%
25749118295

push

github

web-flow
Fix #242: expose Ensembl annotation versions on Gene/Transcript/Exon/Protein (#341)

The transcript_version, gene_version, exon_version, and protein_version
columns were already loaded into the sqlite database from modern Ensembl
GTFs; they were just never surfaced on the Python objects.

Each entity now has a prefixed canonical accessor and a generic alias:

  Gene       gene_version       / versioned_gene_id
              version            / versioned_id  (aliases)

  Transcript transcript_version / versioned_transcript_id
              version            / versioned_id  (aliases)
              protein            -> new Protein view (or None for noncoding)

  Exon       exon_version       / versioned_exon_id
              version            / versioned_id  (aliases)
              exon_version is now a constructor kwarg (default None)

  Protein    protein_id, protein_version (primary)
              id, version, versioned_protein_id, versioned_id (aliases)

All version accessors return None when the GTF omits the corresponding
attribute (e.g. TAIR), and versioned_id falls back to the unversioned id
in that case.

Bump version to 2.7.0.

92 of 101 new or added lines in 7 files covered. (91.09%)

1463 of 1739 relevant lines covered (84.13%)

3.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.12
/pyensembl/transcript.py
1
# Licensed under the Apache License, Version 2.0 (the "License");
2
# you may not use this file except in compliance with the License.
3
# You may obtain a copy of the License at
4
#
5
#     http://www.apache.org/licenses/LICENSE-2.0
6
#
7
# Unless required by applicable law or agreed to in writing, software
8
# distributed under the License is distributed on an "AS IS" BASIS,
9
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
# See the License for the specific language governing permissions and
11
# limitations under the License.
12

13
from memoized_property import memoized_property
4✔
14

15
from .common import memoize
4✔
16
from .exon import Exon
4✔
17
from .locus_with_genome import LocusWithGenome
4✔
18

19

20
def _merge_ranges(ranges):
4✔
21
    """
22
    Sort [(start, end)] inclusive-inclusive ranges and merge any that are
23
    adjacent or overlapping (end+1 == next start).
24
    """
25
    if not ranges:
4✔
26
        return []
×
27
    ordered = sorted(ranges)
4✔
28
    merged = [ordered[0]]
4✔
29
    for start, end in ordered[1:]:
4✔
30
        prev_start, prev_end = merged[-1]
4✔
31
        if start <= prev_end + 1:
4✔
32
            merged[-1] = (prev_start, max(prev_end, end))
4✔
33
        else:
34
            merged.append((start, end))
4✔
35
    return merged
4✔
36

37

38
class Transcript(LocusWithGenome):
4✔
39
    """
40
    Transcript encompasses the locus, exons, and sequence of a transcript.
41

42
    Lazily fetches sequence in case we"re constructing many Transcripts
43
    and not using the sequence, avoid the memory/performance overhead
44
    of fetching and storing sequences from a FASTA file.
45
    """
46

47
    def __init__(
4✔
48
        self,
49
        transcript_id,
50
        transcript_name,
51
        contig,
52
        start,
53
        end,
54
        strand,
55
        biotype,
56
        gene_id,
57
        genome,
58
        support_level=None,
59
    ):
60
        LocusWithGenome.__init__(
4✔
61
            self,
62
            contig=contig,
63
            start=start,
64
            end=end,
65
            strand=strand,
66
            biotype=biotype,
67
            genome=genome,
68
        )
69
        self.transcript_id = transcript_id
4✔
70
        self.transcript_name = transcript_name
4✔
71
        self.gene_id = gene_id
4✔
72
        self.support_level = support_level
4✔
73

74
    @property
4✔
75
    def id(self):
4✔
76
        """
77
        Alias for transcript_id necessary for backward compatibility.
78
        """
79
        return self.transcript_id
4✔
80

81
    @property
4✔
82
    def name(self):
4✔
83
        """
84
        Alias for transcript_name necessary for backward compatibility.
85
        """
86
        return self.transcript_name
4✔
87

88
    def __str__(self):
4✔
89
        return (
4✔
90
            "Transcript(transcript_id='%s',"
91
            " transcript_name='%s',"
92
            " gene_id='%s',"
93
            " biotype='%s',"
94
            " contig='%s',"
95
            " start=%d,"
96
            " end=%d, strand='%s', genome='%s')"
97
        ) % (
98
            self.transcript_id,
99
            self.name,
100
            self.gene_id,
101
            self.biotype,
102
            self.contig,
103
            self.start,
104
            self.end,
105
            self.strand,
106
            self.genome.reference_name,
107
        )
108

109
    def __len__(self):
4✔
110
        """
111
        Length of a transcript is the sum of its exon lengths
112
        """
113
        return sum(len(exon) for exon in self.exons)
4✔
114

115
    def __eq__(self, other):
4✔
116
        return (
4✔
117
            other.__class__ is Transcript
118
            and self.id == other.id
119
            and self.genome == other.genome
120
        )
121

122
    def __hash__(self):
4✔
123
        return hash(self.id)
4✔
124

125
    def to_dict(self):
4✔
126
        state_dict = LocusWithGenome.to_dict(self)
4✔
127
        state_dict["transcript_id"] = self.transcript_id
4✔
128
        state_dict["transcript_name"] = self.name
4✔
129
        state_dict["gene_id"] = self.gene_id
4✔
130
        state_dict["support_level"] = self.support_level
4✔
131
        return state_dict
4✔
132

133
    @property
4✔
134
    def gene(self):
4✔
135
        return self.genome.gene_by_id(self.gene_id)
4✔
136

137
    @property
4✔
138
    def gene_name(self):
4✔
139
        return self.gene.name
4✔
140

141
    @property
4✔
142
    def exons(self):
4✔
143
        # need to look up exon_number alongside ID since each exon may
144
        # appear in multiple transcripts and have a different exon number
145
        # in each transcript.
146
        # Older or non-Ensembl GTFs may omit the exon_id attribute, in
147
        # which case we build Exon objects directly from the exon row
148
        # and synthesize a stable per-transcript ID.
149
        has_exon_id = self.db.column_exists("exon", "exon_id")
4✔
150
        if has_exon_id:
4✔
151
            columns = ["exon_number", "exon_id"]
4✔
152
        else:
153
            columns = ["exon_number", "seqname", "start", "end", "strand"]
4✔
154
        rows = self.db.query(
4✔
155
            columns, filter_column="transcript_id", filter_value=self.id, feature="exon"
156
        )
157

158
        # fill this list in its correct order (by exon_number) by using
159
        # the exon_number as a 1-based list offset
160
        exons = [None] * len(rows)
4✔
161

162
        for row in rows:
4✔
163
            exon_number = int(row[0])
4✔
164
            if exon_number < 1:
4✔
165
                raise ValueError("Invalid exon number: %s" % exon_number)
×
166
            elif exon_number > len(exons):
4✔
167
                raise ValueError(
×
168
                    "Invalid exon number: %s (max expected = %d)"
169
                    % (exon_number, len(exons))
170
                )
171

172
            if has_exon_id:
4✔
173
                exon_id = row[1]
4✔
174
                exon = self.genome.exon_by_id(exon_id)
4✔
175
                if exon is None:
4✔
176
                    raise ValueError(
×
177
                        "Missing exon %s for transcript %s" % (exon_number, self.id)
178
                    )
179
            else:
180
                _, seqname, start, end, strand = row
4✔
181
                exon = Exon(
4✔
182
                    exon_id="%s_exon_%d" % (self.id, exon_number),
183
                    contig=seqname,
184
                    start=start,
185
                    end=end,
186
                    strand=strand,
187
                    gene_name=self.gene_name,
188
                    gene_id=self.gene_id,
189
                )
190

191
            # exon_number is 1-based, convert to list index by subtracting 1
192
            exons[exon_number - 1] = exon
4✔
193
        return exons
4✔
194

195
    # possible annotations associated with transcripts
196
    _TRANSCRIPT_FEATURES = {"start_codon", "stop_codon", "UTR", "CDS"}
4✔
197

198
    @memoize
4✔
199
    def _transcript_feature_position_ranges(self, feature, required=True):
4✔
200
        """
201
        Find start/end chromosomal position range of features
202
        (such as start codon) for this transcript.
203
        """
204
        if feature not in self._TRANSCRIPT_FEATURES:
4✔
205
            raise ValueError("Invalid transcript feature: %s" % feature)
×
206

207
        results = self.db.query(
4✔
208
            select_column_names=["start", "end"],
209
            filter_column="transcript_id",
210
            filter_value=self.id,
211
            feature=feature,
212
        )
213

214
        if required and len(results) == 0:
4✔
215
            raise ValueError(
×
216
                "Transcript %s does not contain feature %s" % (self.id, feature)
217
            )
218
        return results
4✔
219

220
    @memoize
4✔
221
    def _transcript_feature_positions(self, feature):
4✔
222
        """
223
        Get unique positions for feature, raise an error if feature is absent.
224
        """
225
        ranges = self._transcript_feature_position_ranges(feature, required=True)
4✔
226
        results = []
4✔
227
        # a feature (such as a stop codon), maybe be split over multiple
228
        # contiguous ranges. Collect all the nucleotide positions into a
229
        # single list.
230
        for start, end in ranges:
4✔
231
            # since ranges are [inclusive, inclusive] and
232
            # Python ranges are [inclusive, exclusive) we have to increment
233
            # the end position
234
            for position in range(start, end + 1):
4✔
235
                if position in results:
4✔
236
                    raise ValueError(
×
237
                        "Repeated position %d for %s" % (position, feature)
238
                    )
239
                results.append(position)
4✔
240
        return results
4✔
241

242
    @memoize
4✔
243
    def _codon_positions(self, feature):
4✔
244
        """
245
        Parameters
246
        ----------
247
        feature : str
248
            Possible values are "start_codon" or "stop_codon"
249

250
        Returns list of three chromosomal positions.
251
        """
252
        results = self._transcript_feature_positions(feature)
4✔
253
        if len(results) != 3:
4✔
254
            raise ValueError(
×
255
                "Expected 3 positions for %s of %s but got %d"
256
                % (feature, self.id, len(results))
257
            )
258
        return results
4✔
259

260
    @memoized_property
4✔
261
    def contains_start_codon(self):
4✔
262
        """
263
        Does this transcript have an annotated start_codon entry?
264
        """
265
        start_codons = self._transcript_feature_position_ranges(
4✔
266
            "start_codon", required=False
267
        )
268
        return len(start_codons) > 0
4✔
269

270
    @memoized_property
4✔
271
    def contains_stop_codon(self):
4✔
272
        """
273
        Does this transcript have an annotated stop_codon entry?
274
        """
275
        stop_codons = self._transcript_feature_position_ranges(
4✔
276
            "stop_codon", required=False
277
        )
278
        return len(stop_codons) > 0
4✔
279

280
    @memoized_property
4✔
281
    def start_codon_complete(self):
4✔
282
        """
283
        Does the start codon span 3 genomic positions?
284
        """
285
        try:
4✔
286
            self._codon_positions("start_codon")
4✔
287
        except ValueError:
×
288
            return False
×
289
        return True
4✔
290

291
    @memoized_property
4✔
292
    def start_codon_positions(self):
4✔
293
        """
294
        Chromosomal positions of nucleotides in start codon.
295
        """
296
        return self._codon_positions("start_codon")
4✔
297

298
    @memoized_property
4✔
299
    def stop_codon_positions(self):
4✔
300
        """
301
        Chromosomal positions of nucleotides in stop codon.
302
        """
303
        return self._codon_positions("stop_codon")
4✔
304

305
    @memoized_property
4✔
306
    def exon_intervals(self):
4✔
307
        """List of (start,end) tuples for each exon of this transcript,
308
        in the order specified by the 'exon_number' column of the
309
        exon table.
310
        """
311
        results = self.db.query(
×
312
            select_column_names=["exon_number", "start", "end"],
313
            filter_column="transcript_id",
314
            filter_value=self.id,
315
            feature="exon",
316
        )
317
        sorted_intervals = [None] * len(results)
×
318
        for exon_number, start, end in results:
×
319
            sorted_intervals[int(exon_number) - 1] = (start, end)
×
320
        return sorted_intervals
×
321

322
    def spliced_offset(self, position):
4✔
323
        """
324
        Convert from an absolute chromosomal position to the offset into
325
        this transcript"s spliced mRNA.
326

327
        Position must be inside some exon (otherwise raise exception).
328
        """
329
        if type(position) is not int:
4✔
330
            raise TypeError(
×
331
                "Position argument must be an integer, got %s : %s"
332
                % (position, type(position))
333
            )
334

335
        if position < self.start or position > self.end:
4✔
336
            raise ValueError(
×
337
                "Invalid position: %d (must be between %d and %d)"
338
                % (position, self.start, self.end)
339
            )
340

341
        # offset from beginning of unspliced transcript (including introns)
342
        unspliced_offset = self.offset(position)
4✔
343
        total_spliced_offset = 0
4✔
344

345
        # traverse exons in order of their appearance on the strand
346
        # Since absolute positions may decrease if on the negative strand,
347
        # we instead use unspliced offsets to get always increasing indices.
348
        #
349
        # Example:
350
        #
351
        # Exon Name:                exon 1                exon 2
352
        # Spliced Offset:           123456                789...
353
        # Intron vs. Exon: ...iiiiiieeeeeeiiiiiiiiiiiiiiiieeeeeeiiiiiiiiiii...
354
        for exon in self.exons:
4✔
355
            exon_unspliced_start, exon_unspliced_end = self.offset_range(
4✔
356
                exon.start, exon.end
357
            )
358
            # If the relative position is not within this exon, keep a running
359
            # total of the total exonic length-so-far.
360
            #
361
            # Otherwise, if the relative position is within an exon, get its
362
            # offset into that exon by subtracting the exon"s relative start
363
            # position from the relative position. Add that to the total exonic
364
            # length-so-far.
365
            if exon_unspliced_start <= unspliced_offset <= exon_unspliced_end:
4✔
366
                # all offsets are base 0, can be used as indices into
367
                # sequence string
368
                exon_offset = unspliced_offset - exon_unspliced_start
4✔
369
                return total_spliced_offset + exon_offset
4✔
370
            else:
371
                exon_length = len(exon)  # exon_end_position - exon_start_position + 1
4✔
372
                total_spliced_offset += exon_length
4✔
373
        raise ValueError(
×
374
            "Couldn't find position %d on any exon of %s" % (position, self.id)
375
        )
376

377
    @memoized_property
4✔
378
    def start_codon_unspliced_offsets(self):
4✔
379
        """
380
        Offsets from start of unspliced pre-mRNA transcript
381
        of nucleotides in start codon.
382
        """
383
        return [self.offset(position) for position in self.start_codon_positions]
×
384

385
    @memoized_property
4✔
386
    def stop_codon_unspliced_offsets(self):
4✔
387
        """
388
        Offsets from start of unspliced pre-mRNA transcript
389
        of nucleotides in stop codon.
390
        """
391
        return [self.offset(position) for position in self.stop_codon_positions]
×
392

393
    def _contiguous_offsets(self, offsets):
4✔
394
        """
395
        Sorts the input list of integer offsets,
396
        ensures that values are contiguous.
397
        """
398
        offsets.sort()
4✔
399
        for i in range(len(offsets) - 1):
4✔
400
            if offsets[i] + 1 != offsets[i + 1]:
4✔
401
                raise ValueError("Offsets not contiguous: %s" % (offsets,))
×
402
        return offsets
4✔
403

404
    @memoized_property
4✔
405
    def start_codon_spliced_offsets(self):
4✔
406
        """
407
        Offsets from start of spliced mRNA transcript
408
        of nucleotides in start codon.
409
        """
410
        offsets = [
4✔
411
            self.spliced_offset(position) for position in self.start_codon_positions
412
        ]
413
        return self._contiguous_offsets(offsets)
4✔
414

415
    @memoized_property
4✔
416
    def stop_codon_spliced_offsets(self):
4✔
417
        """
418
        Offsets from start of spliced mRNA transcript
419
        of nucleotides in stop codon.
420
        """
421
        offsets = [
4✔
422
            self.spliced_offset(position) for position in self.stop_codon_positions
423
        ]
424
        return self._contiguous_offsets(offsets)
4✔
425

426
    @memoized_property
4✔
427
    def coding_sequence_position_ranges(self):
4✔
428
        """
429
        Return absolute chromosome position ranges for CDS fragments
430
        of this transcript, including the stop codon (which Ensembl
431
        encodes as a separate feature from the CDS).
432
        """
433
        ranges = list(self._transcript_feature_position_ranges("CDS"))
4✔
434
        if self.contains_stop_codon:
4✔
435
            ranges.extend(
4✔
436
                self._transcript_feature_position_ranges("stop_codon", required=False)
437
            )
438
        return _merge_ranges(ranges)
4✔
439

440
    @memoized_property
4✔
441
    def complete(self):
4✔
442
        """
443
        Consider a transcript complete if it has start and stop codons and
444
        a coding sequence whose length is divisible by 3
445
        """
446
        return (
4✔
447
            self.contains_start_codon
448
            and self.start_codon_complete
449
            and self.contains_stop_codon
450
            and self.coding_sequence is not None
451
            and len(self.coding_sequence) % 3 == 0
452
        )
453

454
    @memoized_property
4✔
455
    def sequence(self):
4✔
456
        """
457
        Spliced cDNA sequence of transcript
458
        (includes 5" UTR, coding sequence, and 3" UTR)
459
        """
460
        transcript_id = self.transcript_id
4✔
461
        if transcript_id.startswith("ENS"):
4✔
462
            transcript_id = transcript_id.rsplit(".", 1)[0]
4✔
463
        return self.genome.transcript_sequences.get(transcript_id)
4✔
464

465
    @memoized_property
4✔
466
    def first_start_codon_spliced_offset(self):
4✔
467
        """
468
        Offset of first nucleotide in start codon into the spliced mRNA
469
        (excluding introns)
470
        """
471
        start_offsets = self.start_codon_spliced_offsets
4✔
472
        return min(start_offsets)
4✔
473

474
    @memoized_property
4✔
475
    def last_stop_codon_spliced_offset(self):
4✔
476
        """
477
        Offset of last nucleotide in stop codon into the spliced mRNA
478
        (excluding introns)
479
        """
480
        stop_offsets = self.stop_codon_spliced_offsets
4✔
481
        return max(stop_offsets)
4✔
482

483
    @memoized_property
4✔
484
    def coding_sequence(self):
4✔
485
        """
486
        cDNA coding sequence (from start codon to stop codon, without
487
        any introns)
488
        """
489
        if self.sequence is None:
4✔
490
            return None
×
491

492
        # Some GTF annotations (e.g. fragments in Ensembl Plants) leave a
493
        # protein-coding transcript without a start_codon or stop_codon
494
        # feature. Return None rather than crashing when either endpoint of
495
        # the CDS cannot be located.
496
        if not self.contains_start_codon or not self.contains_stop_codon:
4✔
497
            return None
4✔
498

499
        start = self.first_start_codon_spliced_offset
4✔
500
        end = self.last_stop_codon_spliced_offset
4✔
501

502
        # If start codon is the at nucleotide offsets [3,4,5] and
503
        # stop codon is at nucleotide offsets  [20,21,22]
504
        # then start = 3 and end = 22.
505
        #
506
        # Adding 1 to end since Python uses non-inclusive ends in slices/ranges.
507

508
        # pylint: disable=invalid-slice-index
509
        # TODO(tavi) Figure out pylint is not happy with this slice
510
        return self.sequence[start : end + 1]
4✔
511

512
    @memoized_property
4✔
513
    def five_prime_utr_sequence(self):
4✔
514
        """
515
        cDNA sequence of 5' UTR
516
        (untranslated region at the beginning of the transcript)
517
        """
518
        if self.sequence is None or not self.contains_start_codon:
4✔
519
            return None
4✔
520
        # pylint: disable=invalid-slice-index
521
        # TODO(tavi) Figure out pylint is not happy with this slice
522
        return self.sequence[: self.first_start_codon_spliced_offset]
4✔
523

524
    @memoized_property
4✔
525
    def three_prime_utr_sequence(self):
4✔
526
        """
527
        cDNA sequence of 3' UTR
528
        (untranslated region at the end of the transcript)
529
        """
530
        if self.sequence is None or not self.contains_stop_codon:
4✔
531
            return None
4✔
532
        return self.sequence[self.last_stop_codon_spliced_offset + 1 :]
4✔
533

534
    @memoized_property
4✔
535
    def transcript_version(self):
4✔
536
        """
537
        Ensembl annotation version for this transcript (int) or None if the
538
        GTF did not provide a transcript_version attribute.
539
        """
540
        if not self.db.column_exists("transcript", "transcript_version"):
4✔
541
            return None
4✔
542
        result = self.db.query_one(
4✔
543
            select_column_names=["transcript_version"],
544
            filter_column="transcript_id",
545
            filter_value=self.id,
546
            feature="transcript",
547
            required=False,
548
        )
549
        if not result or not result[0]:
4✔
NEW
550
            return None
×
551
        return int(result[0])
4✔
552

553
    @property
4✔
554
    def version(self):
4✔
555
        """Alias for :attr:`transcript_version`."""
556
        return self.transcript_version
4✔
557

558
    @property
4✔
559
    def versioned_transcript_id(self):
4✔
560
        """``transcript_id.transcript_version`` when available, else ``transcript_id``."""
561
        if self.transcript_version is None:
4✔
562
            return self.transcript_id
4✔
563
        return "%s.%d" % (self.transcript_id, self.transcript_version)
4✔
564

565
    @property
4✔
566
    def versioned_id(self):
4✔
567
        """Alias for :attr:`versioned_transcript_id`."""
568
        return self.versioned_transcript_id
4✔
569

570
    @memoized_property
4✔
571
    def protein_id(self):
4✔
572
        result_tuple = self.db.query_one(
4✔
573
            select_column_names=["protein_id"],
574
            filter_column="transcript_id",
575
            filter_value=self.id,
576
            feature="CDS",
577
            distinct=True,
578
            required=False,
579
        )
580
        if result_tuple:
4✔
581
            return result_tuple[0]
4✔
582
        else:
583
            return None
4✔
584

585
    @memoized_property
4✔
586
    def protein(self):
4✔
587
        """
588
        :class:`Protein` view object for this transcript's encoded protein,
589
        or ``None`` if this transcript is non-coding.
590
        """
591
        from .protein import Protein
4✔
592

593
        if not self.protein_id:
4✔
594
            return None
4✔
595
        protein_version = None
4✔
596
        if self.db.column_exists("CDS", "protein_version"):
4✔
597
            result = self.db.query_one(
4✔
598
                select_column_names=["protein_version"],
599
                filter_column="transcript_id",
600
                filter_value=self.id,
601
                feature="CDS",
602
                distinct=True,
603
                required=False,
604
            )
605
            if result and result[0]:
4✔
606
                protein_version = int(result[0])
4✔
607
        return Protein(
4✔
608
            protein_id=self.protein_id, protein_version=protein_version
609
        )
610

611
    @memoized_property
4✔
612
    def protein_sequence(self):
4✔
613
        if self.protein_id:
4✔
614
            return self.genome.protein_sequences.get(self.protein_id)
4✔
615
        else:
616
            return None
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc