diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9173aa1..199aed0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -28,6 +28,10 @@ Fix - Fix packit configuration: use ``upstream_tag_template: v{version}``. +- Fix #33: Change the class :class:`~deprecated.sphinx.SphinxAdapter`: + add the ``line_length`` keyword argument to the constructor to specify the max line length of the directive text. + Sphinx decorators also accept the ``line_length`` argument. + - Fix #34: ``versionadded`` and ``versionchanged`` decorators don't emit ``DeprecationWarning`` anymore on decorated classes. diff --git a/deprecated/sphinx.py b/deprecated/sphinx.py index aa773a8..3f0541e 100644 --- a/deprecated/sphinx.py +++ b/deprecated/sphinx.py @@ -19,6 +19,7 @@ Of course, the ``@deprecated`` decorator will emit a deprecation warning when the function/method is called or the class is constructed. """ +import re import textwrap import wrapt @@ -40,7 +41,15 @@ class SphinxAdapter(ClassicAdapter): - The reason message is obviously added in the directive block if not empty. """ - def __init__(self, directive, reason="", version="", action=None, category=DeprecationWarning): + def __init__( + self, + directive, + reason="", + version="", + action=None, + category=DeprecationWarning, + line_length=70, + ): """ Construct a wrapper adapter. @@ -70,8 +79,13 @@ def __init__(self, directive, reason="", version="", action=None, category=Depre The warning category to use for the deprecation warning. By default, the category class is :class:`~DeprecationWarning`, you can inherit this class to define your own deprecation warning category. + + :type line_length: int + :param line_length: + Max line length of the directive text. If non nul, a long text is wrapped in several lines. """ self.directive = directive + self.line_length = line_length super(SphinxAdapter, self).__init__(reason=reason, version=version, action=action, category=category) def __call__(self, wrapped): @@ -82,26 +96,39 @@ def __call__(self, wrapped): :return: the decorated class or function. """ + # -- build the directive division + fmt = ".. {directive}:: {version}" if self.version else ".. {directive}::" + div_lines = [fmt.format(directive=self.directive, version=self.version)] + width = self.line_length - 3 if self.line_length > 3 else 2 ** 16 reason = textwrap.dedent(self.reason).strip() - reason = '\n'.join( - textwrap.fill(line, width=70, initial_indent=' ', subsequent_indent=' ') for line in reason.splitlines() - ).strip() + for paragraph in reason.splitlines(): + if paragraph: + div_lines.extend( + textwrap.fill( + paragraph, + width=width, + initial_indent=" ", + subsequent_indent=" ", + ).splitlines() + ) + else: + div_lines.append("") + + # -- get the docstring, normalize the trailing newlines docstring = textwrap.dedent(wrapped.__doc__ or "") if docstring: - docstring += "\n\n" - if self.version: - docstring += ".. {directive}:: {version}\n".format(directive=self.directive, version=self.version) - else: - docstring += ".. {directive}::\n".format(directive=self.directive) - if reason: - docstring += " {reason}\n".format(reason=reason) + docstring = re.sub(r"\n*$", "\n\n", docstring, flags=re.DOTALL) + + # -- append the directive division to the docstring + docstring += "".join("{}\n".format(line) for line in div_lines) + wrapped.__doc__ = docstring if self.directive in {"versionadded", "versionchanged"}: return wrapped return super(SphinxAdapter, self).__call__(wrapped) -def versionadded(reason="", version=""): +def versionadded(reason="", version="", line_length=70): """ This decorator can be used to insert a "versionadded" directive in your function/class docstring in order to documents the @@ -116,9 +143,18 @@ def versionadded(reason="", version=""): the version number has the format "MAJOR.MINOR.PATCH", and, in the case of a new functionality, the "PATCH" component should be "0". + :type line_length: int + :param line_length: + Max line length of the directive text. If non nul, a long text is wrapped in several lines. + :return: the decorated function. """ - adapter = SphinxAdapter('versionadded', reason=reason, version=version) + adapter = SphinxAdapter( + 'versionadded', + reason=reason, + version=version, + line_length=line_length, + ) # noinspection PyUnusedLocal @wrapt.decorator(adapter=adapter) @@ -128,7 +164,7 @@ def wrapper(wrapped, instance, args, kwargs): return wrapper -def versionchanged(reason="", version=""): +def versionchanged(reason="", version="", line_length=70): """ This decorator can be used to insert a "versionchanged" directive in your function/class docstring in order to documents the @@ -142,9 +178,18 @@ def versionchanged(reason="", version=""): If you follow the `Semantic Versioning `_, the version number has the format "MAJOR.MINOR.PATCH". + :type line_length: int + :param line_length: + Max line length of the directive text. If non nul, a long text is wrapped in several lines. + :return: the decorated function. """ - adapter = SphinxAdapter('versionchanged', reason=reason, version=version) + adapter = SphinxAdapter( + 'versionchanged', + reason=reason, + version=version, + line_length=line_length, + ) # noinspection PyUnusedLocal @wrapt.decorator(adapter=adapter) @@ -180,6 +225,9 @@ def deprecated(*args, **kwargs): By default, the category class is :class:`~DeprecationWarning`, you can inherit this class to define your own deprecation warning category. + - "line_length": + Max line length of the directive text. If non nul, a long text is wrapped in several lines. + :return: the decorated function. """ directive = kwargs.pop('directive', 'deprecated') diff --git a/tests/test_sphinx_adapter.py b/tests/test_sphinx_adapter.py new file mode 100644 index 0000000..c6831b0 --- /dev/null +++ b/tests/test_sphinx_adapter.py @@ -0,0 +1,128 @@ +# coding: utf-8 +import textwrap + +import pytest + +from deprecated.sphinx import SphinxAdapter +from deprecated.sphinx import deprecated +from deprecated.sphinx import versionadded +from deprecated.sphinx import versionchanged + + +@pytest.mark.parametrize( + "line_length, expected", + [ + ( + 50, + textwrap.dedent( + """ + Description of foo + + :return: nothing + + .. {directive}:: 1.2.3 + foo has changed in this version + + bar bar bar bar bar bar bar bar bar bar bar + bar bar bar bar bar bar bar bar bar bar bar + bar + """ + ), + ), + ( + 0, + textwrap.dedent( + """ + Description of foo + + :return: nothing + + .. {directive}:: 1.2.3 + foo has changed in this version + + bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar bar + """ + ), + ), + ], + ids=["wrapped", "long"], +) +@pytest.mark.parametrize("directive", ["versionchanged", "versionadded", "deprecated"]) +def test_sphinx_adapter(directive, line_length, expected): + lines = [ + "foo has changed in this version", + "", # newline + "bar " * 23, # long line + "", # trailing newline + ] + reason = "\n".join(lines) + adapter = SphinxAdapter(directive, reason=reason, version="1.2.3", line_length=line_length) + + def foo(): + """ + Description of foo + + :return: nothing + """ + + wrapped = adapter.__call__(foo) + expected = expected.format(directive=directive) + assert wrapped.__doc__ == expected + + +@pytest.mark.parametrize("directive", ["versionchanged", "versionadded", "deprecated"]) +def test_sphinx_adapter__empty_docstring(directive): + lines = [ + "foo has changed in this version", + "", # newline + "bar " * 23, # long line + "", # trailing newline + ] + reason = "\n".join(lines) + adapter = SphinxAdapter(directive, reason=reason, version="1.2.3", line_length=50) + + def foo(): + pass + + wrapped = adapter.__call__(foo) + expected = textwrap.dedent( + """\ + .. {directive}:: 1.2.3 + foo has changed in this version + + bar bar bar bar bar bar bar bar bar bar bar + bar bar bar bar bar bar bar bar bar bar bar + bar + """ + ) + expected = expected.format(directive=directive) + assert wrapped.__doc__ == expected + + +@pytest.mark.parametrize( + "decorator_factory, directive", + [ + (versionadded, "versionadded"), + (versionchanged, "versionchanged"), + (deprecated, "deprecated"), + ], +) +def test_decorator_accept_line_length(decorator_factory, directive): + reason = "bar " * 30 + decorator = decorator_factory(reason=reason, version="1.2.3", line_length=50) + + def foo(): + pass + + foo = decorator(foo) + + expected = textwrap.dedent( + """\ + .. {directive}:: 1.2.3 + bar bar bar bar bar bar bar bar bar bar bar + bar bar bar bar bar bar bar bar bar bar bar + bar bar bar bar bar bar bar bar + """ + ) + expected = expected.format(directive=directive) + assert foo.__doc__ == expected