Repository: PlasmaPy/PlasmaPy
Pull request #943 (2020-10-23 03:39:39)

Decorator @modify_docstring
This is a simple decorator that allows for the programmatic prepending and/or appending of docstrings.  The original docstring is stored in a new `__original_doc__` attribute so it can still be retrieved.

This is useful in a scenario where you are overriding a method and the signature does not change, but you want add a little more info to the original docstring.

codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=h1) Report
> Merging [#943](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=desc) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/6f277181fccb0c26cc623942892f7b18daae4a66?el=desc) will **increase** coverage by `0.02%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943/graphs/tree.svg?width=650&height=150&src=pr&token=LzZ5mnXVad)](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #943      +/-   ##
==========================================
+ Coverage   96.19%   96.21%   +0.02%     
==========================================
  Files          60       60              
  Lines        5413     5443      +30     
==========================================
+ Hits         5207     5237      +30     
  Misses        206      206              
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/utils/decorators/\_\_init\_\_.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943/diff?src=pr&el=tree#diff-cGxhc21hcHkvdXRpbHMvZGVjb3JhdG9ycy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [plasmapy/utils/decorators/helpers.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943/diff?src=pr&el=tree#diff-cGxhc21hcHkvdXRpbHMvZGVjb3JhdG9ycy9oZWxwZXJzLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=footer). Last update [6f27718...cd2f0bb](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/943?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

rocco8773: > Also...the file name `test_helpers.py` has the potential for ambiguity with the test helpers that I'm working on starting in #919. I'm wondering if we could change names to avoid this overlap. It's not important, but it did cause me a moment of confusion.

If we rename `test_helpers.py`, then we have to rename `plasmapy/utils/decorators/helpers.py` too.  However, I don't think this is needed.  Functionality in #919 is in a completely different location to the decorators, it doesn't name any `test_helpers.py` file, and it's standard practice the `test_<something>.py` are tests for the `<something>.py` file one level up.  We can't us unique names for every part of the package.

Patch:
diff --git a/changelog/943.feature.rst b/changelog/943.feature.rst
new file mode 100644
index 00000000..9f217e53
--- /dev/null
+++ b/changelog/943.feature.rst
@@ -0,0 +1,2 @@
+Create decorator `~plasmapy.utils.decorators.helpers.modify_docstring`, which allows
+for programmatically prepending and/or appending a docstring.
diff --git a/plasmapy/utils/decorators/__init__.py b/plasmapy/utils/decorators/__init__.py
index 069cb6d5..a337deff 100644
--- a/plasmapy/utils/decorators/__init__.py
+++ b/plasmapy/utils/decorators/__init__.py
@@ -6,6 +6,7 @@
     "check_relativistic",
     "check_values",
     "check_units",
+    "modify_docstring",
     "preserve_signature",
     "validate_quantities",
     "CheckBase",
@@ -23,5 +24,5 @@
     CheckValues,
 )
 from plasmapy.utils.decorators.converter import angular_freq_to_hz
-from plasmapy.utils.decorators.helpers import preserve_signature
+from plasmapy.utils.decorators.helpers import modify_docstring, preserve_signature
 from plasmapy.utils.decorators.validators import validate_quantities, ValidateQuantities
diff --git a/plasmapy/utils/decorators/helpers.py b/plasmapy/utils/decorators/helpers.py
index dc18534e..24b6c62d 100644
--- a/plasmapy/utils/decorators/helpers.py
+++ b/plasmapy/utils/decorators/helpers.py
@@ -1,12 +1,105 @@
 """
 Miscellaneous decorators for various package uses.
 """
-__all__ = ["preserve_signature"]
+__all__ = ["modify_docstring", "preserve_signature"]
 
 import functools
 import inspect
 
 
+def modify_docstring(func=None, prepend: str = None, append: str = None):
+    """
+    A decorator which programmatically prepends and/or appends the docstring
+    of the decorated method/function.  The unmodified/original docstring is
+    saved as the ``__original_doc__`` attribute.
+
+    Parameters
+    ----------
+    func: callable
+        The method/function to be decorated.
+
+    prepend: `str`
+        The string to be prepended to the ``func``'s docstring.
+
+    append: `str`
+        The string to be appended to the ``func``'s docstring.
+
+    Returns
+    -------
+    callable
+        Wrapped version of the function.
+
+    Examples
+    --------
+
+        >>> @modify_docstring(prepend='''Hello''', append='''World''')
+        ... def foo():
+        ...     '''Beautiful'''
+        ...     pass
+        >>> foo.__original_doc__
+        'Beautiful'
+        >>> foo.__doc__
+        'Hello\\n\\nBeautiful\\n\\nWorld'
+
+    """
+
+    def decorator(f):
+        sig = inspect.signature(f)
+
+        @preserve_signature
+        @functools.wraps(f)
+        def wrapper(*args, **kwargs):
+            # combine args and kwargs into dictionary
+            bound_args = sig.bind(*args, **kwargs)
+            bound_args.apply_defaults()
+
+            return f(*bound_args.args, **bound_args.kwargs)
+
+        if prepend is None and append is None:
+            raise TypeError(
+                "Decorator @modify_docstring() missing argument 'prepend' and/or"
+                " 'append', at least one argument is required."
+            )
+
+        # save the original docstring
+        setattr(wrapper, "__original_doc__", wrapper.__doc__)
+        doclines = inspect.cleandoc(wrapper.__doc__).splitlines()
+
+        # prepend docstring lines
+        if isinstance(prepend, str):
+            prependlines = inspect.cleandoc(prepend).splitlines()
+            prependlines.append("")
+        elif prepend is None:
+            prependlines = []
+        else:
+            raise TypeError(
+                f"Expected type str for argument 'prepend', got {type(prepend)}."
+            )
+
+        # append docstring lines
+        if isinstance(append, str):
+            appendlines = inspect.cleandoc(append).splitlines()
+            appendlines = [""] + appendlines
+        elif append is None:
+            appendlines = []
+        else:
+            raise TypeError(
+                f"Expected type str for argument 'append', got {type(append)}."
+            )
+
+        # define new docstring
+        wrapper.__doc__ = "\n".join(prependlines + doclines + appendlines)
+
+        return wrapper
+
+    if func is not None:
+        # `modify_docstring` called as a function
+        return decorator(func)
+    else:
+        # `modify_docstring` called as a decorator "sugar-syntax"
+        return decorator
+
+
 def preserve_signature(f):
     """
     A decorator for decorators, which preserves the signature of the function


Test patch:
diff --git a/plasmapy/utils/decorators/tests/test_helpers.py b/plasmapy/utils/decorators/tests/test_helpers.py
index bbcb4a6a..dfc2d0c1 100644
--- a/plasmapy/utils/decorators/tests/test_helpers.py
+++ b/plasmapy/utils/decorators/tests/test_helpers.py
@@ -1,14 +1,126 @@
 """Tests for module :mod:`plasmapy.utils.decorators`."""
 import inspect
+import pytest
 
 from unittest import mock
 
-from ..helpers import preserve_signature
+from ..helpers import modify_docstring, preserve_signature
 
 
-# ----------------------------------------------------------------------------------------
+# --------------------------------------------------------------------------------------
+# Test Decorator `modify_docstring`
+# --------------------------------------------------------------------------------------
+class TestModifyDocstring:
+    # create function to test on
+    @staticmethod
+    def func_simple_docstring(x: float, y: float) -> float:
+        """A simple docstring."""
+        return x + y
+
+    @staticmethod
+    def func_complex_docstring(x: float, y: float) -> float:
+        """
+        A simple docstring.
+
+        Parameters
+        ----------
+        x: float
+            first argument
+
+        y: float
+            second argument
+
+        Returns
+        -------
+        float
+            addition or arguments
+        """
+        return x + y
+
+    def test_no_arg_exception(self):
+        """Raise exception if decorator is used without modifying docstring."""
+        with pytest.raises(TypeError):
+            modify_docstring(self.func_simple_docstring)
+
+    def test_save_original_doc(self):
+        original_doc = self.func_simple_docstring.__doc__
+        wfoo = modify_docstring(prepend="Hello")(self.func_simple_docstring)
+        assert hasattr(wfoo, "__original_doc__")
+        assert wfoo.__original_doc__ == original_doc
+
+    @pytest.mark.parametrize(
+        "prepend, append, expected", [(5, None, TypeError), (None, 5, TypeError)],
+    )
+    def test_raises(self, prepend, append, expected):
+        with pytest.raises(expected):
+            modify_docstring(
+                prepend=prepend, append=append, func=self.func_simple_docstring
+            )
+
+    def test_preserve_signature(self):
+        wfoo = modify_docstring(prepend="Hello")(self.func_simple_docstring)
+        assert hasattr(wfoo, "__signature__")
+        assert wfoo.__signature__ == inspect.signature(self.func_simple_docstring)
+
+    @pytest.mark.parametrize(
+        "prepend, append, func_name, additions",
+        [
+            (
+                "Hello",
+                "Goodbye",
+                "func_simple_docstring",
+                (["Hello", ""], ["", "Goodbye"]),
+            ),
+            (
+                "Hello",
+                "Goodbye",
+                "func_complex_docstring",
+                (["Hello", ""], ["", "Goodbye"]),
+            ),
+            ("Hello", None, "func_simple_docstring", (["Hello", ""], [])),
+            (None, "Goodbye", "func_simple_docstring", ([], ["", "Goodbye"])),
+            (
+                "\n".join(
+                    ["    Hello", "    ", "        * item 1", "            * item 2"]
+                ),
+                None,
+                "func_simple_docstring",
+                (["Hello", "", "* item 1", "    * item 2", ""], []),
+            ),
+            (
+                None,
+                "\n".join(
+                    [
+                        "    Notes",
+                        "    -----",
+                        "    ",
+                        "        * item 1",
+                        "            * item 2",
+                    ]
+                ),
+                "func_complex_docstring",
+                ([], ["", "Notes", "-----", "", "    * item 1", "        * item 2"]),
+            ),
+        ],
+    )
+    def test_modification(self, prepend, append, func_name, additions):
+        func = getattr(self, func_name)
+
+        expected = "\n".join(
+            additions[0] + inspect.cleandoc(func.__doc__).splitlines() + additions[1]
+        )
+
+        wfunc = modify_docstring(prepend=prepend, append=append)(func)
+        assert wfunc.__doc__ == expected
+
+    def test_arguments_passed(self):
+        wfunc = modify_docstring(prepend="Hello")(self.func_simple_docstring)
+        assert wfunc(5, 4) == 9
+
+
+# --------------------------------------------------------------------------------------
 # Test Decorator `preserve_signature`
-# ----------------------------------------------------------------------------------------
+# --------------------------------------------------------------------------------------
 def test_preserve_signature():
     # create function to mock
     def foo(x: float, y: float) -> float: