Repository: cogent3/cogent3
Pull request #2284: ENH: support sequence format plugins
Author: login: GavinHuttley
Created: 2025-04-22T01:48:17Z | Merged: 2025-04-22T03:23:59Z

[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


Diff:
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 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 @@
     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 @@ def __init__(
         """
         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 @@ def __init__(
         """
         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 @@ def __init__(
             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 @@ def main(
                 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 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.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 @@
 
 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 @@ def _get_init_kwargs(self) -> dict:
             "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 @@ def write(self, filename: str, file_format: OptStr = None, **kwargs) -> None:
         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.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 @@ def _(self, data: list, **kwargs) -> ParserOutputType:
     "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]:
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 get_bytes(path: IdentifierType) -> bytes:
     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


Reviews:
user: login: sourcery-ai[bot]
body: Hey @GavinHuttley - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider adding a mechanism to prioritise plugins, allowing users to specify preferred parsers/writers when multiple plugins support the same format.
- The plugin architecture looks good, but ensure that the performance of plugin-based parsing/writing is comparable to the original implementation.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 3 issues found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 2 issues found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T01:49:20Z
user: login: GavinHuttley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T02:35:36Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T02:35:55Z
user: login: GavinHuttley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T02:54:22Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T02:54:43Z
user: login: GavinHuttley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T02:55:17Z

Review comments:
diff_hunk: @@ -1457,7 +1456,11 @@ def write(self, filename: str, file_format: OptStr = None, **kwargs) -> None:
         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,
path: src/cogent3/core/new_alignment.py
user: login: sourcery-ai[bot]
body: **issue (bug_risk):** Undefined variable 'suffix' in write() method.

The write() method in new_alignment.py uses 'suffix' as an argument when calling get_seq_format_writer_plugin, but 'suffix' is not defined anywhere. You should define or compute the file suffix (perhaps based on the filename) before using it in this call.
created_at: 2025-04-22T01:49:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 none other is available.
path: changelog.d/20250406_125550_Gavin.Huttley.md
user: login: sourcery-ai[bot]
body: **suggestion:** Clarify instructions for finding plugin entry points

Instead of just saying "Look at the cogent3 pyproject.toml", provide more specific guidance.  Should users look for particular keys? Is there a dedicated section?  More detail would be helpful.
created_at: 2025-04-22T01:49:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 none other is available.
path: changelog.d/20250406_125550_Gavin.Huttley.md
user: login: sourcery-ai[bot]
body: **suggestion (typo):** Rephrase "none other"

"if no others are available" would be a smoother phrasing.

```suggestion
falling back to cogent3's internal plugins if no others are available.
```
created_at: 2025-04-22T01:49:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,9 +101,184 @@ def _(self, data: list, **kwargs) -> ParserOutputType:
     "nexus": LineBasedParser(nexus.MinimalNexusAlignParser),
 }
 
-XML_PARSERS = {"gbseq": gbseq.GbSeqXmlParser, "tseq": tinyseq.TinyseqParser}
 
-SeqParserInputTypes = typing.Union[str, pathlib.Path, tuple, list]
+class FastaParser(SequenceParserBase):
path: src/cogent3/parse/sequence.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider using a factory function to generate the parser classes, which will reduce code duplication and improve maintainability by centralizing the shared logic for creating these classes.

You can reduce duplication by using a factory to create these parser classes. For example, define a helper function that returns a subclass of the abstract base class with the required properties:

```python
def make_parser_class(
    name: str,
    suffixes: set[str],
    parser_func: typing.Callable[[SeqParserInputTypes], ParserOutputType]
) -> typing.Type[SequenceParserBase]:
    class _Parser(SequenceParserBase):
        @property
        def name(self) -> str:
            return name

        @property
        def supported_suffixes(self) -> set[str]:
            return suffixes

        @property
        def parser(self) -> typing.Callable[[SeqParserInputTypes], ParserOutputType]:
            return parser_func

    return _Parser
```

Then, create your parser classes with concise definitions:

```python
FastaParser = make_parser_class("fasta", {"fasta", "fa", "fna", "faa", "mfa"}, fasta.iter_fasta_records)
GdeParser = make_parser_class("gde", {"gde"}, LineBasedParser(fasta.MinimalGdeParser))
PhylipParser = make_parser_class("phylip", {"phylip", "phy"}, LineBasedParser(phylip.MinimalPhylipParser))
ClustalParser = make_parser_class("clustal", {"clustal", "aln"}, LineBasedParser(clustal.ClustalParser))
PamlParser = make_parser_class("paml", {"paml"}, LineBasedParser(paml.PamlParser))
NexusParser = make_parser_class("nexus", {"nexus", "nex", "nxs"}, LineBasedParser(nexus.MinimalNexusAlignParser))
GenbankParser = make_parser_class("genbank", {"gb", "gbk", "gbff", "genbank"}, genbank.rich_parser)
MsfParser = make_parser_class("msf", {"msf"}, LineBasedParser(gcg.MsfParser))
XmfaParser = make_parser_class("xmfa", {"xmfa"}, LineBasedParser(fasta.MinimalXmfaParser))
TinyseqParser = make_parser_class("tinyseq", {"tinyseq"}, LineBasedParser(tinyseq.TinyseqParser))
GbSeqParser = make_parser_class("gbseq", {"gbseq"}, gbseq.GbSeqXmlParser)
```

This approach maintains functionality while centralizing shared logic, reducing the complexity and duplication in your code.
created_at: 2025-04-22T01:49:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,19 +1,109 @@
+import abc
 import contextlib
 import os
-import re
+import pathlib
+import typing
 
path: src/cogent3/format/sequence.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider using a single configurable class instead of multiple simple subclasses to reduce boilerplate.

Consider consolidating the multiple simple subclasses into a single configurable class. Instead of having separate classes that only override the two properties, you can define a generic writer class and instantiate it with the proper configuration. For example:

```python
class GenericSequenceWriter(SequenceWriterBase):
    def __init__(self, name: str, supported_suffixes: set[str]) -> None:
        self._name = name
        self._supported_suffixes = supported_suffixes

    @property
    def name(self) -> str:
        return self._name

    @property
    def supported_suffixes(self) -> set[str]:
        return self._supported_suffixes
```

Then, replace the specific writer classes with factory instances:

```python
FASTA_WRITER = GenericSequenceWriter("fasta", {"fasta", "fa", "fna", "faa", "mfa"})
GDE_WRITER = GenericSequenceWriter("gde", {"gde"})
PHYLIP_WRITER = GenericSequenceWriter("phylip", {"phylip", "phy"})
PAML_WRITER = GenericSequenceWriter("paml", {"paml"})
```

This refactor keeps the functionality intact while reducing boilerplate and making it easier to add new formats in the future.
created_at: 2025-04-22T01:49:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1457,7 +1456,11 @@ def write(self, filename: str, file_format: OptStr = None, **kwargs) -> None:
         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,
path: src/cogent3/core/new_alignment.py
user: login: GavinHuttley
body: it's defined on line 1458
created_at: 2025-04-22T02:35:36Z
author_association: COLLABORATOR
diff_hunk: @@ -1457,7 +1456,11 @@ def write(self, filename: str, file_format: OptStr = None, **kwargs) -> None:
         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,
path: src/cogent3/core/new_alignment.py
user: login: sourcery-ai[bot]
body: You're right. I apologize for my previous comment. Looking at the context, the `suffix` variable is indeed defined on line 1458 (which is not shown in the provided diff hunk). 

My initial review comment was incorrect. The code appears to be using the new plugin system to get a sequence format writer, and the `suffix` variable is likely being extracted from the filename before this line of code.

Since the implementation seems consistent with the PR description of implementing a plugin architecture for sequence format parsers and writers, my previous concern is resolved. The code looks like it's correctly leveraging the new plugin system to select an appropriate writer for the given file format and suffix.

Is there anything specific about this implementation you'd like me to elaborate on or clarify?
created_at: 2025-04-22T02:35:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,19 +1,109 @@
+import abc
 import contextlib
 import os
-import re
+import pathlib
+import typing
 
path: src/cogent3/format/sequence.py
user: login: GavinHuttley
body: For consistency with third-party providers, we need these to be types, not instances.
created_at: 2025-04-22T02:54:22Z
author_association: COLLABORATOR
diff_hunk: @@ -1,19 +1,109 @@
+import abc
 import contextlib
 import os
-import re
+import pathlib
+import typing
 
path: src/cogent3/format/sequence.py
user: login: sourcery-ai[bot]
body: You're right about maintaining consistency with third-party providers. In this case, we want to keep the classes as types that can be subclassed or used as base classes for plugins, rather than converting them to instances.

My previous suggestion of using a generic configurable class was well-intentioned but would limit the flexibility needed for a plugin system. The current approach allows third-party developers to create their own sequence writer classes that inherit from `SequenceWriterBase` and implement their specific logic.

The key requirements for the plugin architecture are:
1. Provide a base class (`SequenceWriterBase`) with abstract properties and methods
2. Allow third-party developers to create their own writer classes
3. Maintain a consistent interface for sequence writers

So we'll keep the existing structure with individual classes as types, which supports the plugin architecture more effectively. This approach provides the flexibility needed for extensibility while maintaining a clear, standardized interface for sequence format writers.

Would you like me to elaborate on any aspect of the plugin design or the reasoning behind keeping the classes as types?
created_at: 2025-04-22T02:54:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,9 +101,184 @@ def _(self, data: list, **kwargs) -> ParserOutputType:
     "nexus": LineBasedParser(nexus.MinimalNexusAlignParser),
 }
 
-XML_PARSERS = {"gbseq": gbseq.GbSeqXmlParser, "tseq": tinyseq.TinyseqParser}
 
-SeqParserInputTypes = typing.Union[str, pathlib.Path, tuple, list]
+class FastaParser(SequenceParserBase):
path: src/cogent3/parse/sequence.py
user: login: GavinHuttley
body: Simple is better than complex.
created_at: 2025-04-22T02:55:17Z
author_association: COLLABORATOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-22T01:48:23Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request introduces a plugin architecture for sequence format parsers and writers in cogent3. It allows third-party plugins to be used for handling various sequence formats, with a fallback to cogent3's internal plugins if no third-party plugin is available. The sequence collection class .write() methods and the cogent3.app.io write apps use these plugins. 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.

_No diagrams generated as the changes look simple and do not need a visual representation._

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Introduced a plugin architecture for sequence format parsers, allowing third-party plugins to be used for parsing various sequence formats. | <ul><li>Defined a base class `SequenceParserBase` for sequence format parsers.</li><li>Implemented parser classes for common formats like FASTA, GDE, Phylip, Clustal, Nexus, GenBank, MSF, XMFA, Tinyseq, and GbSeq, inheriting from `SequenceParserBase`.</li><li>Registered the new parsers as entry points in `pyproject.toml` under `cogent3.parse.sequence`.</li><li>Created a `get_seq_format_parser_plugin` function to retrieve the appropriate parser based on format name or file suffix, prioritizing third-party plugins over built-in ones.</li></ul> | `src/cogent3/parse/sequence.py`<br/>`pyproject.toml`<br/>`src/cogent3/_plugin.py` |
| Introduced a plugin architecture for sequence format writers, allowing third-party plugins to be used for writing various sequence formats. | <ul><li>Defined a base class `SequenceWriterBase` for sequence format writers.</li><li>Implemented writer classes for common formats like FASTA, GDE, Phylip and PAML, inheriting from `SequenceWriterBase`.</li><li>Registered the new writers as entry points in `pyproject.toml` under `cogent3.format.sequence`.</li><li>Created a `get_seq_format_writer_plugin` function to retrieve the appropriate writer based on format name or file suffix, prioritizing third-party plugins over built-in ones.</li></ul> | `src/cogent3/format/sequence.py`<br/>`pyproject.toml`<br/>`src/cogent3/_plugin.py` |
| Modified the sequence collection class `write()` method and the cogent3.app.io write apps to use the new sequence format writer plugins. | <ul><li>Modified `SequenceCollection.write()` to use `get_seq_format_writer_plugin` to obtain the appropriate writer.</li><li>The writer is called with the sequence collection and the output path.</li><li>Modified the `load_aligned_seqs` and `load_unaligned_seqs` functions and the loading apps from `cogent3.app.io` module to use the new sequence format parser plugins.</li><li>Modified the `__init__` method of `DelimitedTableLoader` and `AlignmentLoader` in `cogent3/app/io.py` to use `get_seq_format_parser_plugin`.</li></ul> | `src/cogent3/core/new_alignment.py`<br/>`src/cogent3/app/io.py`<br/>`src/cogent3/__init__.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: coveralls
created_at: 2025-04-22T01:59:05Z
body: ## Pull Request Test Coverage Report for [Build 14585792692](https://coveralls.io/builds/73321268)


### Details

* **63** of **71**   **(88.73%)**  changed or added relevant lines in **4** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.007%**) to **90.969%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/cogent3/_plugin.py](https://coveralls.io/builds/73321268/source?filename=src%2Fcogent3%2F_plugin.py#L8) | 28 | 36 | 77.78%
<!-- | **Total:** | **63** | **71** | **88.73%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/73321268/badge)](https://coveralls.io/builds/73321268) |
| :-- | --: |
| Change from base [Build 14584290499](https://coveralls.io/builds/73320247): |  -0.007% |
| Covered Lines: | 29855 |
| Relevant Lines: | 32819 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: COLLABORATOR