Repository: briancurtin/deprecation

Issue:
deprecation_note brakes docstring processing
While joining the `function.__doc__`-string with the `deprecation_note`-string the indentation isn't handled properly. Let's look at this example:

```python
@deprecated(deprecated_in="0.0.3", removed_in="0.1.0",
            current_version="0.0.4",
            details="Do not use this function any more.")
def testfunc():
    """Test function.

    Returns
    -------
    name : str
        Some string
    """
    return "Teststring"

def newfunc():
    """Test function.

    Returns
    -------
    name : str
        Some string
    """
    return "Teststring"
```

Output of `help(testfunc)` (broken):

```
testfunc()
    Test function.
    
        Returns
        -------
        name : str
            Some string
        
    
    *Deprecated in 0.0.3, to be removed in 0.1.0. Do not use this function any more.*
```

Output of `help(newfunc)` (correct):

```
newfunc()
    Test function.
    
    Returns
    -------
    name : str
        Some string
```

The indentation of the function (numpy-style) docstring has been broken by the addition of the `deprecation_note`. While this might seem like a minor cosmetic issue, this breaks proper docstring processing in eg. `sphinx`.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/briancurtin/deprecation/pull/12?src=pr&el=h1) Report
> Merging [#12](https://codecov.io/gh/briancurtin/deprecation/pull/12?src=pr&el=desc) into [master](https://codecov.io/gh/briancurtin/deprecation/commit/70bb27fef996b214d7b698ed3124fb598220eea9?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/briancurtin/deprecation/pull/12/graphs/tree.svg?width=650&height=150&token=6w8zqu2CEQ&src=pr)](https://codecov.io/gh/briancurtin/deprecation/pull/12?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master    #12   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           1      1           
  Lines          56     64    +8     
  Branches        9     10    +1     
=====================================
+ Hits           56     64    +8
```


| [Impacted Files](https://codecov.io/gh/briancurtin/deprecation/pull/12?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [deprecation.py](https://codecov.io/gh/briancurtin/deprecation/pull/12/diff?src=pr&el=tree#diff-ZGVwcmVjYXRpb24ucHk=) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/briancurtin/deprecation/pull/12?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/briancurtin/deprecation/pull/12?src=pr&el=footer). Last update [70bb27f...adfcd2e](https://codecov.io/gh/briancurtin/deprecation/pull/12?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/deprecation.py b/deprecation.py
index 28a21ee..00598db 100644
--- a/deprecation.py
+++ b/deprecation.py
@@ -10,6 +10,7 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 import functools
+import textwrap
 import warnings
 
 from packaging import version
@@ -167,8 +168,31 @@ def deprecated(deprecated_in=None, removed_in=None, current_version=None,
             deprecation_note = ("*Deprecated{deprecated_in}{removed_in}"
                                 "{period}{details}*".format(**parts))
 
-            function.__doc__ = "\n\n".join([existing_docstring,
+            pos = existing_docstring.find("\n")
+
+            if pos != -1:
+                # With a multi-line docstring, when we modify
+                # existing_docstring to add our deprecation_note,
+                # if we're not careful we'll interfere with the
+                # indentation levels of the contents below the
+                # first line, or as PEP 257 calls it, the summary
+                # line. Since the summary line can start on the
+                # same line as the """, dedenting the whole thing
+                # won't help. Split the summary and contents up,
+                # dedent the contents independently, then join
+                # summary, dedent'ed contents, and our
+                # deprecation_note.
+
+                summary = existing_docstring[:pos]
+                contents = existing_docstring[pos:]
+
+                function.__doc__ = "".join([summary,
+                                            textwrap.dedent(contents),
+                                            "\n\n",
                                             deprecation_note])
+            else:
+                function.__doc__ = "\n\n".join([existing_docstring,
+                                                deprecation_note])
 
         @functools.wraps(function)
         def _inner(*args, **kwargs):
diff --git a/sample.py b/sample.py
index be00938..9373753 100644
--- a/sample.py
+++ b/sample.py
@@ -14,6 +14,19 @@ def won():
     return 1
 
 
+@deprecation.deprecated(deprecated_in="1.0", removed_in="2.0",
+                        current_version=__version__,
+                        details="Use the ``one`` function instead")
+def uno():
+    """Esta función regresa 1
+
+    This is Spanish for 'This function returns 1'
+
+    This is also here to show that multiline docstrings work
+    """
+    return 1
+
+
 def one():
     """This function returns 1"""
     return 1
diff --git a/setup.py b/setup.py
index ffe453a..3da8a87 100644
--- a/setup.py
+++ b/setup.py
@@ -28,6 +28,7 @@ setup(name="deprecation",
           "Programming Language :: Python :: 3.4",
           "Programming Language :: Python :: 3.5",
           "Programming Language :: Python :: 3.6",
+          "Programming Language :: Python :: 3.7",
           "Programming Language :: Python :: Implementation :: CPython",
           "Programming Language :: Python :: Implementation :: PyPy",
           "Topic :: Software Development :: Libraries :: Python Modules"]
diff --git a/tox.ini b/tox.ini
index 3458187..026704c 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py35,py34,py27,pypy,flake8
+envlist = py37,py36,py35,py34,py27,pypy3,pypy,flake8
 skipsdist = True
 
 [testenv]


Test patch:
diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py
index 3364f9c..cf9d420 100644
--- a/tests/test_deprecation.py
+++ b/tests/test_deprecation.py
@@ -43,6 +43,30 @@ class Test_deprecated(unittest2.TestCase):
 
                 self.assertEqual(fn.__doc__, test["__doc__"])
 
+    def test_multiline_docstring(self):
+        docstring = "summary line\n\ndetails\nand more details\n"
+        for test in [{"args": {},
+                      "__doc__": "%s\n\n*Deprecated*"},
+                     {"args": {"deprecated_in": "1.0"},
+                      "__doc__": "%s\n\n*Deprecated in 1.0.*"},
+                     {"args": {"deprecated_in": "1.0", "removed_in": "2.0"},
+                      "__doc__": "%s\n\n*Deprecated in 1.0, "
+                                 "to be removed in 2.0.*"},
+                     {"args": {"deprecated_in": "1.0", "removed_in": "2.0",
+                               "details": "some details"},
+                      "__doc__": "%s\n\n*Deprecated in 1.0, "
+                                 "to be removed in 2.0. some details*"}]:
+            with self.subTest(**test):
+                @deprecation.deprecated(**test["args"])
+                def fn():
+                    """summary line
+
+                    details
+                    and more details
+                    """
+
+                self.assertEqual(fn.__doc__, test["__doc__"] % docstring)
+
     def test_warning_raised(self):
         ret_val = "lololol"