Repository: cogent3/cogent3
Pull request #1291 (2023-02-08 07:41:43)

ENH: define_app decorator propagates docs


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index bd58ee39b..97dffd01c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ test = [
     "black",
     "isort",
     "kaleido",
+    "pandas",
     "plotly",
     "psutil",
     "pytest",
diff --git a/src/cogent3/app/composable.py b/src/cogent3/app/composable.py
index 7b3315f29..29f6d3e15 100644
--- a/src/cogent3/app/composable.py
+++ b/src/cogent3/app/composable.py
@@ -20,6 +20,7 @@ from cogent3.app.typing import get_constraint_names, type_tree
 from cogent3.util import parallel as PAR
 from cogent3.util import progress_display as UI
 from cogent3.util.misc import (
+    docstring_to_summary_rest,
     extend_docstring_from,
     get_object_provenance,
     in_jupyter,
@@ -1087,6 +1088,8 @@ def _class_from_func(func):
     sig = inspect.signature(func)
     class_name = func.__name__
     _main = _set_hints(_main, *_get_raw_hints(func, 1))
+    summary, body = docstring_to_summary_rest(func.__doc__)
+    func.__doc__ = None
 
     _class_dict = {"__init__": _init, "main": _main, "_user_func": staticmethod(func)}
 
@@ -1097,6 +1100,8 @@ def _class_from_func(func):
     result = types.new_class(class_name, (), exec_body=lambda x: x.update(_class_dict))
     result.__module__ = module  # necessary for pickle support
     result._func_sig = sig
+    result.__doc__ = summary
+    result.__init__.__doc__ = body
     return result
 
 
diff --git a/src/cogent3/app/io_new.py b/src/cogent3/app/io_new.py
index 170da78ff..763602572 100644
--- a/src/cogent3/app/io_new.py
+++ b/src/cogent3/app/io_new.py
@@ -171,16 +171,20 @@ def open_data_store(
 
 @define_app(skip_not_completed=False)
 def pickle_it(data: SerialisableType) -> bytes:
+    """Serialises data using pickle."""
     return pickle.dumps(data)
 
 
 @define_app(skip_not_completed=False)
 def unpickle_it(data: bytes) -> SerialisableType:
+    "Deserialises pickle data."
     return pickle.loads(data)
 
 
 @define_app(skip_not_completed=False)
 class compress:
+    """Compresses bytes data."""
+
     def __init__(self, compressor: callable = gzip_compress):
         """
         Parameters
@@ -196,6 +200,8 @@ class compress:
 
 @define_app(skip_not_completed=False)
 class decompress:
+    """Decompresses data."""
+
     def __init__(self, decompressor: callable = gzip_decompress):
         """
         Parameters
@@ -472,6 +478,8 @@ class load_db:
 
 @define_app(app_type=WRITER)
 class write_json:
+    """Writes data in json format."""
+
     def __init__(
         self,
         data_store: DataStoreABC,
@@ -507,6 +515,8 @@ class write_json:
 
 @define_app(app_type=WRITER)
 class write_seqs:
+    """Write sequences in standard formats."""
+
     @extend_docstring_from(write_json.__init__, pre=False)
     def __init__(
         self,
@@ -537,6 +547,8 @@ class write_seqs:
 
 @define_app(app_type=WRITER)
 class write_tabular:
+    """Writes tabular data in text format supported by the cogent3 Table object."""
+
     @extend_docstring_from(write_json.__init__, pre=False)
     def __init__(
         self,
diff --git a/src/cogent3/util/misc.py b/src/cogent3/util/misc.py
index 8f54489bc..def76ae8d 100644
--- a/src/cogent3/util/misc.py
+++ b/src/cogent3/util/misc.py
@@ -6,6 +6,7 @@ import re
 import warnings
 
 from random import randint
+from typing import Tuple
 from warnings import warn
 
 import numpy
@@ -966,6 +967,30 @@ def extend_docstring_from(source, pre=False):
     return docstring_inheriting_decorator
 
 
+_doc_block = re.compile(
+    r"^\s*(Parameters|Notes|Raises)", flags=re.IGNORECASE | re.MULTILINE
+)
+
+
+def docstring_to_summary_rest(text: str) -> Tuple[str, str]:
+    """separates the summary at the start of a docstring from the rest
+
+    Notes
+    -----
+    Assumes numpydoc style.
+    """
+    if not text:
+        return "", ""
+
+    pos = _doc_block.search(text)
+    if pos is None:
+        return text, ""
+
+    summary = text[: pos.start()].rstrip()
+    text = text[pos.start() :]
+    return summary, text.lstrip("\n").rstrip(" ")
+
+
 def ascontiguousarray(source_array, dtype=None):
     if source_array is not None:
         return numpy.ascontiguousarray(source_array, dtype=dtype)


Test patch:
diff --git a/tests/test_app/test_composable.py b/tests/test_app/test_composable.py
index 0cfb1d66d..aacef1135 100644
--- a/tests/test_app/test_composable.py
+++ b/tests/test_app/test_composable.py
@@ -1215,5 +1215,29 @@ def test_skip_not_completed():
     __app_registry.pop(get_object_provenance(takes_not_completed), None)
 
 
+def test_copies_doc_from_func():
+    @define_app
+    def delme(val: c3types.SerialisableType) -> dict:
+        """my docstring"""
+        return val.to_rich_dict()
+
+    assert delme.__doc__ == "my docstring"
+
+    @define_app
+    def delme2(val: c3types.SerialisableType) -> dict:
+        """my docstring
+        Notes
+        -----
+        body
+        """
+        return val.to_rich_dict()
+
+    assert delme2.__doc__ == "my docstring"
+    assert delme2.__init__.__doc__.split() == ["Notes", "-----", "body"]
+
+    __app_registry.pop(get_object_provenance(delme), None)
+    __app_registry.pop(get_object_provenance(delme2), None)
+
+
 if __name__ == "__main__":
     main()
diff --git a/tests/test_util/test_misc.py b/tests/test_util/test_misc.py
index f9b4aeba2..9af29c59e 100644
--- a/tests/test_util/test_misc.py
+++ b/tests/test_util/test_misc.py
@@ -7,6 +7,8 @@ from copy import copy, deepcopy
 from os import remove, rmdir
 from unittest import TestCase, main
 
+import pytest
+
 from numpy.testing import assert_allclose
 
 from cogent3.util.misc import (
@@ -25,6 +27,7 @@ from cogent3.util.misc import (
     adjusted_gt_minprob,
     adjusted_within_bounds,
     curry,
+    docstring_to_summary_rest,
     extend_docstring_from,
     get_independent_coords,
     get_merged_by_value_coords,
@@ -1225,5 +1228,48 @@ def test_is_in_jupyter():
     del module.get_ipython
 
 
+def foo1():
+    """some text"""
+
+
+def foo2():
+    """some text
+
+    Notes
+    -----
+    body
+    """
+
+
+def foo3():
+    """
+    Notes
+    -----
+    body
+    """
+
+
+def foo4():
+    ...
+
+
+_sum_expect = "some text"
+_body_expect = ["Notes", "-----", "body"]
+
+
+@pytest.mark.parametrize(
+    "foo,sum_exp,body_exp",
+    (
+        (foo1, _sum_expect, []),
+        (foo2, _sum_expect, _body_expect),
+        (foo3, "", _body_expect),
+        (foo4, "", []),
+    ),
+)
+def test_docstring_to_summary_rest(foo, sum_exp, body_exp):
+    summary, body = docstring_to_summary_rest(foo.__doc__)
+    assert summary == sum_exp and body.split() == body_exp
+
+
 if __name__ == "__main__":
     main()