Repository: cogent3/cogent3
Pull request #1433 (2023-05-12 06:14:47)

ENH: tweaks to Feature objects


Patch:
diff --git a/src/cogent3/core/alignment.py b/src/cogent3/core/alignment.py
index d3263a29f..d2fb28693 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -4836,13 +4836,69 @@ class Alignment(AlignmentI, SequenceCollection):
             seqs.append((seq_name, seq))
         return self.__class__(moltype=self.moltype, data=seqs, **kwargs)
 
-    def project_annotation(self, seq_name, annot):
-        target_aligned = self.named_seqs[seq_name]
+    def get_projected_feature(self, *, seqid: str, feature: Feature) -> Feature:
+        """returns an alignment feature projected onto the seqid sequence
+
+        Parameters
+        ----------
+        seqid
+            name of the sequence to project the feature onto
+        feature
+            a Feature, bound to self, that will be projected
+
+        Returns
+        -------
+        a new Feature bound to seqid
+
+        Notes
+        -----
+        The alignment coordinates of feature are converted into the seqid
+        sequence coordinates and the object is bound to that sequence.
+
+        The feature is added to the annotation_db.
+        """
+        target_aligned = self.named_seqs[seqid]
+        if feature.parent is not self:
+            raise ValueError("Feature does not belong to this alignment")
+        result = feature.remapped_to(target_aligned.data, target_aligned.map)
+
+        if not self.annotation_db:
+            # todo gah improve bound db initialisation
+            self.annotation_db = DEFAULT_ANNOTATION_DB()
+
+        self.annotation_db.add_feature(**feature.to_dict())
+        return result
+
+    @c3warn.deprecated_callable(
+        "2023.7",
+        reason="use <collection>.get_projected_feature()",
+        is_discontinued=True,
+    )
+    def project_annotation(self, seqid, annot):
+        """projects the alignment coordinate annotation onto seq"""
+        target_aligned = self.named_seqs[seqid]
         if annot.parent is not self:
             raise ValueError("Feature does not belong to this alignment")
         return annot.remapped_to(target_aligned.data, target_aligned.map)
 
+    def get_projected_features(self, *, seqid: str, **kwargs) -> list[Feature]:
+        """projects all features from other sequences onto seqid"""
+        # todo gah should there be a generator version,
+        # iter_projected_features()?
+        annots = []
+        for name in self.names:
+            if name == seqid:
+                continue
+            annots.extend(list(self.get_features(seqid=name, **kwargs)))
+        return [self.get_projected_feature(seqid=seqid, feature=a) for a in annots]
+
+    @c3warn.deprecated_callable(
+        "2023.7",
+        reason="use <collection>.get_projected_features()",
+        is_discontinued=True,
+    )
     def get_projected_annotations(self, *, seqid: str, **kwargs):
+        # todo gah make sure the original sequence is not the seqid
         aln_annots = list(self.get_features(**kwargs))
         return [self.project_annotation(seqid, a) for a in aln_annots]
 
@@ -4854,10 +4910,7 @@ class Alignment(AlignmentI, SequenceCollection):
     def get_annotations_from_seq(
         self, seq_name, annotation_type="*", **kwargs
     ):  # pragma: no cover
-        aligned = self.named_seqs[seq_name]
-        return aligned.get_features_matching(
-            self, annotation_type=annotation_type, **kwargs
-        )
+        return self.get_features(seqid=seq_name, biotype=annotation_type, **kwargs)
 
     @c3warn.deprecated_callable(
         "2023.7", reason="use <collection>.get_features()", is_discontinued=True
@@ -4867,8 +4920,8 @@ class Alignment(AlignmentI, SequenceCollection):
     ):  # pragma: no cover
         result = []
         for seq_name in self.names:
-            a = self.get_annotations_from_seq(
-                seq_name, annotation_type=annotation_type, **kwargs
+            a = list(
+                self.get_features(seqid=seq_name, biotype=annotation_type, **kwargs)
             )
             result.extend(a)
         return result
@@ -4880,10 +4933,10 @@ class Alignment(AlignmentI, SequenceCollection):
     )
     def get_by_seq_annotation(self, seq_name, *args):  # pragma: no cover
         result = []
-        for feature in self.get_annotations_from_seq(seq_name, *args):
-            segment = self[feature.map.start : feature.map.end]
+        for feature in self.get_features(seqid=seq_name):
+            segment = self[feature]
             segment.name = '%s "%s" %s to %s of %s' % (
-                feature.type,
+                feature.biotype,
                 feature.name,
                 feature.map.start,
                 feature.map.end,
@@ -4892,13 +4945,18 @@ class Alignment(AlignmentI, SequenceCollection):
             result.append(segment)
         return result
 
-    def with_masked_annotations(self, annot_types, mask_char=None, shadow=False):
+    @c3warn.deprecated_args(
+        "2023.7",
+        reason="consistency with new API",
+        old_new=[("annot_types", "biotypes")],
+    )
+    def with_masked_annotations(self, biotypes, mask_char=None, shadow=False):
         """returns an alignment with annot_types regions replaced by mask_char
         if shadow is False, otherwise all other regions are masked.
 
         Parameters
         ----------
-        annot_types
+        biotypes
             annotation type(s)
         mask_char
             must be a character valid for the seq moltype. The
@@ -4910,9 +4968,11 @@ class Alignment(AlignmentI, SequenceCollection):
         """
         masked_seqs = []
         for seq in self.seqs:
-            # we mask each sequence using these spans
-            masked_seqs += [seq._masked_annotations(annot_types, mask_char, shadow)]
+            # todo gah why is _masked_annotations semi-private? add public method
+            # on Aligned
+            masked_seqs += [seq._masked_annotations(biotypes, mask_char, shadow)]
         new = self.__class__(data=masked_seqs, info=self.info, name=self.name)
+        new.annotation_db = deepcopy(self.annotation_db)
         return new
 
     @extend_docstring_from(ArrayAlignment.filtered)
@@ -5267,6 +5327,7 @@ class Alignment(AlignmentI, SequenceCollection):
         if not on_alignment and feature["seqid"]:
             return self.named_seqs[feature["seqid"]].make_feature(feature, self)
 
+        feature["seqid"] = feature.get("seqid", None)
         # there's no sequence to bind to, the feature is directly on self
         # todo gah check handling of strand etc..., maybe reuse code
         # in Sequence?
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 157107881..285360588 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -481,13 +481,18 @@ class Feature:
         return len(self.map)
 
     def __repr__(self):
-        name = f' "{self.name}"'
-        return f'"{self.seqid}" {self.biotype}{name} at {self.map}'
+        name = self.__class__.__name__
+        txt = ", ".join(
+            f"{attr}={getattr(self, attr)!r}"
+            for attr in ("seqid", "biotype", "name", "map", "parent")
+        )
+        return f"{name}({txt})"
 
     def remapped_to(self, grandparent, gmap):
+        seqid = grandparent.name or f"from {self.seqid!r}"
         kwargs = {
             **self._serialisable,
-            **{"map": gmap[self.map], "parent": grandparent, "seqid": grandparent.name},
+            **{"map": gmap[self.map], "parent": grandparent, "seqid": seqid},
         }
         return self.__class__(**kwargs)
 
@@ -555,6 +560,19 @@ class Feature:
 
         return make_shape(type_=self)
 
+    def to_dict(self):
+        """returns"""
+        result = {
+            **self._serialisable,
+            **dict(
+                spans=self.map.get_coordinates(),
+                strand="-" if self.map.reverse else "+",
+            ),
+        }
+        for key in ("map", "parent"):
+            result.pop(key, None)
+        return result
+
 
 class _Feature(_Annotatable, _Serialisable):  # pragma: no cover
     qualifier_names = ["type", "name"]
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index e4b76a7e6..fde467fc7 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -1107,12 +1107,38 @@ class Sequence(SequenceI):
         parent_id: Optional[str] = None,
         strand: Optional[str] = None,
         on_alignment: bool = False,
+        seqid: Optional[str] = None,
     ) -> Feature:
+        """
+        add a feature to annotation_db
+
+        Parameters
+        ----------
+        biotype
+            biological type
+        name
+            name of the feature
+        spans
+            coordinates for this sequence
+        parent_id
+            name of the feature parent
+        strand
+            '+' or '-', defaults to '+'
+        on_alignment
+            whether the feature spans are alignment coordinates
+        seqid
+            ignored since the feature is added to this sequence
+
+        Returns
+        -------
+        Feature instance
+        """
         if self.annotation_db is None:
             self.annotation_db = GffAnnotationDb()
 
         feature_data = FeatureDataType(
-            seqid=self.name, **{n: v for n, v in locals().items() if n != "self"}
+            seqid=self.name,
+            **{n: v for n, v in locals().items() if n not in ("self", "seqid")},
         )
 
         self.annotation_db.add_feature(**feature_data)


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 0b1c7b17f..28c9c0ec3 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -3187,21 +3187,21 @@ def test_get_annotations_from_any_seq(cls):
     seqs.annotation_db = db
     got = list(seqs.get_features())
     assert len(got) == 3
-    assert 'exon "annotation1" at [3:8]/9' in str(got[0])
-    assert 'exon "annotation2" at [1:2]/9' in str(got[1])
-    assert 'exon "annotation3" at [3:6]/9' in str(got[2])
+    assert "biotype='exon', name='annotation1', map=[3:8]/9" in str(got[0])
+    assert "biotype='exon', name='annotation2', map=[1:2]/9" in str(got[1])
+    assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[2])
 
     got = list(seqs.get_features(name="annotation1"))
     assert len(got) == 1
-    assert 'exon "annotation1" at [3:8]/9' in str(got[0])
+    assert "biotype='exon', name='annotation1', map=[3:8]/9" in str(got[0])
 
     got = list(seqs.get_features(biotype="exon", name="annotation2"))
     assert len(got) == 1
-    assert 'exon "annotation2" at [1:2]/9' in str(got[0])
+    assert "biotype='exon', name='annotation2', map=[1:2]/9" in str(got[0])
 
     got = list(seqs.get_features(name="annotation3"))
     assert len(got) == 1
-    assert 'exon "annotation3" at [3:6]/9' in str(got[0])
+    assert "biotype='exon', name='annotation3', map=[3:6]/9" in str(got[0])
 
 
 def test_annotate_matches_to():
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index 6be963b80..abd90281d 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -3,7 +3,7 @@ import unittest
 import pytest
 
 from cogent3 import DNA, make_aligned_seqs, make_unaligned_seqs
-from cogent3.core.location import Map, Span, as_map
+from cogent3.core.location import Map, Span
 
 
 def makeSampleSequence(name, with_gaps=False):
@@ -155,7 +155,9 @@ def test_feature_projection_ungapped(ann_aln):
     aln_ltr = list(ann_aln.get_features(biotype="LTR"))[0]
     seq_name = "FAKE01"
     expected = expecteds[seq_name]
-    seq_ltr = ann_aln.project_annotation(seq_name, aln_ltr)
+    num = ann_aln.annotation_db.num_matches()
+    seq_ltr = ann_aln.get_projected_feature(seqid=seq_name, feature=aln_ltr)
+    assert ann_aln.annotation_db.num_matches() == num + 1
     assert str(seq_ltr.get_slice()) == expected
     assert seq_ltr.seqid == seq_name
     assert seq_ltr.parent is ann_aln.get_seq(seq_name)
@@ -167,7 +169,7 @@ def test_feature_projection_gapped(ann_aln):
     aln_ltr = list(ann_aln.get_features(biotype="LTR"))[0]
     seq_name = "FAKE02"
     expected = expecteds[seq_name]
-    seq_ltr = ann_aln.project_annotation(seq_name, aln_ltr)
+    seq_ltr = ann_aln.get_projected_feature(seqid=seq_name, feature=aln_ltr)
 
     with pytest.raises(ValueError):
         seq_ltr.get_slice(complete=True)
@@ -226,3 +228,48 @@ def test_slice_seq_with_partial_start(ann_seq, annot_type, num):
         gapless = feat.without_lost_spans()
         # the sliced feature without gaps is shorter
         assert len(gapless) < len(feat)
+
+
+def test_seq_feature_to_dict():
+    """create the attributes necessary to write into the user table"""
+    seq = DNA.make_seq("ATTGTACGCCCCTGA", name="test_seq")
+    feature_data = {
+        "biotype": "CDS",
+        "name": "fake",
+        "spans": [
+            (5, 10),
+        ],
+        "strand": "+",
+        "seqid": "test_seq",
+        "on_alignment": False,
+    }
+    expect = {k: v for k, v in feature_data.items() if k != "on_alignment"}
+    f = seq.make_feature(feature_data)
+    got = f.to_dict()
+    assert got == expect
+    c = seq.add_feature(**got)
+    assert c.to_dict() == expect
+    assert str(f.get_slice()) == str(c.get_slice())
+
+
+def test_aln_feature_to_dict():
+    seqs = [
+        makeSampleSequence("s1", with_gaps=False),
+        makeSampleSequence("s2", with_gaps=True),
+    ]
+    aln = make_aligned_seqs(seqs, array_align=False)
+    feature_data = {
+        "biotype": "CDS",
+        "name": "fake",
+        "spans": [
+            (5, 10),
+        ],
+        "strand": "+",
+        "seqid": None,
+        "on_alignment": True,
+    }
+    # copy this now because modified by the method
+    expect = {k: v for k, v in feature_data.items() if k != "on_alignment"}
+    f = aln.make_feature(feature=feature_data)
+    d = f.to_dict()
+    assert d == expect
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index 3004eeade..4fd146f8c 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -3,8 +3,7 @@ import pathlib
 import numpy
 import pytest
 
-from cogent3 import DNA, RNA, SequenceCollection, _Table, load_seq
-from cogent3.core.annotation import Feature
+from cogent3 import DNA, SequenceCollection, _Table, load_seq
 from cogent3.core.annotation_db import (
     GffAnnotationDb,
     SupportsFeatures,
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index f4460633b..dcff7f553 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -39,9 +39,8 @@ class FeaturesTest(TestCase):
         # annotations by type and optionally by name:
 
         exons = list(self.s.get_features(biotype="exon"))
-        self.assertEqual(
-            str(exons),
-            '["Orig" exon "fred" at [10:15]/48, "Orig" exon "trev" at [30:40]/48]',
+        assert str(exons).startswith(
+            "[Feature(seqid='Orig', biotype='exon', name='fred', map=[10:15]/48, parent=DnaSequence"
         )
 
     def test_union(self):
@@ -121,7 +120,7 @@ def test_feature_residue():
     assert str(aln), ">x\nC-CCCAAAAA\n>y\n-T----TTTT\n"
     db.add_feature(seqid="x", biotype="exon", name="ex1", spans=[(0, 4)])
     aln_exons = list(aln.get_features(seqid="x", biotype="exon"))
-    assert 'exon "ex1" at [0:1, 2:5]/10' in str(aln_exons)
+    assert "biotype='exon', name='ex1', map=[0:1, 2:5]/10" in str(aln_exons)
     assert aln_exons[0].get_slice().to_dict() == dict(x="CCCC", y="----")
     # Feature.as_one_span(), is applied to the exon that
     # straddles the gap in x. The result is we preserve that feature.
@@ -258,7 +257,7 @@ def test_terminal_gaps():
     )
     aln.annotation_db = db
     aln_exons = list(aln.get_features(seqid="x", biotype="exon"))
-    assert 'exon "fred" at [4:9]/10' in str(aln_exons)
+    assert "biotype='exon', name='fred', map=[4:9]/10" in str(aln_exons)
     assert aln_exons[0].get_slice().to_dict() == dict(x="AAAAA", y="--TTT")
     aln = make_aligned_seqs(
         data=[["x", "-AAAAAAAAA"], ["y", "TTTT--T---"]], array_align=False
@@ -422,10 +421,10 @@ def test_feature_from_alignment():
     # Similarly alignment features can be projected onto the aligned sequences,
     # where they may end up falling across gaps:
 
-    exons = aln.get_projected_annotations(seqid="y", biotype="exon")
+    exons = aln.get_projected_features(seqid="y", biotype="exon")
     assert len(exons) == 1
     assert str(aln.get_seq("y")[exons[0].map.without_gaps()]), "TTT"
-    assert 'exon "fred" at [-2-, 4:7]/8' in str(exons[0])
+    assert "biotype='exon', name='fred', map=[-2-, 4:7]/8" in str(exons[0])
 
 
 def test_nested_get_slice():