Repository: cogent3/cogent3
Pull request #1382 (2023-04-24 06:14:48)

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/alignment.py b/src/cogent3/core/alignment.py
index 54f06a18c..326c85a3d 100644
--- a/src/cogent3/core/alignment.py
+++ b/src/cogent3/core/alignment.py
@@ -18,6 +18,7 @@
     passed in a stream of two-item label, sequence pairs. However, this can
     cause confusion when testing.
 """
+import functools
 import json
 import os
 import re
@@ -28,6 +29,7 @@ from copy import deepcopy
 from functools import total_ordering
 from itertools import combinations
 from types import GeneratorType
+from typing import Iterator, List, Optional, Tuple, Union
 
 import numpy
 
@@ -51,13 +53,18 @@ from numpy.random import choice, permutation, randint
 
 import cogent3  # will use to get at cogent3.parse.fasta.MinimalFastaParser,
 
-from cogent3.core.annotation import Map, _Annotatable
-from cogent3.core.annotation_db import GenbankAnnotationDb, load_annotations
+from cogent3.core.annotation import Annotation, Map, _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.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
@@ -67,6 +74,7 @@ from cogent3.maths.stats.number import CategoryCounter
 from cogent3.maths.util import safe_log
 from cogent3.parse.gff import gff_parser
 from cogent3.util import progress_display as UI
+from cogent3.util import warning as c3warn
 from cogent3.util.dict_array import DictArrayTemplate
 from cogent3.util.io import atomic_write, get_format_suffixes
 from cogent3.util.misc import (
@@ -98,6 +106,9 @@ __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Production"
 
 
+DEFAULT_ANNOTATION_DB = GffAnnotationDb
+
+
 class DataError(Exception):
     pass
 
@@ -338,6 +349,57 @@ def seqs_from_empty(obj, *args, **kwargs):
     raise ValueError("Cannot create empty SequenceCollection.")
 
 
+@functools.singledispatch
+def merged_db_collection(seqs) -> SupportsFeatures:
+    """return one AnnotationDb's
+
+    Parameters
+    ----------
+    seqs
+        iterable list of data
+
+    Returns
+    -------
+    list of all annotation db's
+
+    Raises
+    ------
+    TypeError if different classes of AnnotationDb
+    """
+    first = None
+    merged = None
+    db_attr = "annotation_db"
+    for seq in seqs:
+        if not isinstance(seq, (Aligned, Sequence)):
+            continue
+        if isinstance(seq, Aligned):
+            db = seq.data.annotation_db
+        elif isinstance(seq, Sequence):
+            db = seq.annotation_db
+
+        if first is None and db:
+            # todo gah should this be a copy so immutable?
+            first = db
+            merged = first
+            continue
+
+        if first and db and type(first) != type(db):
+            raise TypeError(
+                f"annotation db types must be equal: {type(first)} != {type(db)}"
+            )
+
+        if first is None or db is None:
+            continue
+        first.update(db)
+
+    return merged
+
+
+@merged_db_collection.register
+def _(seqs: dict) -> SupportsFeatures:
+    return merged_db_collection(seqs.values())
+
+
 @total_ordering
 class _SequenceCollectionBase:
     """
@@ -457,6 +519,16 @@ class _SequenceCollectionBase:
         # read all the data in if we were passed a generator
         if isinstance(data, GeneratorType):
             data = list(data)
+
+        input_type = self._guess_input_type(data)
+        if input_type in ("aln", "collection"):
+            anno_db = merged_db_collection(data.seqs)
+        elif input_type == "kv_pairs":
+            anno_db = merged_db_collection(dict(data))
+        elif isinstance(data, (tuple, list)):
+            anno_db = merged_db_collection(data)
+        else:
+            anno_db = None
         # set the name
         self.name = name
         names = list(names) if names is not None else names
@@ -511,7 +583,7 @@ class _SequenceCollectionBase:
         self._set_additional_attributes(curr_seqs)
 
         self._repr_policy = dict(num_seqs=10, num_pos=60, ref_name="longest", wrap=60)
-        self._annotation_db = None
+        self._annotation_db = anno_db or None
 
     @property
     def annotation_db(self):
@@ -1995,23 +2067,46 @@ class _SequenceCollectionBase:
 class SequenceCollection(_SequenceCollectionBase):
     """Container for unaligned sequences"""
 
-    def copy_annotations(self, unaligned):
-        """Copies annotations from seqs in unaligned to self, matching by name.
-
-        Alignment programs like ClustalW don't preserve annotations,
-        so this method is available to copy annotations off the unaligned
-        sequences.
+    def copy_annotations(self, seq_db: SupportsFeatures) -> None:
+        """copy annotations into attached annotation db
 
-        unaligned should be a dictionary of Sequence instances.
+        Parameters
+        ----------
+        seq_db
+            compatible annotation db
 
-        Ignores sequences that are not in self, so safe to use on larger dict
-        of seqs that are not in the current collection/alignment.
+        Notes
+        -----
+        Only copies annotations for records with seqid self.names
         """
-        for name, seq in list(unaligned.items()):
-            if name in self.named_seqs:
-                self.named_seqs[name].copy_annotations(seq)
+        if not isinstance(seq_db, SupportsFeatures):
+            raise TypeError(
+                f"type {type(seq_db)} does not match SupportsFeatures interface"
+            )
+
+        if not self.annotation_db:
+            # todo gah add ability to query multiple values in Annotation db
+            num = 0
+            for seqid in self.names:
+                num += seq_db.num_matches(seqid=seqid)
+                if num > 0:
+                    break
+            else:
+                # no matching ID's, nothing to do
+                return
+
+            self.annotation_db = type(seq_db)(
+                data=[]
+            )  # make an empty db of the same type
 
-    def annotate_from_gff(self, f: os.PathLike, seq_ids: list[str]):
+        if 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.names)
+
+    def annotate_from_gff(
+        self, f: os.PathLike, seq_ids: Optional[Union[list[str], str]] = None
+    ):
         """copies annotations from a gff file to a sequence in self
 
         Parameters
@@ -2021,13 +2116,18 @@ class SequenceCollection(_SequenceCollectionBase):
         does not support setting offset, set offset directly on sequences with seq.annotation_offset = offset
 
         """
+        if isinstance(self.annotation_db, GenbankAnnotationDb):
+            raise ValueError(
+                "GenbankAnnotationDb already attached, is incompatible with GffAnnotationDb"
+            )
+
         if isinstance(seq_ids, str):
             seq_ids = [seq_ids]
+        else:
+            seq_ids = self.names
 
         if self.annotation_db is None:
             self.annotation_db = load_annotations(f, seq_ids)
-        elif isinstance(self.annotation_db, GenbankAnnotationDb):
-            raise ValueError("GenbankAnnotationDb already attached")
         else:
             self.annotation_db = load_annotations(f, seq_ids, db=self.annotation_db._db)
 
@@ -2035,9 +2135,104 @@ class SequenceCollection(_SequenceCollectionBase):
         for seq in seq_ids:
             self.get_seq(seq).annotation_db = self.annotation_db
 
+    def add_feature(
+        self,
+        *,
+        seqid: str,
+        biotype: str,
+        name: str,
+        spans: List[Tuple[int, int]],
+        strand: str = "+",
+    ) -> Annotation:
+        """
+        add feature on named sequence
+
+        Parameters
+        ----------
+        seqid
+            seq name to associate with
+        biotype
+            biological type
+        name
+            feature name
+        spans
+            plus strand coordinates
+        strand
+            either '+' or '-'
+
+        Returns
+        -------
+        Annotation
+        """
+        if not self.annotation_db:
+            # todo gah can we define the default in some better way?
+            self.annotation_db = DEFAULT_ANNOTATION_DB(data=[])
+
+        if seqid and seqid not in self.names:
+            raise ValueError(f"unknown {seqid=}")
+
+        feature = {k: v for k, v in locals().items() if k != "self"}
+
+        return self.annotation_db.add_feature(**feature)
+
+    def get_features(
+        self,
+        *,
+        seqid: Optional[str] = None,
+        biotype: Optional[str] = None,
+        name: Optional[str] = None,
+    ) -> Iterator[Annotation]:
+        """yields Annotation instances
+
+        Parameters
+        ----------
+        seqid
+            limit search to features on this named sequence, defaults to search all
+        biotype
+            biotype of the feature, e.g. CDS, gene
+        name
+            name of the feature
+
+        Notes
+        -----
+        When dealing with a nucleic acid moltype, the returned features will
+        yield a sequence segment that is consistently oriented irrespective
+        of strand of the current instance.
+        """
+
+        if self.annotation_db is None:
+            anno_db = merged_db_collection(self.seqs)
+            self.annotation_db = anno_db
+
+        if self.annotation_db is None:
+            return None
+
+        seqids = [seqid] if isinstance(seqid, str) else seqid
+
+        if seqid and not set(seqid) & set(self.names):
+            raise ValueError(f"unknown {seqid=}")
+
+        if seqids is None:
+            for feature in self.annotation_db.get_features_matching(
+                biotype=biotype, name=name
+            ):
+                seqid = feature["seqid"]
+                seq = self.named_seqs[seqid]
+                # passing self only used when self is an Alignment
+                yield seq.make_feature(feature, self)
+            return
+
+        for seqid in seqids:
+            seq = self.named_seqs[seqid]
+            for feature in self.annotation_db.get_features_matching(
+                seqid=seqid, biotype=biotype, name=name
+            ):
+                # passing self only used when self is an Alignment
+                yield seq.make_feature(feature, self)
+
 
 @total_ordering
-class Aligned(object):
+class Aligned:
     """One sequence in an alignment, a map between alignment coordinates and
     sequence coordinates"""
 
@@ -2088,7 +2283,10 @@ class Aligned(object):
         new_seq = self.data.copy()
         if sliced:
             span = self.map.get_covering_span()
-            new_seq = new_seq[span.start : span.end]
+            new_seq = type(new_seq)(
+                str(new_seq[span.start : span.end]), info=new_seq.info
+            )
+            new_seq.annotation_db = None
             new_map = self.map.zeroed()
         else:
             new_map = self.map
@@ -2104,9 +2302,15 @@ class Aligned(object):
     def copy_annotations(self, other):
         self.data.copy_annotations(other)
 
+    @c3warn.deprecated_callable(
+        "2023.10", "handled by <collection>.annotate_from_gff()", is_discontinued=True
+    )
     def annotate_from_gff(self, f):
         self.data.annotate_from_gff(f)
 
+    @c3warn.deprecated_callable(
+        "2023.10", "handled by <collection>.add_feature()", is_discontinued=True
+    )
     def add_feature(self, *args, **kwargs):
         self.data.add_feature(*args, **kwargs)
 
@@ -2189,22 +2393,23 @@ class Aligned(object):
     def remapped_to(self, map):
         return Aligned(map[self.map.inverse()].inverse(), self.data)
 
-    def get_annotations_matching(self, alignment, annotation_type="*", **kwargs):
-
-        from cogent3.util.warning import deprecated
-
-        deprecated(
-            "method",
-            "get_annotations_matching",
-            "get_features_matching",
-            "2023.3",
-        )
+    def make_feature(
+        self, feature: FeatureDataType, alignment: "Alignment"
+    ) -> Annotation:
+        """returns a feature, not written into annotation_db"""
+        annot = self.data.make_feature(feature)
+        return annot.remapped_to(alignment, self.map.inverse())
 
-        for annot in self.data.get_features_matching(
-            feature_type=annotation_type, **kwargs
-        ):
-            yield annot.remapped_to(alignment, self.map.inverse())
+    @c3warn.deprecated_callable(
+        "2023.10", "handled by <collection>.get_features()", is_discontinued=True
+    )
+    def get_annotations_matching(self, alignment, **kwargs):
+        for feature in self.data.annotation_db.get_features_matching(**kwargs):
+            yield self.make_feature(feature, alignment)
 
+    @c3warn.deprecated_callable(
+        "2023.10", "handled by <collection>.get_features()", is_discontinued=True
+    )
     def get_features_matching(self, alignment, annotation_type="*", **kwargs):
         for annot in self.data.get_features_matching(
             feature_type=annotation_type, **kwargs
@@ -4416,7 +4621,6 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
                 aligned_seqs.append(self._seq_to_aligned(s, n))
         self.named_seqs = dict(list(zip(names, aligned_seqs)))
         self.seq_data = self._seqs = aligned_seqs
-        self._annotation_db = None
 
     @property
     def annotation_db(self):
@@ -4430,6 +4634,14 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
             raise TypeError
         self._annotation_db = value
 
+    @annotation_db.setter
+    def annotation_db(self, value):
+        from cogent3.core.annotation_db import SupportsFeatures
+
+        if not isinstance(value, SupportsFeatures):
+            raise TypeError
+        self._annotation_db = value
+
     def _coerce_seqs(self, seqs, is_array):
         if any(isinstance(seq, ArraySequence) for seq in seqs):
             seqs = [self.moltype.make_seq(str(seq), name=seq.name) for seq in seqs]
@@ -4822,3 +5034,60 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
             result[a.type].append(d)
 
         return result
+
+    def add_feature(
+        self,
+        *,
+        biotype: str,
+        name: str,
+        spans: List[Tuple[int, int]],
+        seqid: Optional[str] = None,
+        strand: str = "+",
+        on_alignment: Optional[bool] = None,
+    ) -> Annotation:
+        """
+        add feature on named sequence, or on the alignment itself
+
+        Parameters
+        ----------
+        seqid
+            sequence name, incompatible with on_alignment
+        biotype
+            biological type, e.g. CDS
+        name
+            name of the feature
+        spans
+            plus strand coordinates of feature
+        strand
+            '+' (default) or '-'
+        on_alignment
+            the feature is in alignment coordinates, incompatible with setting
+            seqid. Set to True if seqid not provided.
+
+        Returns
+        -------
+        Annotation
+
+        Raises
+        ------
+        ValueError if define a seqid not on alignment or use seqid and
+        on_alignment.
+        """
+        if seqid and on_alignment is None:
+            on_alignment = False
+        else:
+            on_alignment = on_alignment or True
+
+        if seqid and on_alignment:
+            raise ValueError("seqid and on_alignment are incomatible")
+
+        if seqid and seqid not in self.names:
+            raise ValueError(f"unknown {seqid=}")
+
+        if not self.annotation_db:
+            # probably need to define the default in some better way
+            self.annotation_db = DEFAULT_ANNOTATION_DB(data=[])
+
+        feature = {k: v for k, v in locals().items() if k != "self"}
+
+        return self.annotation_db.add_feature(**feature)
diff --git a/src/cogent3/core/annotation.py b/src/cogent3/core/annotation.py
index 2298709b6..1dec4bc54 100644
--- a/src/cogent3/core/annotation.py
+++ b/src/cogent3/core/annotation.py
@@ -23,24 +23,8 @@ __status__ = "Production"
 
 
 class _AnnotationMixin:
-    def add_feature(self, biotype, name, spans):
-        feat = Feature(self, biotype, name, spans)
-
-        if feat.parent is not self:
-            raise ValueError("doesn't belong here")
-
-        if feat.attached:
-            raise ValueError("already attached")
-
-        if self.annotations is self.__class__.annotations:
-            self.annotations = []
-
-        self.annotations.extend([feat])
-
-        feat.attached = True
-
-        return feat
-
+    # todo gah add a _anotatable_getitem_()? which can be checked
+    # for to deliver the old behaviour
     def get_features_matching(self, feature_type, name=None, extend_query=False):
         """
 
@@ -233,6 +217,24 @@ class _Annotatable(_AnnotationCore, _AnnotationMixin):
     # Subclasses should provide __init__, getOwnTracks, and a _mapped for use by
     # __getitem__
 
+    def add_feature(self, biotype, name, spans):
+        feat = Feature(self, biotype, name, spans)
+
+        if feat.parent is not self:
+            raise ValueError("doesn't belong here")
+
+        if feat.attached:
+            raise ValueError("already attached")
+
+        if self.annotations is self.__class__.annotations:
+            self.annotations = []
+
+        self.annotations.extend([feat])
+
+        feat.attached = True
+
+        return feat
+
     def _shifted_annotations(self, new, shift):
         result = []
         if self.annotations:
@@ -401,6 +403,8 @@ class _Annotatable(_AnnotationCore, _AnnotationMixin):
 
 
 class _Serialisable:
+    # todo gah will need a version check in util/deserialise to allow transforming
+    # old style annotations to new from json
     def to_rich_dict(self):
         """returns {'name': name, 'seq': sequence, 'moltype': moltype.label}"""
         data = copy.deepcopy(self._serialisable)
@@ -429,13 +433,23 @@ class _Serialisable:
         return json.dumps(self.to_rich_dict())
 
 
+# todo gah implement __repr__ and __str__ methods
 class Annotation(_AnnotationCore, _Serialisable):
     """new style annotation, created on demand"""
 
-    __slots__ = "parent", "map", "biotype", "name", "_serialisable", "base", "base_map"
-
-    # todo implement a __new__ to trap args for serialisation purposes
-    def __init__(self, parent, map: Map, biotype: str, name: str):
+    __slots__ = (
+        "parent",
+        "seqid",
+        "map",
+        "biotype",
+        "name",
+        "_serialisable",
+        "base",  # todo gah do we need this?
+        "base_map",  # todo gah do we need this?
+    )
+
+    # todo gah implement a __new__ to trap args for serialisation purposes?
+    def __init__(self, *, parent, seqid: str, map: Map, biotype: str, name: str):
         d = locals()
         exclude = ("self", "__class__", "kw")
         self._serialisable = {k: v for k, v in d.items() if k not in exclude}
@@ -456,10 +470,13 @@ class Annotation(_AnnotationCore, _Serialisable):
         return make_shape(type_=self)
 
     def _mapped(self, slicemap):
-        name = f"{repr(slicemap)} of {self.name}"
-        return self.__class__(
-            parent=self.parent, map=slicemap, biotype=self.biotype, name=name
-        )
+        # the self._serialisable dict is used for serialisation, so we need to
+        # use a copy to create the new instance
+        kwargs = {
+            **self._serialisable,
+            **{"map": slicemap, "name": f"{repr(slicemap)} of {self.name}"},
+        }
+        return self.__class__(**kwargs)
 
     def get_slice(self, complete=True):
         """The corresponding sequence fragment.  If 'complete' is true
@@ -503,8 +520,11 @@ class Annotation(_AnnotationCore, _Serialisable):
         return self.remapped_to(base, self.parent._projected_to_base(base).map)
 
     def remapped_to(self, grandparent, gmap):
-        map = gmap[self.map]
-        return self.__class__(grandparent, map, biotype=self.biotype, name=self.name)
+        kwargs = {
+            **self._serialisable,
+            **{"map": gmap[self.map], "parent": grandparent},
+        }
+        return self.__class__(**kwargs)
 
     def get_coordinates(self):
         """returns sequence coordinates of this Feature as
diff --git a/src/cogent3/core/annotation_db.py b/src/cogent3/core/annotation_db.py
index b88f9e9ef..ed8277c91 100644
--- a/src/cogent3/core/annotation_db.py
+++ b/src/cogent3/core/annotation_db.py
@@ -25,6 +25,7 @@ __status__ = "Production"
 
 OptionalInt = typing.Optional[int]
 OptionalStr = typing.Optional[str]
+OptionalStrList = typing.Optional[typing.Union[str, typing.List[str]]]
 OptionalBool = typing.Optional[bool]
 ReturnType = typing.Tuple[str, tuple]  # the sql statement and corresponding values
 
@@ -80,6 +81,8 @@ class SerialisableType(typing.Protocol):
 class SupportsQueryFeatures(typing.Protocol):  # should be defined centrally
     def get_features_matching(
         self,
+        *,
+        seqid: OptionalStr = None,
         biotype: OptionalStr = None,
         name: OptionalStr = None,
         start: OptionalInt = None,
@@ -90,37 +93,53 @@ class SupportsQueryFeatures(typing.Protocol):  # should be defined centrally
         ...
 
     def get_feature_children(
-        self, name: str, start: OptionalInt = None, end: OptionalInt = None
+        self, *, name: str, start: OptionalInt = None, end: OptionalInt = None
     ) -> typing.List[FeatureDataType]:
         ...
 
     def get_feature_parent(
-        self, name: str, start: OptionalInt = None, end: OptionalInt = None
+        self, *, name: str, start: OptionalInt = None, end: OptionalInt = None
     ) -> typing.List[FeatureDataType]:
         ...
 
+    def num_matches(
+        self,
+        seqid: OptionalStr = None,
+        biotype: OptionalStr = None,
+        name: OptionalStr = None,
+        strand: OptionalStr = None,
+        on_alignment: OptionalBool = None,
+    ) -> int:
+        ...
+
 
 @typing.runtime_checkable
 class SupportsWriteFeatures(typing.Protocol):  # should be defined centrally
     def add_feature(
         self,
-        seqid: str,
+        *,
         biotype: str,
         name: str,
         spans: typing.List[typing.Tuple[int, int]],
-        strand: str = None,
+        seqid: OptionalStr = None,
+        strand: OptionalStr = None,
         on_alignment: bool = False,
     ) -> None:
         ...
 
     def add_records(
         self,
+        *,
         records: typing.Sequence[dict],
         # seqid required for genbank
-        seqid: typing.Optional[str] = None,
+        seqid: OptionalStr = None,
     ) -> None:
         ...
 
+    def update(self, annot_db, seqids: OptionalStrList = None) -> None:
+        # update records with those from an instance of the same type
+        ...
+
 
 @typing.runtime_checkable
 class SupportsFeatures(SupportsQueryFeatures, SupportsWriteFeatures, typing.Protocol):
@@ -317,13 +336,53 @@ def _select_records_sql(
     columns = f"{', '.join(columns)}" if columns else "*"
     sql = f"SELECT {columns} FROM {table_name}"
     if not where:
-        return sql
+        return sql, None
 
     sql = f"{sql} WHERE {where};"
     return sql, vals
 
 
-# todo add support for querying for text within the additional feature,
+def _count_records_sql(
+    table_name: str,
+    conditions: dict,
+    columns: typing.Optional[typing.List[str]] = None,
+    start: OptionalInt = None,
+    end: OptionalInt = None,
+    partial=True,
+) -> ReturnType:
+    """create SQL count statement and values
+
+    Parameters
+    ----------
+    table_name : str
+        containing the data to be selected from
+    columns : Tuple[str]
+        values to select
+    conditions : dict
+        the WHERE conditions
+    start, end : OptionalInt
+        select records whose (start, end) values lie between start and end,
+        or overlap them if (partial is True)
+    partial : bool, optional
+        if False, only records within start, end are included. If True,
+        all records that overlap the segment defined by start, end are included.
+
+    Returns
+    -------
+    str, tuple
+        the SQL statement and the tuple of values
+    """
+
+    where, vals = _matching_conditions(conditions=conditions, partial=partial)
+    sql = f"SELECT COUNT(*) FROM {table_name}"
+    if not where:
+        return sql, None
+
+    sql = f"{sql} WHERE {where};"
+    return sql, vals
+
+
+# todo gah add support for querying for text within the additional feature,
 # for example, add a "attrs_like" argument which users can provide text
 # that will be treated as surrounded by wild-cards
 class SqliteAnnotationDbMixin:
@@ -462,6 +521,23 @@ class SqliteAnnotationDbMixin:
                     reversed=result["strand"] == "-",
                 )
 
+    def num_matches(
+        self,
+        seqid: OptionalStr = None,
+        biotype: OptionalStr = None,
+        name: OptionalStr = None,
+        strand: OptionalStr = None,
+        on_alignment: OptionalBool = None,
+    ) -> int:
+        """return the number of records matching condition"""
+        kwargs = {k: v for k, v in locals().items() if k != "self"}
+        num = 0
+        for table_name in self.table_names:
+            sql, values = _count_records_sql(table_name, conditions=kwargs)
+            result = list(self._execute_sql(sql, values=values).fetchone())[0]
+            num += result
+        return num
+
     @property
     def describe(self) -> Table:
         """top level description of the annotation db"""
@@ -518,19 +594,37 @@ class SqliteAnnotationDbMixin:
         records["type"] = get_object_provenance(self)
         return records
 
+    def update(self, annot_db, seqids: OptionalStrList = None) -> None:
+        """update records with those from an instance of the same type"""
+        if not isinstance(annot_db, type(self)):
+            raise TypeError(f"{type(annot_db)} != {type(self)}")
+
+        self._update_db_from_rich_dict(annot_db.to_rich_dict(), seqids=seqids)
+
     @classmethod
     def from_dict(cls, data: dict):
-        data.pop("type")
         # make an empty db
-        db = cls([])
+        db = cls(data=[])
+        db._update_db_from_rich_dict(data)
+        return db
+
+    def _update_db_from_rich_dict(self, data: dict, seqids: OptionalStr = None):
+        data.pop("type", None)
+        if isinstance(seqids, str):
+            seqids = {seqids}
+        elif seqids is not None:
+            seqids = set(seqids) - {None}  # make sure None is not part of this!
+
+        # todo gah prevent duplication of existing records
         for table_name, records in data.items():
             for record in records:
+                if seqids and record["seqid"] not in seqids:
+                    continue
                 record["spans"] = numpy.array(record["spans"], dtype=int)
                 sql, vals = _add_record_sql(
                     table_name, {k: v for k, v in record.items() if v is not None}
                 )
-                db._execute_sql(sql, vals)
-        return db
+                self._execute_sql(sql, vals)
 
 
 class GffAnnotationDb(SqliteAnnotationDbMixin):
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index e1f204889..8fe91a9ff 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -873,7 +873,7 @@ class Sequence(_Annotatable, SequenceI):
     def annotation_db(self, value):
         from cogent3.core.annotation_db import SupportsFeatures
 
-        if not isinstance(value, SupportsFeatures):
+        if value and not isinstance(value, SupportsFeatures):
             raise TypeError
         self._annotation_db = value
 
@@ -921,7 +921,7 @@ class Sequence(_Annotatable, SequenceI):
         ):
             yield self.make_feature(feature)
 
-    def make_feature(self, feature: FeatureDataType) -> Annotation:
+    def make_feature(self, feature: FeatureDataType, *args) -> Annotation:
         """
         return an Annotation instance from feature data
 
@@ -938,7 +938,10 @@ class Sequence(_Annotatable, SequenceI):
         feature = dict(feature)
         seq_rced = self._seq.reversed
         feature.pop("seqid", None)  # not required here as provided
-        revd = feature.pop("reversed", None)
+        # todo gah check consistency of relationship between reversed and strand
+        # i.e. which object has responsibility for transforming the strand value
+        # (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))
         if revd and not seq_rced:
@@ -957,7 +960,7 @@ class Sequence(_Annotatable, SequenceI):
             # then reversed
             fmap = fmap.nucleic_reversed().reversed()
 
-        return Annotation(self, fmap, **feature)
+        return Annotation(parent=self, seqid=self.name, map=fmap, **feature)
 
     def annotate_from_gff(self, f: os.PathLike, offset=None):
         """copies annotations from a gff file to self,
@@ -982,6 +985,7 @@ class Sequence(_Annotatable, SequenceI):
 
     def add_feature(
         self,
+        *,
         biotype: str,
         name: str,
         spans,
@@ -991,15 +995,14 @@ class Sequence(_Annotatable, SequenceI):
         if self.annotation_db is None:
             self.annotation_db = GffAnnotationDb([])
 
-        self.annotation_db.add_feature(
-            seqid=self.name,
-            biotype=biotype,
-            name=name,
-            spans=spans,
-            strand=strand,
-            on_alignment=on_alignment,
+        feature_data = FeatureDataType(
+            seqid=self.name, **{n: v for n, v in locals().items() if n != "self"}
         )
 
+        self.annotation_db.add_feature(**feature_data)
+        feature_data.pop("on_alignment")
+        return self.make_feature(feature_data)
+
     def to_moltype(self, moltype):
         """returns copy of self with moltype seq
 
@@ -1033,10 +1036,13 @@ class Sequence(_Annotatable, SequenceI):
 
     def copy(self):
         """returns a copy of self"""
-        new = self.__class__(self._seq, name=self.name, info=self.info)
+        new = self.__class__(self._seq[:], name=self.name, info=self.info)
         if self.is_annotated():
             for annot in self.annotations:
                 annot.copy_annotations_to(new)
+
+        # todo gah revisit copying and how are we handling _seq too
+        new._annotation_db = self._annotation_db
         return new
 
     def _get_feature_start(self, feature):
@@ -1294,7 +1300,8 @@ class Sequence(_Annotatable, SequenceI):
 
     def is_annotated(self):
         """returns True if sequence has any annotations"""
-        return len(self.annotations) != 0
+        num = self.annotation_db.num_matches() if self.annotation_db else 0
+        return num != 0 or len(self.annotations) != 0
 
     def annotate_matches_to(self, pattern, annot_type, name, allow_multiple=False):
         """Adds an annotation at sequence positions matching pattern.
@@ -1329,7 +1336,9 @@ class Sequence(_Annotatable, SequenceI):
         num_match = len(pos) if allow_multiple else 1
         return [
             self.add_feature(
-                annot_type, f"{name}:{i}" if allow_multiple else name, [pos[i]]
+                biotype=annot_type,
+                name=f"{name}:{i}" if allow_multiple else name,
+                spans=[pos[i]],
             )
             for i in range(num_match)
         ]


Test patch:
diff --git a/tests/test_core/test_alignment.py b/tests/test_core/test_alignment.py
index 39bdcb16c..692b86acd 100644
--- a/tests/test_core/test_alignment.py
+++ b/tests/test_core/test_alignment.py
@@ -15,7 +15,9 @@ from numpy import array, log2, nan, transpose
 from numpy.testing import assert_allclose, assert_equal
 
 from cogent3 import (
+    get_code,
     load_aligned_seqs,
+    load_seq,
     load_unaligned_seqs,
     make_aligned_seqs,
     make_seq,
@@ -69,6 +71,9 @@ __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Production"
 
 
+DATA_DIR = pathlib.Path(__file__).parent.parent / "data"
+
+
 class alignment_tests(TestCase):
     """Tests of top-level functions."""
 
@@ -341,18 +346,6 @@ class SequenceCollectionBaseTests(object):
             # ArrayAlignment is allowed to strip Info objects
             self.assertEqual([i.info.x for i in b.seqs], [5, 4, 3])
 
-    def test_init_annotated_seqs(self):
-        """correctly construct from list with annotated seq"""
-        if self.Class == ArrayAlignment:
-            # this class cannot be annotated
-            return
-        seq = make_seq("GCCAGGGGGGAAAG-GGAGAA", name="seq1")
-        _ = seq.add_feature("exon", "name", [(4, 10)])
-        coll = self.Class(data=[seq])
-        got_seq = coll.get_seq("seq1")
-        ann = got_seq.annotations[0]
-        self.assertEqual(str(got_seq[ann]), "GGGGGG")
-
     def test_init_pairs(self):
         """SequenceCollection init from list of (key,val) pairs should work correctly"""
         seqs = [["x", "XXX"], ["b", "BBB"], ["c", "CCC"]]
@@ -875,27 +868,9 @@ class SequenceCollectionBaseTests(object):
         aln = self.Class({"seq1": "ACGU", "seq2": "CGUA", "seq3": "CCGU"})
         self.assertEqual(aln.num_seqs, 3)
 
-    def test_copy_annotations(self):
-        """SequenceCollection copy_annotations should copy from seq objects"""
-        if self.Class == ArrayAlignment:
-            return
-        aln = self.Class({"seq1": "ACGU", "seq2": "CGUA", "seq3": "CCGU"})
-        seq_1 = Sequence("ACGU", name="seq1")
-        seq_1.add_feature("xyz", "abc", [(1, 2)])
-        seq_5 = Sequence("ACGUAAAAAA", name="seq5")
-        seq_5.add_feature("xyzzz", "abc", [(1, 2)])
-        annot = {"seq1": seq_1, "seq5": seq_5}
-        aln.copy_annotations(annot)
-        aln_seq_1 = aln.named_seqs["seq1"]
-        if not hasattr(aln_seq_1, "annotations"):
-            aln_seq_1 = aln_seq_1.data
-        aln_seq_2 = aln.named_seqs["seq2"]
-        if not hasattr(aln_seq_2, "annotations"):
-            aln_seq_2 = aln_seq_2.data
-        self.assertEqual(len(aln_seq_1.annotations), 1)
-        self.assertEqual(aln_seq_1.annotations[0].name, "abc")
-        self.assertEqual(len(aln_seq_2.annotations), 0)
-
+    @pytest.mark.xfail(
+        reason="todo: gah remove test, annotate_from_gff only supports file types"
+    )
     def test_annotate_from_gff(self):
         """SequenceCollection.annotate_from_gff should read gff features"""
         aln = self.Class({"seq1": "ACGU", "seq2": "CGUA", "seq3": "C-GU"})
@@ -924,33 +899,6 @@ class SequenceCollectionBaseTests(object):
             matches = [m for m in aln_seq_3.get_features_matching("*")]
             self.assertFalse("-" in matches[0].get_slice())
 
-    def test_annotate_from_gff3(self):
-        """annotate_from_gff should work on data from gff3 files"""
-        from cogent3.parse.fasta import FastaParser
-
-        if self.Class == ArrayAlignment:
-            return
-
-        fasta_path = os.path.join("data/c_elegans_WS199_dna_shortened.fasta")
-        gff3_path = os.path.join("data/c_elegans_WS199_shortened_gff.gff3")
-        name, seq = next(FastaParser(fasta_path))
-
-        # using annotate_from_gff will nest annotations
-        aln = self.Class({name: seq})
-        aln.annotate_from_gff(gff3_path)
-        aln_seq = aln.named_seqs[name]
-        if not hasattr(aln_seq, "annotations"):
-            aln_seq = aln_seq.data
-        matches = [m for m in aln_seq.get_features_matching("*", extend_query=True)]
-        # 13 features with one having 2 parents, so 14 instances should be found
-        self.assertEqual(len(matches), 14)
-        matches = [m for m in aln_seq.get_features_matching("gene")]
-        self.assertEqual(len(matches), 1)
-        matches = matches[0].get_features_matching("mRNA")
-        self.assertEqual(len(matches), 1)
-        matches = matches[0].get_features_matching("exon")
-        self.assertEqual(len(matches), 3)
-
     def test_add(self):
         """__add__ should concatenate sequence data, by name"""
         align1 = self.Class({"a": "AAAA", "b": "TTTT", "c": "CCCC"})
@@ -1312,7 +1260,7 @@ class SequenceCollectionBaseTests(object):
         if type(self.Class) != ArrayAlignment:
             # we annotated Human
             seq = seqs.get_seq("Human")
-            _ = seq.add_feature("exon", "fred", [(10, 15)])
+            _ = seq.add_feature(biotype="exon", name="fred", spans=[(10, 15)])
 
         _ = seqs.dotplot(show_progress=False)
 
@@ -1429,7 +1377,7 @@ class SequenceCollectionBaseTests(object):
 
         if self.Class == SequenceCollection:
             # this class cannot slice
-            return True
+            return
 
         # should persist in slicing
         self.assertEqual(
@@ -1440,7 +1388,7 @@ class SequenceCollectionBaseTests(object):
         """the wrap argument affects the number of columns"""
         if self.Class == SequenceCollection:
             # this class does not have this method
-            return True
+            return
 
         # indirectly tested via counting number of occurrences of 'class="label"'
         seqs = self.Class({"a": "AAAAA", "b": "AAA--"})
@@ -2872,85 +2820,6 @@ class AlignmentTests(AlignmentBaseTests, TestCase):
             ValueError, aln1.add_from_ref_aln, aln2_wrong_refseq
         )  # test wrong_refseq
 
-    def test_annotate_matches_to(self):
-        """Aligned.annotate_matches_to correctly delegates to sequence"""
-        from cogent3 import get_code
-
-        aln = Alignment(dict(x="TTCCACTTCCGCTT"), moltype="dna")
-        seq = aln.named_seqs["x"]
-        pattern = "CCRC"
-        annot = seq.annotate_matches_to(
-            pattern=pattern, annot_type="domain", name="fred", allow_multiple=True
-        )
-        got = [a.get_slice() for a in annot]
-        matches = ["CCAC", "CCGC"]
-        self.assertEqual(got, matches)
-        annot = seq.annotate_matches_to(
-            pattern=pattern, annot_type="domain", name="fred", allow_multiple=False
-        )
-        got = [a.get_slice() for a in annot]
-        self.assertEqual(got, matches[:1])
-
-        # handles regex from aa
-        aln = Alignment(dict(x="TTCCACTTCCGCTT"), moltype="dna")
-        gc = get_code(1)
-        aa_regex = gc.to_regex("FHF")
-        s = aln.named_seqs["x"].annotate_matches_to(
-            aa_regex, "domain", "test", allow_multiple=False
-        )
-        a = list(aln.get_annotations_from_seq("x"))[0]
-        self.assertEqual(str(aln[a].named_seqs["x"]), "TTCCACTTC")
-
-    def test_deepcopy(self):
-        """correctly deepcopy Aligned objects in an alignment"""
-        path = "data/brca1_5.paml"
-        # generates an annotatable Alignment object
-        aln = load_aligned_seqs(path, array_align=False, moltype="dna")
-        # when the annotation is outside(before) boundary of the slice
-        aln.named_seqs["NineBande"].data.add_feature("exon", "annot1", [(0, 10)])
-        # when the annotation is across boundary of the slice
-        aln.named_seqs["Mouse"].data.add_feature("exon", "annot2", [(10, 21)])
-        # when the annotation is within boundary of the slice
-        aln.named_seqs["Human"].data.add_feature("exon", "annot3", [(20, 25)])
-        # when the annotation is across boundary of the slice
-        aln.named_seqs["HowlerMon"].data.add_feature("exon", "annot4", [(25, 32)])
-        # when the annotation is outside(after) boundary of the slice
-        aln.named_seqs["DogFaced"].data.add_feature("exon", "annot5", [(40, 45)])
-        aln = aln[20:30]
-
-        # for these species, each has an annotation spanning slice boundary or within it
-        for name in ["Mouse", "Human", "HowlerMon"]:
-            new_seq = aln.named_seqs[name].deepcopy(sliced=True)
-            seq = aln.named_seqs[name]
-            self.assertNotEqual(new_seq.map.parent_length, seq.map.parent_length)
-
-            self.assertEqual(len(new_seq.data), 10)
-            self.assertTrue(new_seq.data.is_annotated())
-            self.assertEqual(len(new_seq.data.annotations), 1)
-            # tests the case when sliced argument if False
-            new_seq = aln.named_seqs[name].deepcopy(sliced=False)
-            self.assertEqual(new_seq.map.parent_length, seq.map.parent_length)
-            self.assertEqual(len(new_seq.data), len(aln.named_seqs[name].data))
-            self.assertTrue(new_seq.data.is_annotated())
-        # for these species, each has an annotation outside slice
-        for name in ["NineBande", "DogFaced"]:
-            new_seq = aln.named_seqs[name].deepcopy(sliced=True)
-            self.assertEqual(len(new_seq.data), 10)
-            self.assertFalse(new_seq.data.is_annotated())
-            # tests the case when sliced argument if False
-            new_seq = aln.named_seqs[name].deepcopy(sliced=False)
-            self.assertEqual(len(new_seq.data), len(aln.named_seqs[name].data))
-            self.assertTrue(new_seq.data.is_annotated())
-            self.assertEqual(len(new_seq.data.annotations), 1)
-
-        # add another human annotation that is outside slice
-        aln.named_seqs["Human"].data.add_feature("exon", "annot6", [(40, 45)])
-        # tests the case when sliced argument if False regarding the Human sequence
-        new_seq = aln.named_seqs["Human"].deepcopy(sliced=False)
-        self.assertEqual(len(new_seq.data), len(aln.named_seqs["Human"].data))
-        self.assertTrue(new_seq.data.is_annotated())
-        self.assertEqual(len(new_seq.data.annotations), 2)
-
     def test_deepcopy2(self):
         """ "Aligned.deepcopy correctly handles gapped sequences"""
         seqs = self.Class(
@@ -2982,12 +2851,12 @@ class AlignmentTests(AlignmentBaseTests, TestCase):
         s1 = Sequence("TTTTTTAAAA", name="test_seq1")
         s2 = Sequence("AAAATTTTTT", name="test_seq2")
         s3 = Sequence("AATTTTTAAA", name="test_seq3")
-        s1.add_feature("exon", "fred", [(0, 6)])
-        s2.add_feature("exon", "fred", [(4, 10)])
-        s3.add_feature("exon", "fred", [(2, 7)])
+        s1.add_feature(biotype="exon", name="fred", spans=[(0, 6)])
+        s2.add_feature(biotype="exon", name="fred", spans=[(4, 10)])
+        s3.add_feature(biotype="exon", name="fred", spans=[(2, 7)])
         data = {"seq1": s1, "seq2": s2, "seq3": s3}
         aln = self.Class(data=data)
-        aln.add_feature("demo", "one", [(0, 1), (2, 4)])
+        aln.add_feature(biotype="demo", name="one", spans=[(0, 1), (2, 4)])
         rna = aln.to_moltype("rna")
         for name in rna.names:
             orig_seq = aln.get_seq(name)
@@ -3001,40 +2870,14 @@ class AlignmentTests(AlignmentBaseTests, TestCase):
         self.assertEqual({s.data.moltype.label for s in rna.seqs}, {"rna"})
         self.assertEqual(rna.moltype.label, "rna")
 
-    def test_get_annotations_from_any_seq(self):
-        """get_annotations_from_any_seq returns correct annotations"""
-        data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
-        seqs = self.Class(data, moltype=DNA)
-        seqs.get_seq("seq1").add_feature("exon", "annotation1", [(3, 8)])
-        seqs.get_seq("seq2").add_feature("exon", "annotation2", [(1, 2)])
-        seqs.get_seq("seq3").add_feature("exon", "annotation3", [(3, 6)])
-        got = seqs.get_annotations_from_any_seq()
-        self.assertEqual(len(got), 3)
-        self.assertEqual(str(got[0]), 'exon "annotation1" at [3:8]/9')
-        self.assertEqual(str(got[1]), 'exon "annotation2" at [1:2]/9')
-        self.assertEqual(str(got[2]), 'exon "annotation3" at [3:6]/9')
-
-        got = seqs.get_annotations_from_any_seq(annotation_type="*", name="annotation1")
-        self.assertEqual(len(got), 1)
-        self.assertEqual(str(got[0]), 'exon "annotation1" at [3:8]/9')
-
-        got = seqs.get_annotations_from_any_seq(
-            annotation_type="exon", name="annotation2"
-        )
-        self.assertEqual(len(got), 1)
-        self.assertEqual(str(got[0]), 'exon "annotation2" at [1:2]/9')
-
-        got = seqs.get_annotations_from_any_seq(annotation_type="*", name="annotation3")
-        self.assertEqual(len(got), 1)
-        self.assertEqual(str(got[0]), 'exon "annotation3" at [3:6]/9')
-
+    @pytest.mark.xfail(reason="todo gah: delete this test, unsupported behaviour")
     def test_rename_handles_annotations(self):
         """rename seqs on Alignment preserves annotations"""
-        from cogent3.core.annotation import Feature
-
         data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
         seqs = self.Class(data, moltype=DNA)
-        x = seqs.get_seq("seq1").add_feature("exon", "fred", [(3, 8)])
+        x = seqs.get_seq("seq1").add_feature(
+            biotype="exon", name="fred", spans=[(3, 8)]
+        )
         expect = str(x.get_slice())
         new = seqs.rename_seqs(lambda x: x.upper())
         got = list(new.get_annotations_from_any_seq("exon"))[0]
@@ -3398,6 +3241,220 @@ def test_to_dna_raises():
         seq.to_moltype("dna")
 
 
-# run tests if invoked from command line
-if __name__ == "__main__":
-    main()
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_annotate_from_gff3(cls):
+    """annotate_from_gff should work on data from gff3 files"""
+    fasta_path = os.path.join("data/c_elegans_WS199_dna_shortened.fasta")
+    gff3_path = os.path.join("data/c_elegans_WS199_shortened_gff.gff3")
+    seq = load_seq(fasta_path, moltype="dna")
+
+    # using annotate_from_gff will nest annotations
+    seq_coll = cls({seq.name: seq})
+    seq_coll.annotate_from_gff(gff3_path)
+    member_seq = seq_coll.get_seq(seq.name)
+
+    matches = list(member_seq.get_features_matching())
+    # 11 features
+    assert len(matches) == 11
+    matches = list(member_seq.get_features_matching(feature_type="gene"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="mRNA"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="exon"))
+    assert len(matches) == 3
+
+
+@pytest.fixture(scope="session")
+def seqcoll_db():
+    fasta_path = os.path.join("data/c_elegans_WS199_dna_shortened.fasta")
+    gff3_path = os.path.join("data/c_elegans_WS199_shortened_gff.gff3")
+    seq = load_seq(fasta_path, moltype="dna")
+    seq_coll = SequenceCollection({seq.name: seq})
+    seq_coll.annotate_from_gff(gff3_path)
+    return seq_coll
+
+
+def test_seqcoll_query(seqcoll_db):
+    """querying from a SequenceCollection produces features bound to their seqs"""
+    matches = list(seqcoll_db.get_features(seqid="I"))
+    # 11 features
+    assert len(matches) == 11
+    matches = list(seqcoll_db.get_features(seqid="I", biotype="gene"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="mRNA"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="exon"))
+    assert len(matches) == 3
+
+
+def test_align_get_features():
+    #                0123456789   the positions
+    seq1 = make_seq("ACG--ACCGT", moltype="dna", name="seq1")
+    seq2 = make_seq("ACGGGCCCGT", moltype="dna", name="seq2")
+    #                  *****      the CDS feature
+    seq2.add_feature(biotype="CDS", name="fake01", spans=[(2, 7)], strand="+")
+    aln = make_aligned_seqs(data=[seq1, seq2], array_align=False)
+    feat = list(aln.get_features(biotype="CDS"))[0]
+    sl = aln[feat]
+    # slice is correct length
+    assert len(sl) == (7 - 2)
+    # returns correct value
+    assert sl.to_dict() == dict(seq1="G--AC", seq2="GGGCC")
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_init_annotated_seqs(cls):
+    """correctly construct from list with annotated seq"""
+    seq = make_seq("GCCAGGGGGGAAAG-GGAGAA", name="seq1")
+    _ = seq.add_feature(biotype="exon", name="name", spans=[(4, 10)])
+    coll = cls(data=[seq])
+    features = list(coll.get_features(biotype="exon"))
+    assert len(features) == 1
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_get_annotations_from_any_seq(cls):
+    """get_annotations_from_any_seq returns correct annotations"""
+    data = {"seq1": "ACGTACGTA", "seq2": "ACCGAA---", "seq3": "ACGTACGTT"}
+    seqs = cls(data, moltype=DNA)
+    seqs.get_seq("seq1").add_feature(biotype="exon", name="annotation1", spans=[(3, 8)])
+    seqs.get_seq("seq2").add_feature(biotype="exon", name="annotation2", spans=[(1, 2)])
+    seqs.get_seq("seq3").add_feature(biotype="exon", name="annotation3", spans=[(3, 6)])
+    got = list(seqs.get_features())
+    assert len(got) == 3
+    assert str(got[0]) == 'exon "annotation1" at [3:8]/9'
+    assert str(got[1]) == 'exon "annotation2" at [1:2]/9'
+    assert str(got[2]) == 'exon "annotation3" at [3:6]/9'
+
+    got = list(seqs.get_features(name="annotation1"))
+    assert len(got) == 1
+    assert str(got[0]) == 'exon "annotation1" at [3:8]/9'
+
+    got = list(seqs.get_features(biotype="exon", name="annotation2"))
+    assert len(got) == 1
+    assert str(got[0]) == 'exon "annotation2" at [1:2]/9'
+
+    got = list(seqs.get_features(name="annotation3"))
+    assert len(got) == 1
+    assert str(got[0]) == 'exon "annotation3" at [3:6]/9'
+
+
+def test_annotate_matches_to():
+    """Aligned.annotate_matches_to correctly delegates to sequence"""
+
+    aln = Alignment(dict(x="TTCCACTTCCGCTT"), moltype="dna")
+    seq = aln.named_seqs["x"]
+    pattern = "CCRC"
+    annot = seq.annotate_matches_to(
+        pattern=pattern, annot_type="domain", name="fred", allow_multiple=True
+    )
+    got = [a.get_slice() for a in annot]
+    matches = ["CCAC", "CCGC"]
+    assert got == matches
+    annot = seq.annotate_matches_to(
+        pattern=pattern, annot_type="domain", name="fred", allow_multiple=False
+    )
+    got = [a.get_slice() for a in annot]
+    assert got == matches[:1]
+
+    # handles regex from aa
+    aln = Alignment(dict(x="TTCCACTTCCGCTT"), moltype="dna")
+    gc = get_code(1)
+    aa_regex = gc.to_regex("FHF")
+    s = aln.named_seqs["x"].annotate_matches_to(
+        aa_regex, "domain", "test", allow_multiple=False
+    )
+    a = list(aln.get_features(seqid="x"))[0]
+    assert str(aln[a].named_seqs["x"]) == "TTCCACTTC"
+
+
+@pytest.fixture(scope="function")
+def gb_db():
+    from cogent3.core.annotation_db import load_annotations
+
+    return load_annotations(DATA_DIR / "annotated_seq.gb")
+
+
+@pytest.fixture(scope="function")
+def gff_db():
+    from cogent3.core.annotation_db import load_annotations
+
+    return load_annotations(DATA_DIR / "simple.gff")
+
+
+@pytest.mark.parametrize("cls", (SequenceCollection, Alignment))
+def test_copy_annotations(cls, gff_db):
+    """copy_annotations copies records from annotation db"""
+
+    seq_coll = cls({"seq1": "ACGU", "seq2": "CGUA", "test_seq": "CCGU"})
+    seq_coll.add_feature(seqid="seq1", biotype="xyz", name="abc", spans=[(1, 2)])
+    seq_coll.add_feature(seqid="seq2", biotype="xyzzz", name="abc", spans=[(1, 2)])
+    expect = seq_coll.annotation_db.num_matches() + gff_db.num_matches()
+    seq_coll.copy_annotations(gff_db)
+    assert seq_coll.annotation_db.num_matches() == expect
+
+
+def test_copy_annotations_incompat_fails(seqcoll_db, gb_db):
+    """copy_annotations copies records from annotation db"""
+    with pytest.raises(TypeError):
+        seqcoll_db.copy_annotations(gb_db)
+
+
+def test_copy_annotations_incompat_type_fails(seqcoll_db, gb_db):
+    """copy_annotations copies records from annotation db"""
+    with pytest.raises(TypeError):
+        seqcoll_db.copy_annotations({"a": "ACGGT"})
+
+
+def test_deepcopy():
+    """correctly deepcopy Aligned objects in an alignment"""
+    path = DATA_DIR / "brca1_5.paml"
+    # generates an annotatable Alignment object
+    aln = load_aligned_seqs(path, array_align=False, moltype="dna")
+    # when the annotation is outside(before) boundary of the slice
+    aln.add_feature(seqid="NineBande", biotype="exon", name="annot1", spans=[(0, 10)])
+    # when the annotation is across boundary of the slice
+    aln.add_feature(seqid="Mouse", biotype="exon", name="annot2", spans=[(10, 21)])
+    # when the annotation is within boundary of the slice
+    aln.add_feature(seqid="Human", biotype="exon", name="annot3", spans=[(20, 25)])
+    # when the annotation is across boundary of the slice
+    aln.add_feature(seqid="HowlerMon", biotype="exon", name="annot4", spans=[(25, 32)])
+    # when the annotation is outside(after) boundary of the slice
+    aln.add_feature(seqid="DogFaced", biotype="exon", name="annot5", spans=[(40, 45)])
+    aln = aln[20:30]
+
+    # for these species, each has an annotation spanning slice boundary or within it
+    for name in ["Mouse", "Human", "HowlerMon"]:
+        new_seq = aln.named_seqs[name].deepcopy(sliced=True)
+        seq = aln.named_seqs[name]
+        assert new_seq.map.parent_length, seq.map.parent_length
+
+        assert len(new_seq.data) == 10
+        assert new_seq.data.is_annotated()
+        assert len(new_seq.data.annotations) == 1
+        # tests the case when sliced argument if False
+        new_seq = aln.named_seqs[name].deepcopy(sliced=False)
+        assert new_seq.map.parent_length == seq.map.parent_length
+        assert len(new_seq.data) == len(aln.named_seqs[name].data)
+        assert new_seq.data.is_annotated()
+
+    # for these species, each has an annotation outside slice
+    for name in ["NineBande", "DogFaced"]:
+        new_seq = aln.named_seqs[name].deepcopy(sliced=True)
+        assert len(new_seq.data) == 10
+        assert new_seq.data.is_annotated()
+        # tests the case when sliced argument if False
+        new_seq = aln.named_seqs[name].deepcopy(sliced=False)
+        assert len(new_seq.data) == len(aln.named_seqs[name].data)
+        assert new_seq.data.is_annotated()
+        assert len(new_seq.data.annotations) == 1
+
+    # add another human annotation that is outside slice
+    aln.named_seqs["Human"].data.add_feature(
+        biotype="exon", name="annot6", spans=[(40, 45)]
+    )
+    # tests the case when sliced argument if False regarding the Human sequence
+    new_seq = aln.named_seqs["Human"].deepcopy(sliced=False)
+    assert len(new_seq.data) == len(aln.named_seqs["Human"].data)
+    assert new_seq.data.is_annotated()
+    assert len(new_seq.data.annotations) == 2
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index d3fb600ee..32d23455c 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -26,13 +26,16 @@ __status__ = "Production"
 DATA_DIR = pathlib.Path(__file__).parent.parent / "data"
 
 
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
 def gff_db():
-    paths = (
-        "sample_data/Homo_sapiens.GRCh38.109.chromosome.22.gff3.gz",
-        "data/c_elegans_WS199_shortened_gff.gff3",
-    )
-    return load_annotations(paths[1])
+    path = DATA_DIR / "c_elegans_WS199_shortened_gff.gff3"
+    return load_annotations(path)
+
+
+@pytest.fixture(scope="function")
+def gff_small_db():
+    path = DATA_DIR / "simple.gff"
+    return load_annotations(path)
 
 
 @pytest.fixture()
@@ -102,6 +105,20 @@ def test_gff_counts(gff_db):
     assert len(got) > 0
 
 
+def test_gff_num_matches(gff_db):
+    count = gff_db.num_matches()
+    assert count == 11
+    assert gff_db.num_matches(seqid="I") == 11
+    assert gff_db.num_matches(seqid="IV") == 0
+
+
+def test_gb_num_matches(gb_db):
+    count = gb_db.num_matches()
+    assert count == 10  # value from manual count from file
+    assert gb_db.num_matches(seqid="AE017341") == 10
+    assert gb_db.num_matches(seqid="IV") == 0
+
+
 def test_gff_find_user_features(gff_db):
     record = dict(
         seqid="2", name="gene-01", biotype="gene", spans=[(23, 33)], strand="+"
@@ -135,8 +152,7 @@ def test_empty_data():
 # testing GenBank files
 @pytest.fixture(scope="session")
 def gb_db():
-    paths = ("data/annotated_seq.gb",)
-    return load_annotations(paths[0])
+    return load_annotations(DATA_DIR / "annotated_seq.gb")
 
 
 @pytest.mark.parametrize("parent_biotype, name", (("gene", "CNA00110"),))
@@ -485,3 +501,40 @@ def test_sequence_collection_annotate_from_gff():
 
     got = list(seq.get_features_matching(feature_type="CpG"))
     assert len(got) == 1
+
+
+def test_seq_coll_query():
+    """obtain same results when querying from collection as from seq"""
+    seqs = {"test_seq": "ATCGATCGATCG", "test_seq2": "GATCGATCGATC"}
+    seq_coll = SequenceCollection(seqs)
+    seq_coll.annotate_from_gff(DATA_DIR / "simple.gff", seq_ids="test_seq")
+
+    seq = seq_coll.get_seq("test_seq")
+    # the seq for which the seqid was provided is annotated
+    assert seq.annotation_db is not None
+    expect = list(seq.get_features_matching())
+    got = seq_coll.get_features(seqid="test_seq")
+    # the seq for which the seqid was NOT provided is NOT annotated
+    assert seq_coll.get_seq("test_seq2").annotation_db is None
+    # the annotation_db on the seq and the seq collection are the same object
+    assert seq.annotation_db is seq_coll.annotation_db
+    got = list(seq.get_features_matching(feature_type="CDS"))
+    assert len(got) == 2
+
+    got = list(seq.get_features_matching(feature_type="CpG"))
+    assert len(got) == 1
+
+
+def test_gff_update_existing(gff_db, gff_small_db):
+    expect = gff_db.num_matches() + gff_small_db.num_matches()
+    gff_db.update(gff_small_db)
+    assert gff_db.num_matches() == expect
+
+
+@pytest.mark.parametrize("seqids", (None, "23", [None], ["23"]))
+def test_gff_update_existing_specify_seqid(gff_db, gff_small_db, seqids):
+    expect = gff_db.num_matches() + gff_small_db.num_matches(
+        seqid=seqids[0] if isinstance(seqids, list) else seqids
+    )
+    gff_db.update(gff_small_db, seqids=seqids)
+    assert gff_db.num_matches() == expect
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index c3110bb53..44358b34d 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -2,7 +2,6 @@ from unittest import TestCase, main
 
 from cogent3 import ASCII, DNA, make_aligned_seqs
 from cogent3.core.annotation import Feature, Variable
-
 # Complete version of manipulating sequence annotations
 from cogent3.util.deserialise import deserialise_object
 
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index c6eb9f9fb..2918f68ab 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -1,18 +1,20 @@
 """Unit tests for Sequence class and its subclasses.
 """
-
 import json
 import os
+import pathlib
 import re
 
 from pickle import dumps
-from unittest import TestCase, main
+from unittest import TestCase
 
 import pytest
 
 from numpy import array
 from numpy.testing import assert_allclose, assert_equal
 
+import cogent3
+
 from cogent3.core.annotation import Feature, SimpleVariable, Variable
 from cogent3.core.moltype import (
     ASCII,
@@ -50,6 +52,8 @@ __maintainer__ = "Gavin Huttley"
 __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Production"
 
+DATADIR = pathlib.Path(__file__).parent.parent / "data"
+
 
 class SequenceTests(TestCase):
     """Tests of the Sequence class."""
@@ -92,11 +96,15 @@ class SequenceTests(TestCase):
     def test_copy(self):
         """correctly returns a copy version of self"""
         s = Sequence("TTTTTTTTTTAAAA", name="test_copy")
-        annot1 = s.add_feature("exon", "annot1", [(0, 10)])
-        annot2 = s.add_feature("exon", "annot2", [(10, 14)])
+        annot1 = s.add_feature(biotype="exon", name="annot1", spans=[(0, 10)])
+        annot2 = s.add_feature(biotype="exon", name="annot2", spans=[(10, 14)])
         got = s.copy()
-        got_annot1 = got.get_features_matching(feature_type="exon", name="annot1")[0]
-        got_annot2 = got.get_features_matching(feature_type="exon", name="annot2")[0]
+        got_annot1 = list(
+            got.get_features_matching(feature_type="exon", name="annot1")
+        )[0]
+        got_annot2 = list(
+            got.get_features_matching(feature_type="exon", name="annot2")
+        )[0]
         self.assertIsNot(got, s)
         self.assertIsNot(got_annot1, annot1)
         self.assertIsNot(got_annot2, annot2)
@@ -106,8 +114,8 @@ class SequenceTests(TestCase):
         self.assertEqual(got.moltype, s.moltype)
         annot1_slice = str(annot1.get_slice())
         annot2_slice = str(annot2.get_slice())
-        got1_slice = str(got.annotations[0].get_slice())
-        got2_slice = str(got.annotations[1].get_slice())
+        got1_slice = str(got_annot1.get_slice())
+        got2_slice = str(got_annot2.get_slice())
         self.assertEqual(annot1_slice, got1_slice)
         self.assertEqual(annot2_slice, got2_slice)
 
@@ -224,20 +232,6 @@ class SequenceTests(TestCase):
         with self.assertRaises(ValueError):
             s.to_moltype("")
 
-    def test_annotate_from_gff(self):
-        """correctly annotates a Sequence from a gff file"""
-        from cogent3.parse.fasta import FastaParser
-
-        fasta_path = os.path.join("data/c_elegans_WS199_dna_shortened.fasta")
-        gff3_path = os.path.join("data/c_elegans_WS199_shortened_gff.gff3")
-        name, seq = next(FastaParser(fasta_path))
-
-        sequence = Sequence(seq)
-        sequence.annotate_from_gff(gff3_path)
-        matches = [m for m in sequence.get_features_matching()]
-        # 13 features with one having 2 parents, so 14 instances should be found
-        self.assertEqual(len(matches), 14)
-
     @pytest.mark.xfail(
         reason="todo: annotate_from_gff needs to be using bound annotation_db"
     )
@@ -255,7 +249,7 @@ class SequenceTests(TestCase):
         #                       *****     exon
         # ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC...
         seq = DNA.make_seq("ACCCCGGAAAATTTTTTTTTAAGGGGGAAAAAAAAACCCCCCC", name="22")
-        gff3_path = os.path.join("data/ensembl_sample.gff3")
+        gff3_path = DATADIR / "ensembl_sample.gff3"
         seq.annotate_from_gff(gff3_path)
         # we have 1 "full chromosome" annotation, 3 generic regions and 1 gene
         self.assertEqual(len(seq.annotations), 5)
@@ -970,13 +964,10 @@ class SequenceTests(TestCase):
 
     def test_is_annotated(self):
         """is_annotated operates correctly"""
-        from cogent3.core.annotation import _Annotatable
-
         s = self.SEQ("ACGGCTGAAGCGCTCCGGGTTTAAAACG")
-        annotatable = isinstance(s, _Annotatable)
-        if annotatable:
+        if hasattr(s, "annotation_db"):
             self.assertFalse(s.is_annotated())
-            _ = s.add_feature("gene", "blah", [(0, 10)])
+            _ = s.add_feature(biotype="gene", name="blah", spans=[(0, 10)])
             self.assertTrue(s.is_annotated())
         else:
             with self.assertRaises(AttributeError):
@@ -1955,6 +1946,32 @@ def one_seq():
     return make_seq("AACCTGGAACC", moltype="dna")
 
 
+@pytest.fixture(scope="session")
+def worm_seq_path():
+    return DATADIR / "c_elegans_WS199_dna_shortened.fasta"
+
+
+@pytest.fixture(scope="session")
+def worm_gff_path():
+    return DATADIR / "c_elegans_WS199_shortened_gff.gff3"
+
+
+def test_annotate_from_gff(worm_seq_path, worm_gff_path):
+    """correctly annotates a Sequence from a gff file"""
+    # duplicated in test_alignment on seq collection
+    seq = cogent3.load_seq(worm_seq_path, moltype="dna")
+
+    seq.annotate_from_gff(worm_gff_path)
+    matches = list(seq.get_features_matching())
+    assert len(matches) == 11
+    matches = list(seq.get_features_matching(feature_type="gene"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="mRNA"))
+    assert len(matches) == 1
+    matches = list(matches[0].get_children(biotype="exon"))
+    assert len(matches) == 3
+
+
 @pytest.mark.parametrize("rc", (False, True))
 def test_seq_repr(one_seq, rc):
     pat = re.compile("[ACGT]+")
@@ -1970,7 +1987,18 @@ def test_seq_repr(one_seq, rc):
     assert expect.startswith(got), (expect, got)
 
 
-def test_gav3():
+@pytest.mark.xfail(reason="todo: to be implemented")
+def test_db_bind_fails_with_incompatible_moltype(worm_seq_path, worm_gff_path):
+    from cogent3.core.annotation_db import load_annotations
+
+    seq = cogent3.load_seq(worm_seq_path, moltype="text")
+    db = load_annotations(worm_gff_path)
+    with pytest.raises(TypeError):
+        seq.annotation_db = db
+
+
+@pytest.mark.xfail(reason="todo: write test")
+def test_annotation_from_slice_with_stride():
     raise NotImplementedError(
         "check annotations get correct sequence when there's a stride"
     )