Repository: cogent3/cogent3
Pull request #1404 (2023-05-01 22:50:05)

Annotation refactor


Patch:
diff --git a/changelog.d/20230501_083455_Gavin.Huttley.md b/changelog.d/20230501_083455_Gavin.Huttley.md
new file mode 100644
index 000000000..c284c434f
--- /dev/null
+++ b/changelog.d/20230501_083455_Gavin.Huttley.md
@@ -0,0 +1,41 @@
+<!--
+A new scriv changelog fragment.
+
+Uncomment the section that is right (remove the HTML comment wrapper).
+-->
+
+<!--
+### Contributers
+
+- A bullet item for the Contributers category.
+
+-->
+<!--
+### ENH
+
+- A bullet item for the ENH category.
+
+-->
+<!--
+### BUG
+
+- A bullet item for the BUG category.
+
+-->
+<!--
+### DOC
+
+- A bullet item for the DOC category.
+
+-->
+<!--
+### Deprecations
+
+- A bullet item for the Deprecations category.
+
+-->
+### Discontinued
+
+- Dropping support for attaching / detaching individual annotation
+  instances from an alignment.
+
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/alignment.py b/src/cogent3/core/alignment.py
index 419119e13..fa02ba71e 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -65,7 +65,6 @@ from cogent3.core.genetic_code import get_code
 from cogent3.core.info import Info as InfoClass
 from cogent3.core.profile import PSSM, MotifCountsArray
 from cogent3.core.sequence import ArraySequence, Sequence, frac_same
-
 # which is a circular import otherwise.
 from cogent3.format.alignment import save_to_filename
 from cogent3.format.fasta import alignment_to_fasta
@@ -2063,6 +2062,31 @@ class _SequenceCollectionBase:
 class SequenceCollection(_SequenceCollectionBase):
     """Container for unaligned sequences"""
 
+    def _apply_annotation_db_to_seqs(self) -> None:
+        """propagates the db bound to self across all sequences
+
+        Raises
+        ------
+        ValueError if a sequence has a different instance
+        """
+        attr = "annotation_db"
+        self_db = self.annotation_db
+        type_self_db = type(self_db)
+        for seq in self.seqs:
+            if hasattr(seq, "data"):
+                seq = seq.data
+            seq_db = seq.annotation_db
+            if seq_db and not isinstance(seq_db, type_self_db):
+                raise ValueError(
+                    f"inconsistent state with self db {type_self_db} and {seq.name!r} db {type(seq_db)}"
+                )
+            elif seq_db and seq_db is not self_db:
+                raise ValueError(
+                    f"inconsistent state {seq.name!r} has a different instance of {type(seq_db)}"
+                )
+
+            seq.annotation_db = self_db
+
     def copy_annotations(self, seq_db: SupportsFeatures) -> None:
         """copy annotations into attached annotation db
 
@@ -2099,6 +2123,7 @@ class SequenceCollection(_SequenceCollectionBase):
             raise TypeError(f"type {type(seq_db)} != {type(self.annotation_db)}")
 
         self.annotation_db.update(seq_db, seqids=self.names)
+        self._apply_annotation_db_to_seqs()
 
     def annotate_from_gff(
         self, f: os.PathLike, seq_ids: Optional[Union[list[str], str]] = None
@@ -2161,6 +2186,7 @@ class SequenceCollection(_SequenceCollectionBase):
         biotype: str,
         name: str,
         spans: List[Tuple[int, int]],
+        parent_id: Optional[str] = None,
         strand: str = "+",
     ) -> Annotation:
         """
@@ -2170,6 +2196,8 @@ class SequenceCollection(_SequenceCollectionBase):
         ----------
         seqid
             seq name to associate with
+        parent_id
+            name of the parent feature
         biotype
             biological type
         name
@@ -2193,6 +2221,7 @@ class SequenceCollection(_SequenceCollectionBase):
         feature = {k: v for k, v in locals().items() if k != "self"}
 
         self.annotation_db.add_feature(**feature)
+        feature.pop("parent_id", None)
         return self.make_feature(feature=feature)
 
     def get_features(
@@ -5085,6 +5114,7 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
         name: str,
         spans: List[Tuple[int, int]],
         seqid: Optional[str] = None,
+        parent_id: Optional[str] = None,
         strand: str = "+",
         on_alignment: Optional[bool] = None,
     ) -> Annotation:
@@ -5095,6 +5125,8 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
         ----------
         seqid
             sequence name, incompatible with on_alignment
+        parent_id
+            name of the parent feature
         biotype
             biological type, e.g. CDS
         name
@@ -5118,8 +5150,8 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
         """
         if seqid and on_alignment is None:
             on_alignment = False
-        else:
-            on_alignment = on_alignment or True
+        elif not on_alignment:
+            on_alignment = True if on_alignment is None else False
 
         if seqid and on_alignment:
             raise ValueError("seqid and on_alignment are incomatible")
@@ -5134,7 +5166,8 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
         feature = {k: v for k, v in locals().items() if k != "self"}
 
         self.annotation_db.add_feature(**feature)
-        feature.pop("on_alignment", None)
+        for discard in ("on_alignment", "parent_id"):
+            feature.pop(discard, None)
         return self.make_feature(feature=feature, on_alignment=on_alignment)
 
     def make_feature(
@@ -5167,6 +5200,9 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
         -----
         To get a feature AND add it to annotation_db, use add_feature().
         """
+        if on_alignment is None:
+            on_alignment = feature.pop("on_alignment", None)
+
         if not on_alignment:
             return self.named_seqs[feature["seqid"]].make_feature(feature, self)
 
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 46134562e..42d3a5298 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -462,18 +462,19 @@ class Annotation(_AnnotationCore, _Serialisable):
             return self
         keep = self.map.nongap()
         return self.__class__(
-            self.parent,
-            self.map[keep],
+            parent=self.parent,
+            map=self.map[keep],
             biotype=self.biotype,
             name=self.name,
             seqid=self.seqid,
         )
 
     def as_one_span(self):
+        """returns a feature that preserves any gaps"""
         new_map = self.map.get_covering_span()
         return self.__class__(
-            self.parent,
-            new_map,
+            parent=self.parent,
+            map=new_map,
             biotype=self.biotype,
             name=f"one-span {self.name}",
             seqid=self.seqid,
@@ -516,7 +517,7 @@ class Annotation(_AnnotationCore, _Serialisable):
     def remapped_to(self, grandparent, gmap):
         kwargs = {
             **self._serialisable,
-            **{"map": gmap[self.map], "parent": grandparent},
+            **{"map": gmap[self.map], "parent": grandparent, "seqid": grandparent.name},
         }
         return self.__class__(**kwargs)
 
@@ -530,7 +531,15 @@ class Annotation(_AnnotationCore, _Serialisable):
         make_feature = self.parent.make_feature
         db = self.parent.annotation_db
         for child in db.get_feature_children(biotype=biotype, name=self.name):
-            yield make_feature(child)
+            yield make_feature(feature=child)
+
+    def get_parent(self, biotype: Optional[str] = None):
+        """generator returns parent features of self optionally matching biotype"""
+        make_feature = self.parent.make_feature
+        db = self.parent.annotation_db
+        for child in db.get_feature_parent(biotype=biotype, name=self.name):
+
+            yield make_feature(feature=child)
 
     def union(self, features):
         """return as a single Annotation
@@ -613,10 +622,20 @@ class _Feature(_Annotatable, _Serialisable):
 
         return make_shape(type_=self)
 
-    def attach(self):
+    @c3warn.deprecated_callable(
+        "2023.7",
+        reason="no longer valid given usage of database to store annotations",
+        is_discontinued=True,
+    )
+    def attach(self):  # pragma: no cover
         self.parent.attach_annotations([self])
 
-    def detach(self):
+    @c3warn.deprecated_callable(
+        "2023.7",
+        reason="no longer valid given usage of database to store annotations",
+        is_discontinued=True,
+    )
+    def detach(self):  # pragma: no cover
         self.parent.detach_annotations([self])
 
     def _mapped(self, slicemap):
diff --git a/src/cogent3/core/annotation_db.py b/src/cogent3/core/annotation_db.py
index c5ef9ff27..fcf0194f0 100644
--- a/src/cogent3/core/annotation_db.py
+++ b/src/cogent3/core/annotation_db.py
@@ -123,6 +123,7 @@ class SupportsWriteFeatures(typing.Protocol):  # should be defined centrally
         name: str,
         spans: typing.List[typing.Tuple[int, int]],
         seqid: OptionalStr = None,
+        parent_id: OptionalStr = None,
         strand: OptionalStr = None,
         on_alignment: bool = False,
     ) -> None:
@@ -434,12 +435,14 @@ class SqliteAnnotationDbMixin:
 
     def add_feature(
         self,
+        *,
         seqid: str,
         biotype: str,
         name: str,
         spans: typing.List[typing.Tuple[int, int]],
-        strand: str = None,
-        on_alignment: bool = False,
+        parent_id: OptionalStr = None,
+        strand: OptionalStr = None,
+        on_alignment: OptionalBool = False,
     ) -> None:
         """adds a record to user table
 
@@ -735,22 +738,25 @@ class GffAnnotationDb(SqliteAnnotationDbMixin):
         return reduced
 
     def _get_feature_by_id(
-        self, table_name: str, column: str, name: str, biotype: OptionalStr = None
+        self,
+        table_name: str,
+        columns: list[str],
+        column: str,
+        name: str,
+        biotype: OptionalStr = None,
     ) -> typing.List[FeatureDataType]:
         # we return the parent_id because `get_feature_parent()` requires it
         sql, vals = _select_records_sql(
             table_name=table_name,
             conditions={column: name, "biotype": biotype},
-            columns=["biotype", "spans", "strand", "name", "parent_id"],
+            columns=columns,
         )
         for result in self._execute_sql(sql, values=vals):
-            yield {
-                "biotype": result["biotype"],
-                "name": result["name"],
-                "spans": [tuple(c) for c in result["spans"]],
-                "reversed": result["strand"] == "-",
-                "parent_id": result["parent_id"],
-            }
+            result = dict(zip(result.keys(), result))
+            result["on_alignment"] = result.get("on_alignment", None)
+            result["spans"] = [tuple(c) for c in result["spans"]]
+            result["reversed"] = result.pop("strand", None) == "-"
+            yield result
 
     def get_feature_children(
         self, name: str, biotype: OptionalStr = None, **kwargs
@@ -759,8 +765,12 @@ class GffAnnotationDb(SqliteAnnotationDbMixin):
         # kwargs is used because other classes need start / end
         # just uses search matching
         for table_name in self.table_names:
+            columns = "seqid", "biotype", "spans", "strand", "name", "parent_id"
+            if table_name == "user":
+                columns += ("on_alignment",)
             for result in self._get_feature_by_id(
                 table_name=table_name,
+                columns=columns,
                 column="parent_id",
                 name=f"%{name}%",
                 biotype=biotype,
@@ -773,8 +783,11 @@ class GffAnnotationDb(SqliteAnnotationDbMixin):
     ) -> typing.Iterator[FeatureDataType]:
         """yields parents of name"""
         for table_name in self.table_names:
+            columns = "seqid", "biotype", "spans", "strand", "name", "parent_id"
+            if table_name == "user":
+                columns += ("on_alignment",)
             for result in self._get_feature_by_id(
-                table_name=table_name, column="name", name=f"%{name}%"
+                table_name=table_name, columns=columns, column="name", name=f"%{name}%"
             ):
                 # multiple values for parent means this is better expressed
                 # as an OR clause
@@ -783,6 +796,7 @@ class GffAnnotationDb(SqliteAnnotationDbMixin):
                     if parent := list(
                         self._get_feature_by_id(
                             table_name=table_name,
+                            columns=columns,
                             column="name",
                             name=name,
                         )
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 535cd3e84..858b7b9c1 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -37,15 +37,17 @@ from numpy import (
 from numpy.random import permutation
 
 from cogent3.core.alphabet import AlphabetError
-from cogent3.core.annotation import Annotation, Map, _Annotatable
+from cogent3.core.annotation import Annotation, _Annotatable
 from cogent3.core.annotation_db import (
     FeatureDataType,
     GenbankAnnotationDb,
     GffAnnotationDb,
+    SupportsFeatures,
     load_annotations,
 )
 from cogent3.core.genetic_code import get_code
 from cogent3.core.info import Info as InfoClass
+from cogent3.core.location import LostSpan, Map
 from cogent3.format.fasta import alignment_to_fasta
 from cogent3.maths.stats.contingency import CategoryCounts
 from cogent3.maths.stats.number import CategoryCounter
@@ -969,8 +971,6 @@ class Sequence(_Annotatable, SequenceI):
             spans = array(feature.pop("spans"), dtype=int)
             for i, v in enumerate(spans.ravel()):
                 rel_pos = self._seq.relative_position(v)
-                if rel_pos < 0 or rel_pos > len(self):
-                    raise NotImplementedError
                 spans.ravel()[i] = rel_pos
 
             if reversed:
@@ -1033,7 +1033,37 @@ class Sequence(_Annotatable, SequenceI):
         # (a string) into a bool?
         revd = feature.pop("reversed", None) or feature.pop("strand", None) == "-"
         spans = feature.pop("spans", None)
-        fmap = Map(locations=spans, parent_length=len(self))
+
+        vals = array(spans)
+        pre = abs(vals.min()) if vals.min() < 0 else 0
+        post = abs(vals.max() - len(self)) if vals.max() > len(self) else 0
+
+        # we find the spans > len(self)
+        new_spans = []
+        for coord in vals:
+            new = coord[:]
+            if coord.min() < 0 < coord.max():
+                new = coord[:]
+                new[new < 0] = 0
+            elif coord.min() < len(self) < coord.max():
+                new[new > len(self)] = len(self)
+
+            if coord[0] == coord[1]:
+                # would really to adjust pre and post to be up to the next span
+                continue
+            new_spans.append(new.tolist())
+
+        fmap = Map(locations=new_spans, parent_length=len(self))
+        if pre or post:
+            # create a lost span to represent the segment missing from
+            # the instance
+            spans = fmap.spans
+            if pre:
+                spans.insert(0, LostSpan(pre))
+            if post:
+                spans.append(LostSpan(post))
+            fmap = Map(spans=spans, parent_length=len(self))
+
         if revd and not seq_rced:
             # the sequence is on the plus strand, and the
             # feature coordinates are also for the plus strand
@@ -1080,7 +1110,8 @@ class Sequence(_Annotatable, SequenceI):
         biotype: str,
         name: str,
         spans: List[Tuple[int, int]],
-        strand: str = None,
+        parent_id: Optional[str] = None,
+        strand: Optional[str] = None,
         on_alignment: bool = False,
     ) -> Annotation:
         if self.annotation_db is None:
@@ -1091,7 +1122,8 @@ class Sequence(_Annotatable, SequenceI):
         )
 
         self.annotation_db.add_feature(**feature_data)
-        feature_data.pop("on_alignment")
+        for discard in ("on_alignment", "parent_id"):
+            feature_data.pop(discard)
         return self.make_feature(feature_data)
 
     def to_moltype(self, moltype):
@@ -1122,8 +1154,35 @@ class Sequence(_Annotatable, SequenceI):
         """Returns filtered seq; used to do DNA/RNA conversions."""
         return seq
 
-    def copy_annotations(self, other):
-        self.annotations = other.annotations[:]
+    def copy_annotations(self, seq_db: SupportsFeatures) -> None:
+        """copy annotations into attached annotation db
+
+        Parameters
+        ----------
+        seq_db
+            compatible annotation db
+
+        Notes
+        -----
+        Only copies annotations for records with seqid self.name
+        """
+        if not isinstance(seq_db, SupportsFeatures):
+            raise TypeError(
+                f"type {type(seq_db)} does not match SupportsFeatures interface"
+            )
+
+        if not seq_db.num_matches(seqid=self.name):
+            return
+
+        if not self.annotation_db:
+            # todo gah add ability to query multiple values in Annotation db
+            self.annotation_db = type(seq_db)(
+                data=[]
+            )  # make an empty db of the same type
+        elif not isinstance(seq_db, type(self.annotation_db)):
+            raise TypeError(f"type {type(seq_db)} != {type(self.annotation_db)}")
+
+        self.annotation_db.update(seq_db, seqids=self.name)
 
     def copy(self):
         """returns a copy of self"""


Test patch:
diff --git a/tests/test_core/test_annotation.py b/tests/test_core/test_annotation.py
index 1a0278de8..6cc26fba3 100644
--- a/tests/test_core/test_annotation.py
+++ b/tests/test_core/test_annotation.py
@@ -50,20 +50,6 @@ class TestAnnotations(unittest.TestCase):
         self.seq = makeSampleSequence("seq1")
         self.aln = makeSampleAlignment()
 
-    @pytest.mark.xfail(
-        reason="todo gah get_features() needs to convert span indices into relative indices"
-    )
-    def test_slice_seq_with_annotations(self):
-        # we make sure the slice contains both features intact
-        # for simplifying the test
-        newseq = self.seq[10:]
-        for annot_type in ["CDS", "5'UTR"]:
-            orig = list(self.seq.get_features(biotype=annot_type))[0]
-            new = list(newseq.get_features(biotype=annot_type))[0]
-            assert orig.name == new.name
-            assert len(orig) == len(new)
-            assert str(newseq[new]) == str(self.seq[orig]), annot_type
-
     def test_add_annotated_seqs_drops_annotations(self):
         # retain link to annotation db as long as a simple slice
         a = self.seq[:5]
@@ -105,19 +91,6 @@ class TestAnnotations(unittest.TestCase):
             expected = seq_expecteds[annot_type]
             assert observed == expected
 
-    @pytest.mark.xfail(reason="todo gah update to latest API")
-    def test_feature_projection(self):
-        expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
-        aln_ltr = self.aln.get_features_matching("LTR")[0]
-        for seq_name in ["FAKE01", "FAKE02"]:
-            expected = expecteds[seq_name]
-            seq_ltr = self.aln.project_annotation(seq_name, aln_ltr)
-            if "-" in expected:
-                self.assertRaises(ValueError, seq_ltr.get_slice)
-                seq_ltr = seq_ltr.without_lost_spans()
-                expected = expected.replace("-", "")
-            self.assertEqual(seq_ltr.get_slice(), expected)
-
     def test_feature_copy_annotations_to(self):
         """test correct copy of annotations"""
         orig = DnaSequence("TTTTTTTTTTAAAA", name="Orig")
@@ -248,3 +221,88 @@ def test_region_union_on_alignment(annot_type, reversed):
     new = new.get_slice().to_dict()
     expected = aln_expecteds[annot_type]
     assert expected == new, (annot_type, expected, new)
+
+
+@pytest.fixture()
+def ann_aln():
+    # synthetic annotated alignment
+    return makeSampleAlignment()
+
+
+def test_feature_projection_ungapped(ann_aln):
+    # projection onto ungapped sequence
+    expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
+    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)
+    assert str(seq_ltr.get_slice()) == expected
+    assert seq_ltr.seqid == seq_name
+    assert seq_ltr.parent is ann_aln.get_seq(seq_name)
+
+
+def test_feature_projection_gapped(ann_aln):
+    # projection onto gapped sequence
+    expecteds = {"FAKE01": "CCCAAAATTTTTT", "FAKE02": "CCC-----TTTTT"}
+    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)
+
+    with pytest.raises(ValueError):
+        seq_ltr.get_slice()
+
+    # to get the annotation on the seq coord
+    seq_ltr = seq_ltr.without_lost_spans()
+    expected = expected.replace("-", "")
+    assert str(seq_ltr.get_slice()) == expected
+    assert seq_ltr.seqid == seq_name
+    assert seq_ltr.parent is ann_aln.get_seq(seq_name)
+
+
+@pytest.fixture()
+def ann_seq():
+    return makeSampleSequence("seq1")
+
+
+@pytest.mark.parametrize("annot_type", ("CDS", "5'UTR"))
+def test_slice_seq_with_full_annotations(ann_seq, annot_type):
+    # this slice contains both features intact
+    newseq = ann_seq[10:]
+    orig = list(ann_seq.get_features(biotype=annot_type))[0]
+    new = list(newseq.get_features(biotype=annot_type))[0]
+    assert orig.name == new.name
+    assert len(orig) == len(new)
+    assert str(newseq[new]) == str(ann_seq[orig]), annot_type
+
+
+@pytest.mark.parametrize("annot_type,num", (("CDS", 0), ("5'UTR", 1)))
+def test_slice_seq_with_partial_end(ann_seq, annot_type, num):
+    # this slice contains both features intact
+    newseq = ann_seq[:14]
+    # only UTR is present
+    new = list(newseq.get_features(biotype=annot_type, allow_partial=True))
+    assert len(new) == num, annot_type
+    if num:
+        feat = new[0]
+        # length of the feature is the same as the original
+        assert len(feat) == len(list(ann_seq.get_features(biotype=annot_type))[0])
+        gapless = feat.without_lost_spans()
+        # the sliced feature without gaps is shorter
+        assert len(gapless) < len(feat)
+
+
+@pytest.mark.parametrize("annot_type,num", (("CDS", 1), ("5'UTR", 0)))
+def test_slice_seq_with_partial_start(ann_seq, annot_type, num):
+    # this slice contains both features intact
+    newseq = ann_seq[18:]
+    # only UTR is present
+    new = list(newseq.get_features(biotype=annot_type, allow_partial=True))
+    assert len(new) == num, annot_type
+    if num:
+        feat = new[0]
+        # length of the feature is the same as the original
+        assert len(feat) == len(list(ann_seq.get_features(biotype=annot_type))[0])
+        gapless = feat.without_lost_spans()
+        # the sliced feature without gaps is shorter
+        assert len(gapless) < len(feat)
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index 99aa4e696..7e646eae9 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -299,6 +299,28 @@ def test_gav2():
     assert str(got) == str(expect)
 
 
+def test_add_feature_with_parent():
+    db = GffAnnotationDb(data=[])
+    db.add_feature(
+        seqid="s1",
+        biotype="cds",
+        name="GG",
+        spans=[(0, 100)],
+        strand="+",
+    )
+    db.add_feature(
+        seqid="s1",
+        biotype="exon",
+        name="child",
+        spans=[(10, 30)],
+        strand="+",
+        parent_id="GG",
+    )
+    got = list(db.get_features_matching(name="GG"))[0]
+    child = list(db.get_feature_children(got["name"]))[0]
+    assert child["name"] == "child"
+
+
 def test_get_features_matching_matching_features(anno_db: GffAnnotationDb, seq):
     """
     Test that `get_features_matching` returns a list with all matching features in the annotation database.
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index 9ca7f5905..4f350d46d 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -4,7 +4,7 @@ import pytest
 
 from cogent3 import ASCII, DNA, make_aligned_seqs
 from cogent3.core.annotation import Feature, Variable
-
+from cogent3.core.annotation_db import GenbankAnnotationDb, GffAnnotationDb
 # Complete version of manipulating sequence annotations
 from cogent3.util.deserialise import deserialise_object
 
@@ -55,41 +55,6 @@ class FeaturesTest(TestCase):
             '["Orig" exon "fred" at [10:15]/48, "Orig" exon "trev" at [30:40]/48]',
         )
 
-    @pytest.mark.xfail(reason="todo gah update test to use latest API")
-    def test_get_nested_annotations_matching(self):
-        """correctly identifies all features of a given type when nested annotations"""
-
-        seq = DNA.make_seq("AAAAAAAAA", name="x")
-        exon = seq.add_annotation(Feature, "exon", "fred", [(3, 8)])
-        nested_exon = exon.add_annotation(Feature, "exon", "fred", [(3, 7)])
-        exons = seq.get_features(biotype="exon", extend_query=True)
-        self.assertEqual(len(exons), 2)
-        self.assertEqual(str(exons), '[exon "fred" at [3:8]/9, exon "fred" at [3:7]/5]')
-        # tests multiple layers of nested annotations
-        nested_exon.add_annotation(Feature, "exon", "fred", [(3, 6)])
-        exons = seq.get_features(biotype="exon", extend_query=True)
-        self.assertEqual(len(exons), 3)
-        self.assertEqual(
-            str(exons),
-            '[exon "fred" at [3:8]/9, exon "fred" at [3:7]/5, exon "fred" at [3:6]/4]',
-        )
-        # tests extend_query=False, and only get back the base exon
-        exons = seq.get_features(biotype="exon")
-        self.assertEqual(len(exons), 1)
-        self.assertEqual(str(exons), '[exon "fred" at [3:8]/9]')
-
-    @pytest.mark.xfail(reason="todo gah update test to use latest API")
-    def test_get_features_matching2(self):
-        """get_features_matching returns empty feature if no matches"""
-
-        # If the sequence does not have a matching feature
-        # you get back an empty list, and slicing the sequence
-        # with that returns a sequence of length 0.
-
-        dont_exist = self.s.get_features(biotype="dont_exist")
-        self.assertEqual(dont_exist, [])
-        self.assertEqual(str(self.s[dont_exist]), "")
-
     def test_union(self):
         """combines multiple features into one"""
 
@@ -117,23 +82,6 @@ class FeaturesTest(TestCase):
         shadow = exon1.union(exons).shadow()
         assert str(shadow.get_slice()) == expect
 
-    @pytest.mark.xfail(reason="todo gah delete test? or update to new API")
-    def test_feature_attach_detach(self):
-        """correctly associate, disassociate from seq"""
-
-        # Features are generally attached to the thing they annotate,
-        # but in those cases where a free-floating feature is created it can
-        # ater be attached:
-
-        exons = self.s.get_features(biotype="exon")
-        self.assertEqual(len(self.s.annotations), 2)
-        region = self.s.get_region_covering_all(exons)
-        self.assertEqual(len(self.s.annotations), 2)
-        region.attach()
-        self.assertEqual(len(self.s.annotations), 3)
-        region.detach()
-        self.assertEqual(len(self.s.annotations), 2)
-
     @pytest.mark.xfail(reason="todo gah update test to use latest API")
     def test_feature_reverse(self):
         """reverse complement of features"""
@@ -223,22 +171,6 @@ class FeaturesTest(TestCase):
         self.assertEqual(str(copied), '[exon "A" at [5:5, -4-]/5]')
         self.assertEqual(str(copied[0].get_slice()), ">x\n----\n>y\n----\n")
 
-    @pytest.mark.xfail(reason="todo gah delete test, not supporting this")
-    def test_seq_different_name_with_same_length(self):
-        """copying features between sequences"""
-
-        # You can copy to a sequence with a different name,
-        # in a different alignment if the feature lies within the length
-
-        aln = make_aligned_seqs(
-            data=[["x", "-AAAAAAAAA"], ["y", "TTTT--TTTT"]], array_align=False
-        )
-        seq = DNA.make_seq("CCCCCCCCCCCCCCCCCCCC", "x")
-        seq.add_feature("exon", "A", [(5, 8)])
-        aln.get_seq("y").copy_annotations(seq)
-        copied = list(aln.get_features(seqid="y", name="exon"))
-        self.assertEqual(str(copied), '[exon "A" at [7:10]/10]')
-
     @pytest.mark.xfail(reason="todo gah update test to use latest API")
     def test_seq_shorter(self):
         """lost spans on shorter sequences"""
@@ -275,49 +207,6 @@ class FeaturesTest(TestCase):
         aln_exons = list(aln.get_features(seqid="x", name="exon"))
         self.assertEqual(str(aln_exons[0].get_slice()), ">x\nAAAAA\n>y\n--T--\n")
 
-    @pytest.mark.xfail(reason="todo gah update test to use latest API")
-    def test_feature_residue(self):
-        """seq features on alignment operate in sequence coordinates"""
-        # In this case, only those residues included within the feature are
-        # covered - note the omission of the T in y opposite the gap in x.
-
-        aln = make_aligned_seqs(
-            data=[["x", "C-CCCAAAAA"], ["y", "-T----TTTT"]],
-            moltype=DNA,
-            array_align=False,
-        )
-        self.assertEqual(str(aln), ">x\nC-CCCAAAAA\n>y\n-T----TTTT\n")
-        exon = aln.get_seq("x").add_feature("exon", "ex1", [(0, 4)])
-        self.assertEqual(str(exon), 'exon "ex1" at [0:4]/9')
-        self.assertEqual(str(exon.get_slice()), "CCCC")
-        aln_exons = list(aln.get_features(seqid="x", name="exon"))
-        self.assertEqual(str(aln_exons), '[exon "ex1" at [0:1, 2:5]/10]')
-        self.assertEqual(str(aln_exons[0].get_slice()), ">x\nCCCC\n>y\n----\n")
-
-        # Feature.as_one_span(), is applied to the exon that
-        # straddles the gap in x. The result is we preserve that feature.
-
-        self.assertEqual(
-            str(aln_exons[0].as_one_span().get_slice()), ">x\nC-CCC\n>y\n-T---\n"
-        )
-
-        # These properties also are consistently replicated with reverse
-        # complemented sequences.
-
-        aln_rc = aln.rc()
-        rc_exons = list(aln_rc.get_annotations_from_any_seq("exon"))
-        # not using as_one_span, so gap removed from x
-        self.assertEqual(str(aln_rc[rc_exons]), ">x\nCCCC\n>y\n----\n")
-        self.assertEqual(
-            str(aln_rc[rc_exons[0].as_one_span()]), ">x\nC-CCC\n>y\n-T---\n"
-        )
-
-        # Features can provide their coordinates, useful for custom analyses.
-
-        all_exons = aln.get_region_covering_all(aln_exons)
-        coords = all_exons.get_coordinates()
-        assert coords == [(0, 1), (2, 5)]
-
     @pytest.mark.xfail(reason="todo gah change test to new api")
     def test_annotated_region_masks(self):
         """masking a sequence with specific features"""
@@ -731,3 +620,147 @@ class FeaturesTest(TestCase):
             pattern=pattern, annot_type="domain", name="fred", allow_multiple=False
         )
         self.assertEqual(annot, [])
+
+
+def test_copy_annotations():
+    """copying features from a db"""
+    aln = make_aligned_seqs(
+        data=[["x", "-AAAAAAAAA"], ["y", "TTTT--CCCT"]], array_align=False
+    )
+    db = GffAnnotationDb(data=[])
+    db.add_feature(seqid="y", biotype="exon", name="A", spans=[(5, 8)])
+    aln.copy_annotations(db)
+    feat = list(aln.get_features(seqid="y", biotype="exon"))[0]
+    assert feat.get_slice().to_dict() == dict(x="AAA", y="CCT")
+
+
+def test_feature_residue():
+    """seq features on alignment operate in sequence coordinates"""
+    # In this case, only those residues included within the feature are
+    # covered - note the omission of the T in y opposite the gap in x.
+
+    aln = make_aligned_seqs(
+        data=[["x", "C-CCCAAAAA"], ["y", "-T----TTTT"]],
+        moltype=DNA,
+        array_align=False,
+    )
+    assert str(aln), ">x\nC-CCCAAAAA\n>y\n-T----TTTT\n"
+    exon = aln.get_seq("x").add_feature(biotype="exon", name="ex1", spans=[(0, 4)])
+    assert 'exon "ex1" at [0:4]/9' in str(exon)
+    assert str(exon.get_slice()), "CCCC"
+    aln_exons = list(aln.get_features(seqid="x", biotype="exon"))
+    assert 'exon "ex1" at [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.
+    exon_full_aln = aln_exons[0].as_one_span()
+    assert exon_full_aln.get_slice().to_dict() == dict(x="C-CCC", y="-T---")
+
+    # These properties also are consistently replicated with reverse
+    # complemented sequences.
+
+    aln_rc = aln.rc()
+    rc_exons = list(aln_rc.get_features(biotype="exon"))[0]
+    assert rc_exons.get_slice().to_dict() == dict(x="CCCC", y="----")
+    assert rc_exons.as_one_span().get_slice().to_dict() == dict(x="C-CCC", y="-T---")
+    return
+    # Features can provide their coordinates, useful for custom analyses.
+
+    coords = all_exons[0].get_coordinates()
+    assert coords == [(0, 1), (2, 5)]
+
+
+@pytest.fixture()
+def ann_seq():
+    # A Sequence with a couple of exons on it.
+    s = DNA.make_seq("AAGAAGAAGACCCCCAAAAAAAAAATTTTTTTTTTAAAAAAAAAAAAA", name="Orig")
+    exon1 = s.add_feature(biotype="gene", name="a-gene", spans=[(10, 40)])
+    exon1 = s.add_feature(
+        biotype="exon", name="fred", spans=[(10, 15), (30, 40)], parent_id="a-gene"
+    )
+    return s
+
+
+def test_get_features_no_matches(ann_seq):
+    """get_features returns empty list if no matches"""
+
+    # If the sequence does not have a matching feature
+    # you get back an empty list, and slicing the sequence
+    # with that returns a sequence of length 0.
+
+    dont_exist = list(ann_seq.get_features(biotype="dont_exist"))
+    assert dont_exist == []
+
+
+def _add_features(obj, on_alignment):
+    kwargs = dict(on_alignment=on_alignment) if on_alignment else {}
+    obj.add_feature(biotype="CDS", name="GG", spans=[(0, 10)], strand="+", **kwargs)
+    obj.add_feature(
+        biotype="exon",
+        name="child",
+        spans=[(3, 6)],
+        strand="+",
+        parent_id="GG",
+        **kwargs,
+    )
+    obj.add_feature(
+        biotype="exon",
+        name="not-child",
+        spans=[(3, 6)],
+        strand="+",
+        parent_id="AA",
+        **kwargs,
+    )
+    return obj
+
+
+def test_feature_query_child_seq():
+    s = DNA.make_seq("AAAGGGAAAA", name="s1")
+    s = _add_features(s, on_alignment=False)
+    gene = list(s.get_features(biotype="CDS"))[0]
+    child = list(gene.get_children())
+    assert len(child) == 1
+    child = child[0]
+    assert child.name == "child"
+    assert str(child.get_slice()) == str(s[3:6])
+
+
+def test_feature_query_parent_seq():
+    s = DNA.make_seq("AAAGGGAAAA", name="s1")
+    s = _add_features(s, on_alignment=False)
+    exon = list(s.get_features(name="child"))[0]
+    parent = list(exon.get_parent())
+    assert len(parent) == 1
+    parent = parent[0]
+    assert parent.name == "GG"
+    assert str(parent.get_slice()) == str(s[0:10])
+
+
+def test_feature_query_child_aln():
+    aln = make_aligned_seqs(
+        data=[["x", "-AAAGGGGGAAC-CT"], ["y", "TTTT--TTTTAGGGA"]],
+        array_align=False,
+        moltype="dna",
+    )
+    aln = _add_features(aln, on_alignment=True)
+    gene = list(aln.get_features(biotype="CDS"))[0]
+    child = list(gene.get_children())
+    assert len(child) == 1
+    child = child[0]
+    assert child.name == "child"
+    assert child.get_slice().to_dict() == aln[3:6].to_dict()
+
+
+def test_feature_query_parent_aln():
+    aln = make_aligned_seqs(
+        data=[["x", "-AAAGGGGGAAC-CT"], ["y", "TTTT--TTTTAGGGA"]],
+        array_align=False,
+        moltype="dna",
+    )
+    aln = _add_features(aln, on_alignment=True)
+    child = list(aln.get_features(name="child"))[0]
+    parent = list(child.get_parent())
+    assert len(parent) == 1
+    parent = parent[0]
+    assert parent.name == "GG"
+    assert parent.get_slice().to_dict() == aln[0:10].to_dict()
diff --git a/tests/test_parse/test_cigar.py b/tests/test_parse/test_cigar.py
index 62c4ee033..794fbcee4 100644
--- a/tests/test_parse/test_cigar.py
+++ b/tests/test_parse/test_cigar.py
@@ -76,10 +76,11 @@ class TestCigar(unittest.TestCase):
         i = 1
         for start, end in self.slices:
             self.aln.get_seq("FAKE01").add_feature(
-                "annot%d" % i, "annot", [(start, end)]
+                biotype=f"annot{i}", name="annot", spans=[(start, end)]
             )
-            annot = self.aln.get_annotations_from_any_seq("annot%d" % i)
-            slice_aln = aln.get_region_covering_all(annot).as_one_span().get_slice()
+            annot = list(self.aln.get_features(biotype=f"annot{i}"))
+            annot = annot[0].union(annot[1:])
+            slice_aln = annot.as_one_span().get_slice()
             i += 1
 
             cmp_aln = CigarParser(
@@ -90,7 +91,7 @@ class TestCigar(unittest.TestCase):
                 start=start,
                 end=end,
             )
-            assert cmp_aln == slice_aln
+            assert cmp_aln.to_dict() == slice_aln.to_dict()
 
 
 if __name__ == "__main__":