Repository: cogent3/cogent3
Pull request #2243 (2025-03-17 01:12:44)

Add support for hook style plugins, fixes  #2021


Patch:
diff --git a/changelog.d/20250314_131212_Gavin.Huttley.md b/changelog.d/20250314_131212_Gavin.Huttley.md
index 9c674c666..03528f6a2 100644
--- a/changelog.d/20250314_131212_Gavin.Huttley.md
+++ b/changelog.d/20250314_131212_Gavin.Huttley.md
@@ -9,13 +9,26 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 - First time contributions from @c-jonesy, @Yixuan567, @Qinzi27
   and @je634 👏🚀🎉
 
-
 ### ENH
 
 - The cogent3.core.annotation.Feature class now gets an xattr property.
   This allows developers to pack additional data into the Feature object
   without compromising the core API.
-
+- cogent3 now supports hook-style plugins🎉. The first hook is for computing
+  quick_tree() trees. This method is on our alignment class and on our distance
+  matrix class. It currently defaults to the cogent3 quick_tree() app, which
+  applies the NJ algorithm. For developers, you can see what hooks are supported
+  by looking in cogent3._plugin at functions named `get_<something>_hook()`. All
+  third-party hooks must adhere to the cogent3 app interface.
+
+  Developers, register your hook by adding a section `[project.entry-points."cogent3.hook"]`
+  section to your `pyproject.toml` file. Assign to the hook name (e.g. `quick_tree`) the string
+  reference to the app supporting the hook. For example,
+  `quick_tree = "my_package.my_module.my_quick_tree_app"`.
+
+  Users, specify which package you want to use by setting `<obj>.quick_tree(use_hook="<package name>")`.
+  For example, `use_hook="my_package"` would invoke the one defined above. Set `use_hook="cogent3"`
+  to force using the cogent3 default.
 
 <!--
 ### BUG
@@ -35,9 +48,11 @@ Uncomment the section that is right (remove the HTML comment wrapper).
 - A bullet item for the Deprecations category.
 
 -->
-<!--
+
 ### Discontinued
 
-- A bullet item for the Discontinued category.
+- new_alignment.Alignment.quick_tree() no longer supports doing bootstraps.
+  This is because bootstrapping is not quick! If you want this, post a feature
+  request and we will implement a separate app for this.
+
 
--->
diff --git a/doc/cookbook/building_phylogenies.rst b/doc/cookbook/building_phylogenies.rst
index a6daf214a..c55e5747b 100644
--- a/doc/cookbook/building_phylogenies.rst
+++ b/doc/cookbook/building_phylogenies.rst
@@ -20,16 +20,10 @@ Both the ``ArrayAlignment`` and ``Alignment`` classes support this.
     from cogent3 import load_aligned_seqs
 
     aln = load_aligned_seqs("data/primate_brca1.fasta", moltype="dna")
-    tree = aln.quick_tree(calc="TN93", show_progress=False)
+    tree = aln.quick_tree(calc="TN93")
     tree = tree.balanced()  # purely for display
     print(tree.ascii_art())
 
-The ``quick_tree()`` method also supports non-parametric bootstrapping. The number of resampled alignments is specified using the ``bootstrap`` argument. In the following, trees are estimated from 100 resampled alignments and merged into a single consensus topology using a weighted consensus tree algorithm.
-
-.. jupyter-execute::
-
-    tree = aln.quick_tree(calc="TN93", bootstrap=100, show_progress=False)
-
 Using the ``DistanceMatrix`` object
 -----------------------------------
 
@@ -39,7 +33,7 @@ Using the ``DistanceMatrix`` object
 
     aln = load_aligned_seqs("data/primate_brca1.fasta", moltype="dna")
     dists = aln.distance_matrix(calc="TN93")
-    tree = dists.quick_tree(show_progress=False)
+    tree = dists.quick_tree()
     tree = tree.balanced()  # purely for display
     print(tree.ascii_art())
 
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index 0e11b1423..5c23ee56f 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -7,7 +7,7 @@ import pathlib
 import pickle
 import warnings
 from collections.abc import Callable
-from typing import Optional, Union
+from typing import Optional
 
 from cogent3._version import __version__
 from cogent3.app import app_help, available_apps, get_app, open_data_store  # noqa
diff --git a/src/cogent3/_plugin.py b/src/cogent3/_plugin.py
new file mode 100644
index 000000000..0f57bb603
--- /dev/null
+++ b/src/cogent3/_plugin.py
@@ -0,0 +1,64 @@
+import functools
+import typing
+
+import stevedore
+
+if typing.TYPE_CHECKING:
+    from cogent3.core.tree import PhyloNode
+    from cogent3.evolve.fast_distance import DistanceMatrix
+
+# Entry point for plugins to register themselves as hooks
+HOOK_ENTRY_POINT = "cogent3.hook"
+
+
+@functools.cache
+def get_quick_tree_hook(
+    *,
+    name: str | None = None,
+) -> typing.Callable[["DistanceMatrix"], "PhyloNode"] | None:
+    """returns app instance registered for quick_tree
+
+    Parameters
+    ----------
+    name
+        name of package to get the app from
+
+    Notes
+    -----
+    The app must take a DistanceMatrix as input and return a PhyloNode
+    """
+    import cogent3
+
+    mgr = stevedore.hook.HookManager(
+        namespace=HOOK_ENTRY_POINT,
+        name="quick_tree",
+        invoke_on_load=False,
+    )
+    if name != "cogent3":
+        for extension in mgr.extensions:
+            if name is None or extension.module_name.startswith(name):
+                return extension.plugin()
+
+    return cogent3.get_app("quick_tree")
+
+
+# Entry point for plugins to register themselves as apps
+APP_ENTRY_POINT = "cogent3.app"
+
+# private global to hold an ExtensionManager instance for apps
+# Note that this is relied on for the tests
+__apps = None
+
+
+def get_app_manager() -> stevedore.ExtensionManager:
+    """
+    Lazy load a stevedore ExtensionManager to collect apps.
+    """
+    global __apps  # noqa: PLW0603
+    if not __apps:
+        __apps = stevedore.ExtensionManager(
+            namespace=APP_ENTRY_POINT,
+            invoke_on_load=False,
+        )
+
+    return __apps
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index ac91a4271..2d3df2bb2 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -7,19 +7,18 @@ import textwrap
 import warnings
 from typing import TYPE_CHECKING
 
-import stevedore
+from cogent3._plugin import get_app_manager
 
 from .composable import is_app, is_app_composable
-from .io import open_data_store
+from .io import open_data_store  # noqa
 
 if TYPE_CHECKING:
-    from cogent3.util.table import Table
+    from stevedore.extension import Extension
 
-# Entry_point for apps to register themselves as plugins
-APP_ENTRY_POINT = "cogent3.app"
+    from cogent3.util.table import Table
 
 
-def _get_extension_attr(extension):
+def _get_extension_attr(extension: Extension) -> list[str]:
     """
     This function returns app details for display.
 
@@ -47,34 +46,16 @@ def _get_extension_attr(extension):
     ]
 
 
-def _make_types(app) -> dict:
+def _make_types(app: type) -> dict:
     """returns type hints for the input and output"""
     _types = {"_data_types": [], "_return_types": []}
     for tys in _types:
         types = getattr(app, tys, None) or []
-        types = [types] if type(types) == str else types
+        types = [types] if isinstance(types, str) else types
         _types[tys] = [{None: ""}.get(e, e) for e in types]
     return _types
 
 
-# private global to hold an ExtensionManager instance
-__apps = None
-
-
-def get_app_manager() -> stevedore.ExtensionManager:
-    """
-    Lazy load a stevedore ExtensionManager to collect apps.
-    """
-    global __apps
-    if not __apps:
-        __apps = stevedore.ExtensionManager(
-            namespace=APP_ENTRY_POINT,
-            invoke_on_load=False,
-        )
-
-    return __apps
-
-
 def available_apps(name_filter: str | None = None) -> Table:
     """
     returns Table listing the available apps
@@ -149,7 +130,7 @@ def _make_signature(app: type) -> str:
     return f"{sig_prefix}(\n{params},\n)"
 
 
-def _doc_summary(doc):
+def _doc_summary(doc: str) -> str:
     """return first para of docstring"""
     result = []
     for line in doc.splitlines():
@@ -160,7 +141,7 @@ def _doc_summary(doc):
     return " ".join(result)
 
 
-def _get_app_matching_name(name: str):
+def _get_app_matching_name(name: str) -> type:
     """name can include module name"""
 
     if "." in name:
diff --git a/src/cogent3/app/tree.py b/src/cogent3/app/tree.py
index b812eec25..83e8135a7 100644
--- a/src/cogent3/app/tree.py
+++ b/src/cogent3/app/tree.py
@@ -96,11 +96,11 @@ class uniformize_tree:
 class quick_tree:
     """Computes a Neighbour Joining tree from pairwise distances."""
 
-    def __init__(self, drop_invalid=False) -> None:
+    def __init__(self, drop_invalid: bool = False) -> None:
         """
         Parameters
         ----------
-        drop_invalid : bool
+        drop_invalid
             drops all rows / columns with an invalid entry. If True, sequences
             for which a distance could not be calculated are excluded and
             the resulting tree will be for the subset of labels with strictly
@@ -125,7 +125,7 @@ class quick_tree:
             tree = make_tree(treestring=newick, underscore_unmunge=True)
         else:
             (result,) = gnj(dists.to_dict(), keep=1, show_progress=False)
-            (score, tree) = result
+            _, tree = result
 
         return tree
 
diff --git a/src/cogent3/core/annotation_db.py b/src/cogent3/core/annotation_db.py
index f211e1ff3..f147a2105 100644
--- a/src/cogent3/core/annotation_db.py
+++ b/src/cogent3/core/annotation_db.py
@@ -89,7 +89,7 @@ class FeatureDataType(typing.TypedDict):
     seqid: str  # name of the sseq
     biotype: str  # rename type attr of cogent3 Annotatables to match this?
     name: str  # rename to name to match cogent3 Annotatable.name?
-    spans: list[tuple[int, int]]
+    spans: list[tuple[int, int]] | numpy.ndarray
     strand: str  # "-" if feature on reverse strand
     on_alignment: bool  # True if feature on an alignment
     xattr: dict[str, typing.Any]  # extra attributes
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index f648afb64..99d7ae1b5 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -64,6 +64,8 @@ from cogent3.util.union_dict import UnionDict
 # both collections can indicate sequences that are reverse complemented
 # by providing their names to the reversed_seqs argument.
 
+if typing.TYPE_CHECKING:
+    from cogent3.core.tree import PhyloNode
 
 DEFAULT_ANNOTATION_DB = BasicAnnotationDb
 
@@ -5446,16 +5448,13 @@ class Alignment(SequenceCollection):
 
         return result
 
-    @UI.display_wrap
     def quick_tree(
         self,
         calc: str = "pdist",
-        bootstrap: OptInt = None,
         drop_invalid: bool = False,
         parallel: bool = False,
-        show_progress: bool = False,
-        ui=None,  # refactor: type hint
-    ):
+        use_hook: str | None = None,
+    ) -> PhyloNode:
         """Returns a phylogenetic tree.
 
         Parameters
@@ -5463,68 +5462,31 @@ class Alignment(SequenceCollection):
         calc
             a pairwise distance calculator or name of one. For options see
             cogent3.evolve.fast_distance.available_distances
-        bootstrap
-            Number of non-parametric bootstrap replicates. Resamples alignment
-            columns with replacement and builds a phylogeny for each such
-            resampling.
         drop_invalid
             If True, sequences for which a pairwise distance could not be
             calculated are excluded. If False, an ArithmeticError is raised if
             a distance could not be computed on observed data.
         parallel
             parallel execution of distance calculations
-        show_progress
-            controls progress display for distance calculation
+        use_hook
+            name of a third-party package that implements the quick_tree
+            hook. If not specified, defaults to the first available hook or
+            the cogent3 quick_tree() app. To force default, set
+            use_hook="cogent3".
 
         Returns
         -------
-        a phylogenetic tree. If bootstrap specified, returns the weighted
-        majority consensus. Support for each node is stored as
-        edge.params['params'].
-
-        Notes
-        -----
-        Sequences in the observed alignment for which distances could not be
-        computed are omitted. Bootstrap replicates are required to have
-        distances for all seqs present in the observed data distance matrix.
+        a phylogenetic tree
         """
-        from cogent3.phylo.consensus import weighted_majority_rule
-        from cogent3.phylo.nj import gnj
-
         dm = self.distance_matrix(
             calc=calc,
             drop_invalid=drop_invalid,
             parallel=parallel,
         )
-        results = gnj(dm, keep=1, show_progress=show_progress)
-        kept_names = set(dm.template.names[0])
-        if bootstrap:
-            subaln = (
-                self.take_seqs(kept_names) if set(self.names) != kept_names else self
-            )
-            for _ in ui.series(range(bootstrap), count=bootstrap, noun="bootstrap"):
-                b = subaln.sample(with_replacement=True)
-                try:
-                    bdist = b.distance_matrix(
-                        calc=calc,
-                        drop_invalid=drop_invalid,
-                        parallel=parallel,
-                    )
-                except ArithmeticError:
-                    # all sequences must have a pairwise distance to allow
-                    # constructing a consensus tree
-                    continue
-                bresult = gnj(bdist, keep=1, show_progress=show_progress)
-                results.extend(bresult)
-
-            consense = weighted_majority_rule(results)
-            assert len(consense) == 1
-            results = consense
-
-        tree = results[0]
-        if not bootstrap:
-            tree = tree[1]
-        return tree
+        qtree = cogent3._plugin.get_quick_tree_hook(name=use_hook)  # noqa: SLF001
+        # we directly use the app main method, as this means any errors will be
+        # raised as exceptions
+        return qtree.main(dm)
 
     def trim_stop_codons(self, gc: Any = None, strict: bool = False, **kwargs):
         # refactor: array
@@ -5562,7 +5524,7 @@ class Alignment(SequenceCollection):
         include_stop: bool = False,
         trim_stop: bool = True,
         **kwargs,
-    ):
+    ) -> typing_extensions.Self:
         if not self.moltype.is_nucleic:
             msg = f"moltype must be a DNA/RNA, not {self.moltype.name!r}"
             raise new_moltype.MolTypeError(
diff --git a/src/cogent3/evolve/fast_distance.py b/src/cogent3/evolve/fast_distance.py
index f7c166f9d..53f95196d 100644
--- a/src/cogent3/evolve/fast_distance.py
+++ b/src/cogent3/evolve/fast_distance.py
@@ -14,6 +14,9 @@ from cogent3.util.dict_array import DictArray
 from cogent3.util.misc import get_object_provenance
 from cogent3.util.progress_display import display_wrap
 
+if typing.TYPE_CHECKING:
+    from cogent3.core.tree import PhyloNode
+
 PySeq = typing.Sequence
 PySeqStr = PySeq[str]
 
@@ -834,22 +837,25 @@ class DistanceMatrix(DictArray):
         keep = set(names) ^ exclude
         return self.take_dists(keep)
 
-    def quick_tree(self, show_progress=False):
-        """returns a neighbour joining tree
+    def quick_tree(self, use_hook: str | None = None) -> "PhyloNode":
+        """Returns a phylogenetic tree
 
-        Returns
-        -------
-        an estimated Neighbour Joining Tree, note that invalid distances are dropped
-        prior to building the tree
-        """
-        from cogent3.phylo.nj import nj
+        Parameters
+        ----------
+        use_hook
+            name of a third-party package that implements the quick_tree
+            hook. If not specified, defaults to the first available hook or
+            the cogent3 quick_tree() app. To force default, set
+            use_hook="cogent3".
 
+        Notes
+        -----
+        Invalid distances are dropped prior to building the tree.
+        Defaults to the Neighbour Joining Tree algorithm.
+        """
+        qtree = cogent3._plugin.get_quick_tree_hook(name=use_hook)  # noqa: SLF001
         dists = self.drop_invalid()
-        if not dists or dists.shape[0] == 1:
-            msg = "Too few distances to build a treenj"
-            raise ValueError(msg)
-        dists = dists.to_dict()
-        return nj(dists, show_progress=show_progress)
+        return qtree(dists)
 
     def max_pair(self) -> tuple[str, str]:
         """returns the pair of names with the maximum distance


Test patch:
diff --git a/tests/test_app/test_plugins.py b/tests/test_app/test_plugins.py
index 6b54f8db5..9563f9f11 100644
--- a/tests/test_app/test_plugins.py
+++ b/tests/test_app/test_plugins.py
@@ -42,8 +42,8 @@ def mock_extension_manager(extension_manager_factory, monkeypatch):
     def _mock_extension_manager(extensions):
         # Create a mocked ExtensionManager with the specified mock extensions
         mocked_manager = extension_manager_factory(extensions)
-        # Patch the __apps variable in cogent3.apps module to use the mocked_manager
-        monkeypatch.setattr(cogent3.app, "__apps", mocked_manager)
+        # Patch the __apps variable in cogent3._plugin module to use the mocked_manager
+        monkeypatch.setattr(cogent3._plugin, "__apps", mocked_manager)
         return mocked_manager
 
     return _mock_extension_manager
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index 731e9b7d9..e12cb7680 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -4790,23 +4790,15 @@ def brca1_data():
 
 
 @pytest.mark.parametrize("calc", ["hamming", None])
-def test_alignment_quick_tree(calc, brca1_data):
+@pytest.mark.parametrize("use_hook", ["cogent3", None, "not present"])
+def test_alignment_quick_tree(calc, brca1_data, use_hook):
     """quick tree method returns tree"""
     aln = new_alignment.make_aligned_seqs(brca1_data, moltype="dna")[:100]
     aln = aln.take_seqs(["Human", "Rhesus", "HowlerMon", "Galago", "Mouse"])
-    kwargs = {"show_progress": False}
-    if calc:
-        kwargs["calc"] = calc
-
-    # bootstrap
-    tree = aln.quick_tree(bootstrap=2, **kwargs)
+    kwargs = {"use_hook": use_hook}
+    kwargs = {**kwargs, "calc": calc} if calc else kwargs
+    tree = aln.quick_tree(**kwargs)
     assert set(tree.get_tip_names()) == set(aln.names)
-    types = {
-        type(float(edge.params["support"]))
-        for edge in tree.preorder()
-        if not edge.is_root()
-    }
-    assert types == {float}
 
 
 @pytest.fixture
diff --git a/tests/test_evolve/test_distance.py b/tests/test_evolve/test_distance.py
index 4a90cd376..f26f0ccdb 100644
--- a/tests/test_evolve/test_distance.py
+++ b/tests/test_evolve/test_distance.py
@@ -854,7 +854,8 @@ def test_take_dists():
     assert_allclose(got1.array.astype(float), got2.array.astype(float))
 
 
-def test_build_phylogeny():
+@pytest.mark.parametrize("use_hook", ["cogent3", None, "not present"])
+def test_build_phylogeny(use_hook):
     """build a NJ tree"""
     from cogent3 import make_tree
 
@@ -881,7 +882,7 @@ def test_build_phylogeny():
         ("TombBat", "LittleBro"): 0.12,
     }
     dists = DistanceMatrix(dists)
-    got = dists.quick_tree(show_progress=False)
+    got = dists.quick_tree(use_hook=use_hook)
     expect = make_tree(
         treestring="((TombBat,(DogFaced,FlyingFox)),LittleBro,FreeTaile)",
     )