Commit 0fceaf45 authored by Yury Selivanov's avatar Yury Selivanov

inspect.signautre: Fix functools.partial support. Issue #21117

parent 7ddf3eba
...@@ -1511,7 +1511,8 @@ def _signature_get_partial(wrapped_sig, partial, extra_args=()): ...@@ -1511,7 +1511,8 @@ def _signature_get_partial(wrapped_sig, partial, extra_args=()):
# look like after applying a 'functools.partial' object (or alike) # look like after applying a 'functools.partial' object (or alike)
# on it. # on it.
new_params = OrderedDict(wrapped_sig.parameters.items()) old_params = wrapped_sig.parameters
new_params = OrderedDict(old_params.items())
partial_args = partial.args or () partial_args = partial.args or ()
partial_keywords = partial.keywords or {} partial_keywords = partial.keywords or {}
...@@ -1525,32 +1526,57 @@ def _signature_get_partial(wrapped_sig, partial, extra_args=()): ...@@ -1525,32 +1526,57 @@ def _signature_get_partial(wrapped_sig, partial, extra_args=()):
msg = 'partial object {!r} has incorrect arguments'.format(partial) msg = 'partial object {!r} has incorrect arguments'.format(partial)
raise ValueError(msg) from ex raise ValueError(msg) from ex
for arg_name, arg_value in ba.arguments.items():
param = new_params[arg_name] transform_to_kwonly = False
if arg_name in partial_keywords: for param_name, param in old_params.items():
# We set a new default value, because the following code try:
# is correct: arg_value = ba.arguments[param_name]
# except KeyError:
# >>> def foo(a): print(a) pass
# >>> print(partial(partial(foo, a=10), a=20)()) else:
# 20 if param.kind is _POSITIONAL_ONLY:
# >>> print(partial(partial(foo, a=10), a=20)(a=30)) # If positional-only parameter is bound by partial,
# 30 # it effectively disappears from the signature
# new_params.pop(param_name)
# So, with 'partial' objects, passing a keyword argument is continue
# like setting a new default value for the corresponding
# parameter if param.kind is _POSITIONAL_OR_KEYWORD:
# if param_name in partial_keywords:
# We also mark this parameter with '_partial_kwarg' # This means that this parameter, and all parameters
# flag. Later, in '_bind', the 'default' value of this # after it should be keyword-only (and var-positional
# parameter will be added to 'kwargs', to simulate # should be removed). Here's why. Consider the following
# the 'functools.partial' real call. # function:
new_params[arg_name] = param.replace(default=arg_value, # foo(a, b, *args, c):
_partial_kwarg=True) # pass
#
elif (param.kind not in (_VAR_KEYWORD, _VAR_POSITIONAL) and # "partial(foo, a='spam')" will have the following
not param._partial_kwarg): # signature: "(*, a='spam', b, c)". Because attempting
new_params.pop(arg_name) # to call that partial with "(10, 20)" arguments will
# raise a TypeError, saying that "a" argument received
# multiple values.
transform_to_kwonly = True
# Set the new default value
new_params[param_name] = param.replace(default=arg_value)
else:
# was passed as a positional argument
new_params.pop(param.name)
continue
if param.kind is _KEYWORD_ONLY:
# Set the new default value
new_params[param_name] = param.replace(default=arg_value)
if transform_to_kwonly:
assert param.kind is not _POSITIONAL_ONLY
if param.kind is _POSITIONAL_OR_KEYWORD:
new_param = new_params[param_name].replace(kind=_KEYWORD_ONLY)
new_params[param_name] = new_param
new_params.move_to_end(param_name)
elif param.kind in (_KEYWORD_ONLY, _VAR_KEYWORD):
new_params.move_to_end(param_name)
elif param.kind is _VAR_POSITIONAL:
new_params.pop(param.name)
return wrapped_sig.replace(parameters=new_params.values()) return wrapped_sig.replace(parameters=new_params.values())
...@@ -2069,7 +2095,7 @@ class Parameter: ...@@ -2069,7 +2095,7 @@ class Parameter:
`Parameter.KEYWORD_ONLY`, `Parameter.VAR_KEYWORD`. `Parameter.KEYWORD_ONLY`, `Parameter.VAR_KEYWORD`.
''' '''
__slots__ = ('_name', '_kind', '_default', '_annotation', '_partial_kwarg') __slots__ = ('_name', '_kind', '_default', '_annotation')
POSITIONAL_ONLY = _POSITIONAL_ONLY POSITIONAL_ONLY = _POSITIONAL_ONLY
POSITIONAL_OR_KEYWORD = _POSITIONAL_OR_KEYWORD POSITIONAL_OR_KEYWORD = _POSITIONAL_OR_KEYWORD
...@@ -2079,8 +2105,7 @@ class Parameter: ...@@ -2079,8 +2105,7 @@ class Parameter:
empty = _empty empty = _empty
def __init__(self, name, kind, *, default=_empty, annotation=_empty, def __init__(self, name, kind, *, default=_empty, annotation=_empty):
_partial_kwarg=False):
if kind not in (_POSITIONAL_ONLY, _POSITIONAL_OR_KEYWORD, if kind not in (_POSITIONAL_ONLY, _POSITIONAL_OR_KEYWORD,
_VAR_POSITIONAL, _KEYWORD_ONLY, _VAR_KEYWORD): _VAR_POSITIONAL, _KEYWORD_ONLY, _VAR_KEYWORD):
...@@ -2105,8 +2130,6 @@ class Parameter: ...@@ -2105,8 +2130,6 @@ class Parameter:
self._name = name self._name = name
self._partial_kwarg = _partial_kwarg
@property @property
def name(self): def name(self):
return self._name return self._name
...@@ -2123,8 +2146,8 @@ class Parameter: ...@@ -2123,8 +2146,8 @@ class Parameter:
def kind(self): def kind(self):
return self._kind return self._kind
def replace(self, *, name=_void, kind=_void, annotation=_void, def replace(self, *, name=_void, kind=_void,
default=_void, _partial_kwarg=_void): annotation=_void, default=_void):
'''Creates a customized copy of the Parameter.''' '''Creates a customized copy of the Parameter.'''
if name is _void: if name is _void:
...@@ -2139,11 +2162,7 @@ class Parameter: ...@@ -2139,11 +2162,7 @@ class Parameter:
if default is _void: if default is _void:
default = self._default default = self._default
if _partial_kwarg is _void: return type(self)(name, kind, default=default, annotation=annotation)
_partial_kwarg = self._partial_kwarg
return type(self)(name, kind, default=default, annotation=annotation,
_partial_kwarg=_partial_kwarg)
def __str__(self): def __str__(self):
kind = self.kind kind = self.kind
...@@ -2169,17 +2188,6 @@ class Parameter: ...@@ -2169,17 +2188,6 @@ class Parameter:
id(self), self.name) id(self), self.name)
def __eq__(self, other): def __eq__(self, other):
# NB: We deliberately do not compare '_partial_kwarg' attributes
# here. Imagine we have a following situation:
#
# def foo(a, b=1): pass
# def bar(a, b): pass
# bar2 = functools.partial(bar, b=1)
#
# For the above scenario, signatures for `foo` and `bar2` should
# be equal. '_partial_kwarg' attribute is an internal flag, to
# distinguish between keyword parameters with defaults and
# keyword parameters which got their defaults from functools.partial
return (issubclass(other.__class__, Parameter) and return (issubclass(other.__class__, Parameter) and
self._name == other._name and self._name == other._name and
self._kind == other._kind and self._kind == other._kind and
...@@ -2219,12 +2227,7 @@ class BoundArguments: ...@@ -2219,12 +2227,7 @@ class BoundArguments:
def args(self): def args(self):
args = [] args = []
for param_name, param in self._signature.parameters.items(): for param_name, param in self._signature.parameters.items():
if (param.kind in (_VAR_KEYWORD, _KEYWORD_ONLY) or if param.kind in (_VAR_KEYWORD, _KEYWORD_ONLY):
param._partial_kwarg):
# Keyword arguments mapped by 'functools.partial'
# (Parameter._partial_kwarg is True) are mapped
# in 'BoundArguments.kwargs', along with VAR_KEYWORD &
# KEYWORD_ONLY
break break
try: try:
...@@ -2249,8 +2252,7 @@ class BoundArguments: ...@@ -2249,8 +2252,7 @@ class BoundArguments:
kwargs_started = False kwargs_started = False
for param_name, param in self._signature.parameters.items(): for param_name, param in self._signature.parameters.items():
if not kwargs_started: if not kwargs_started:
if (param.kind in (_VAR_KEYWORD, _KEYWORD_ONLY) or if param.kind in (_VAR_KEYWORD, _KEYWORD_ONLY):
param._partial_kwarg):
kwargs_started = True kwargs_started = True
else: else:
if param_name not in self.arguments: if param_name not in self.arguments:
...@@ -2332,18 +2334,14 @@ class Signature: ...@@ -2332,18 +2334,14 @@ class Signature:
name = param.name name = param.name
if kind < top_kind: if kind < top_kind:
msg = 'wrong parameter order: {} before {}' msg = 'wrong parameter order: {!r} before {!r}'
msg = msg.format(top_kind, kind) msg = msg.format(top_kind, kind)
raise ValueError(msg) raise ValueError(msg)
elif kind > top_kind: elif kind > top_kind:
kind_defaults = False kind_defaults = False
top_kind = kind top_kind = kind
if (kind in (_POSITIONAL_ONLY, _POSITIONAL_OR_KEYWORD) and if kind in (_POSITIONAL_ONLY, _POSITIONAL_OR_KEYWORD):
not param._partial_kwarg):
# If we have a positional-only or positional-or-keyword
# parameter, that does not have its default value set
# by 'functools.partial' or other "partial" signature:
if param.default is _empty: if param.default is _empty:
if kind_defaults: if kind_defaults:
# No default for this parameter, but the # No default for this parameter, but the
...@@ -2518,15 +2516,6 @@ class Signature: ...@@ -2518,15 +2516,6 @@ class Signature:
parameters_ex = () parameters_ex = ()
arg_vals = iter(args) arg_vals = iter(args)
if partial:
# Support for binding arguments to 'functools.partial' objects.
# See 'functools.partial' case in 'signature()' implementation
# for details.
for param_name, param in self.parameters.items():
if (param._partial_kwarg and param_name not in kwargs):
# Simulating 'functools.partial' behavior
kwargs[param_name] = param.default
while True: while True:
# Let's iterate through the positional arguments and corresponding # Let's iterate through the positional arguments and corresponding
# parameters # parameters
......
...@@ -1959,6 +1959,8 @@ class TestSignatureObject(unittest.TestCase): ...@@ -1959,6 +1959,8 @@ class TestSignatureObject(unittest.TestCase):
def test_signature_on_partial(self): def test_signature_on_partial(self):
from functools import partial from functools import partial
Parameter = inspect.Parameter
def test(): def test():
pass pass
...@@ -1994,15 +1996,22 @@ class TestSignatureObject(unittest.TestCase): ...@@ -1994,15 +1996,22 @@ class TestSignatureObject(unittest.TestCase):
self.assertEqual(self.signature(partial(test, b=1, c=2)), self.assertEqual(self.signature(partial(test, b=1, c=2)),
((('a', ..., ..., "positional_or_keyword"), ((('a', ..., ..., "positional_or_keyword"),
('b', 1, ..., "positional_or_keyword"), ('b', 1, ..., "keyword_only"),
('c', 2, ..., "keyword_only"), ('c', 2, ..., "keyword_only"),
('d', ..., ..., "keyword_only")), ('d', ..., ..., "keyword_only")),
...)) ...))
self.assertEqual(self.signature(partial(test, 0, b=1, c=2)), self.assertEqual(self.signature(partial(test, 0, b=1, c=2)),
((('b', 1, ..., "positional_or_keyword"), ((('b', 1, ..., "keyword_only"),
('c', 2, ..., "keyword_only"), ('c', 2, ..., "keyword_only"),
('d', ..., ..., "keyword_only"),), ('d', ..., ..., "keyword_only")),
...))
self.assertEqual(self.signature(partial(test, a=1)),
((('a', 1, ..., "keyword_only"),
('b', ..., ..., "keyword_only"),
('c', ..., ..., "keyword_only"),
('d', ..., ..., "keyword_only")),
...)) ...))
def test(a, *args, b, **kwargs): def test(a, *args, b, **kwargs):
...@@ -2014,13 +2023,18 @@ class TestSignatureObject(unittest.TestCase): ...@@ -2014,13 +2023,18 @@ class TestSignatureObject(unittest.TestCase):
('kwargs', ..., ..., "var_keyword")), ('kwargs', ..., ..., "var_keyword")),
...)) ...))
self.assertEqual(self.signature(partial(test, a=1)),
((('a', 1, ..., "keyword_only"),
('b', ..., ..., "keyword_only"),
('kwargs', ..., ..., "var_keyword")),
...))
self.assertEqual(self.signature(partial(test, 1, 2, 3)), self.assertEqual(self.signature(partial(test, 1, 2, 3)),
((('args', ..., ..., "var_positional"), ((('args', ..., ..., "var_positional"),
('b', ..., ..., "keyword_only"), ('b', ..., ..., "keyword_only"),
('kwargs', ..., ..., "var_keyword")), ('kwargs', ..., ..., "var_keyword")),
...)) ...))
self.assertEqual(self.signature(partial(test, 1, 2, 3, test=True)), self.assertEqual(self.signature(partial(test, 1, 2, 3, test=True)),
((('args', ..., ..., "var_positional"), ((('args', ..., ..., "var_positional"),
('b', ..., ..., "keyword_only"), ('b', ..., ..., "keyword_only"),
...@@ -2067,7 +2081,7 @@ class TestSignatureObject(unittest.TestCase): ...@@ -2067,7 +2081,7 @@ class TestSignatureObject(unittest.TestCase):
return a return a
_foo = partial(partial(foo, a=10), a=20) _foo = partial(partial(foo, a=10), a=20)
self.assertEqual(self.signature(_foo), self.assertEqual(self.signature(_foo),
((('a', 20, ..., "positional_or_keyword"),), ((('a', 20, ..., "keyword_only"),),
...)) ...))
# check that we don't have any side-effects in signature(), # check that we don't have any side-effects in signature(),
# and the partial object is still functioning # and the partial object is still functioning
...@@ -2076,42 +2090,87 @@ class TestSignatureObject(unittest.TestCase): ...@@ -2076,42 +2090,87 @@ class TestSignatureObject(unittest.TestCase):
def foo(a, b, c): def foo(a, b, c):
return a, b, c return a, b, c
_foo = partial(partial(foo, 1, b=20), b=30) _foo = partial(partial(foo, 1, b=20), b=30)
self.assertEqual(self.signature(_foo), self.assertEqual(self.signature(_foo),
((('b', 30, ..., "positional_or_keyword"), ((('b', 30, ..., "keyword_only"),
('c', ..., ..., "positional_or_keyword")), ('c', ..., ..., "keyword_only")),
...)) ...))
self.assertEqual(_foo(c=10), (1, 30, 10)) self.assertEqual(_foo(c=10), (1, 30, 10))
_foo = partial(_foo, 2) # now 'b' has two values -
# positional and keyword
with self.assertRaisesRegex(ValueError, "has incorrect arguments"):
inspect.signature(_foo)
def foo(a, b, c, *, d): def foo(a, b, c, *, d):
return a, b, c, d return a, b, c, d
_foo = partial(partial(foo, d=20, c=20), b=10, d=30) _foo = partial(partial(foo, d=20, c=20), b=10, d=30)
self.assertEqual(self.signature(_foo), self.assertEqual(self.signature(_foo),
((('a', ..., ..., "positional_or_keyword"), ((('a', ..., ..., "positional_or_keyword"),
('b', 10, ..., "positional_or_keyword"), ('b', 10, ..., "keyword_only"),
('c', 20, ..., "positional_or_keyword"), ('c', 20, ..., "keyword_only"),
('d', 30, ..., "keyword_only")), ('d', 30, ..., "keyword_only"),
),
...)) ...))
ba = inspect.signature(_foo).bind(a=200, b=11) ba = inspect.signature(_foo).bind(a=200, b=11)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (200, 11, 20, 30)) self.assertEqual(_foo(*ba.args, **ba.kwargs), (200, 11, 20, 30))
def foo(a=1, b=2, c=3): def foo(a=1, b=2, c=3):
return a, b, c return a, b, c
_foo = partial(foo, a=10, c=13) _foo = partial(foo, c=13) # (a=1, b=2, *, c=13)
ba = inspect.signature(_foo).bind(11)
ba = inspect.signature(_foo).bind(a=11)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (11, 2, 13)) self.assertEqual(_foo(*ba.args, **ba.kwargs), (11, 2, 13))
ba = inspect.signature(_foo).bind(11, 12) ba = inspect.signature(_foo).bind(11, 12)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (11, 12, 13)) self.assertEqual(_foo(*ba.args, **ba.kwargs), (11, 12, 13))
ba = inspect.signature(_foo).bind(11, b=12) ba = inspect.signature(_foo).bind(11, b=12)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (11, 12, 13)) self.assertEqual(_foo(*ba.args, **ba.kwargs), (11, 12, 13))
ba = inspect.signature(_foo).bind(b=12) ba = inspect.signature(_foo).bind(b=12)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (10, 12, 13)) self.assertEqual(_foo(*ba.args, **ba.kwargs), (1, 12, 13))
_foo = partial(_foo, b=10)
ba = inspect.signature(_foo).bind(12, 14) _foo = partial(_foo, b=10, c=20)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (12, 14, 13)) ba = inspect.signature(_foo).bind(12)
self.assertEqual(_foo(*ba.args, **ba.kwargs), (12, 10, 20))
def foo(a, b, c, d, **kwargs):
pass
sig = inspect.signature(foo)
params = sig.parameters.copy()
params['a'] = params['a'].replace(kind=Parameter.POSITIONAL_ONLY)
params['b'] = params['b'].replace(kind=Parameter.POSITIONAL_ONLY)
foo.__signature__ = inspect.Signature(params.values())
sig = inspect.signature(foo)
self.assertEqual(str(sig), '(a, b, /, c, d, **kwargs)')
self.assertEqual(self.signature(partial(foo, 1)),
((('b', ..., ..., 'positional_only'),
('c', ..., ..., 'positional_or_keyword'),
('d', ..., ..., 'positional_or_keyword'),
('kwargs', ..., ..., 'var_keyword')),
...))
self.assertEqual(self.signature(partial(foo, 1, 2)),
((('c', ..., ..., 'positional_or_keyword'),
('d', ..., ..., 'positional_or_keyword'),
('kwargs', ..., ..., 'var_keyword')),
...))
self.assertEqual(self.signature(partial(foo, 1, 2, 3)),
((('d', ..., ..., 'positional_or_keyword'),
('kwargs', ..., ..., 'var_keyword')),
...))
self.assertEqual(self.signature(partial(foo, 1, 2, c=3)),
((('c', 3, ..., 'keyword_only'),
('d', ..., ..., 'keyword_only'),
('kwargs', ..., ..., 'var_keyword')),
...))
self.assertEqual(self.signature(partial(foo, 1, c=3)),
((('b', ..., ..., 'positional_only'),
('c', 3, ..., 'keyword_only'),
('d', ..., ..., 'keyword_only'),
('kwargs', ..., ..., 'var_keyword')),
...))
def test_signature_on_partialmethod(self): def test_signature_on_partialmethod(self):
from functools import partialmethod from functools import partialmethod
......
...@@ -106,6 +106,11 @@ Library ...@@ -106,6 +106,11 @@ Library
(Original patches by Hirokazu Yamamoto and Amaury Forgeot d'Arc, with (Original patches by Hirokazu Yamamoto and Amaury Forgeot d'Arc, with
suggested wording by David Gutteridge) suggested wording by David Gutteridge)
- Issue #21117: Fix inspect.signature to better support functools.partial.
Due to the specifics of functools.partial implementation,
positional-or-keyword arguments passed as keyword arguments become
keyword-only.
IDLE IDLE
---- ----
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment