Repository: cogent3/cogent3
Pull request #1228 (2022-12-13 23:33:54)

ENH: add functions for simplifying app handling


Patch:
diff --git a/src/cogent3/__init__.py b/src/cogent3/__init__.py
index 3d5538f34..351ad632a 100644
--- a/src/cogent3/__init__.py
+++ b/src/cogent3/__init__.py
@@ -10,7 +10,7 @@ import warnings
 
 from typing import Callable, Optional, Union
 
-from cogent3.app import available_apps
+from cogent3.app import app_help, available_apps, get_app
 from cogent3.core.alignment import (
     Alignment,
     ArrayAlignment,
diff --git a/src/cogent3/app/__init__.py b/src/cogent3/app/__init__.py
index 2c2e84ee3..b796dac6a 100644
--- a/src/cogent3/app/__init__.py
+++ b/src/cogent3/app/__init__.py
@@ -1,5 +1,9 @@
+from __future__ import annotations
+
 import contextlib
 import importlib
+import inspect
+import re
 
 
 __author__ = "Gavin Huttley"
@@ -11,18 +15,16 @@ __maintainer__ = "Gavin Huttley"
 __email__ = "Gavin.Huttley@anu.edu.au"
 __status__ = "Alpha"
 
-__all__ = ["align", "composable", "dist", "evo", "io", "sample", "translate", "tree"]
-
-
-def _doc_summary(doc):
-    """return first para of docstring"""
-    result = []
-    for line in doc.splitlines():
-        line = line.strip()
-        if not line:
-            break
-        result.append(line)
-    return " ".join(result)
+__all__ = [
+    "align",
+    "composable",
+    "dist",
+    "evo",
+    "io_new",
+    "sample",
+    "translate",
+    "tree",
+]
 
 
 def _get_app_attr(name, is_composable):
@@ -67,3 +69,114 @@ def available_apps():
 
     header = ["module", "name", "composable", "doc", "input type", "output type"]
     return Table(header=header, data=rows)
+
+
+_get_param = re.compile('(?<=").+(?=")')
+
+
+def _make_signature(app: type) -> str:
+    init_sig = inspect.signature(app.__init__)
+    params = ", ".join(
+        [
+            _get_param.findall(repr(v))[0]
+            for k, v in init_sig.parameters.items()
+            if k != "self"
+        ]
+    )
+    return f"{app.__name__}({params})"
+
+
+def _doc_summary(doc):
+    """return first para of docstring"""
+    result = []
+    for line in doc.splitlines():
+        if line := line.strip():
+            result.append(line)
+        else:
+            break
+    return " ".join(result)
+
+
+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")
+
+    if table.shape[0] == 0:
+        raise NameError(f"no app matching name {name!r}")
+    elif table.shape[0] > 1:
+        raise NameError(f"too many apps matching name {name!r},\n{table}")
+    modname, _ = table.tolist(columns=["module", "name"])[0]
+    mod = importlib.import_module(modname)
+    return getattr(mod, name)
+
+
+def get_app(name: str, *args, **kwargs):
+    """returns app instance, use app_help() to display arguments
+
+    Parameters
+    ----------
+    name
+        app name, e.g. 'minlength', or can include module information,
+        e.g. 'cogent3.app.sample.minlength'. Use latter (qualified class name)
+        style when 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 the fully
+    qualified class name, as shown above.
+    """
+    return _get_app_matching_name(name)(*args, **kwargs)
+
+
+def _make_head(text: str) -> list[str]:
+    """makes a restructured text formatted header"""
+    return [text, "-" * len(text)]
+
+
+def _clean_params_docs(text: str) -> str:
+    """remove unnecessary indentation"""
+    text = text.splitlines(keepends=False)
+    prefix = re.compile(r"^\s{8}")  # expected indentation of constructor doc
+    doc = []
+    for i, l in enumerate(text):
+        l = prefix.sub("", l)
+        if l.strip():
+            doc.append(l)
+
+    return "\n".join(doc)
+
+
+def app_help(name: str):
+    """displays help for the named app
+
+    Parameters
+    ----------
+    name
+        app name, e.g. 'minlength', or can include module information,
+        e.g. 'cogent3.app.sample.minlength'. Use latter (qualified class name)
+        style when multiple matches to name.
+    """
+    app = _get_app_matching_name(name)
+    docs = []
+    if app.__doc__.strip():
+        docs.extend(_make_head("Overview") + [app.__doc__, ""])
+
+    docs.extend(_make_head("Signature") + [_make_signature(app)])
+    if app.__init__.__doc__.strip():
+        docs.extend(["", _clean_params_docs(app.__init__.__doc__)])
+
+    print("\n".join(docs))
diff --git a/src/cogent3/app/sample.py b/src/cogent3/app/sample.py
index 75fa154a9..2cff8f29c 100644
--- a/src/cogent3/app/sample.py
+++ b/src/cogent3/app/sample.py
@@ -194,8 +194,7 @@ class take_codon_positions:
         gc="Standard",
         moltype="dna",
     ):
-        """selects the indicated codon positions from an alignment
-
+        """
         Parameters
         ----------
         positions
@@ -205,7 +204,7 @@ class take_codon_positions:
             if True, returns third positions from four-fold degenerate codons.
             Overrides positions.
         gc
-            identifer for a genetic code or a genetic code instance
+            identifier for a genetic code or a genetic code instance
         moltype : str
             molecular type, must be either DNA or RNA
         """


Test patch:
diff --git a/tests/test_app/test_init.py b/tests/test_app/test_init.py
index ea0c75f3e..90f08a34e 100644
--- a/tests/test_app/test_init.py
+++ b/tests/test_app/test_init.py
@@ -4,7 +4,9 @@ import os
 from tempfile import TemporaryDirectory
 from unittest import TestCase, main
 
-from cogent3 import available_apps
+import pytest
+
+from cogent3 import app_help, available_apps, get_app
 from cogent3.app import align, dist, evo, io, sample, translate, tree
 from cogent3.app.composable import (
     LOADER,
@@ -146,5 +148,33 @@ def test_available_apps_local():
     __app_registry.pop(get_object_provenance(dummy), None)
 
 
+@pytest.mark.parametrize("name", ("sample.min_length", "min_length"))
+def test_get_app(name):
+    __app_registry.pop(get_object_provenance(min_length), None)
+    app = get_app(name, 500)
+    assert app.__class__.__name__.endswith(name.split(".")[-1])
+
+
+@define_app
+def min_length(val: int) -> int:
+    return val
+
+
+def test_get_app_fail():
+
+    __app_registry[get_object_provenance(min_length)] = True
+
+    with pytest.raises(NameError):
+        _ = get_app("min_length", 500)
+
+    __app_registry.pop(get_object_provenance(min_length), None)
+
+
+def test_app_help(capsys):
+    app_help("omit_degenerates")
+    got = capsys.readouterr().out
+    assert got.startswith("Overview")
+
+
 if __name__ == "__main__":
     main()