Repository: cogent3/cogent3
Pull request #2284 (2025-04-22 01:48:17)

ENH: support sequence format plugins
[NEW] Implement plugin architecture for cogent3 sequence format parsers and
     writers. Third party parsers and writers can be added to the cogent3
     by creating a plugin. Parser plugin's must inherit from
     `cogent3.parse.sequence.SequenceParserBase`. Writer plugins
     must inherit from `cogent3.format.sequence.SequenceWriterBase`. Look at
     the cogent3 pyproject.toml for the plugin entry points.
     We default to using third party plugins for the following formats,
     falling back to cogent3's internal plugins if none other is available.

[CHANGED] the sequence collection class .write() methods and the cogent3.app.io
     write apps use these plugins.

[CHANGED] the cogent3.load_aligned_seqs and cogent3.load_unaligned_seqs functions in the
     plus the loading apps from cogent3.app.io module use these plugins.

## Summary by Sourcery

Implement a plugin architecture for sequence format parsers and writers in cogent3, allowing third-party plugins to extend sequence parsing and writing capabilities

New Features:
- Create a plugin system for sequence format parsers and writers using entry points
- Add base classes SequenceParserBase and SequenceWriterBase for defining sequence format plugins
- Implement plugin discovery and fallback mechanisms for sequence parsing and writing

Enhancements:
- Refactor sequence parsing and writing to use a more flexible plugin-based approach
- Update core sequence loading and writing methods to use the new plugin system
- Improve support for multiple sequence file formats through a standardized plugin interface

Chores:
- Update pyproject.toml to define entry points for built-in sequence parsers and writers
- Modify type hints and import statements to support the new plugin architecture


Patch:
diff --git a/changelog.d/20250406_125550_Gavin.Huttley.md b/changelog.d/20250406_125550_Gavin.Huttley.md
index a1fd80ac5..66695ae2d 100644
--- a/changelog.d/20250406_125550_Gavin.Huttley.md
+++ b/changelog.d/20250406_125550_Gavin.Huttley.md
@@ -12,7 +12,15 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 -->
 ### ENH
 
-- new type `SequenceCollection` and `Alignment` classes now have
+- Implement plugin architecture for cogent3 sequence format parsers and
+  writers. Third party parsers and writers can be added to the cogent3
+  by creating a plugin. Parser plugin's must inherit from 
+  `cogent3.parse.sequence.SequenceParserBase`. Writer plugins
+  must inherit from `cogent3.format.sequence.SequenceWriterBase`. Look at
+  the cogent3 pyproject.toml for the plugin entry points.
+  We default to using third party plugins for the following formats,
+  falling back to cogent3's internal plugins if no others are available.
+- New type `SequenceCollection` and `Alignment` classes now have
   a public `name_map` property. This records the mapping between
   the sequence names in the collection and the names in the
   underlying seqsdata container. This is required for using annotations.
diff --git a/pyproject.toml b/pyproject.toml
index 7fef8d523..17ad1c82c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -285,3 +285,23 @@ natsel_neutral = "cogent3.app.evo:natsel_neutral"
 natsel_zhang = "cogent3.app.evo:natsel_zhang"
 natsel_sitehet = "cogent3.app.evo:natsel_sitehet"
 natsel_timehet = "cogent3.app.evo:natsel_timehet"
+
+[project.entry-points."cogent3.parse.sequence"]
+fasta = "cogent3.parse.sequence:FastaParser"
+paml = "cogent3.parse.sequence:PamlParser"
+gde = "cogent3.parse.sequence:GdeParser"
+nexus = "cogent3.parse.sequence:NexusParser"
+phylip = "cogent3.parse.sequence:PhylipParser"
+clustal = "cogent3.parse.sequence:ClustalParser"
+genbank = "cogent3.parse.sequence:GenbankParser"
+msf = "cogent3.parse.sequence:MsfParser"
+xmfa = "cogent3.parse.sequence:XmfaParser"
+tinyseq = "cogent3.parse.sequence:TinyseqParser"
+gbseq = "cogent3.parse.sequence:GbSeqParser"
+
+[project.entry-points."cogent3.format.sequence"]
+fasta = "cogent3.format.sequence:FastaWriter"
+paml = "cogent3.format.sequence:PamlWriter"
+gde = "cogent3.format.sequence:GdeWriter"
+phylip = "cogent3.format.sequence:PhylipWriter"
+
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index 5c23ee56f..3e87fd9c9 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -7,7 +7,6 @@ import pathlib
 import pickle
 import warnings
 from collections.abc import Callable
-from typing import Optional
 
 from cogent3._version import __version__
 from cogent3.app import app_help, available_apps, get_app, open_data_store  # noqa
@@ -307,20 +306,24 @@ def _load_files_to_unaligned_seqs(
     )
 
 
-def _load_seqs(file_format, filename, fmt, parser_kw):
+def _load_seqs(file_suffix: str, filename: str, format_name: str, parser_kw: dict):
     """utility function for loading sequences"""
+    from cogent3._plugin import get_seq_format_parser_plugin
+
     if not is_url(filename):
         filename = pathlib.Path(filename).expanduser()
-    fmt = fmt or file_format
-    if not fmt:
+    if not (format_name or file_suffix):
         msg = "could not determined file format, set using the format argument"
         raise ValueError(msg)
     parser_kw = parser_kw or {}
-    parser = get_parser(fmt)
+    parser = get_seq_format_parser_plugin(
+        format_name=format_name,
+        file_suffix=file_suffix,
+    )
     return list(parser(filename, **parser_kw))
 
 
-T = Optional[_anno_db.SupportsFeatures]
+T = _anno_db.SupportsFeatures | None
 
 
 def _load_genbank_seq(
diff --git a/src/cogent3/_plugin.py b/src/cogent3/_plugin.py
index 0f57bb603..45afb68c1 100644
--- a/src/cogent3/_plugin.py
+++ b/src/cogent3/_plugin.py
@@ -1,11 +1,16 @@
 import functools
+import pathlib
 import typing
 
 import stevedore
 
 if typing.TYPE_CHECKING:
+    from cogent3.core.new_alignment import Alignment, SequenceCollection
     from cogent3.core.tree import PhyloNode
     from cogent3.evolve.fast_distance import DistanceMatrix
+    from cogent3.parse.sequence import ParserOutputType, SeqParserInputTypes
+
+SeqsTypes = typing.Union["SequenceCollection", "Alignment"]
 
 # Entry point for plugins to register themselves as hooks
 HOOK_ENTRY_POINT = "cogent3.hook"
@@ -42,6 +47,98 @@ def get_quick_tree_hook(
     return cogent3.get_app("quick_tree")
 
 
+# registry for parsing sequence file formats
+SEQ_PARSER_ENTRY_POINT = "cogent3.parse.sequence"
+
+
+@functools.cache
+def get_seq_format_parser_plugin(
+    *,
+    format_name: str | None = None,
+    file_suffix: str | None = None,
+) -> typing.Callable[["SeqParserInputTypes"], "ParserOutputType"] | None:
+    """returns sequence format parser
+
+    Parameters
+    ----------
+    format_name
+        name of sequence format
+    file_suffix
+        suffix of file to parse
+
+    Notes
+    -----
+    We default to third-party plugins if they are available, otherwise we
+    use a built-in parser.
+    """
+
+    mgr = stevedore.ExtensionManager(
+        namespace=SEQ_PARSER_ENTRY_POINT,
+        invoke_on_load=True,
+    )
+    built_in = None
+    for ext in mgr.extensions:
+        plugin = ext.plugin()
+        if file_suffix in plugin.supported_suffixes or plugin.name == format_name:
+            if ext.module_name.startswith("cogent3."):
+                built_in = plugin.parser
+                continue
+            return plugin.parser
+
+    if built_in:
+        # if we have a built-in plugin, return it
+        return built_in
+
+    msg = f"Unknown parser for format {format_name!r} or file suffix {file_suffix!r}"
+    raise ValueError(msg)
+
+
+# registry for writing sequence file formats
+SEQ_FORMAT_ENTRY_POINT = "cogent3.format.sequence"
+
+
+@functools.cache
+def get_seq_format_writer_plugin(
+    *,
+    format_name: str | None = None,
+    file_suffix: str | None = None,
+) -> typing.Callable[[SeqsTypes], pathlib.Path] | None:
+    """returns sequence format writer
+
+    Parameters
+    ----------
+    format_name
+        name of sequence format
+    file_suffix
+        suffix of file to parse
+
+    Notes
+    -----
+    We default to third-party plugins if they are available, otherwise we
+    use a built-in parser.
+    """
+
+    mgr = stevedore.ExtensionManager(
+        namespace=SEQ_FORMAT_ENTRY_POINT,
+        invoke_on_load=True,
+    )
+    built_in = None
+    for ext in mgr.extensions:
+        plugin = ext.plugin()
+        if file_suffix in plugin.supported_suffixes or plugin.name == format_name:
+            if ext.module_name.startswith("cogent3."):
+                built_in = plugin
+                continue
+            return plugin
+
+    if built_in:
+        # if we have a built-in plugin, return it
+        return built_in
+
+    msg = f"Unknown writer for format {format_name!r} or file suffix {file_suffix!r}"
+    raise ValueError(msg)
+
+
 # Entry point for plugins to register themselves as apps
 APP_ENTRY_POINT = "cogent3.app"
 
diff --git a/src/cogent3/app/io.py b/src/cogent3/app/io.py
index 901854d5f..5d03d5777 100644
--- a/src/cogent3/app/io.py
+++ b/src/cogent3/app/io.py
@@ -21,8 +21,6 @@ from cogent3.core.profile import (
     make_pssm_from_tabular,
 )
 from cogent3.evolve.fast_distance import DistanceMatrix
-from cogent3.format.sequence import FORMATTERS
-from cogent3.parse.sequence import PARSERS
 from cogent3.util.deserialise import deserialise_object
 from cogent3.util.table import Table
 
@@ -313,7 +311,9 @@ class load_aligned:
         """
         moltype = moltype or ("text" if _NEW_TYPE else "bytes")
         self.moltype = cogent3.get_moltype(moltype)
-        self._parser = PARSERS[format.lower()]
+        self._parser = cogent3._plugin.get_seq_format_parser_plugin(  # noqa: SLF001
+            format_name=format.lower(),
+        )
 
     T = SerialisableType | AlignedSeqsType
 
@@ -346,7 +346,9 @@ class load_unaligned:
         """
         moltype = moltype or ("text" if _NEW_TYPE else "bytes")
         self.moltype = cogent3.get_moltype(moltype)
-        self._parser = PARSERS[format.lower()]
+        self._parser = cogent3._plugin.get_seq_format_parser_plugin(  # noqa: SLF001
+            format_name=format.lower(),
+        )
 
     T = SerialisableType | UnalignedSeqsType
 
@@ -599,7 +601,9 @@ class write_seqs:
             msg = f"invalid type {type(data_store)!r} for data_store"
             raise TypeError(msg)
         self.data_store = data_store
-        self._formatter = FORMATTERS[format]
+        self._formatter = cogent3._plugin.get_seq_format_writer_plugin(  # noqa: SLF001
+            format_name=format.lower(),
+        )
         self._id_from_source = id_from_source
 
     def main(
@@ -616,7 +620,7 @@ class write_seqs:
                 data=data.to_json(),
             )
 
-        data = self._formatter(data.to_dict())
+        data = self._formatter.formatted(data)
         return self.data_store.write(unique_id=identifier, data=data)
 
 
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index 299aef44c..3cdefb123 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -14,7 +14,7 @@ from collections.abc import Callable, Iterable, Iterator, Mapping
 from collections.abc import Sequence as PySeq
 from functools import singledispatch, singledispatchmethod
 from pathlib import Path
-from typing import Any, Optional, Union
+from typing import Any, Optional
 
 import numba
 import numpy
@@ -42,7 +42,6 @@ from cogent3.core.location import (
 from cogent3.core.profile import PSSM, MotifCountsArray, MotifFreqsArray, load_pssm
 from cogent3.format.fasta import seqs_to_fasta
 from cogent3.format.phylip import alignment_to_phylip
-from cogent3.format.sequence import save_to_filename
 from cogent3.maths.stats.number import CategoryCounter
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
@@ -70,25 +69,25 @@ if typing.TYPE_CHECKING:
 
 DEFAULT_ANNOTATION_DB = BasicAnnotationDb
 
-OptInt = Optional[int]
-OptFloat = Optional[float]
-OptStr = Optional[str]
-OptList = Optional[list]
-OptIterStr = Optional[Iterable[str]]
+OptInt = int | None
+OptFloat = float | None
+OptStr = str | None
+OptList = list | None
+OptIterStr = Iterable[str] | None
 PySeqStr = PySeq[str]
-OptPySeqStr = Optional[PySeqStr]
-OptDict = Optional[dict]
-OptBool = Optional[bool]
-OptSliceRecord = Optional[new_sequence.SliceRecord]
+OptPySeqStr = PySeqStr | None
+OptDict = dict | None
+OptBool = bool | None
+OptSliceRecord = new_sequence.SliceRecord | None
 DictStrStr = dict[str, str]
 DictStrInt = dict[str, int]
-OptCallable = Optional[Callable]
-OptRenamerCallable = Optional[Callable[[str], str]]
-OptPathType = Union[str, Path, None]
-StrORArray = Union[str, numpy.ndarray[int]]
-StrORBytesORArray = Union[str, bytes, numpy.ndarray[int]]
-StrORBytesORArrayOrSeq = Union[str, bytes, numpy.ndarray[int], new_sequence.Sequence]
-MolTypes = Union[str, new_moltype.MolType]
+OptCallable = Callable | None
+OptRenamerCallable = Callable[[str], str] | None
+OptPathType = str | Path | None
+StrORArray = str | numpy.ndarray[int]
+StrORBytesORArray = str | bytes | numpy.ndarray[int]
+StrORBytesORArrayOrSeq = str | bytes | numpy.ndarray[int] | new_sequence.Sequence
+MolTypes = str | new_moltype.MolType
 
 # small number: 1-EPS is almost 1, and is used for things like the
 # default number of gaps to allow in a column.
@@ -712,6 +711,17 @@ class SequenceCollection:
             "is_reversed": self._is_reversed,
         }
 
+    @property
+    def storage(self) -> str:
+        """returns the storage type of the collection"""
+        return self._seqs_data
+
+    @storage.setter
+    def storage(self, value: typing.Any) -> None:
+        """storage cannot be set after initialisation"""
+        msg = "storage cannot be set after initialisation"
+        raise TypeError(msg)
+
     @property
     def seqs(self) -> _IndexableSeqs:
         return self._seqs
@@ -1457,7 +1467,11 @@ class SequenceCollection:
         if "order" not in kwargs:
             kwargs["order"] = self.names
 
-        save_to_filename(self.to_dict(), filename, file_format, **kwargs)
+        writer = cogent3._plugin.get_seq_format_writer_plugin(  # noqa: SLF001
+            format_name=file_format,
+            file_suffix=suffix,
+        )
+        _ = writer.write(seqcoll=self, path=filename, **kwargs)
 
     def dotplot(
         self,
diff --git a/src/cogent3/format/sequence.py b/src/cogent3/format/sequence.py
index 8e2e50fa5..5e61c6412 100644
--- a/src/cogent3/format/sequence.py
+++ b/src/cogent3/format/sequence.py
@@ -1,19 +1,119 @@
+import abc
 import contextlib
 import os
-import re
+import pathlib
+import typing
 
-from cogent3.format.fasta import seqs_to_fasta
-from cogent3.format.gde import alignment_to_gde
-from cogent3.format.paml import alignment_to_paml
-from cogent3.format.phylip import alignment_to_phylip
+from cogent3.format import clustal, fasta, gde, paml, phylip
 from cogent3.parse.record import FileFormatError
 from cogent3.util.io import atomic_write
 
-# TODO convert formatters so str(formatter) returns correctly formatted
-# string, and rename method names, rename base class name (sequences, not
-# alignment)
+if typing.TYPE_CHECKING:
+    from cogent3.core.new_alignment import Alignment, SequenceCollection
 
-_compression = re.compile(r"\.(gz|bz2)$")
+
+SeqsTypes = typing.Union["SequenceCollection", "Alignment"]
+
+FORMATTERS = {
+    "phylip": phylip.alignment_to_phylip,
+    "paml": paml.alignment_to_paml,
+    "fasta": fasta.seqs_to_fasta,
+    "gde": gde.alignment_to_gde,
+    "clustal": clustal.clustal_from_alignment,
+}
+
+
+class SequenceWriterBase(abc.ABC):
+    """Base class for sequence format parsers."""
+
+    @property
+    @abc.abstractmethod
+    def name(self) -> str:
+        """name of the format"""
+        ...
+
+    @property
+    @abc.abstractmethod
+    def supported_suffixes(self) -> set[str]:
+        """Return list of file suffixes this parser supports"""
+        ...
+
+    def formatted(self, seqcoll: SeqsTypes, **kwargs) -> str:
+        """returns a string representation of the sequence collection
+
+        Parameters
+        ----------
+        seqcoll
+            sequence collection to format, must have a to_dict() method
+        """
+        formatter = FORMATTERS[self.name]
+        return formatter(seqcoll.to_dict(), **kwargs)
+
+    def write(
+        self,
+        *,
+        path: pathlib.Path,
+        seqcoll: SeqsTypes,
+        **kwargs,
+    ) -> pathlib.Path:
+        """returns a path after writing the sequence collection to a file
+        Parameters
+        ----------
+        path
+            path to the file to write
+        seqcoll
+            sequence collection to write, must have a to_dict() method
+        kwargs
+            additional arguments to pass to the formatter
+        """
+        output = self.formatted(seqcoll, **kwargs)
+        with atomic_write(path, mode="wt") as f:
+            f.write(output)
+        return path
+
+
+class FastaWriter(SequenceWriterBase):
+    @property
+    def name(self) -> str:
+        return "fasta"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"fasta", "fa", "fna", "faa", "mfa"}
+
+
+class GdeWriter(SequenceWriterBase):
+    @property
+    def name(self) -> str:
+        return "gde"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"gde"}
+
+
+class PhylipWriter(SequenceWriterBase):
+    """Parser for PHYLIP format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "phylip"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"phylip", "phy"}
+
+
+class PamlWriter(SequenceWriterBase):
+    """Parser for PAML format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "paml"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"paml"}
 
 
 def save_to_filename(alignment, filename, format, **kw) -> None:
@@ -43,13 +143,3 @@ def write_alignment_to_file(f, alignment, format, **kw) -> None:
     contents = FORMATTERS[format](alignment, **kw)
     f.write(contents)
     f.close()
-
-
-FORMATTERS = {
-    "phylip": alignment_to_phylip,
-    "paml": alignment_to_paml,
-    "fasta": seqs_to_fasta,
-    "mfa": seqs_to_fasta,
-    "fa": seqs_to_fasta,
-    "gde": alignment_to_gde,
-}
diff --git a/src/cogent3/parse/sequence.py b/src/cogent3/parse/sequence.py
index f1b92ad1a..4cd918468 100644
--- a/src/cogent3/parse/sequence.py
+++ b/src/cogent3/parse/sequence.py
@@ -1,5 +1,6 @@
 """Delegator for sequence data format parsers."""
 
+import abc
 import functools
 import pathlib
 import typing
@@ -17,10 +18,31 @@ from cogent3.parse import (
 )
 from cogent3.util.io import iter_splitlines
 
-_lc_to_wc = "".join([[chr(x), "?"]["A" <= chr(x) <= "Z"] for x in range(256)])
+ParserOutputType = typing.Iterable[tuple[str, str] | dict]
 
+SeqParserInputTypes = str | pathlib.Path | tuple | list
 
-ParserOutputType = typing.Iterable[tuple[str, str]]
+
+class SequenceParserBase(abc.ABC):
+    """Base class for sequence format parsers."""
+
+    @property
+    @abc.abstractmethod
+    def name(self) -> str:
+        """name of the format"""
+        ...
+
+    @property
+    @abc.abstractmethod
+    def supported_suffixes(self) -> set[str]:
+        """Return list of file suffixes this parser supports"""
+        ...
+
+    @property
+    @abc.abstractmethod
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        """returns a parser"""
+        ...
 
 
 class LineBasedParser:
@@ -79,9 +101,184 @@ PARSERS = {
     "nexus": LineBasedParser(nexus.MinimalNexusAlignParser),
 }
 
-XML_PARSERS = {"gbseq": gbseq.GbSeqXmlParser, "tseq": tinyseq.TinyseqParser}
 
-SeqParserInputTypes = typing.Union[str, pathlib.Path, tuple, list]
+class FastaParser(SequenceParserBase):
+    """Parser for FASTA format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "fasta"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"fasta", "fa", "fna", "faa", "mfa"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return fasta.iter_fasta_records
+
+
+class GdeParser(SequenceParserBase):
+    """Parser for GDE format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "gde"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"gde"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(fasta.MinimalGdeParser)
+
+
+class PhylipParser(SequenceParserBase):
+    """Parser for PHYLIP format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "phylip"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"phylip", "phy"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(phylip.MinimalPhylipParser)
+
+
+class ClustalParser(SequenceParserBase):
+    """Parser for Clustal format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "clustal"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"clustal", "aln"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(clustal.ClustalParser)
+
+
+class PamlParser(SequenceParserBase):
+    """Parser for PAML format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "paml"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"paml"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(paml.PamlParser)
+
+
+class NexusParser(SequenceParserBase):
+    """Parser for Nexus format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "nexus"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"nexus", "nex", "nxs"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(nexus.MinimalNexusAlignParser)
+
+
+class GenbankParser(SequenceParserBase):
+    """Parser for GenBank format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "genbank"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"gb", "gbk", "gbff", "genbank"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return genbank.rich_parser
+
+
+class MsfParser(SequenceParserBase):
+    """Parser for MSF format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "msf"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"msf"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(gcg.MsfParser)
+
+
+class XmfaParser(SequenceParserBase):
+    """Parser for XMFA format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "xmfa"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"xmfa"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(fasta.MinimalXmfaParser)
+
+
+class TinyseqParser(SequenceParserBase):
+    """Parser for Tinyseq format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "tinyseq"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"tinyseq"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return LineBasedParser(tinyseq.TinyseqParser)
+
+
+class GbSeqParser(SequenceParserBase):
+    """Parser for GbSeq format sequence files."""
+
+    @property
+    def name(self) -> str:
+        return "gbseq"
+
+    @property
+    def supported_suffixes(self) -> set[str]:
+        return {"gbseq"}
+
+    @property
+    def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
+        return gbseq.GbSeqXmlParser
+
+
+XML_PARSERS = {"gbseq": gbseq.GbSeqXmlParser, "tseq": tinyseq.TinyseqParser}
 
 
 def get_parser(fmt: str) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:


Test patch:
diff --git a/tests/test_app/test_io.py b/tests/test_app/test_io.py
index 03732fff8..6dd96f93d 100644
--- a/tests/test_app/test_io.py
+++ b/tests/test_app/test_io.py
@@ -127,7 +127,7 @@ def test_source_proxy_simple(fasta_dir):
     assert isinstance(got[0], source_proxy)
 
 
-@pytest.mark.parametrize("suffix", ["nex", "paml", "fasta"])
+@pytest.mark.parametrize("suffix", ["nexus", "paml", "fasta"])
 def test_load_aligned(DATA_DIR, suffix):
     """should handle nexus too"""
     dstore = DataStoreDirectory(DATA_DIR, suffix=suffix, limit=2)
@@ -136,8 +136,7 @@ def test_load_aligned(DATA_DIR, suffix):
     # TODO: checking class name rather than isinstance during
     #  migration to new_type, revert to isinstance when
     #  that migration is complete
-    for result in results:
-        assert result.__class__.__name__.endswith("Alignment")
+    assert all(r.__class__.__name__.endswith("Alignment") for r in results)
 
 
 def test_load_unaligned(DATA_DIR):
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 9660f8595..1f5770e2a 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -5754,3 +5754,35 @@ def test_aln_mixed_strand_rced_seq():
     assert s2 == "GAGGTA"
     s2rc = s2.rc()
     assert s2rc == "TACCTC"
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_seqcoll_storage(mk_cls):
+    data = {
+        "seq1": "ATCG",
+        "seq2": "TAGC",
+    }
+    seqcoll = mk_cls(data, moltype="dna")
+    expect_class = (
+        new_alignment.SeqsData
+        if mk_cls is new_alignment.make_unaligned_seqs
+        else new_alignment.AlignedSeqsData
+    )
+    assert isinstance(seqcoll.storage, expect_class)
+
+
+@pytest.mark.parametrize(
+    "mk_cls",
+    [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs],
+)
+def test_seqcoll_storage_immutable(mk_cls):
+    data = {
+        "seq1": "ATCG",
+        "seq2": "TAGC",
+    }
+    seqcoll = mk_cls(data, moltype="dna")
+    with pytest.raises(TypeError):
+        seqcoll.storage = "new_storage"
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index 20ea6f976..3fcd67cdf 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -2468,7 +2468,7 @@ def test_parent_start_stop_singletons(index, rev, ascii_alphabet):
 
 
 def test_get_drawable(DATA_DIR):
-    seq = cogent3.load_seq(DATA_DIR / "annotated_seq.gb")
+    seq = cogent3.load_seq(DATA_DIR / "annotated_seq.gb", new_type=True, moltype="dna")
     seq = seq[2000:4000]
     biotypes = "CDS", "gene", "mRNA"
     for feat in seq.get_features(biotype=biotypes, allow_partial=True):
@@ -2976,7 +2976,11 @@ def test_sequence_serialisation_round_trip(moltype, data):
 def aa_moltype(DATA_DIR, tmp_path):
     # make a directory that contains both DNA and protein
     outpath = tmp_path / "aa.fa"
-    aln = cogent3.load_aligned_seqs(DATA_DIR / "brca1_5.paml", moltype="dna")
+    aln = cogent3.load_aligned_seqs(
+        DATA_DIR / "brca1_5.paml",
+        moltype="dna",
+        new_type=True,
+    )
     aa = aln.get_translation()
     aa.write(outpath)
     return outpath