Repository: cogent3/cogent3
Pull request #1801 (2024-04-01 20:58:57)

App namespace resolution  #1789
Added the ability to dynamically create and register apps for the purpose of testing name space collisions 

Added the ability to mock up the stevedore.extension.ExtensionManager so we can directly put extensions in without having to change the virtual environment's site-packages

Added tests for namespace collision 


Patch:
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index ec598d1ac..7e5beafab 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -7,7 +7,7 @@ import re
 import textwrap
 import warnings
 
-from stevedore.extension import ExtensionManager
+import stevedore
 
 from cogent3.util.table import Table
 
@@ -15,7 +15,10 @@ from .composable import is_app, is_app_composable
 from .io import open_data_store
 
 
-def _get_app_attr(name):
+# from stevedore.extension import ExtensionManager
+
+
+def _get_extension_attr(extension):
     """
     This function returns app details for display.
 
@@ -24,7 +27,7 @@ def _get_app_attr(name):
     This function also loads the module the app is in.
     """
 
-    obj = apps()[name].plugin
+    obj = extension.plugin
 
     if not is_app(obj):
         warnings.warn(
@@ -34,8 +37,8 @@ def _get_app_attr(name):
     _types = _make_types(obj)
 
     return [
-        obj.__module__,
-        name,
+        extension.module_name,
+        extension.name,
         is_app_composable(obj),
         _doc_summary(obj.__doc__ or ""),
         ", ".join(sorted(_types["_data_types"])),
@@ -57,7 +60,7 @@ def _make_types(app) -> dict:
 __apps = None
 
 
-def apps(force: bool = False) -> ExtensionManager:
+def apps(force: bool = False) -> stevedore.ExtensionManager:
     """
     Lazy load a stevedore ExtensionManager to collect apps.
 
@@ -73,8 +76,14 @@ def apps(force: bool = False) -> 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:
-        __apps = ExtensionManager(namespace="cogent3.app", invoke_on_load=False)
+        __apps = stevedore.ExtensionManager(
+            namespace="cogent3.app", invoke_on_load=False
+        )
     return __apps
 
 
@@ -96,16 +105,18 @@ def available_apps(name_filter: str | None = None, force: bool = False) -> Table
 
     rows = []
 
-    for app in apps(force).names():
-        if any(app.startswith(d) for d in deprecated):
+    extensions = apps(force)
+
+    for extension in extensions:
+        if any(extension.name.startswith(d) for d in deprecated):
             continue
 
-        if name_filter and name_filter not in app:
+        if name_filter and name_filter not in extension.name:
             continue
 
         with contextlib.suppress(AttributeError):
             # probably a local scope issue in testing!
-            rows.append(_get_app_attr(app))
+            rows.append(_get_extension_attr(extension))
 
     header = ["module", "name", "composable", "doc", "input type", "output type"]
     return Table(header=header, data=rows)
@@ -117,6 +128,13 @@ _get_param = re.compile('(?<=").+(?=")')
 def _make_signature(app: type) -> str:
     from cogent3.util.misc import get_object_provenance
 
+    if app is None:
+        raise ValueError("app cannot be None")
+
+    # if app is an instance, get the underlying class
+    if not inspect.isclass(app):
+        app = app.__class__
+
     init_sig = inspect.signature(app.__init__)
     app_name = app.__name__
     params = [f'"{app_name}"']
@@ -165,24 +183,28 @@ def _doc_summary(doc):
 
 def _get_app_matching_name(name: str):
     """name can include module name"""
-    table = available_apps()
+
     if "." in name:
         modname, name = name.rsplit(".", maxsplit=1)
-        table = table.filtered(
-            lambda x: x[0].endswith(modname) and x[1] == name,
-            columns=["module", "name"],
-        )
     else:
-        table = table.filtered(lambda x: name == x, columns="name")
+        modname = None
+
+    extensions_matching = [extension for extension in apps() if extension.name == name]
+    if not extensions_matching:
+        raise ValueError(f"App {name!r} not found. Please check for typos.")
 
-    try:
-        app = apps()[name].plugin
-    except KeyError:
+    if modname:
+        for extension in extensions_matching:
+            if extension.module_name.endswith(modname):
+                return extension.plugin
         raise ValueError(f"App {name!r} not found. Please check for typos.")
+
+    elif len(extensions_matching) == 1:
+        return extensions_matching[0].plugin
     else:
-        if table.shape[0] > 1:
-            raise NameError(f"Too many apps matching name {name!r},\n{table}")
-        return app
+        raise NameError(
+            f"Too many apps matching name {name!r},\n{available_apps().filtered(lambda x: name == x, columns='name')}"
+        )
 
 
 def get_app(_app_name: str, *args, **kwargs):


Test patch:
diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
index 65270b15f..fcba64e15 100644
--- a/tests/test_app/test_init.py
+++ b/tests/test_app/test_init.py
@@ -9,7 +9,6 @@ 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.util.misc import get_object_provenance
 from cogent3.util.table import Table
 
 
@@ -120,17 +119,6 @@ class TestAvailableApps(TestCase):
                     app_a + app_b
 
 
-def test_available_apps_local():
-    """available_apps robust to local scope apps"""
-
-    @define_app
-    def dummy(val: int) -> int:
-        return val
-
-    apps = available_apps()
-    assert isinstance(apps, Table)
-
-
 @pytest.mark.parametrize("name", ("sample.min_length", "min_length"))
 def test_get_app(name):
     app = get_app(name, 500)
@@ -143,20 +131,6 @@ def test_get_app_kwargs():
     _ = get_app("model", "F81", name="F81-model")
 
 
-# TODO: install an external app and test it appears in the _plugin_manager
-@define_app
-def min_length(val: int) -> int:
-    return val
-
-
-@pytest.mark.xfail(reason="test registration of a new app via an external app")
-def test_get_app_fail():
-    __app_registry[get_object_provenance(min_length)] = True
-
-    with pytest.raises(NameError):
-        _ = get_app("min_length", 500)
-
-
 def test_app_help(capsys):
     app_help("compress")
     got = capsys.readouterr().out
@@ -164,31 +138,6 @@ def test_app_help(capsys):
     assert got.count("bytes") >= 2  # both input and output types are bytes
 
 
-@define_app
-class blah:
-    def __init__(self):
-        self.constant = 2
-
-    def main(self, val: int) -> int:
-        return val + self.constant
-
-
-@pytest.mark.xfail(reason="Constructing apps on the fly is no longer supported")
-@pytest.mark.parametrize(
-    "app_doc,init_doc", ((None, None), ("text", None), (None, "text"), ("text", "text"))
-)
-def test_app_help_no_docs(capsys, app_doc, init_doc):
-    blah.__doc__ = app_doc
-    blah.__init__.__doc__ = init_doc
-    app_help("blah")
-    got = capsys.readouterr().out
-    if app_doc:
-        assert "Overview" in got
-
-    if init_doc:
-        assert "Options" in got
-
-
 @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."
diff --git a/tests/test_app/test_plugins.py b/tests/test_app/test_plugins.py
new file mode 100644
index 000000000..e51db650d
--- /dev/null
+++ b/tests/test_app/test_plugins.py
@@ -0,0 +1,168 @@
+import sys
+import time
+
+from importlib.metadata import EntryPoint
+from unittest.mock import patch
+
+import pytest
+
+from stevedore import extension
+from stevedore.extension import ExtensionManager
+
+import cogent3
+
+from cogent3.app import (
+    _make_apphelp_docstring,
+    app_help,
+    available_apps,
+    get_app,
+)
+from cogent3.app.composable import define_app
+from cogent3.util.table import Table
+
+
+@pytest.fixture
+def extension_manager_factory():
+    """Fixture to create mocked ExtensionManager instances with given extensions."""
+
+    def _factory(extensions):
+        with patch("stevedore.ExtensionManager") as mock_manager_constructor:
+            mock_manager = ExtensionManager.make_test_instance(
+                extensions=extensions, namespace="TESTING"
+            )
+            mock_manager_constructor.return_value = mock_manager
+            return mock_manager
+
+    return _factory
+
+
+@pytest.fixture
+def mock_extension_manager(extension_manager_factory, monkeypatch):
+    """Fixture to mock the ExtensionManager with the given extensions."""
+
+    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)
+        return mocked_manager
+
+    return _mock_extension_manager
+
+
+def create_extension(
+    plugin: object, name: str = None, module_name: str = "module1"
+) -> extension.Extension:
+    if name is None:
+        name = plugin.__name__
+    return extension.Extension(
+        name=name,
+        entry_point=EntryPoint(
+            name=name, value=f"{module_name}:{name}", group="TESTING"
+        ),
+        plugin=plugin,
+        obj=None,
+    )
+
+
+def test_Install_app_class(mock_extension_manager):
+    @define_app
+    class uppercase:
+        """Test app that converts a string to uppercase"""
+
+        def main(self, data: str) -> str:
+            return data.upper()
+
+    mock_extension_manager([create_extension(uppercase)])
+
+    appercase = get_app("uppercase")
+    assert appercase("hello") == "HELLO"
+    assert appercase.__doc__ in _make_apphelp_docstring(appercase.__class__)
+
+
+def test_Install_app_function(mock_extension_manager):
+    @define_app
+    def uppercase(data: str) -> str:
+        """Test function that converts a string to uppercase"""
+        return data.upper()
+
+    mock_extension_manager([create_extension(uppercase)])
+
+    appercase = get_app("uppercase")
+    assert appercase("hello") == "HELLO"
+    assert appercase.__doc__ in _make_apphelp_docstring(appercase.__class__)
+
+
+@pytest.mark.parametrize("app_doc", [None, "text"])
+@pytest.mark.parametrize("init_doc", [None, "text"])
+def test_app_docs(mock_extension_manager, app_doc, init_doc):
+    @define_app
+    class documented_app:
+        """This is a test app that has a __init__, and a docstring"""
+
+        def __init__(self):
+            self.constant = 2
+
+        def main(self, val: int) -> int:
+            return val + self.constant
+
+    mock_extension_manager([create_extension(documented_app)])
+
+    assert cogent3.app.apps().names() == ["documented_app"]
+    app = get_app("documented_app")
+    app.__class__.__doc__ = app_doc
+    app.__class__.__init__.__doc__ = init_doc
+    app_help("documented_app")
+    got = _make_apphelp_docstring(app.__class__)
+    assert "Options" in got
+
+
+def test_namespace_collision(mock_extension_manager):
+    @define_app
+    class app1:
+        def main(self, data: str) -> str:
+            return data.upper()
+
+    @define_app
+    class app2:
+        def main(self, data: str) -> str:
+            return data.lower()
+
+    # create two apps with the same name and different modules
+    mock_extension_manager(
+        [
+            create_extension(app1, module_name="module1"),
+            create_extension(app2, name="app1", module_name="module2"),
+        ]
+    )
+
+    assert cogent3.app.apps().names() == ["app1", "app1"]
+
+    with pytest.raises(NameError):
+        _ = get_app(
+            "app1"
+        )  # request app by name only, when there are multiple apps with the same name
+
+    app_by_module_name_1 = get_app("module1.app1")  # request app by name and module
+    app_by_module_name_2 = get_app("module2.app1")
+    assert app_by_module_name_1("Hello") == "HELLO"
+    assert app_by_module_name_2("Hello") == "hello"
+
+    composition = app_by_module_name_1 + app_by_module_name_2
+    assert composition("Hello") == "hello"
+
+
+def test_available_apps_local(mock_extension_manager):
+    """available_apps robust to local scope apps"""
+
+    @define_app
+    def dummy(val: int) -> int:
+        return val
+
+    mock_extension_manager([create_extension(dummy)])
+    apps = available_apps()
+    assert isinstance(apps, Table)
+    apps.filtered(lambda x: dummy.__name__ == x, columns="name")
+    assert apps.shape[0] == 1
+
+