From b56d93158a1db32a057cad8f43d06c0252b3372a Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Thu, 23 Feb 2017 17:28:08 -0800 Subject: [PATCH 1/4] On Py3, use inspect.signature for more accurate signature calculation This improves handling of wrapped functions and bound methods. It turns out that we no longer need to hack in support for functools.partial; inspect.signature handles this automatically. Added a test to make sure this didn't/doesn't regress. --- sphinx/util/inspect.py | 91 ++++++++++++++++++++++++-------------- tests/test_util_inspect.py | 55 +++++++++++++++++++++++ 2 files changed, 112 insertions(+), 34 deletions(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 58e750a53..1e9c50e94 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -28,41 +28,64 @@ 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.""" + 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_util_inspect.py b/tests/test_util_inspect.py index 2dcbc37d5..9432cd5a5 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -10,8 +10,63 @@ """ from unittest import TestCase +from six import PY3 +import functools +from textwrap import dedent + from sphinx.util import inspect +class TestGetArgSpec(TestCase): + 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): From 678eff821f0d4bb5adddbde4812dff4abe6eb9f4 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Fri, 24 Feb 2017 05:40:24 -0800 Subject: [PATCH 2/4] Update test to match py3's more accurate signature --- tests/test_autodoc.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index d8c2e0079..c1c3716c6 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', From a3b80bc87a86291021782870343204cd4d4ac6f6 Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Fri, 24 Feb 2017 06:07:36 -0800 Subject: [PATCH 3/4] Make sure that getargspec raises on built-in types --- sphinx/util/inspect.py | 9 +++++++++ tests/test_util_inspect.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 1e9c50e94..20c9a6c81 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -40,6 +40,15 @@ if PY3: def getargspec(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 = [] diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index 9432cd5a5..49024127e 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -13,10 +13,15 @@ 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 From 2fd78648e659067b0031ad43ee69530ffd2e385c Mon Sep 17 00:00:00 2001 From: "Nathaniel J. Smith" Date: Fri, 24 Feb 2017 15:10:35 -0800 Subject: [PATCH 4/4] Uglify the code to make flake8 happier --- sphinx/util/inspect.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 20c9a6c81..ff755570a 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -43,9 +43,9 @@ if PY3: # 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__")): + 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))