Repository: cogent3/cogent3
Pull request #1410 (2023-05-03 22:49:40)

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 2e9e8bc82..b0e3d525b 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
@@ -630,22 +629,34 @@ class _SequenceCollectionBase:
 
     def copy(self):
         """Returns deep copy of self."""
-        return self.__class__(self, moltype=self.moltype, info=self.info)
+        result = self.__class__(self, moltype=self.moltype, info=self.info)
+        result._annotation_db = deepcopy(self.annotation_db)
+        return result
 
-    def deepcopy(self, sliced=True):
-        """Returns deep copy of self."""
+    def deepcopy(self, sliced: bool = True):
+        """returns deep copy of self.
+
+        Parameters
+        ----------
+        sliced
+            if True, reduces the sequence to current
+            interval. This also causes dropping
+            annotations.
+        """
         new_seqs = dict()
+        db = None if sliced else deepcopy(self.annotation_db)
         for seq in self.seqs:
             try:
-                new_seq = seq.deepcopy(sliced=sliced)
+                new_seq = seq.deepcopy(sliced=sliced, exclude_annotations=True)
             except AttributeError:
-                new_seq = seq.copy()
+                new_seq = seq.copy(exclude_annotations=True)
             new_seqs[seq.name] = new_seq
 
         info = deepcopy(self.info)
         result = self.__class__(
             new_seqs, moltype=self.moltype, info=info, force_same_data=True
         )
+        result.annotation_db = db
         result._repr_policy.update(self._repr_policy)
         return result
 
@@ -2326,19 +2337,21 @@ class Aligned:
         """Returns a shallow copy of self"""
         return self.__class__(self.map, self.data)
 
-    def deepcopy(self, sliced=True):
+    def deepcopy(self, sliced=True, exclude_annotations=False):
         """
         Parameters
         -----------
         sliced : bool
             Slices underlying sequence with start/end of self coordinates. This
             has the effect of breaking the connection to any longer parent sequence.
+        exclude_annotations
+            drops annotation_db when True
 
         Returns
         -------
         a copy of self
         """
-        new_seq = self.data.copy()
+        new_seq = self.data.copy(exclude_annotations=exclude_annotations)
         if sliced:
             span = self.map.get_covering_span()
             new_seq = type(new_seq)(
@@ -5205,7 +5218,7 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
         if on_alignment is None:
             on_alignment = feature.pop("on_alignment", None)
 
-        if not on_alignment:
+        if not on_alignment and feature["seqid"]:
             return self.named_seqs[feature["seqid"]].make_feature(feature, self)
 
         # there's no sequence to bind to, the feature is directly on self
@@ -5273,20 +5286,17 @@ class Alignment(_Annotatable, AlignmentI, SequenceCollection):
             on_alignment=on_alignment,
             allow_partial=allow_partial,
         ):
-            if on_al := feature.pop("on_alignment", on_alignment):
-                if seq_map is None:
-                    seq_map = self.seqs[0].map
-                # gah todo use the Aligned map to transform the absolute
-                # index from the db into a relative index
-                spans = numpy.array(feature["spans"])
-                spans = seq_map.relative_position(spans)
-                feature["spans"] = spans.tolist()
-                # and if i've been reversed...?
-                feature["reversed"] = seq_map.reverse
-                yield self.make_feature(feature=feature, on_alignment=on_al)
+            if feature["seqid"]:
                 continue
-
-                seqid = feature["seqid"]
-                seq = self.named_seqs[seqid]
-                # passing self only used when self is an Alignment
-                yield seq.make_feature(feature, self)
+            on_al = feature.pop("on_alignment", on_alignment)
+            if feature["seqid"]:
+                raise RuntimeError(f"{on_alignment=} {feature=}")
+            if seq_map is None:
+                seq_map = self.seqs[0].map
+
+            spans = numpy.array(feature["spans"])
+            spans = seq_map.relative_position(spans)
+            feature["spans"] = spans.tolist()
+            # and if i've been reversed...?
+            feature["reversed"] = seq_map.reverse
+            yield self.make_feature(feature=feature, on_alignment=on_al)
diff --git a/src/cogent3/core/annotation_db.py b/src/cogent3/core/annotation_db.py
index 158adeb3b..322e19fd7 100644
--- a/src/cogent3/core/annotation_db.py
+++ b/src/cogent3/core/annotation_db.py
@@ -1,4 +1,5 @@
 import collections
+import inspect
 import json
 import os
 import pathlib
@@ -403,6 +404,36 @@ class SqliteAnnotationDbMixin:
         "spans": "array",
         "on_alignment": "INT",
     }
+    # args to exclude from serialisation init
+    _exclude_init = "db", "data"
+
+    def __new__(klass, *args, **kwargs):
+        obj = object.__new__(klass)
+        init_sig = inspect.signature(klass.__init__)
+        bargs = init_sig.bind_partial(klass, *args, **kwargs)
+        bargs.apply_defaults()
+        init_vals = bargs.arguments
+        for param in klass._exclude_init:
+            init_vals.pop(param)
+        init_vals.pop("self", None)
+
+        obj._serialisable = init_vals
+        return obj
+
+    def __deepcopy__(self, memodict=None):
+        memodict = memodict or {}
+        new = self.__class__(source=self.source)
+        new._db.deserialize(self._db.serialize())
+        return new
+
+    def __getstate__(self):
+        return {"data": self._db.serialize(), "source": self.source}
+
+    def __setstate__(self, state):
+        new = self.__class__(source=state.pop("source", None))
+        new._db.deserialize(state["data"])
+        self.__dict__.update(new.__dict__)
+        return self
 
     @property
     def table_names(self) -> tuple[str]:
@@ -598,7 +629,25 @@ class SqliteAnnotationDbMixin:
                 counts.update(v for r in result if (v := r["biotype"]))
         return counts
 
-    def to_rich_dict(self):
+    def to_rich_dict(self) -> dict:
+        """returns a dict suitable for json serialisation"""
+        result = {
+            "type": get_object_provenance(self),
+            "version": __version__,
+            "tables": {},
+            "init_args": {**self._serialisable},
+        }
+        tables = result["tables"]
+        for table_name in self.table_names:
+            table_data = []
+            for record in self._get_records_matching(table_name):
+                store = {k: v for k, v in zip(record.keys(), record) if v is not None}
+                store["spans"] = store["spans"].tolist()
+                table_data.append(store)
+            tables[table_name] = table_data
+        return result
+
+    def _to_rich_dict(self):
         # todo drop columns from records with no value
         # top level dict for each table_name
         records = {}
@@ -626,19 +675,21 @@ class SqliteAnnotationDbMixin:
     @classmethod
     def from_dict(cls, data: dict):
         # make an empty db
-        db = cls()
+        init_args = data.pop("init_args")
+        db = cls(**init_args)
         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)
+        data.pop("version")
         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 table_name, records in data["tables"].items():
             for record in records:
                 if seqids and record["seqid"] not in seqids:
                     continue
@@ -648,6 +699,9 @@ class SqliteAnnotationDbMixin:
                 )
                 self._execute_sql(sql, vals)
 
+    def to_json(self) -> str:
+        return json.dumps(self.to_rich_dict())
+
 
 class GffAnnotationDb(SqliteAnnotationDbMixin):
     _table_names = "gff", "user"
@@ -981,6 +1035,28 @@ def deserialise_gb_db(data: dict):
     return GenbankAnnotationDb.from_dict(data)
 
 
+@register_deserialiser("annotation_to_annotation_db")
+def convert_annotation_to_annotation_db(data: dict) -> dict:
+    from cogent3.util.deserialise import deserialise_map_spans
+
+    db = GffAnnotationDb()
+
+    seqid = data.pop("name", None)
+    anns = data.pop("data")
+    for ann in anns:
+        ann = ann.pop("annotation_construction")
+        m = deserialise_map_spans(ann.pop("map"))
+        spans = m.get_coordinates()
+        strand = "-" if m.reverse else "+"
+        biotype = ann.pop("type")
+        name = ann.pop("name")
+        db.add_feature(
+            seqid=seqid, biotype=biotype, name=name, spans=spans, strand=strand
+        )
+
+    return db
+
+
 def _db_from_genbank(path, db):
     from cogent3 import open_
     from cogent3.parse.genbank import MinimalGenbankParser
diff --git a/src/cogent3/core/sequence.py b/src/cogent3/core/sequence.py
index 4e4970e54..8252c70f8 100644
--- a/src/cogent3/core/sequence.py
+++ b/src/cogent3/core/sequence.py
@@ -10,8 +10,8 @@ Sequences are intended to be immutable. This is not enforced by the code for
 performance reasons, but don't alter the MolType or the sequence data after
 creation.
 """
-
 import contextlib
+import copy
 import json
 import os
 import re
@@ -128,7 +128,7 @@ class SequenceI(object):
             label = self.name
         return alignment_to_fasta({label: str(self)}, block_size=block_size)
 
-    def to_rich_dict(self):
+    def to_rich_dict(self, exclude_annotations=False):
         """returns {'name': name, 'seq': sequence, 'moltype': moltype.label}"""
         info = {} if self.info is None else self.info
         if not info.get("Refs", None) is None and "Refs" in info:
@@ -144,13 +144,12 @@ class SequenceI(object):
             version=__version__,
         )
 
-        try:
-            annotations = [a.to_rich_dict() for a in self.annotations]
-        except AttributeError:
-            annotations = []
-
-        if annotations:
-            data["annotations"] = annotations
+        if (
+            hasattr(self, "annotation_db")
+            and self.annotation_db
+            and not exclude_annotations
+        ):
+            data["annotation_db"] = self.annotation_db.to_rich_dict()
 
         return data
 
@@ -1183,15 +1182,11 @@ class Sequence(_Annotatable, SequenceI):
 
         self.annotation_db.update(seq_db, seqids=self.name)
 
-    def copy(self):
+    def copy(self, exclude_annotations=False):
         """returns a copy of self"""
         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
+        db = None if exclude_annotations else copy.deepcopy(self.annotation_db)
+        new._annotation_db = db
         return new
 
     def _get_feature_start(self, feature):
@@ -1221,9 +1216,6 @@ class Sequence(_Annotatable, SequenceI):
         shadow
             whether to mask the annotated regions, or everything but
             the annotated regions
-        extend_query : boolean
-            queries sub-annotations if True
-
         """
         if mask_char is None:
             ambigs = [(len(v), c) for c, v in list(self.moltype.ambiguities.items())]
@@ -1232,7 +1224,7 @@ class Sequence(_Annotatable, SequenceI):
         assert mask_char in self.moltype, f"Invalid mask_char {mask_char}"
 
         annotations = []
-        annot_types = [annot_types, [annot_types]][isinstance(annot_types, str)]
+        annot_types = [annot_types] if isinstance(annot_types, str) else annot_types
         for annot_type in annot_types:
             annotations += list(
                 self.get_features(biotype=annot_type, allow_partial=True)
@@ -1254,15 +1246,15 @@ class Sequence(_Annotatable, SequenceI):
 
         i = 0
         segments = []
-        for b, e in region.get_coordinates():
-            segments.extend((str(self._seq[i:b]), mask_char * (e - b)))
+        fmap = region.map.reversed() if region.map.reverse else region.map
+        for b, e in fmap.get_coordinates():
+            segments.extend((str(self[i:b]), mask_char * (e - b)))
             i = e
-        segments.append(str(self._seq[i:]))
+        segments.append(str(self[i:]))
 
         new = self.__class__(
             "".join(segments), name=self.name, check=False, info=self.info
         )
-        new.annotations = self.annotations[:]
         return new
 
     def gapped_by_map_segment_iter(self, map, allow_gaps=True, recode_gaps=False):
@@ -1625,8 +1617,8 @@ class NucleicAcidSequence(Sequence):
         codon_alphabet = gc.get_alphabet(include_stop=include_stop).with_gap_motif()
         # translate the codons
         translation = []
-        for posn in range(0, len(self._seq) - 2, 3):
-            orig_codon = str(self._seq[posn : posn + 3])
+        for posn in range(0, len(self) - 2, 3):
+            orig_codon = str(self[posn : posn + 3])
             try:
                 resolved = codon_alphabet.resolve_ambiguity(orig_codon)
             except AlphabetError:
diff --git a/src/cogent3/util/deserialise.py b/src/cogent3/util/deserialise.py
index fc51d4f26..53b91add9 100644
--- a/src/cogent3/util/deserialise.py
+++ b/src/cogent3/util/deserialise.py
@@ -1,5 +1,5 @@
-#!/usr/bin/env python
 import json
+import re
 
 from importlib import import_module
 
@@ -62,6 +62,14 @@ def _get_class(provenance):
     return klass
 
 
+_pat = re.compile("[a-z]")
+
+
+def str_to_version(v):
+    letter = _pat.search(v)
+    return tuple(f"{v[:letter.start()]}.{letter.group()}.{letter.end():}".split("."))
+
+
 @register_deserialiser(
     "cogent3.util.table.Table",
     "cogent3.util.dict_array.DictArray",
@@ -215,6 +223,11 @@ def deserialise_seq(data, aligned=False):
     data.pop("version", None)
     data["moltype"] = get_moltype(data.pop("moltype"))
     annotations = data.pop("annotations", None)
+    if annotations:
+        annotation_db = {"type": "annotation_to_annotation_db", "data": annotations}
+    else:
+        annotation_db = data.pop("annotation_db", None)
+
     make_seq = data["moltype"].make_seq
     _ = data.pop("type")
     if "-" in data["seq"]:
@@ -225,11 +238,14 @@ def deserialise_seq(data, aligned=False):
     if aligned:
         map_, result = result.parse_out_gaps()
 
-    if annotations:
-        deserialise_annotation(annotations, result)
+    if annotation_db:
+        annotation_db = deserialise_object(annotation_db)
 
     if aligned:
         result = Aligned(map_, result)
+        result.data.annotation_db = annotation_db
+    else:
+        result.annotation_db = annotation_db
 
     return result
 
@@ -243,6 +259,11 @@ def deserialise_seq_collections(data):
     data.pop("version", None)
     data["moltype"] = get_moltype(data.pop("moltype"))
     annotations = data.pop("annotations", None)
+    if annotations:
+        annotation_db = {"type": "annotation_to_annotation_db", "data": annotations}
+    else:
+        annotation_db = data.pop("annotation_db", None)
+
     type_ = data.pop("type")
     klass = _get_class(type_)
     assert "alignment" in type_.lower(), "not alignment type"
@@ -255,8 +276,8 @@ def deserialise_seq_collections(data):
 
     result = klass(seqs, **data)
 
-    if annotations:
-        deserialise_annotation(annotations, result)
+    if annotation_db:
+        result.annotation_db = deserialise_object(annotation_db)
 
     return result
 


Test patch:
diff --git a/tests/test_core/test_annotation_db.py b/tests/test_core/test_annotation_db.py
index 51285cc53..d45e580f8 100644
--- a/tests/test_core/test_annotation_db.py
+++ b/tests/test_core/test_annotation_db.py
@@ -12,6 +12,7 @@ from cogent3.core.annotation_db import (
     load_annotations,
 )
 from cogent3.core.sequence import Sequence
+from cogent3.util import deserialise
 
 
 __author__ = "Gavin Huttley"
@@ -197,7 +198,7 @@ def test_get_features_matching_no_annotation_db(seq):
     """
     Test that `get_features_matching` returns an empty list when no annotation database is attached to the sequence.
     """
-    assert not list(seq.get_features_matching())
+    assert not list(seq.get_features())
 
 
 def test_get_features_matching_no_matching_feature(seq, anno_db):
@@ -209,8 +210,8 @@ def test_get_features_matching_no_matching_feature(seq, anno_db):
         seqid=seq.name, biotype="exon", name="exon1", spans=[(1, 4)], strand="+"
     )
 
-    assert not list(seq.get_features_matching(feature_type="exon", name="non_matching"))
-    assert not list(seq.get_features_matching(feature_type="CDS"))
+    assert not list(seq.get_features(biotype="exon", name="non_matching"))
+    assert not list(seq.get_features(biotype="CDS"))
 
 
 def test_get_features_matching_matching_feature(seq, anno_db):
@@ -221,7 +222,7 @@ def test_get_features_matching_matching_feature(seq, anno_db):
     anno_db.add_feature(
         seqid=seq.name, biotype="exon", name="exon1", spans=[(1, 4)], strand="+"
     )
-    got = list(seq.get_features_matching(feature_type="exon"))
+    got = list(seq.get_features(biotype="exon"))
 
     assert got[0].biotype == "exon"
     assert got[0].name == "exon1"
@@ -281,7 +282,6 @@ def test_gav():
     plus = list(rced.get_features(name="plus"))[0]
     assert str(plus.get_slice()) == plus_seq
     minus = list(rced.get_features(name="minus"))[0]
-    print(rf, minus)
     assert str(minus.get_slice()) == minus_seq
 
 
@@ -333,7 +333,7 @@ def test_get_features_matching_matching_features(anno_db: GffAnnotationDb, seq):
     anno_db.add_feature(
         seqid=seq.name, biotype="exon", name="exon2", spans=[(6, 10)], strand="+"
     )
-    got = list(seq.get_features_matching(feature_type="exon"))
+    got = list(seq.get_features(biotype="exon"))
 
     assert len(got) == 2
 
@@ -341,20 +341,20 @@ def test_get_features_matching_matching_features(anno_db: GffAnnotationDb, seq):
 def test_annotate_from_gff(seq):
     seq.annotate_from_gff(DATA_DIR / "simple.gff")
 
-    got = list(seq.get_features_matching(feature_type="exon"))
+    got = list(seq.get_features(biotype="exon"))
     assert len(got) == 2
 
-    got = list(seq.get_features_matching(feature_type="CDS"))
+    got = list(seq.get_features(biotype="CDS"))
     assert len(got) == 2
 
-    got = list(seq.get_features_matching(feature_type="CpG"))
+    got = list(seq.get_features(biotype="CpG"))
     assert len(got) == 1
 
 
 def test_get_features_matching_start_stop(seq):
     # todo: need to implement LostSpans()
     seq.annotate_from_gff(DATA_DIR / "simple.gff")
-    got = list(seq.get_features_matching(start=2, stop=10, allow_partial=True))
+    got = list(seq.get_features(start=2, stop=10, allow_partial=True))
     assert len(got) == 4
 
 
@@ -367,7 +367,7 @@ def test_matching_conditions():
 def test_get_features_matching_start_stop_seqview(seq):
     """testing that get_features_matching adjusts"""
     seq.annotate_from_gff(DATA_DIR / "simple.gff")
-    seq_features = list(seq.get_features_matching(start=0, stop=3, allow_partial=True))
+    seq_features = list(seq.get_features(start=0, stop=3, allow_partial=True))
     assert len(seq_features) == 3
 
     # edge case, only 1 features that overlaps with index 12
@@ -375,7 +375,7 @@ def test_get_features_matching_start_stop_seqview(seq):
     # possibly a bug in the SQL generating code
     subseq = seq[9:]
     seq_features_features = list(
-        subseq.get_features_matching(start=3, stop=10, allow_partial=True)
+        subseq.get_features(start=3, stop=10, allow_partial=True)
     )
     assert len(seq_features_features) == 1
 
@@ -397,7 +397,7 @@ def test_get_slice():
 
 
 def test_feature_get_children(seq_db):
-    feat = list(seq_db.get_features_matching(name="Transcript:B0019.1"))[0]
+    feat = list(seq_db.get_features(name="Transcript:B0019.1"))[0]
     new_feat_5pUTR = list(feat.get_children(biotype="five_prime_UTR"))
     assert len(new_feat_5pUTR) == 1
 
@@ -435,9 +435,9 @@ def test_rc_get_slice_positive_feature(anno_db):
         seqid=seq.name, biotype="exon", name="exon1", spans=[(2, 6)], strand="+"
     )
 
-    feat = list(seq.get_features_matching(name="exon1"))[0]
+    feat = list(seq.get_features(name="exon1"))[0]
     r_seq = seq.rc()
-    r_feat = list(r_seq.get_features_matching(name="exon1"))[0]
+    r_feat = list(r_seq.get_features(name="exon1"))[0]
 
     assert feat.get_slice() == r_feat.get_slice()
 
@@ -446,7 +446,7 @@ def test_add_feature(seq):
     """Sequence supports manual adding of features for a seq with no bound AnnotationDb"""
     record = dict(name="gene-01", biotype="gene", spans=[(12, 16)], strand="+")
     seq.add_feature(**record)
-    feats = list(seq.get_features_matching(feature_type="gene"))
+    feats = list(seq.get_features(biotype="gene"))
 
     assert seq.annotation_db is not None
     assert len(feats) == 1
@@ -459,7 +459,7 @@ def test_add_feature_existing_db(simple_seq_gff_db):
     simple_seq_gff_db.add_feature(**record)
 
     # total features should be 5+1=6
-    all_feats = list(simple_seq_gff_db.get_features_matching())
+    all_feats = list(simple_seq_gff_db.get_features())
     assert len(all_feats) == 6
 
 
@@ -478,27 +478,11 @@ def test__getitem__(simple_seq_gff_db):
     )
 
 
-def test_to_moltype_dna():
-    """to_moltype("dna") ensures conversion from T to U"""
-    seq = DNA.make_seq("AAAAGGGGTTT", name="seq1")
-    rna = seq.to_moltype("rna")
-
-    assert "T" not in rna
-
-
-def test_to_moltype_rna():
-    """to_moltype("rna") ensures conversion from U to T"""
-    seq = RNA.make_seq("AAAAGGGGUUU", name="seq1")
-    rna = seq.to_moltype("dna")
-
-    assert "U" not in rna
-
-
 def test_annotate_from_gff_multiple_calls(seq):
     """5 records in each gff file, total features on seq should be 10"""
     seq.annotate_from_gff(DATA_DIR / "simple.gff")
     seq.annotate_from_gff(DATA_DIR / "simple2.gff")
-    assert len(list(seq.get_features_matching())) == 10
+    assert len(list(seq.get_features())) == 10
 
 
 def test_sequence_collection_annotate_from_gff():
@@ -517,17 +501,17 @@ def test_sequence_collection_annotate_from_gff():
     assert seq_coll.get_seq("test_seq").annotation_db is seq_coll.annotation_db
     got = list(seq_coll.get_seq("test_seq").get_features(allow_partial=True))
     assert len(got) == 5
-    got = list(seq.get_features(feature_type="CDS"))
+    got = list(seq.get_features(biotype="CDS"))
     assert len(got) == 2
 
-    got = list(seq.get_features(feature_type="CpG"))
+    got = list(seq.get_features(biotype="CpG"))
     assert len(got) == 1
 
     # the seq for which the seqid was NOT provided also has a reference to the same db
     seq2 = seq_coll.get_seq("test_seq2")
     assert seq2.annotation_db is not None
     # querying on that sequence returns []
-    got = list(seq2.get_features(feature_type="CDS"))
+    got = list(seq2.get_features(biotype="CDS"))
     assert not got
 
 
@@ -553,10 +537,10 @@ def test_seq_coll_query():
     assert got == expect
     # 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"))
+    got = list(seq.get_features(biotype="CDS"))
     assert len(got) == 2
 
-    got = list(seq.get_features_matching(feature_type="CpG"))
+    got = list(seq.get_features(biotype="CpG"))
     assert len(got) == 1
 
 
@@ -593,9 +577,51 @@ def test_relative_position_positive_feature(anno_db):
         seqid=seq.name, biotype="exon", name="exon1", spans=[(2, 6)], strand="+"
     )
 
-    orig_feat_span = list(seq.get_features_matching(name="exon1"))[0].map
+    orig_feat_span = list(seq.get_features(name="exon1"))[0].map
     view = seq[2:]
-    view_feat_span = list(view.get_features_matching(name="exon1"))[0].map
+    view_feat_span = list(view.get_features(name="exon1"))[0].map
 
     assert orig_feat_span[0].start - 2 == view_feat_span[0].start
     assert orig_feat_span[0].end - 2 == view_feat_span[0].end
+
+
+def test_deepcopy(gff_db):
+    import copy
+
+    new = copy.deepcopy(gff_db)
+    new.add_feature(
+        seqid="s1", biotype="exon", name="copied-exon", spans=[(2, 6)], strand="+"
+    )
+    assert new.num_matches() == gff_db.num_matches() + 1
+    assert len(list(new.get_features_matching(name="copied-exon"))) == 1
+    assert len(list(gff_db.get_features_matching(name="copied-exon"))) == 0
+
+
+def test_pickling(gff_db):
+    import pickle
+
+    recon = pickle.loads(pickle.dumps(gff_db))
+    assert isinstance(recon, type(gff_db))
+    recon.add_feature(
+        seqid="s1", biotype="exon", name="copied-exon", spans=[(2, 6)], strand="+"
+    )
+    assert recon.num_matches() == gff_db.num_matches() + 1
+
+
+@pytest.mark.parametrize("db_name", ("gff_db", "gb_db"))
+def test_to_rich_dict(db_name, request):
+    db = request.getfixturevalue(db_name)
+    data = db.to_rich_dict()
+    assert data["init_args"]["source"] == ":memory:"
+    if db_name == "gb_db":
+        assert "seqid" in data["init_args"]
+
+
+@pytest.mark.parametrize("db_name", ("gff_db", "gb_db"))
+def test_deserialise(db_name, request):
+    db = request.getfixturevalue(db_name)
+    data = db.to_json()
+    got = deserialise.deserialise_object(data)
+    assert got is not db
+    assert isinstance(got, type(db))
+    assert got.num_matches() == db.num_matches()
diff --git a/tests/test_core/test_features.py b/tests/test_core/test_features.py
index 6f699978c..dd2f61705 100755
--- a/tests/test_core/test_features.py
+++ b/tests/test_core/test_features.py
@@ -1,11 +1,10 @@
-from unittest import TestCase, main
+from unittest import TestCase
 
 import pytest
 
 from cogent3 import ASCII, DNA, make_aligned_seqs
 from cogent3.core.annotation import Feature, Variable
 from cogent3.core.annotation_db import GffAnnotationDb
-
 # Complete version of manipulating sequence annotations
 from cogent3.util.deserialise import deserialise_object
 
@@ -170,32 +169,6 @@ class FeaturesTest(TestCase):
             'cpgsite "cpg" at [8:10]/10',
         )
 
-    @pytest.mark.xfail(reason="todo gah update test to use latest API")
-    def test_constructor_equivalence(self):
-        # These different constructions should generate the same output.
-        data = [["human", "CGAAACGTTT"], ["mouse", "CTAAACGTCG"]]
-        as_series = make_aligned_seqs(data=data, array_align=False)
-        as_items = make_aligned_seqs(data=data, array_align=False)
-
-        serial = as_series.with_masked_annotations(["cpgsite"])
-        itemwise = as_items.with_masked_annotations(["cpgsite"])
-        self.assertEqual(str(serial), str(itemwise))
-
-        # Annotations should be correctly masked,
-        # whether the sequence has been reverse complemented or not.
-        # We use the plus/minus strand CDS containing sequences created above.
-        plus = DNA.make_seq("AAGGGGAAAACCCCCAAAAAAAAAATTTTTTTTTTAAA", name="plus")
-        _ = plus.add_annotation(Feature, "CDS", "gene", [(2, 6), (10, 15), (25, 35)])
-        minus = plus.rc()
-        self.assertEqual(
-            str(plus.with_masked_annotations("CDS")),
-            "AA????AAAA?????AAAAAAAAAA??????????AAA",
-        )
-        self.assertEqual(
-            str(minus.with_masked_annotations("CDS")),
-            "TTT??????????TTTTTTTTTT?????TTTT????TT",
-        )
-
     @pytest.mark.xfail(
         reason="todo gah implement support for annotation_db serialisation"
     )
@@ -293,28 +266,6 @@ class FeaturesTest(TestCase):
         self.assertEqual(str(x.data.annotations[0].get_slice()), "GGGGG")
         self.assertEqual(str(x.data.annotations[1].get_slice()), "C")
 
-    @pytest.mark.xfail(reason="todo gah update test to use latest API")
-    def test_roundtrip_rc_annotated_align(self):
-        """should work for an alignment that has been reverse complemented"""
-        # the key that exposed the bug was a gap in the middle of the sequence
-        aln = make_aligned_seqs(
-            data=[["x", "-AAAGGGGGAAC-CT"], ["y", "TTTT--TTTTAGGGA"]],
-            array_align=False,
-            moltype="dna",
-        )
-        aln.get_seq("x").add_annotation(Feature, "exon", "E1", [(3, 8)])
-        aln.get_seq("x").add_annotation(Feature, "exon", "E2", [(10, 13)])
-
-        raln = aln.rc()
-        json = raln.to_json()
-        got = deserialise_object(json)
-        self.assertEqual(got.to_dict(), raln.to_dict())
-        orig_annots = {
-            a.name: a.get_slice() for a in raln.get_annotations_from_any_seq()
-        }
-        got_annots = {a.name: a.get_slice() for a in got.get_annotations_from_any_seq()}
-        self.assertEqual(got_annots, orig_annots)
-
     @pytest.mark.xfail(reason="todo gah delete test not supporting Variable class")
     def test_roundtrip_variable(self):
         """should recover the Variable feature type"""
@@ -714,3 +665,84 @@ def test_nested_get_slice():
     s.add_feature(biotype="repeat", name="bob", spans=[(12, 17)], parent_id="fred")
     f = list(ex.get_children())[0]
     assert str(f.get_slice()) == str(s[12:17])
+
+
+def test_roundtrip_annotated_seq():
+    """should work for an alignment that has been reverse complemented"""
+    # the key that exposed the bug was a gap in the middle of the sequence
+    seq = DNA.make_seq(
+        "AAAGGGGGAACCT",
+        name="x",
+    )
+    seq.add_feature(biotype="exon", name="E1", spans=[(3, 8)])
+    seq.add_feature(biotype="exon", name="E2", spans=[(10, 13)])
+    rd = seq.to_rich_dict()
+    ...
+
+
+def test_roundtrip_rc_annotated_align():
+    """should work for an alignment that has been reverse complemented"""
+    # the key that exposed the bug was a gap in the middle of the sequence
+    aln = make_aligned_seqs(
+        data=[["x", "-AAAGGGGGAAC-CT"], ["y", "TTTT--TTTTAGGGA"]],
+        array_align=False,
+        moltype="dna",
+    )
+    aln.get_seq("x").add_feature(biotype="exon", name="E1", spans=[(3, 8)])
+    aln.get_seq("x").add_feature(biotype="exon", name="E2", spans=[(10, 13)])
+
+    raln = aln.rc()
+    rd = raln.to_rich_dict()
+    json = raln.to_json()
+    got = deserialise_object(json)
+    assert got.to_dict() == raln.to_dict()
+    orig_annots = {a.name: a.get_slice() for a in raln.get_features()}
+    got_annots = {a.name: a.get_slice() for a in got.get_features()}
+    assert got_annots == orig_annots
+
+
+def test_masking_strand_agnostic_seq():
+    db = GffAnnotationDb()
+    db.add_feature(
+        seqid="plus", biotype="CDS", name="gene", spans=[(2, 6), (10, 15), (25, 35)]
+    )
+
+    # Annotations should be correctly masked,
+    # whether the sequence has been reverse complemented or not.
+    # We use the plus/minus strand CDS containing sequences created above.
+    plus = DNA.make_seq("AAGGGGAAAACCCCCAAAAAAAAAATTTTTTTTTTAAA", name="plus")
+    plus.annotation_db = db
+    masked = plus.with_masked_annotations("CDS")
+    assert len(masked) == len(plus)
+    assert str(masked) == "AA????AAAA?????AAAAAAAAAA??????????AAA"
+    minus = plus.rc()
+    masked = minus.with_masked_annotations("CDS")
+    assert len(masked) == len(minus)
+    assert str(masked) == "TTT??????????TTTTTTTTTT?????TTTT????TT"
+
+
+def test_masking_strand_agnostic_aln():
+    db = GffAnnotationDb()
+    db.add_feature(
+        seqid="x", biotype="CDS", name="gene", spans=[(2, 6), (10, 15), (25, 35)]
+    )
+    aln = make_aligned_seqs(
+        {
+            "x": "AAGGGGAAAACCCCCAAAAAAAAAATTTTTTTTTTAAA",
+            "y": "AAGGGGAAAACCCCCGGGGGGGGGGTTTTTTTTTTAAA",
+        },
+        moltype="dna",
+        array_align=False,
+    )
+    aln.annotation_db = db
+    masked = aln.with_masked_annotations("CDS")
+    assert masked.to_dict() == {
+        "x": "AA????AAAA?????AAAAAAAAAA??????????AAA",
+        "y": str(aln.named_seqs["y"]),
+    }
+    rc = aln.rc()
+    masked = rc.with_masked_annotations("CDS")
+    assert masked.to_dict() == {
+        "x": "TTT??????????TTTTTTTTTT?????TTTT????TT",
+        "y": str(rc.named_seqs["y"]),
+    }
diff --git a/tests/test_core/test_sequence.py b/tests/test_core/test_sequence.py
index 01888a02f..9004a993b 100644
--- a/tests/test_core/test_sequence.py
+++ b/tests/test_core/test_sequence.py
@@ -1313,6 +1313,16 @@ class ModelSequenceTests(SequenceTests):
         self.assertEqual(c.to_dict(), {"a": 3, "b": 1, "-": 1})
 
 
+@pytest.mark.parametrize("seq,rc", (("ATGTTT", False), ("AAACAT", True)))
+def test_translation(seq, rc):
+    seq = DNA.make_seq(seq)
+    if rc:
+        seq = seq.rc()
+    assert str(seq) == "ATGTTT"
+    aa = seq.get_translation()
+    assert str(aa) == "MF"
+
+
 @pytest.mark.parametrize("start", (None, 0, 1, 10, -1, -10))
 @pytest.mark.parametrize("stop", (None, 10, 8, 1, 0, -1, -11))
 @pytest.mark.parametrize("step", (None, 1, 2, -1, -2))
@@ -2122,3 +2132,19 @@ def test_annotate_gff_nested_features():
     assert len(ann) == 2
     exon_seqs = ("TTTTTTTTT", "GGGGG")
     assert tuple(str(ex.get_slice()) for ex in exons) == exon_seqs
+
+
+def test_to_moltype_dna():
+    """to_moltype("dna") ensures conversion from T to U"""
+    seq = DNA.make_seq("AAAAGGGGTTT", name="seq1")
+    rna = seq.to_moltype("rna")
+
+    assert "T" not in rna
+
+
+def test_to_moltype_rna():
+    """to_moltype("rna") ensures conversion from U to T"""
+    seq = RNA.make_seq("AAAAGGGGUUU", name="seq1")
+    rna = seq.to_moltype("dna")
+
+    assert "U" not in rna
diff --git a/tests/test_util/test_deserialise.py b/tests/test_util/test_deserialise.py
index 8d149a6bd..2bdcefa13 100644
--- a/tests/test_util/test_deserialise.py
+++ b/tests/test_util/test_deserialise.py
@@ -88,11 +88,12 @@ class TestDeserialising(TestCase):
         data = dict(A="TTGTA", B="GGCT")
         seqs = make_unaligned_seqs(data=data, moltype="dna")
 
-        f = seqs.named_seqs["A"].add_feature("gene", "n1", [(2, 5)])
+        f = seqs.named_seqs["A"].add_feature(biotype="gene", name="n1", spans=[(2, 5)])
         data = seqs.to_json()
         expect = str(f.get_slice())
         got = deserialise_object(data)
-        self.assertEqual(str(got.named_seqs["A"].annotations[0].get_slice()), expect)
+        feat = list(got.get_features(seqid="A"))[0]
+        self.assertEqual(str(feat.get_slice()), expect)
 
     def test_roundtrip_arrayalign(self):
         """ArrayAlignment to_json enables roundtrip"""
@@ -542,5 +543,117 @@ class TestDeserialising(TestCase):
                 return tuple(data["data"])
 
 
-if __name__ == "__main__":
-    main()
+def test_convert_annotation_to_annotation_db():
+    from cogent3.core.annotation_db import (
+        GffAnnotationDb,
+        convert_annotation_to_annotation_db,
+    )
+
+    data = {
+        "name": "A",
+        "data": [
+            {
+                "annotation_construction": {
+                    "type": "gene",
+                    "name": "n1",
+                    "map": {
+                        "spans": [
+                            {
+                                "start": 2,
+                                "end": 5,
+                                "tidy_start": False,
+                                "tidy_end": False,
+                                "value": None,
+                                "reverse": False,
+                                "type": "cogent3.core.location.Span",
+                                "version": "2023.2.12a1",
+                            }
+                        ],
+                        "tidy": False,
+                        "parent_length": 5,
+                        "termini_unknown": False,
+                        "type": "cogent3.core.location.Map",
+                        "version": "2023.2.12a1",
+                    },
+                },
+                "type": "cogent3.core.annotation.AnnotatableFeature",
+                "version": "2023.2.12a1",
+            }
+        ],
+    }
+    data = convert_annotation_to_annotation_db(data)
+    # which can be turned back into an annotation db
+    db = deserialise_object(data)
+    assert isinstance(db, GffAnnotationDb)
+    assert db.num_matches() == 1
+
+
+def test_deser_annotated_aln():
+    data = {
+        "seqs": {
+            "A": {
+                "name": "A",
+                "seq": "--TTGTAGTTGA",
+                "moltype": "dna",
+                "info": None,
+                "type": "cogent3.core.sequence.DnaSequence",
+                "version": "2023.2.12a1",
+            },
+            "B": {
+                "name": "B",
+                "seq": "AATTGTAGTTGA",
+                "moltype": "dna",
+                "info": None,
+                "type": "cogent3.core.sequence.DnaSequence",
+                "version": "2023.2.12a1",
+            },
+        },
+        "moltype": "dna",
+        "info": {"source": "unknown"},
+        "type": "cogent3.core.alignment.Alignment",
+        "version": "2023.2.12a1",
+        "annotations": [
+            {
+                "annotation_construction": {
+                    "type": "CDS",
+                    "name": "norwegian",
+                    "map": {
+                        "spans": [
+                            {
+                                "start": 0,
+                                "end": 3,
+                                "tidy_start": False,
+                                "tidy_end": False,
+                                "value": None,
+                                "reverse": False,
+                                "type": "cogent3.core.location.Span",
+                                "version": "2023.2.12a1",
+                            },
+                            {
+                                "start": 5,
+                                "end": 9,
+                                "tidy_start": False,
+                                "tidy_end": False,
+                                "value": None,
+                                "reverse": False,
+                                "type": "cogent3.core.location.Span",
+                                "version": "2023.2.12a1",
+                            },
+                        ],
+                        "tidy": False,
+                        "parent_length": 12,
+                        "termini_unknown": False,
+                        "type": "cogent3.core.location.Map",
+                        "version": "2023.2.12a1",
+                    },
+                },
+                "type": "cogent3.core.annotation.AnnotatableFeature",
+                "version": "2023.2.12a1",
+            }
+        ],
+    }
+    aln = deserialise_object(data)
+    assert aln.annotation_db.num_matches() == 1
+    feat = list(aln.get_features(biotype="CDS"))
+    assert len(feat) == 1
+    print(feat)