Repository: cogent3/cogent3
Pull request #2243: Add support for hook style plugins, fixes  #2021
Author: login: GavinHuttley
Created: 2025-03-17T01:12:44Z | Merged: 2025-03-17T02:12:15Z


Linked issue #2021: Defining hook plugins
In the [stevedore pattern description](https://docs.openstack.org/stevedore/latest/user/patterns_loading.html) Cogent3 app plugins are of type "extension". This issue is about adding a new plugin type, "hooks".

According to the `stevedore` hierarchy a hook has a single name with many entries. A hook would be triggered by a specific cogent3 object method. For instance, the statement `<alignment class instance>.quick_tree(func="piqtree_phylo")` generates a pairwise DistanceMatrix instance whose `quick_tree()` method is the actual hook definition and where the loading of `piqtree_nj` occurs.

I'm currently thinking that a developer could declare that one of their Cogent3 plugin apps is suitable for a specific hook. That suitability is defined by the data type the hook applies and the return type the app delivers.

We break from the `stevedore' approach by using type hints rather than abstract base classes to define the interface. 

## Questions

Does stevedore provide direct facilities for defining a callback function for an individual hook that we can use to check the types? The answer is that there does not appear to be a direct mechanism. 

## Our approach

We should centralise the definition of hooks. Since they are not restricted to apps, should the module be top-level and private? Or we could define a new submodule for plugins and move the app plugin infrastructure there, too?

Use the namespace "cogent3.hooks". The event name is then something like, e.g. "phylo_from_distances". Where we define these hooks we should include the input type and the output type for the hook name. Possibly a class that validates selected hook apps?

Diff:
diff --git a/changelog.d/20250314_131212_Gavin.Huttley.md b/changelog.d/20250314_131212_Gavin.Huttley.md
index 9c674c6665..03528f6a2d 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 a6daf214a8..c55e5747b9 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 0e11b14230..5c23ee56f5 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -7,7 +7,7 @@
 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 0000000000..0f57bb6037
--- /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 ac91a42712..2d3df2bb21 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -7,19 +7,18 @@
 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 b812eec256..83e8135a7d 100644
--- a/src/cogent3/app/tree.py
+++ b/src/cogent3/app/tree.py
@@ -96,11 +96,11 @@ def main(self, tree: TreeType) -> SerialisableType | TreeType:
 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 @@ def main(self, dists: PairwiseDistanceType) -> SerialisableType | TreeType:
             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 f211e1ff39..f147a2105c 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 f648afb644..99d7ae1b54 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -64,6 +64,8 @@
 # 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 @@ def distance_matrix(
 
         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 @@ def quick_tree(
         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 @@ def get_translation(
         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 f7c166f9df..53f95196d0 100644
--- a/src/cogent3/evolve/fast_distance.py
+++ b/src/cogent3/evolve/fast_distance.py
@@ -14,6 +14,9 @@
 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 @@ def drop_invalid(self):
         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
diff --git a/tests/test_app/test_plugins.py b/tests/test_app/test_plugins.py
index 6b54f8db53..9563f9f11f 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 731e9b7d99..e12cb76800 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 4a90cd376a..f26f0ccdb8 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)",
     )


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

**Overall Comments**:
- The hook implementation looks good, but consider adding a check to ensure that the loaded plugin adheres to the expected interface (takes a DistanceMatrix, returns a PhyloNode).
- The changes to quick_tree in new_alignment.py and fast_distance.py look good, but it might be worth adding a test case that specifically uses a third-party hook to ensure that the plugin system is working as expected.

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

- 🟢 **General issues**: all looks good
- 🟢 **Security**: all looks good
- 🟡 **Testing**: 2 issues found
- 🟡 **Complexity**: 1 issue 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-03-17T01:13:38Z
user: login: GavinHuttley
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T01:19:32Z

Review comments:
diff_hunk: @@ -4794,19 +4794,12 @@ def test_alignment_quick_tree(calc, brca1_data):
     """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}
+    kwargs = {}
     if calc:
         kwargs["calc"] = calc
 
-    # bootstrap
-    tree = aln.quick_tree(bootstrap=2, **kwargs)
+    tree = aln.quick_tree(**kwargs)
path: tests/test_core/test_new_alignment.py
user: login: sourcery-ai[bot]
body: **issue (testing):** Missing test cases for new `use_hook` argument

The `quick_tree` method now accepts a `use_hook` argument.  We need to add tests that exercise this new parameter, including cases where a valid hook name is provided, an invalid hook name is provided, and when the argument is not provided at all.
created_at: 2025-03-17T01:13:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -881,7 +881,7 @@ def test_build_phylogeny():
         ("TombBat", "LittleBro"): 0.12,
     }
     dists = DistanceMatrix(dists)
-    got = dists.quick_tree(show_progress=False)
+    got = dists.quick_tree()
path: tests/test_evolve/test_distance.py
user: login: sourcery-ai[bot]
body: **issue (testing):** Missing test for the `use_hook` argument in `DistanceMatrix.quick_tree()`

Similar to the `quick_tree` method of the `Alignment` class, the `DistanceMatrix.quick_tree()` method now also accepts a `use_hook` argument.  Tests should be added to cover this new functionality, including valid and invalid hook names, as well as the case where the argument is omitted.
created_at: 2025-03-17T01:13:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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(
path: src/cogent3/_plugin.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the inner loop into a helper function to reduce the nested conditional and improve readability by isolating the matching logic.

```markdown
Consider extracting the inner loop into a small helper function to reduce the nested conditional. This will clarify the intent and isolate the matching logic. For example:

```python
def find_extension_plugin(extensions, name: str | None) -> typing.Callable:
    """Return the plugin from extensions matching the given name, if any."""
    for extension in extensions:
        if name is None or extension.module_name.startswith(name):
            return extension.plugin()
    return None
```

Then refactor `get_quick_tree_hook` as follows:

```python
@functools.cache
def get_quick_tree_hook(*, name: str | None = None) -> typing.Callable[["DistanceMatrix"], "PhyloNode"] | None:
    import cogent3

    mgr = stevedore.hook.HookManager(
        namespace=HOOK_ENTRY_POINT,
        name="quick_tree",
        invoke_on_load=False,
    )
    if name != "cogent3":
        plugin = find_extension_plugin(mgr.extensions, name)
        if plugin:
            return plugin
    return cogent3.get_app("quick_tree")
```

This maintains the functionality while reducing nesting and improving readability.
```
created_at: 2025-03-17T01:13:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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(
path: src/cogent3/_plugin.py
user: login: GavinHuttley
body: a bit ridiculous to be frank
created_at: 2025-03-17T01:19:32Z
author_association: COLLABORATOR

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

## Reviewer's Guide by Sourcery

This pull request introduces a hook-style plugin system for cogent3, enabling third-party packages to extend cogent3 functionality. The initial implementation focuses on the `quick_tree` method, allowing users to select different tree-building algorithms from different packages. Additionally, the app management was refactored, and type hinting was added for PhyloNode to improve code clarity and prevent circular dependencies.

#### Sequence diagram for quick_tree with hook

```mermaid
sequenceDiagram
    participant User
    participant Alignment
    participant _plugin
    participant HookApp

    User->>Alignment: quick_tree(use_hook="HookApp")
    Alignment->>_plugin: get_quick_tree_hook(name="HookApp")
    _plugin-->>Alignment: HookApp
    Alignment->>HookApp: main(distance_matrix)
    HookApp-->>Alignment: PhyloNode
    Alignment-->>User: PhyloNode
```

#### Sequence diagram for quick_tree without hook

```mermaid
sequenceDiagram
    participant User
    participant Alignment
    participant _plugin
    participant quick_tree_app

    User->>Alignment: quick_tree()
    Alignment->>_plugin: get_quick_tree_hook(name=None)
    _plugin-->>Alignment: quick_tree_app
    Alignment->>quick_tree_app: main(distance_matrix)
    quick_tree_app-->>Alignment: PhyloNode
    Alignment-->>User: PhyloNode
```

#### Updated class diagram for Alignment

```mermaid
classDiagram
    class Alignment {
        +quick_tree(calc: str, drop_invalid: bool, parallel: bool, use_hook: str | None) PhyloNode
    }
    note for Alignment "quick_tree now accepts a use_hook parameter"
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Introduced hook-style plugins for extending cogent3 functionality, starting with the `quick_tree` method. | <ul><li>Added a `use_hook` parameter to the `quick_tree` methods in `Alignment` and `DistanceMatrix` classes to allow users to specify a third-party package for tree construction.</li><li>Implemented `get_quick_tree_hook` in `cogent3._plugin` to retrieve the appropriate app based on the `use_hook` parameter.</li><li>Registered the cogent3 `quick_tree` app as the default hook.</li><li>Updated documentation to explain how to register and use hook-style plugins.</li><li>Removed bootstrap functionality from `new_alignment.Alignment.quick_tree()`.</li></ul> | `src/cogent3/core/new_alignment.py`<br/>`src/cogent3/evolve/fast_distance.py`<br/>`changelog.d/20250314_131212_Gavin.Huttley.md`<br/>`src/cogent3/_plugin.py` |
| Refactored app management to use `cogent3._plugin`. | <ul><li>Moved `get_app_manager` to `cogent3._plugin`.</li><li>Updated tests to reflect the change in location of `get_app_manager`.</li></ul> | `src/cogent3/app/__init__.py`<br/>`tests/test_app/test_plugins.py`<br/>`src/cogent3/_plugin.py` |
| Added type hinting for PhyloNode. | <ul><li>Added a conditional import for `PhyloNode` to avoid circular dependencies.</li></ul> | `src/cogent3/core/new_alignment.py`<br/>`src/cogent3/evolve/fast_distance.py` |
| Modified FeatureDataType to allow numpy arrays for spans. | <ul><li>Updated the type annotation for `spans` in `FeatureDataType` to include `numpy.ndarray`.</li></ul> | `src/cogent3/core/annotation_db.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-03-17T01:24:41Z
body: ## Pull Request Test Coverage Report for [Build 13889584311](https://coveralls.io/builds/72751233)


### Details

* **36** of **44**   **(81.82%)**  changed or added relevant lines in **7** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage decreased (**-0.006%**) to **91.447%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [src/cogent3/core/new_alignment.py](https://coveralls.io/builds/72751233/source?filename=src%2Fcogent3%2Fcore%2Fnew_alignment.py#L68) | 3 | 4 | 75.0%
| [src/cogent3/evolve/fast_distance.py](https://coveralls.io/builds/72751233/source?filename=src%2Fcogent3%2Fevolve%2Ffast_distance.py#L18) | 4 | 5 | 80.0%
| [src/cogent3/app/__init__.py](https://coveralls.io/builds/72751233/source?filename=src%2Fcogent3%2Fapp%2F__init__.py#L16) | 7 | 9 | 77.78%
| [src/cogent3/_plugin.py](https://coveralls.io/builds/72751233/source?filename=src%2Fcogent3%2F_plugin.py#L7) | 18 | 22 | 81.82%
<!-- | **Total:** | **36** | **44** | **81.82%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/72751233/badge)](https://coveralls.io/builds/72751233) |
| :-- | --: |
| Change from base [Build 13888445984](https://coveralls.io/builds/72750386): |  -0.006% |
| Covered Lines: | 34312 |
| Relevant Lines: | 37521 |

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

author_association: COLLABORATOR