Repository: cogent3/cogent3
Pull request #1916 (2024-06-24 22:50:58)

ENH: implement serialisation methods on new_alphabet classes
[NEW] added to_rich_dict(), to_json(), from_rich_dict() methods
    to AlphabetABC definition.

[NEW] implement all on CharAlphabet and KmerAlphabet and register
    from_rich_dict class methods as deserialiser


Patch:
diff --git a/src/cogent3/core/new_alphabet.py b/src/cogent3/core/new_alphabet.py
index 7ae03e548..de76c1305 100644
--- a/src/cogent3/core/new_alphabet.py
+++ b/src/cogent3/core/new_alphabet.py
@@ -1,5 +1,6 @@
 import functools
 import itertools
+import json
 import typing
 
 from abc import ABC, abstractmethod
@@ -7,6 +8,9 @@ from abc import ABC, abstractmethod
 import numba
 import numpy
 
+from cogent3.util.deserialise import register_deserialiser
+from cogent3.util.misc import get_object_provenance
+
 
 StrORBytes = typing.Union[str, bytes]
 StrORBytesORArray = typing.Union[str, bytes, numpy.ndarray]
@@ -129,6 +133,16 @@ class AlphabetABC(ABC):
     @abstractmethod
     def missing_index(self) -> OptInt: ...
 
+    @abstractmethod
+    def to_rich_dict(self) -> dict: ...
+
+    @abstractmethod
+    def to_json(self) -> str: ...
+
+    @classmethod
+    @abstractmethod
+    def from_rich_dict(cls, data: dict) -> None: ...
+
 
 class MonomerAlphabetABC(ABC):
     @abstractmethod
@@ -390,6 +404,32 @@ class CharAlphabet(tuple, AlphabetABC, MonomerAlphabetABC):
         """returns seq as a byte string"""
         return self._arr2bytes(seq)
 
+    def to_rich_dict(self) -> dict:
+        """returns a serialisable dictionary"""
+        from cogent3._version import __version__
+
+        return {
+            "chars": list(self),
+            "gap": self.gap_char,
+            "missing": self.missing_char,
+            "version": __version__,
+            "type": get_object_provenance(self),
+        }
+
+    def to_json(self) -> str:
+        """returns a serialisable string"""
+        return json.dumps(self.to_rich_dict())
+
+    @classmethod
+    def from_rich_dict(cls, data: dict) -> "CharAlphabet":
+        """returns an instance from a serialised dictionary"""
+        return cls(data["chars"], gap=data["gap"], missing=data["missing"])
+
+
+@register_deserialiser(get_object_provenance(CharAlphabet))
+def deserialise_char_alphabet(data) -> CharAlphabet:
+    return CharAlphabet.from_rich_dict(data)
+
 
 @numba.jit(nopython=True)
 def coord_conversion_coeffs(num_states, k, dtype=None):  # pragma: no cover
@@ -761,6 +801,41 @@ class KmerAlphabet(tuple, AlphabetABC, KmerAlphabetABC):
         max_val = max(self.missing_index or 0, self.gap_index or 0, self.num_canonical)
         return seq.min() >= 0 and seq.max() <= max_val if len(seq) else True
 
+    def to_rich_dict(self) -> dict:
+        """returns a serialisable dictionary"""
+        from cogent3._version import __version__
+
+        return {
+            "words": list(self),
+            "monomers": self.monomers.to_rich_dict(),
+            "k": self.k,
+            "gap": self.gap_char,
+            "missing": self.missing_char,
+            "version": __version__,
+            "type": get_object_provenance(self),
+        }
+
+    def to_json(self) -> str:
+        """returns a serialisable string"""
+        return json.dumps(self.to_rich_dict())
+
+    @classmethod
+    def from_rich_dict(cls, data: dict) -> "KmerAlphabet":
+        """returns an instance from a serialised dictionary"""
+        monomers = CharAlphabet.from_rich_dict(data["monomers"])
+        return cls(
+            words=data["words"],
+            monomers=monomers,
+            k=data["k"],
+            gap=data["gap"],
+            missing=data["missing"],
+        )
+
+
+@register_deserialiser(get_object_provenance(KmerAlphabet))
+def deserialise_kmer_alphabet(data) -> KmerAlphabet:
+    return KmerAlphabet.from_rich_dict(data)
+
 
 _alphabet_moltype_map = {}
 


Test patch:
diff --git a/tests/test_core/test_new_alphabet.py b/tests/test_core/test_new_alphabet.py
index 0b46c89ab..99783a05f 100644
--- a/tests/test_core/test_new_alphabet.py
+++ b/tests/test_core/test_new_alphabet.py
@@ -501,3 +501,48 @@ def test_kmeralpha_from_index_invalid():
     dinuc = alpha.get_kmer_alphabet(k=2, include_gap=False)
     with pytest.raises(ValueError):
         dinuc.from_index(16)
+
+
+@pytest.mark.parametrize("gap", ("", "-"))
+@pytest.mark.parametrize("missing", ("", "?"))
+def test_serialise_charalphabet(gap, missing):
+    alpha = new_alphabet.CharAlphabet(
+        list(f"TCAG{gap}{missing}"), gap=gap or None, missing=missing or None
+    )
+    ser = alpha.to_rich_dict()
+    deser = new_alphabet.CharAlphabet.from_rich_dict(ser)
+    assert deser == alpha
+
+
+@pytest.mark.parametrize("gap", ("", "-"))
+@pytest.mark.parametrize("missing", ("", "?"))
+@pytest.mark.parametrize("include_gap", (True, False))
+def test_serialise_kmeralphabet(gap, missing, include_gap):
+    alpha = new_alphabet.CharAlphabet(
+        list(f"TCAG{gap}{missing}"), gap=gap or None, missing=missing or None
+    )
+    kmers = alpha.get_kmer_alphabet(k=2, include_gap=include_gap)
+    ser = kmers.to_rich_dict()
+    deser = new_alphabet.KmerAlphabet.from_rich_dict(ser)
+    assert deser == kmers
+
+
+@pytest.mark.parametrize(
+    "alpha",
+    (
+        new_moltype.DNA.alphabet,
+        new_moltype.DNA.gapped_alphabet,
+        new_moltype.DNA.gapped_alphabet.get_kmer_alphabet(k=2),
+    ),
+)
+def test_deserialise_alphas(alpha):
+    from cogent3.util.deserialise import deserialise_object
+
+    ser = alpha.to_rich_dict()
+    got = deserialise_object(ser)
+    assert got == alpha
+
+    # now from json
+    ser = alpha.to_json()
+    got = deserialise_object(ser)
+    assert got == alpha
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index 280481a9e..e442a91f3 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -46,230 +46,6 @@ def test_moltype_make_bytes_seq():
 
 
 # Tests from test_sequence.py
-@pytest.fixture(scope="function")
-def one_seq():
-    return new_moltype.DNA.make_seq(seq="AACCTGGAACC")
-
-
-def test_seq_repr(one_seq):
-    pat = re.compile("[ACGT]+")
-    expect = str(one_seq)
-    seq = one_seq
-
-    got = pat.findall(repr(seq))[0]
-    assert expect.startswith(got), (expect, got)
-
-
-@pytest.mark.xfail(reason="AssertionError: ('GGTTCCAGGTT', 'CCAAGGT')")
-def test_seq_repr_rc(one_seq):
-    pat = re.compile("[ACGT]+")
-    dna = one_seq.moltype
-    expect = dna.rc(str(one_seq))
-    seq = one_seq.rc()
-
-    got = pat.findall(repr(seq))[0]
-    assert expect.startswith(got), (expect, got)
-
-
-def test_annotation_from_slice_with_stride():
-    seq = new_moltype.DNA.make_seq(seq="AAACGCGCGAAAAAAA", name="s1")
-    seq.add_feature(biotype="exon", name="ex1", spans=[(3, 9)])
-    f = list(seq.get_features(name="ex1"))[0]
-    assert str(f.get_slice()) == "CGCGCG"
-    s1 = seq[1::2]
-    f = list(s1.get_features(name="ex1"))[0]
-    assert str(f.get_slice()) == "CCC"
-
-
-def test_absolute_position_base_cases(one_seq):
-    """with no offset or view, the absolute index should remain unchanged"""
-    got = one_seq._seq.absolute_position(5)
-    assert got == 5
-
-    # an index outside the range of the sequence should raise an IndexError
-    with pytest.raises(IndexError):
-        one_seq._seq.absolute_position(20)
-
-    with pytest.raises(IndexError):
-        one_seq._seq.absolute_position(-20)
-
-
-def test_absolute_position_positive(one_seq):
-    # with an offset, the abs index should be offset + index
-    one_seq.annotation_offset = 2
-    got = one_seq._seq.absolute_position(2)
-    assert got == 2 + 2
-
-    # with an offset and start, the abs index should be offset + start + index
-    view = one_seq[2::]
-    view.annotation_offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
-    got = view._seq.absolute_position(2)
-    assert got == 2 + 2 + 2
-
-    # with an offset, start and step, the abs index should be offset + start + index * step
-    view = one_seq[2::2]
-    view.annotation_offset = 2
-    got = view._seq.absolute_position(2)
-    assert got == 2 + 2 + 2 * 2
-
-
-def test_relative_position_base_cases(one_seq):
-    """with no offset or view, the absolute index should remain unchanged"""
-    got = one_seq._seq.relative_position(5)
-    assert got == 5
-
-    # a -ve index  should raise an IndexError
-    with pytest.raises(IndexError):
-        one_seq._seq.relative_position(-5)
-
-
-@pytest.fixture(scope="function")
-def integer_seq():
-    a = new_moltype.BYTES.most_degen_alphabet()
-    return new_sequence.SeqView(seq="0123456789", alphabet=a)
-
-
-def test_relative_position(integer_seq):
-    """This test checks if the method returns the correct relative positions when
-    the given index precedes or exceeds the range of the SeqView."""
-
-    view = integer_seq[1:9:]
-    # view = "12345678"
-    got = view.relative_position(0)
-    # precedes the view, so should return -1
-    assert got == -1
-    # exceeds the view, but still returns a value
-    got = view.relative_position(10)
-    assert got == 9
-
-
-def test_relative_position_step_GT_one(integer_seq):
-    """This test checks if the method returns the correct relative positions when
-    the given index precedes or exceeds the range of the SeqView with a step greater than one.
-    """
-
-    # precedes the view, with step > 1
-    view = integer_seq[2:7:2]
-    # view = "246", precedes the view by 1 step
-    got = view.relative_position(0)
-    assert got == -1
-    # precedes the view by 0.5 step, default behaviour is to round up to 0
-    got = view.relative_position(1)
-    assert got == 0
-    # exceeds the view by two steps, len(view) + 2 = 4
-    got = view.relative_position(10)
-    assert got == 4
-
-
-@pytest.mark.parametrize("sliced", (False, True))
-@pytest.mark.parametrize("rev", (False, True))
-def test_seqview_copy(sliced, rev, integer_seq):
-    raw_data = integer_seq.seq
-    integer_seq = integer_seq[::-1] if rev else integer_seq
-    raw_data = raw_data[::-1] if rev else raw_data
-
-    slice_start = 2
-    slice_end = 4
-    sv = integer_seq[slice_start:slice_end]
-    copied = sv.copy(sliced=sliced)
-
-    assert copied.str_value == raw_data[slice_start:slice_end]
-    assert copied.is_reversed == integer_seq.is_reversed
-    assert sliced and copied.seq is not sv.seq or copied.seq is integer_seq.seq
-
-
-def test_relative_position_with_remainder(integer_seq):
-    """tests relative_position when the index given is excluded from the view as it falls on
-    a position that is 'stepped over'"""
-    view = integer_seq[1:9:2]
-    # view = "1357"
-    got = view.relative_position(2)
-    # 2 is stepped over in the view, so we return the index of 3 (which is 1)
-    assert got == 1
-
-    # setting the arg stop=True will adjust to the largest number, smaller than the given abs value, that is in the view
-    got = view.relative_position(8, stop=True)
-    # 8 is excluded from the view, so we return the index of 7 (which is 3)
-    assert got == 3
-
-
-@pytest.mark.parametrize("value", (0, 3))
-@pytest.mark.parametrize("offset", (None, 1, 2))
-@pytest.mark.parametrize("start", (None, 1, 2))
-@pytest.mark.parametrize("stop", (None, 10, 11))
-@pytest.mark.parametrize("step", (None, 1, 2))
-def test_absolute_relative_roundtrip(one_seq, value, offset, start, stop, step):
-    # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
-    view = one_seq[start:stop:step]
-    view.annotation_offset = offset or 0
-    abs_val = view._seq.absolute_position(value)
-    rel_val = view._seq.relative_position(abs_val)
-    assert rel_val == value
-
-
-@pytest.mark.parametrize("value", (0, 2))
-@pytest.mark.parametrize("offset", (None, 1, 2))
-@pytest.mark.parametrize("start", (None, -1, -2))
-@pytest.mark.parametrize("stop", (None, -10))
-@pytest.mark.parametrize("step", (-1, -2))
-def test_absolute_relative_roundtrip_reverse(
-    integer_seq, value, offset, start, stop, step
-):
-    # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
-    view = integer_seq[start:stop:step]
-    view.offset = offset or 0
-    abs_val = view.absolute_position(value)
-    rel_val = view.relative_position(abs_val)
-    assert view.offset == (offset or 0)
-    assert (view[rel_val]).str_value == view[value].str_value
-
-
-def test_annotate_gff_nested_features(DATA_DIR):
-    """correctly annotate a sequence with nested features"""
-    # the synthetic example
-    #          1111111111222222222333333333334
-    # 1234567890123456789012345678901234567890
-    #  **** biological_region
-    #                                     ** biological_region
-    #                                       * biological_region
-    #      *******************************  gene
-    #         *********************   mRNA
-    #            *********            exon
-    #                       *****     exon
-    # ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC...
-    seq = new_moltype.DNA.make_seq(
-        seq="ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC", name="22"
-    )
-    gff3_path = DATA_DIR / "ensembl_sample.gff3"
-    # TODO: directly assign an annotation_db, annotate_from_gff to be discontinued
-    seq.annotate_from_gff(gff3_path)
-    # we have 8 records in the gff file
-    assert seq.annotation_db.num_matches() == 8
-
-    # get the gene and check it has a single annotation and that
-    # its slice is correct
-    ann = list(seq.get_features(biotype="gene"))
-    assert len(ann) == 1
-    ann_seq = ann[0].get_slice()
-    assert str(ann_seq) == "GGAAAATTTTTTTTTAAGGGGGAAAAAAAAA"
-    # the gene has 1 transcript
-    gene = ann[0]
-    mrna = list(gene.get_children(biotype="mRNA"))
-    assert len(mrna) == 1
-    mrna = mrna[0]
-    ann_seq = mrna.get_slice()
-    assert str(ann_seq) == "AAAATTTTTTTTTAAGGGGGAAA"
-
-    # the transcript has 2 exons, from the parent feature
-    exons = list(mrna.get_children(biotype="exon"))
-    assert len(exons) == 2
-    # or the sequence
-    ann = list(seq.get_features(biotype="exon"))
-    assert len(ann) == 2
-    exon_seqs = ("TTTTTTTTT", "GGGGG")
-    assert tuple(str(ex.get_slice()) for ex in exons) == exon_seqs
-
-
 @pytest.mark.xfail(
     reason="AttributeError: 'MolType' object has no attribute 'coerce_str'"
 )
@@ -606,8 +382,9 @@ def test_copied_parent_coordinates(sliced, rev, start_stop):
 
 
 @pytest.mark.parametrize("rev", (False, True))
-def test_parent_coordinates(one_seq, rev):
-    seq = one_seq[1:1]
+def test_parent_coordinates(rev):
+    seq = new_moltype.DNA.make_seq(seq="ACGGTGGGAC")
+    seq = seq[1:1]
     seq = seq.rc() if rev else seq
     seq.name = "sliced"  # this assignment does not affect the
     # note that when a sequence has zero length, the parent seqid is None
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index b44ce362f..d4f147d6d 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -2082,6 +2082,217 @@ def test_annotate_from_gff(worm_seq_path, worm_gff_path):
     assert len(matches) == 3
 
 
+@pytest.mark.parametrize("rc", (False, True))
+def test_seq_repr(one_seq, rc):
+    pat = re.compile("[ACGT]+")
+    dna = one_seq.moltype
+    if rc:
+        expect = dna.rc(str(one_seq))
+        seq = one_seq.rc()
+    else:
+        expect = str(one_seq)
+        seq = one_seq
+
+    got = pat.findall(repr(seq))[0]
+    assert expect.startswith(got), (expect, got)
+
+
+def test_annotation_from_slice_with_stride():
+    seq = DNA.make_seq("AAACGCGCGAAAAAAA", name="s1")
+    seq.add_feature(biotype="exon", name="ex1", spans=[(3, 9)])
+    f = list(seq.get_features(name="ex1"))[0]
+    assert str(f.get_slice()) == "CGCGCG"
+    s1 = seq[1::2]
+    f = list(s1.get_features(name="ex1"))[0]
+    assert str(f.get_slice()) == "CCC"
+
+
+def test_absolute_position_base_cases(one_seq):
+    """with no offset or view, the absolute index should remain unchanged"""
+    got = one_seq._seq.absolute_position(5)
+    assert got == 5
+
+    # an index outside the range of the sequence should raise an IndexError
+    with pytest.raises(IndexError):
+        one_seq._seq.absolute_position(20)
+
+    with pytest.raises(IndexError):
+        one_seq._seq.absolute_position(-20)
+
+
+def test_absolute_position_positive(one_seq):
+    # with an offset, the abs index should be offset + index
+    one_seq.annotation_offset = 2
+    got = one_seq._seq.absolute_position(2)
+    assert got == 2 + 2
+
+    # with an offset and start, the abs index should be offset + start + index
+    view = one_seq[2::]
+    view.annotation_offset = 2  # todo: do we want the annotation_offset to be preserved when slicing? I think yes
+    got = view._seq.absolute_position(2)
+    assert got == 2 + 2 + 2
+
+    # with an offset, start and step, the abs index should be offset + start + index * step
+    view = one_seq[2::2]
+    view.annotation_offset = 2
+    got = view._seq.absolute_position(2)
+    assert got == 2 + 2 + 2 * 2
+
+
+def test_relative_position_base_cases(one_seq):
+    """with no offset or view, the absolute index should remain unchanged"""
+    got = one_seq._seq.relative_position(5)
+    assert got == 5
+
+    # a -ve index  should raise an IndexError
+    with pytest.raises(IndexError):
+        one_seq._seq.relative_position(-5)
+
+
+@pytest.fixture(scope="function")
+def integer_seq():
+    return SeqView(seq="0123456789")
+
+
+def test_relative_position(integer_seq):
+    """This test checks if the method returns the correct relative positions when
+    the given index precedes or exceeds the range of the SeqView."""
+
+    view = integer_seq[1:9:]
+    # view = "12345678"
+    got = view.relative_position(0)
+    # precedes the view, so should return -1
+    assert got == -1
+    # exceeds the view, but still returns a value
+    got = view.relative_position(10)
+    assert got == 9
+
+
+def test_relative_position_step_GT_one(integer_seq):
+    """This test checks if the method returns the correct relative positions when
+    the given index precedes or exceeds the range of the SeqView with a step greater than one.
+    """
+
+    # precedes the view, with step > 1
+    view = integer_seq[2:7:2]
+    # view = "246", precedes the view by 1 step
+    got = view.relative_position(0)
+    assert got == -1
+    # precedes the view by 0.5 step, default behaviour is to round up to 0
+    got = view.relative_position(1)
+    assert got == 0
+    # exceeds the view by two steps, len(view) + 2 = 4
+    got = view.relative_position(10)
+    assert got == 4
+
+
+@pytest.mark.parametrize("sliced", (False, True))
+@pytest.mark.parametrize("rev", (False, True))
+def test_seqview_copy(sliced, rev, integer_seq):
+    raw_data = integer_seq.seq
+    if rev:
+        integer_seq = integer_seq[::-1]
+        raw_data = raw_data[::-1]
+
+    slice_start = 2
+    slice_end = 4
+    sv = integer_seq[slice_start:slice_end]
+    copied = sv.copy(sliced=sliced)
+
+    assert copied.value == raw_data[slice_start:slice_end]
+    assert copied.is_reversed == integer_seq.is_reversed
+    assert sliced and copied.seq is not sv.seq or copied.seq is integer_seq.seq
+
+
+def test_relative_position_with_remainder(integer_seq):
+    """tests relative_position when the index given is excluded from the view as it falls on
+    a position that is 'stepped over'"""
+    view = integer_seq[1:9:2]
+    # view = "1357"
+    got = view.relative_position(2)
+    # 2 is stepped over in the view, so we return the index of 3 (which is 1)
+    assert got == 1
+
+    # setting the arg stop=True will adjust to the largest number, smaller than the given abs value, that is in the view
+    got = view.relative_position(8, stop=True)
+    # 8 is excluded from the view, so we return the index of 7 (which is 3)
+    assert got == 3
+
+
+@pytest.mark.parametrize("value", (0, 3))
+@pytest.mark.parametrize("offset", (None, 1, 2))
+@pytest.mark.parametrize("start", (None, 1, 2))
+@pytest.mark.parametrize("stop", (None, 10, 11))
+@pytest.mark.parametrize("step", (None, 1, 2))
+def test_absolute_relative_roundtrip(one_seq, value, offset, start, stop, step):
+    # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
+    view = one_seq[start:stop:step]
+    view.annotation_offset = offset or 0
+    abs_val = view._seq.absolute_position(value)
+    rel_val = view._seq.relative_position(abs_val)
+    assert rel_val == value
+
+
+@pytest.mark.parametrize("value", (0, 2))
+@pytest.mark.parametrize("offset", (None, 1, 2))
+@pytest.mark.parametrize("start", (None, -1, -2))
+@pytest.mark.parametrize("stop", (None, -10))
+@pytest.mark.parametrize("step", (-1, -2))
+def test_absolute_relative_roundtrip_reverse(
+    integer_seq, value, offset, start, stop, step
+):
+    # a round trip from relative to absolute then from absolute to relative, should return the same value we began with
+    view = integer_seq[start:stop:step]
+    view.offset = offset or 0
+    abs_val = view.absolute_position(value)
+    rel_val = view.relative_position(abs_val)
+    assert view.offset == (offset or 0)
+    assert (view[rel_val]).value == view[value].value
+
+
+def test_annotate_gff_nested_features(DATA_DIR):
+    """correctly annotate a sequence with nested features"""
+    # the synthetic example
+    #          1111111111222222222333333333334
+    # 1234567890123456789012345678901234567890
+    #  **** biological_region
+    #                                     ** biological_region
+    #                                       * biological_region
+    #      *******************************  gene
+    #         *********************   mRNA
+    #            *********            exon
+    #                       *****     exon
+    # ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC...
+    seq = DNA.make_seq("ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC", name="22")
+    gff3_path = DATA_DIR / "ensembl_sample.gff3"
+    seq.annotate_from_gff(gff3_path)
+    # we have 8 records in the gff file
+    assert seq.annotation_db.num_matches() == 8
+
+    # get the gene and check it has a single annotation and that
+    # its slice is correct
+    ann = list(seq.get_features(biotype="gene"))
+    assert len(ann) == 1
+    ann_seq = ann[0].get_slice()
+    assert str(ann_seq) == "GGAAAATTTTTTTTTAAGGGGGAAAAAAAAA"
+    # the gene has 1 transcript
+    gene = ann[0]
+    mrna = list(gene.get_children(biotype="mRNA"))
+    assert len(mrna) == 1
+    mrna = mrna[0]
+    ann_seq = mrna.get_slice()
+    assert str(ann_seq) == "AAAATTTTTTTTTAAGGGGGAAA"
+
+    # the transcript has 2 exons, from the parent feature
+    exons = list(mrna.get_children(biotype="exon"))
+    assert len(exons) == 2
+    # or the sequence
+    ann = list(seq.get_features(biotype="exon"))
+    assert len(ann) == 2
+    exon_seqs = ("TTTTTTTTT", "GGGGG")
+    assert tuple(str(ex.get_slice()) for ex in exons) == exon_seqs
+
+
 def test_to_rich_dict():  # ported for Sequence
     """Sequence to_dict works"""
     r = ArraySequence("AAGGCC", name="seq1")