diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 43824865e..2ae31db15 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -28,41 +28,73 @@ memory_address_re = re.compile(r' at 0x[0-9a-f]{8,16}(?=>)', re.IGNORECASE) if PY3: - from functools import partial - + # Copied from the definition of inspect.getfullargspec from Python master, + # and modified to remove the use of special flags that break decorated + # callables and bound methods in the name of backwards compatibility. Used + # under the terms of PSF license v2, which requires the above statement + # and the following: + # + # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, + # 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017 Python Software + # Foundation; All Rights Reserved def getargspec(func): - """Like inspect.getargspec but supports functools.partial as well.""" - if inspect.ismethod(func): - func = func.__func__ - if type(func) is partial: - orig_func = func.func - argspec = getargspec(orig_func) - args = list(argspec[0]) - defaults = list(argspec[3] or ()) - kwoargs = list(argspec[4]) - kwodefs = dict(argspec[5] or {}) - if func.args: - args = args[len(func.args):] - for arg in func.keywords or (): - try: - i = args.index(arg) - len(args) - del args[i] - try: - del defaults[i] - except IndexError: - pass - except ValueError: # must be a kwonly arg - i = kwoargs.index(arg) - del kwoargs[i] - del kwodefs[arg] - return inspect.FullArgSpec(args, argspec[1], argspec[2], - tuple(defaults), kwoargs, - kwodefs, argspec[6]) - while hasattr(func, '__wrapped__'): - func = func.__wrapped__ - if not inspect.isfunction(func): - raise TypeError('%r is not a Python function' % func) - return inspect.getfullargspec(func) + """Like inspect.getfullargspec but supports bound methods, and wrapped + methods.""" + # On 3.5+, signature(int) or similar raises ValueError. On 3.4, it + # succeeds with a bogus signature. We want a TypeError uniformly, to + # match historical behavior. + if (isinstance(func, type) and + is_builtin_class_method(func, "__new__") and + is_builtin_class_method(func, "__init__")): + raise TypeError( + "can't compute signature for built-in type {}".format(func)) + + sig = inspect.signature(func) + + args = [] + varargs = None + varkw = None + kwonlyargs = [] + defaults = () + annotations = {} + defaults = () + kwdefaults = {} + + if sig.return_annotation is not sig.empty: + annotations['return'] = sig.return_annotation + + for param in sig.parameters.values(): + kind = param.kind + name = param.name + + if kind is inspect.Parameter.POSITIONAL_ONLY: + args.append(name) + elif kind is inspect.Parameter.POSITIONAL_OR_KEYWORD: + args.append(name) + if param.default is not param.empty: + defaults += (param.default,) + elif kind is inspect.Parameter.VAR_POSITIONAL: + varargs = name + elif kind is inspect.Parameter.KEYWORD_ONLY: + kwonlyargs.append(name) + if param.default is not param.empty: + kwdefaults[name] = param.default + elif kind is inspect.Parameter.VAR_KEYWORD: + varkw = name + + if param.annotation is not param.empty: + annotations[name] = param.annotation + + if not kwdefaults: + # compatibility with 'func.__kwdefaults__' + kwdefaults = None + + if not defaults: + # compatibility with 'func.__defaults__' + defaults = None + + return inspect.FullArgSpec(args, varargs, varkw, defaults, + kwonlyargs, kwdefaults, annotations) else: # 2.7 from functools import partial diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index b7dfbed31..7857c292b 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -10,6 +10,8 @@ :license: BSD, see LICENSE for details. """ +from six import PY3 + from util import SphinxTestApp, Struct # NOQA import pytest @@ -752,6 +754,10 @@ def test_generate(): # test autodoc_member_order == 'source' directive.env.ref_context['py:module'] = 'test_autodoc' + if PY3: + roger_line = ' .. py:classmethod:: Class.roger(a, *, b=2, c=3, d=4, e=5, f=6)' + else: + roger_line = ' .. py:classmethod:: Class.roger(a, e=5, f=6)' assert_order(['.. py:class:: Class(arg)', ' .. py:attribute:: Class.descr', ' .. py:method:: Class.meth()', @@ -761,7 +767,7 @@ def test_generate(): ' .. py:attribute:: Class.docattr', ' .. py:attribute:: Class.udocattr', ' .. py:attribute:: Class.mdocattr', - ' .. py:classmethod:: Class.roger(a, e=5, f=6)', + roger_line, ' .. py:classmethod:: Class.moore(a, e, f) -> happiness', ' .. py:attribute:: Class.inst_attr_comment', ' .. py:attribute:: Class.inst_attr_string', diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index affa70993..740f98c7a 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -10,8 +10,68 @@ """ from unittest import TestCase +from six import PY3 +import functools +from textwrap import dedent +import pytest + from sphinx.util import inspect +class TestGetArgSpec(TestCase): + def test_getargspec_builtin_type(self): + with pytest.raises(TypeError): + inspect.getargspec(int) + + def test_getargspec_partial(self): + def fun(a, b, c=1, d=2): + pass + p = functools.partial(fun, 10, c=11) + + if PY3: + # Python 3's partial is rather cleverer than Python 2's, and we + # have to jump through some hoops to define an equivalent function + # in a way that won't confuse Python 2's parser: + ns = {} + exec(dedent(""" + def f_expected(b, *, c=11, d=2): + pass + """), ns) + f_expected = ns["f_expected"] + else: + def f_expected(b, d=2): + pass + expected = inspect.getargspec(f_expected) + + assert expected == inspect.getargspec(p) + + def test_getargspec_bound_methods(self): + def f_expected_unbound(self, arg1, **kwargs): + pass + expected_unbound = inspect.getargspec(f_expected_unbound) + + def f_expected_bound(arg1, **kwargs): + pass + expected_bound = inspect.getargspec(f_expected_bound) + + class Foo: + def method(self, arg1, **kwargs): + pass + + bound_method = Foo().method + + @functools.wraps(bound_method) + def wrapped_bound_method(*args, **kwargs): + pass + + assert expected_unbound == inspect.getargspec(Foo.method) + if PY3: + # On py2, the inspect functions don't properly handle bound + # methods (they include a spurious 'self' argument) + assert expected_bound == inspect.getargspec(bound_method) + # On py2, the inspect functions can't properly handle wrapped + # functions (no __wrapped__ support) + assert expected_bound == inspect.getargspec(wrapped_bound_method) + class TestSafeGetAttr(TestCase): def test_safe_getattr_with_default(self):