Repository: cogent3/cogent3
Pull request #1816 (2024-04-21 22:27:25)

Pull features from dynamic-plugin_loading into plugin-support branch
This PR updates plugin-support
- removing features of plugin_support that relied upon internals of 
  - stevedore
  - importlib
  - pkg_resources
- addressed bug introduced by treating a cogent3.Table as mutable
- added error handling for apps that overload the __add__ operator
- reordered imports to be alphabetically sorted
- changed identifiers to meet pep8 standard
- adding Changelog content



Patch:
diff --git a/changelog.d/20240418_113855_richard_dynamic_plugin_loading.md b/changelog.d/20240418_113855_richard_dynamic_plugin_loading.md
new file mode 100644
index 000000000..d590ad9c2
--- /dev/null
+++ b/changelog.d/20240418_113855_richard_dynamic_plugin_loading.md
@@ -0,0 +1,39 @@
+<!--
+A new scriv changelog fragment.
+
+Uncomment the section that is right (remove the HTML comment wrapper).
+-->
+
+### Contributors
+
+- Implementation of plug-in support :building_construction: by @khiron 
+
+### ENH
+
+- Plugin support that will allow 3rd-party developers to add their custom 
+functionality to the cogent3 pipeline as first class apps
+
+<!--
+### BUG
+
+- A bullet item for the BUG category.
+
+-->
+<!--
+### DOC
+
+- A bullet item for the DOC category.
+
+-->
+<!--
+### Deprecations
+
+- A bullet item for the Deprecations category.
+
+-->
+<!--
+### Discontinued
+
+- A bullet item for the Discontinued category.
+
+-->
diff --git a/pyproject.toml b/pyproject.toml
index 570e14104..0f1a9f0d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,9 +22,9 @@ dependencies = ["chardet",
         "numba>=0.59.0; python_version=='3.12'",
         "scipy",
         "scitrack",
+        "stevedore",
         "tqdm",
-        "typing_extensions",
-        "stevedore"]
+        "typing_extensions"]
 # remember to update version in requires-python and classifiers
 requires-python = ">=3.9,<3.13"
 classifiers = [
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index 7e5beafab..3e23c2f3f 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
 import contextlib
-import importlib
 import inspect
 import re
 import textwrap
@@ -15,7 +14,8 @@ from .composable import is_app, is_app_composable
 from .io import open_data_store
 
 
-# from stevedore.extension import ExtensionManager
+# Entry_point for apps to register themselves as plugins
+APP_ENTRY_POINT = "cogent3.app"
 
 
 def _get_extension_attr(extension):
@@ -60,43 +60,25 @@ def _make_types(app) -> dict:
 __apps = None
 
 
-def apps(force: bool = False) -> stevedore.ExtensionManager:
+def get_app_manager() -> stevedore.ExtensionManager:
     """
     Lazy load a stevedore ExtensionManager to collect apps.
-
-    Parameters
-    ----------
-    force : bool, optional
-        If set to True, forces the re-creation of the ExtensionManager,
-        even if it already exists. Default is False.
-
-    Returns
-    -------
-    ExtensionManager
-        The ExtensionManager instance that collects apps.
     """
     global __apps
-    if force:
-        importlib.invalidate_caches()
-        importlib.reload(stevedore)
-        stevedore.extension.ExtensionManager.ENTRY_POINT_CACHE.clear()
-    if not __apps or force:
+    if not __apps:
         __apps = stevedore.ExtensionManager(
-            namespace="cogent3.app", invoke_on_load=False
+            namespace=APP_ENTRY_POINT, invoke_on_load=False
         )
+
     return __apps
 
 
-def available_apps(name_filter: str | None = None, force: bool = False) -> Table:
+def available_apps(name_filter: str | None = None) -> Table:
     """returns Table listing the available apps
 
-    Parameters
-    ----------
-    name_filter
-        include apps whose name includes name_filter
-    force : bool, optional
-        If set to True, forces the re-creation of the ExtensionManager,
-        even if it already exists. Default is False.
+    Notes
+    -----
+    If force is set, the app_manager will refresh it's cache of apps.
     """
     from cogent3.util.table import Table
 
@@ -105,7 +87,7 @@ def available_apps(name_filter: str | None = None, force: bool = False) -> Table
 
     rows = []
 
-    extensions = apps(force)
+    extensions = get_app_manager()
 
     for extension in extensions:
         if any(extension.name.startswith(d) for d in deprecated):
@@ -189,7 +171,9 @@ def _get_app_matching_name(name: str):
     else:
         modname = None
 
-    extensions_matching = [extension for extension in apps() if extension.name == name]
+    extensions_matching = [
+        extension for extension in get_app_manager() if extension.name == name
+    ]
     if not extensions_matching:
         raise ValueError(f"App {name!r} not found. Please check for typos.")
 
@@ -210,24 +194,10 @@ def _get_app_matching_name(name: str):
 def get_app(_app_name: str, *args, **kwargs):
     """returns app instance, use app_help() to display arguments
 
-    Parameters
-    ----------
-    _app_name
-        app name, e.g. 'minlength', or can include module information,
-        e.g. 'cogent3.app.sample.minlength' or 'sample.minlength'. Use the
-        latter (qualified class name) style when there are multiple matches
-        to name.
-    *args, **kwargs
-        positional and keyword arguments passed through to the app
-
-    Returns
-    -------
-    cogent3 app instance
-
     Raises
     ------
     NameError when multiple apps have the same name. In that case use a
-    qualified class name, as shown above.
+    qualified class name.
     """
     return _get_app_matching_name(_app_name)(*args, **kwargs)
 
diff --git a/src/cogent3/app/composable.py b/src/cogent3/app/composable.py
index 8f6a2a621..5d00bf807 100644
--- a/src/cogent3/app/composable.py
+++ b/src/cogent3/app/composable.py
@@ -603,7 +603,10 @@ def define_app(
             raise TypeError(
                 f"remove 'input' attribute in {klass.__name__!r}, this functionality provided by define_app"
             )
-
+        if composable and getattr(klass, "__add__", None):
+            raise TypeError(
+                f"remove '__add__' method in {klass.__name__!r}, this functionality provided by define_app"
+            )
         for meth in method_list:
             # make sure method not defined by user before adding
             if inspect.isfunction(getattr(klass, meth, None)):


Test patch:
diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
index fcba64e15..fe3462679 100644
--- a/tests/test_app/test_init.py
+++ b/tests/test_app/test_init.py
@@ -8,7 +8,7 @@ from unittest import TestCase
 import pytest
 
 from cogent3 import app_help, available_apps, get_app, open_data_store
-from cogent3.app.composable import LOADER, WRITER, define_app, is_app
+from cogent3.app.composable import LOADER, WRITER, is_app
 from cogent3.util.table import Table
 
 
@@ -138,26 +138,6 @@ def test_app_help(capsys):
     assert got.count("bytes") >= 2  # both input and output types are bytes
 
 
-@pytest.mark.xfail(reason="Constructing apps on the fly is no longer supported")
-def test_app_help_doctest_examples(capsys):
-    app_doc = "A line of text describing the app."
-    init_doc = "\n        Parameters\n        ----------\n        arg\n        arg description\n\n        Examples\n        --------\n        How to use the app\n\n        >>>blah(arg)\n        output\n"
-    blah.__doc__ = app_doc
-    blah.__init__.__doc__ = init_doc
-    app_help("blah")
-    got = capsys.readouterr().out
-    # Two new lines after the end of Parameters should be preserved
-    expect1 = "arg description\n\nExamples\n--------\n"
-    # Two new lines within Examples are preserved
-    expect2 = "How to use the app\n\n>>>blah(arg)\noutput\n"
-    # Two new lines at the end of Examples preserved
-    expect3 = "output\n\nInput type"
-
-    assert expect1 in got
-    assert expect2 in got
-    assert expect3 in got
-
-
 @pytest.mark.parametrize(
     "app_name", ("bootstrap", "from_primitive", "load_db", "take_named_seqs")[:1]
 )
diff --git a/tests/test_app/test_plugins.py b/tests/test_app/test_plugins.py
index e76f9ea3c..1d5d4852a 100644
--- a/tests/test_app/test_plugins.py
+++ b/tests/test_app/test_plugins.py
@@ -1,6 +1,3 @@
-import sys
-import time
-
 from importlib.metadata import EntryPoint
 from unittest.mock import patch
 
@@ -65,7 +62,7 @@ def create_extension(
     )
 
 
-def test_Install_app_class(mock_extension_manager):
+def test_install_app_class(mock_extension_manager):
     @define_app
     class uppercase:
         """Test app that converts a string to uppercase"""
@@ -80,7 +77,7 @@ def test_Install_app_class(mock_extension_manager):
     assert appercase.__doc__ in _make_apphelp_docstring(appercase.__class__)
 
 
-def test_Install_app_function(mock_extension_manager):
+def test_install_app_function(mock_extension_manager):
     @define_app
     def uppercase(data: str) -> str:
         """Test function that converts a string to uppercase"""
@@ -108,7 +105,7 @@ def test_app_docs(mock_extension_manager, app_doc, init_doc):
 
     mock_extension_manager([create_extension(documented_app)])
 
-    assert cogent3.app.apps().names() == ["documented_app"]
+    assert cogent3.app.get_app_manager().names() == ["documented_app"]
     app = get_app("documented_app")
     app.__class__.__doc__ = app_doc
     app.__class__.__init__.__doc__ = init_doc
@@ -136,7 +133,7 @@ def test_namespace_collision(mock_extension_manager):
         ]
     )
 
-    assert cogent3.app.apps().names() == ["app1", "app1"]
+    assert cogent3.app.get_app_manager().names() == ["app1", "app1"]
 
     with pytest.raises(NameError):
         _ = get_app(
@@ -162,5 +159,5 @@ def test_available_apps_local(mock_extension_manager):
     mock_extension_manager([create_extension(dummy)])
     apps = available_apps()
     assert isinstance(apps, Table)
-    apps.filtered(lambda x: dummy.__name__ == x, columns="name")
+    apps = apps.filtered(lambda x: dummy.__name__ == x, columns="name")
     assert apps.shape[0] == 1