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

openvax / pyensembl / 24733539236

21 Apr 2026 04:17PM UTC coverage: 83.468% (+0.3%) from 83.208%
24733539236

Pull #331

github

iskandr
Fix Transcript.exons on GTFs without exon_id attribute

Ensembl release 54 and some non-Ensembl GTFs (e.g. UCSC refseq/gencode)
omit the exon_id attribute. pyensembl's installer already treats the
column as optional (database.py:134), but Transcript.exons still
unconditionally SELECTed exon_id, crashing with
sqlite3.OperationalError: no such column: exon_id.

Transcript.exons now checks db.column_exists("exon", "exon_id") and
falls back to building Exon objects directly from the exon row with a
synthesized per-transcript ID of the form "<transcript_id>_exon_<n>".

Adds a regression test that builds an Ensembl-style GTF with
exon_number but no exon_id and verifies both exon ordering and
synthesized IDs.

Bumps to 2.6.7.
Pull Request #331: Fix Transcript.exons crash when GTF lacks exon_id attribute

17 of 18 new or added lines in 2 files covered. (94.44%)

1343 of 1609 relevant lines covered (83.47%)

3.34 hits per line

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

88.41
/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✔
NEW
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
        start = self.first_start_codon_spliced_offset
4✔
493
        end = self.last_stop_codon_spliced_offset
4✔
494

495
        # If start codon is the at nucleotide offsets [3,4,5] and
496
        # stop codon is at nucleotide offsets  [20,21,22]
497
        # then start = 3 and end = 22.
498
        #
499
        # Adding 1 to end since Python uses non-inclusive ends in slices/ranges.
500

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

505
    @memoized_property
4✔
506
    def five_prime_utr_sequence(self):
4✔
507
        """
508
        cDNA sequence of 5' UTR
509
        (untranslated region at the beginning of the transcript)
510
        """
511
        # pylint: disable=invalid-slice-index
512
        # TODO(tavi) Figure out pylint is not happy with this slice
513
        return self.sequence[: self.first_start_codon_spliced_offset]
4✔
514

515
    @memoized_property
4✔
516
    def three_prime_utr_sequence(self):
4✔
517
        """
518
        cDNA sequence of 3' UTR
519
        (untranslated region at the end of the transcript)
520
        """
521
        return self.sequence[self.last_stop_codon_spliced_offset + 1 :]
4✔
522

523
    @memoized_property
4✔
524
    def protein_id(self):
4✔
525
        result_tuple = self.db.query_one(
4✔
526
            select_column_names=["protein_id"],
527
            filter_column="transcript_id",
528
            filter_value=self.id,
529
            feature="CDS",
530
            distinct=True,
531
            required=False,
532
        )
533
        if result_tuple:
4✔
534
            return result_tuple[0]
4✔
535
        else:
536
            return None
×
537

538
    @memoized_property
4✔
539
    def protein_sequence(self):
4✔
540
        if self.protein_id:
4✔
541
            return self.genome.protein_sequences.get(self.protein_id)
4✔
542
        else:
543
            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