Repository: cogent3/cogent3
Pull request #1411 (2023-05-04 02:26:02)

Annotation refactor


Patch:
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index 3de4e33b5..56e2acd9c 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -19,7 +19,6 @@ from cogent3.core.alignment import (
 )
 from cogent3.core.annotation_db import load_annotations
 from cogent3.core.genetic_code import available_codes, get_code
-
 # note that moltype has to be imported last, because it sets the moltype in
 # the objects created by the other modules.
 from cogent3.core.moltype import (
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index cd6d91f40..967a6e96c 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -447,7 +447,7 @@ class Annotation(_AnnotationCore, _Serialisable):
         }
         return self.__class__(**kwargs)
 
-    def get_slice(self, complete: bool = True, allow_gaps: bool = False):
+    def get_slice(self, complete: bool = False, allow_gaps: bool = False):
         """
         The corresponding sequence fragment.
 
diff --git a/src/cogent3/core/moltype.py b/src/cogent3/core/moltype.py
index b8129a49f..f274ac315 100644
--- a/src/cogent3/core/moltype.py
+++ b/src/cogent3/core/moltype.py
@@ -759,7 +759,7 @@ class MolType(object):
         name = name or getattr(seq, "name", None)
         if isinstance(seq, ArraySequence):
             seq = str(seq)
-        return self._make_seq(self.coerce_str(seq), name, **kwargs)
+        return self._make_seq(seq=self.coerce_str(seq), name=name, **kwargs)
 
     def make_array_seq(self, seq, name=None, **kwargs):
         """
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 8252c70f8..b6e09188e 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -143,6 +143,9 @@ class SequenceI(object):
             type=get_object_provenance(self),
             version=__version__,
         )
+        if hasattr(self, "annotation_offset"):
+            offset = self.annotation_offset or self._seq.start
+            data.update(dict(annotation_offset=offset))
 
         if (
             hasattr(self, "annotation_db")
@@ -804,16 +807,20 @@ class Sequence(_Annotatable, SequenceI):
         preserve_case=False,
         gaps_allowed=True,
         wildcards_allowed=True,
+        annotation_offset=0,
     ):
         """Initialize a sequence.
 
         Parameters
         ----------
-            seq: the raw sequence string, default is ''
-
-            name: the sequence name
-
-            check: if True (the default), validates against the MolType
+        seq
+            the raw sequence string, default is ''
+        name
+            the sequence name
+        check
+            if True (the default), validates against the MolType
+        annotation_offset
+            integer indicating start position relative to annotations
         """
 
         if name is None and hasattr(seq, "name"):
@@ -846,6 +853,7 @@ class Sequence(_Annotatable, SequenceI):
         self._repr_policy = dict(num_pos=60)
 
         self._annotation_db = None
+        self.annotation_offset = annotation_offset
 
     @property
     def annotation_offset(self):
@@ -2088,9 +2096,16 @@ class ABSequence(Sequence):
 class ByteSequence(Sequence):
     """Used for storing arbitrary bytes."""
 
-    def __init__(self, seq="", name=None, info=None, check=False, preserve_case=True):
-        super(ByteSequence, self).__init__(
-            seq, name=name, info=info, check=check, preserve_case=preserve_case
+    def __init__(
+        self, seq="", name=None, info=None, check=False, preserve_case=True, **kwargs
+    ):
+        super().__init__(
+            seq=seq,
+            name=name,
+            info=info,
+            check=check,
+            preserve_case=preserve_case,
+            **kwargs,
         )
 
 


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 7ca5082f0..f705742cb 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -803,49 +803,6 @@ class SequenceCollectionBaseTests(object):
         got = align_norm.to_nexus("protein")
         self.assertEqual(got, expect)
 
-    def test_to_rich_dict(self):
-        """to_rich_dict produces correct dict"""
-        aln = self.Class({"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"})
-        try:
-            seq_type = get_object_provenance(aln.seqs[0].data)
-        except AttributeError:
-            seq_type = get_object_provenance(aln.seqs[0])
-
-        got = aln.to_rich_dict()
-        expect = {
-            "seqs": {
-                "seq1": {
-                    "name": "seq1",
-                    "seq": "ACGG",
-                    "info": None,
-                    "type": seq_type,
-                    "moltype": aln.moltype.label,
-                    "version": __version__,
-                },
-                "seq2": {
-                    "name": "seq2",
-                    "seq": "CGCA",
-                    "info": None,
-                    "type": seq_type,
-                    "moltype": aln.moltype.label,
-                    "version": __version__,
-                },
-                "seq3": {
-                    "name": "seq3",
-                    "seq": "CCG-",
-                    "info": None,
-                    "type": seq_type,
-                    "moltype": aln.moltype.label,
-                    "version": __version__,
-                },
-            },
-            "moltype": aln.moltype.label,
-            "info": None,
-            "type": get_object_provenance(aln),
-            "version": __version__,
-        }
-        self.assertEqual(got, expect)
-
     def test_to_json(self):
         """roundtrip of to_json produces correct dict"""
         aln = self.Class({"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"})
@@ -3441,3 +3398,54 @@ def test_seq_rename_drops_annotations(cls):
     assert seqs.annotation_db is not None
     new = seqs.rename_seqs(lambda x: x.upper())
     assert new.annotation_db is None
+
+
+@pytest.mark.parametrize(
+    "cls,with_offset",
+    ((SequenceCollection, True), (Alignment, True), (ArrayAlignment, False)),
+)
+def test_to_rich_dict(cls, with_offset):
+    """to_rich_dict produces correct dict"""
+    aln = cls({"seq1": "ACGG", "seq2": "CGCA", "seq3": "CCG-"})
+    try:
+        seq_type = get_object_provenance(aln.seqs[0].data)
+    except AttributeError:
+        seq_type = get_object_provenance(aln.seqs[0])
+
+    got = aln.to_rich_dict()
+    expect = {
+        "seqs": {
+            "seq1": {
+                "name": "seq1",
+                "seq": "ACGG",
+                "info": None,
+                "type": seq_type,
+                "moltype": aln.moltype.label,
+                "version": __version__,
+                "annotation_offset": 0,
+            },
+            "seq2": {
+                "name": "seq2",
+                "seq": "CGCA",
+                "info": None,
+                "type": seq_type,
+                "moltype": aln.moltype.label,
+                "version": __version__,
+                "annotation_offset": 0,
+            },
+            "seq3": {
+                "name": "seq3",
+                "seq": "CCG-",
+                "info": None,
+                "type": seq_type,
+                "moltype": aln.moltype.label,
+                "version": __version__,
+                "annotation_offset": 0,
+            },
+        },
+        "moltype": aln.moltype.label,
+        "info": None,
+        "type": get_object_provenance(aln),
+        "version": __version__,
+    }
+    assert got == expect
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index 6624d9864..88abea06e 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -196,7 +196,7 @@ def test_feature_projection_gapped(ann_aln):
     seq_ltr = ann_aln.project_annotation(seq_name, aln_ltr)
 
     with pytest.raises(ValueError):
-        seq_ltr.get_slice()
+        seq_ltr.get_slice(complete=True)
 
     # to get the annotation on the seq coord
     seq_ltr = seq_ltr.without_lost_spans()
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index d45e580f8..3b6e5469c 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -139,13 +139,6 @@ def test_gff_find_user_features(gff_db):
     assert found
 
 
-def test_to_rich_dict(gff_db):
-    # not a very good test! needs to be serialise / deserialize
-    rd = gff_db.to_rich_dict()
-    assert "type" in rd
-    _ = GffAnnotationDb.from_dict(rd)
-
-
 def test_empty_data():
     _ = GffAnnotationDb()
 
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index 3184b2eba..3c35b02e2 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -5,7 +5,6 @@ import pytest
 from cogent3 import ASCII, DNA, make_aligned_seqs
 from cogent3.core.annotation import Feature, Variable
 from cogent3.core.annotation_db import GffAnnotationDb
-
 # Complete version of manipulating sequence annotations
 from cogent3.util.deserialise import deserialise_object
 
@@ -170,27 +169,6 @@ class FeaturesTest(TestCase):
             'cpgsite "cpg" at [8:10]/10',
         )
 
-    @pytest.mark.xfail(
-        reason="todo gah implement support for annotation_db serialisation"
-    )
-    def test_roundtrip_json(self):
-        """features can roundtrip from json"""
-
-        seq = DNA.make_seq("AAAAATATTATTGGGT")
-        seq.add_annotation(Feature, "exon", "myname", [(0, 5)])
-        got = seq.to_json()
-        new = deserialise_object(got)
-        feat = new.get_features(biotype="exon")[0]
-        self.assertEqual(str(feat.get_slice()), "AAAAA")
-
-        # now with a list span
-        seq = seq[3:]
-        feat = seq.get_features(biotype="exon")[0]
-        got = seq.to_json()
-        new = deserialise_object(got)
-        feat = new.get_features(biotype="exon")[0]
-        self.assertEqual(str(feat.get_slice(complete=False)), "AA")
-
     @pytest.mark.xfail(
         reason="todo gah update test to use latest API plus support serialisation"
     )
@@ -669,7 +647,7 @@ def test_nested_get_slice():
 
 
 def test_roundtrip_annotated_seq():
-    """should work for an alignment that has been reverse complemented"""
+    """should work for a seq that has been reverse complemented"""
     # the key that exposed the bug was a gap in the middle of the sequence
     seq = DNA.make_seq(
         "AAAGGGGGAACCT",
@@ -677,8 +655,11 @@ def test_roundtrip_annotated_seq():
     )
     seq.add_feature(biotype="exon", name="E1", spans=[(3, 8)])
     seq.add_feature(biotype="exon", name="E2", spans=[(10, 13)])
-    rd = seq.to_rich_dict()
-    ...
+
+    rseq = deserialise_object(seq.to_json())
+    orig_annots = {a.name: str(a.get_slice()) for a in seq.get_features()}
+    got_annots = {a.name: str(a.get_slice()) for a in rseq.get_features()}
+    assert got_annots == orig_annots
 
 
 def test_roundtrip_rc_annotated_align():
@@ -693,7 +674,6 @@ def test_roundtrip_rc_annotated_align():
     aln.get_seq("x").add_feature(biotype="exon", name="E2", spans=[(10, 13)])
 
     raln = aln.rc()
-    rd = raln.to_rich_dict()
     json = raln.to_json()
     got = deserialise_object(json)
     assert got.to_dict() == raln.to_dict()
@@ -747,3 +727,22 @@ def test_masking_strand_agnostic_aln():
         "x": "TTT??????????TTTTTTTTTT?????TTTT????TT",
         "y": str(rc.named_seqs["y"]),
     }
+
+
+def test_roundtrip_json():
+    """features can roundtrip from json"""
+
+    seq = DNA.make_seq("AAAAATATTATTGGGT")
+    seq.add_feature(biotype="exon", name="myname", spans=[(0, 5)])
+    got = seq.to_json()
+    new = deserialise_object(got)
+    feat = list(new.get_features(biotype="exon"))[0]
+    assert str(feat.get_slice()) == "AAAAA"
+
+    # now with a list span
+    seq = seq[3:]
+    got = seq.to_json()
+    new = deserialise_object(got)
+    assert new.annotation_offset == 3
+    feat = list(new.get_features(biotype="exon", allow_partial=True))[0]
+    assert str(feat.get_slice()) == "AA"
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 9004a993b..958380701 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -159,34 +159,6 @@ class SequenceTests(TestCase):
         r = self.RNA("ugagg")
         assert dumps(r)
 
-    def test_to_rich_dict(self):
-        """Sequence to_dict works"""
-        r = self.SEQ("AAGGCC", name="seq1")
-        got = r.to_rich_dict()
-        expect = {
-            "name": "seq1",
-            "seq": "AAGGCC",
-            "moltype": r.moltype.label,
-            "info": None,
-            "type": get_object_provenance(r),
-            "version": __version__,
-        }
-        self.assertEqual(got, expect)
-
-    def test_to_json(self):
-        """to_json roundtrip recreates to_dict"""
-        r = self.SEQ("AAGGCC", name="seq1")
-        got = json.loads(r.to_json())
-        expect = {
-            "name": "seq1",
-            "seq": "AAGGCC",
-            "moltype": r.moltype.label,
-            "info": None,
-            "type": get_object_provenance(r),
-            "version": __version__,
-        }
-        self.assertEqual(got, expect)
-
     def test_sequence_to_moltype(self):
         """correctly convert to specified moltype"""
         s = Sequence("TTTTTTTTTTAAAA", name="test1")
@@ -2148,3 +2120,41 @@ def test_to_moltype_rna():
     rna = seq.to_moltype("dna")
 
     assert "U" not in rna
+
+
+@pytest.mark.parametrize("cls,with_offset", ((ArraySequence, False), (Sequence, True)))
+def test_to_rich_dict(cls, with_offset):
+    """Sequence to_dict works"""
+    r = cls("AAGGCC", name="seq1")
+    got = r.to_rich_dict()
+    expect = {
+        "name": "seq1",
+        "seq": "AAGGCC",
+        "moltype": r.moltype.label,
+        "info": None,
+        "type": get_object_provenance(r),
+        "version": __version__,
+    }
+    if with_offset:
+        expect["annotation_offset"] = 0
+
+    assert got == expect
+
+
+@pytest.mark.parametrize("cls,with_offset", ((ArraySequence, False), (Sequence, True)))
+def test_to_json(cls, with_offset):
+    """to_json roundtrip recreates to_dict"""
+    r = cls("AAGGCC", name="seq1")
+    got = json.loads(r.to_json())
+    expect = {
+        "name": "seq1",
+        "seq": "AAGGCC",
+        "moltype": r.moltype.label,
+        "info": None,
+        "type": get_object_provenance(r),
+        "version": __version__,
+    }
+    if with_offset:
+        expect["annotation_offset"] = 0
+
+    assert got == expect