Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changed handling of decorators for classes in python 2.7 #9

Merged
merged 6 commits into from
Apr 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
v0.2.3
======
This minor release contains some backward incompatible changes on how to handle
the decorators for classes in python 2.7. Thanks
`@lesteve <https://github.com/lesteve>`__ and
`@guillaumeeb <https://github.com/guillaumeeb>`__ for your input on this.

Changed
-------
* When using the decorators for classes in python 2.7, e.g. via::

>>> @docstrings
... class Something(object):
... "%(replacement)s"

it does not have an effect anymore. This is because class docstrings cannot
be modified in python 2.7 (see issue
`#5 <https://github.com/Chilipp/docrep/issues/5#>`__). The original behaviour
was to raise an error. You can restore the old behaviour by setting
`DocstringProcessor.python2_classes = 'raise'`.
* Some docs have been updated (see PR
`#7 <https://github.com/Chilipp/docrep/pull/7>`__)

Added
-----
* the `DocstringProcessor.python2_classes` to change the handling of classes
in python 2.7

v0.2.2
======
Added
Expand Down
103 changes: 90 additions & 13 deletions docrep/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import types
import six
import inspect
import re
from warnings import warn


__version__ = '0.2.2'
__version__ = '0.2.3'

__author__ = 'Philipp Sommer'

Expand Down Expand Up @@ -226,6 +227,19 @@ class DocstringProcessor(object):
text_sections = ['Warnings', 'Notes', 'Examples', 'See Also',
'References']

#: The action on how to react on classes in python 2
#:
#: When calling::
#:
#: >>> @docstrings
#: ... class NewClass(object):
#: ... """%(replacement)s"""
#:
#: This normaly raises an AttributeError, because the ``__doc__`` attribute
#: of a class in python 2 is not writable. This attribute may be one of
#: ``'ignore', 'raise' or 'warn'``
python2_classes = 'ignore'

def __init__(self, *args, **kwargs):
"""
Parameters
Expand Down Expand Up @@ -256,9 +270,22 @@ def __init__(self, *args, **kwargs):
self.patterns = patterns

def __call__(self, func):
func.__doc__ = func.__doc__ and safe_modulo(func.__doc__, self.params,
stacklevel=3)
return func
"""
Substitute in a docstring of a function with :attr:`params`

Parameters
----------
func: function
function with the documentation whose sections
shall be inserted from the :attr:`params` attribute

See Also
--------
dedent: also dedents the doc
with_indent: also indents the doc"""
doc = func.__doc__ and safe_modulo(func.__doc__, self.params,
stacklevel=3)
return self._set_object_doc(func, doc)

def get_sections(self, s, base,
sections=['Parameters', 'Other Parameters']):
Expand Down Expand Up @@ -342,6 +369,23 @@ def func(f):
return f
return func

def _set_object_doc(self, obj, doc, stacklevel=3):
"""Convenience method to set the __doc__ attribute of a python object
"""
if isinstance(obj, types.MethodType) and six.PY2:
obj = obj.im_func
try:
obj.__doc__ = doc
except AttributeError: # probably python2 class
if (self.python2_classes != 'raise' and
(inspect.isclass(obj) and six.PY2)):
if self.python2_classes == 'warn':
warn("Cannot modify docstring of classes in python2!",
stacklevel=stacklevel)
else:
raise
return obj

def dedent(self, func):
"""
Dedent the docstring of a function and substitute with :attr:`params`
Expand All @@ -351,11 +395,8 @@ def dedent(self, func):
func: function
function with the documentation to dedent and whose sections
shall be inserted from the :attr:`params` attribute"""
if isinstance(func, types.MethodType) and not six.PY3:
func = func.im_func
func.__doc__ = func.__doc__ and self.dedents(func.__doc__,
stacklevel=4)
return func
doc = func.__doc__ and self.dedents(func.__doc__, stacklevel=4)
return self._set_object_doc(func, doc)

def dedents(self, s, stacklevel=3):
"""
Expand All @@ -373,15 +414,51 @@ def dedents(self, s, stacklevel=3):
return safe_modulo(s, self.params, stacklevel=stacklevel)

def with_indent(self, indent=0):
"""
Substitute in the docstring of a function with indented :attr:`params`

Parameters
----------
indent: int
The number of spaces that the substitution should be indented

Returns
-------
function
Wrapper that takes a function as input and substitutes it's
``__doc__`` with the indented versions of :attr:`params`

See Also
--------
with_indents, dedent"""
def replace(func):
if isinstance(func, types.MethodType) and not six.PY3:
func = func.im_func
func.__doc__ = func.__doc__ and self.with_indents(
doc = func.__doc__ and self.with_indents(
func.__doc__, indent=indent, stacklevel=4)
return func
return self._set_object_doc(func, doc)
return replace

def with_indents(self, s, indent=0, stacklevel=3):
"""
Substitute a string with the indented :attr:`params`

Parameters
----------
s: str
The string in which to substitute
indent: int
The number of spaces that the substitution should be indented
stacklevel: int
The stacklevel for the warning raised in :func:`safe_module` when
encountering an invalid key in the string

Returns
-------
str
The substituted string

See Also
--------
with_indent, dedents"""
# we make a new dictionary with objects that indent the original
# strings if necessary. Note that the first line is not indented
d = {key: _StrWithIndentation(val, indent)
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@
'pandas': ('http://pandas.pydata.org/pandas-docs/stable/', None),
'numpy': ('http://docs.scipy.org/doc/numpy/', None),
'matplotlib': ('http://matplotlib.org/', None),
'sphinx': ('http://sphinx-doc.org/', None),
'sphinx': ('http://www.sphinx-doc.org/en/stable/', None),
'xarray': ('http://xarray.pydata.org/en/stable/', None),
'cartopy': ('http://scitools.org.uk/cartopy/docs/latest/', None),
'mpl_toolkits': ('http://matplotlib.org/basemap/', None),
Expand Down
23 changes: 23 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,29 @@ or from the source on github_ via::
.. _github: https://github.com/Chilipp/docrep


.. note::

When using docrep in python 2.7, there is to mention that the ``__doc__``
attribute of classes is not writable, so something like

.. ipython::

In [12]: @docstrings
....: class SomeClass(object):
....: """An awesome class
....:
....: Parameters
....: ----------
....: %(repeated.parameters)s
....: """

would raise an error. There are several workarounds (see
`the issue on github <https://github.com/Chilipp/docrep/issues/5#>`__) but the
default for python 2.7 is, to simply not modify class docstrings. You
can, however, change this behaviour using the
:attr:`DocstringProcessor.python2_classes` attribute.


API Reference
=============

Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def readme():


setup(name='docrep',
version='0.2.2',
version='0.2.3',
description='Python package for docstring repetition',
long_description=readme(),
classifiers=[
Expand All @@ -24,6 +24,7 @@ def readme():
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Operating System :: OS Independent',
],
keywords='docstrings docs docstring napoleon numpy reStructured text',
Expand Down
18 changes: 18 additions & 0 deletions tests/test_docrep.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,24 @@ def test_delete_kwargs(self):
with self.assertWarns(UserWarning):
self.ds.delete_kwargs('test')

@unittest.skipIf(not six.PY2, "Only implemented for python 2.7")
def test_py2_classes(self):
"""Test the handling of classes in python 2.7"""
# this should work
@self.ds
class Test(object):
"""docs"""
# this should not
self.ds.python2_classes = 'raise'
try:
@self.ds
class Test2(object):
"""docs"""
except AttributeError:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a side-comment (not saying that you should do it in this PR at all) but with pytest you can check expected exception like this:

with pytest.raises(AttributeError, 'the regex to check):
    # code that raises

See this for more details.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep I know. I usually force myself to use the unittest framework only and there is no such thing for python 2, only for python 3.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually force myself to use the unittest framework only

Interesting, do you mind to elaborate why?

Copy link
Owner Author

@Chilipp Chilipp Apr 20, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I mainly base my tests on unittest because it integrates well with the object-oriented structure of my packages. Additionally, since it is a built-in library, it is very stable and keeps my dependencies for running the tests low. Anyway, of course I use pytest as well, especially since it provides a better interfaces for running the tests. But if it is only for a minor thing (like here, where I would only use it for this single test method) I'd rather not rely on non built-in libraries. But for other packages with more extensive test suites, I indeed often rely on pytest but still use unittest as a basis

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK thanks for the details. I have to say that there are so many nice things included in pytest both for writing tests and running the tests that you would have to pry it from my cold dead hands ...

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆 agreed 😜

pass
else:
self.fail("Should have raised AttributeError!")


if __name__ == '__main__':
unittest.main()