diff --git a/conftest.py b/conftest.py index 3c1b1d79..765c865c 100644 --- a/conftest.py +++ b/conftest.py @@ -148,3 +148,11 @@ def skip_pre_python37(environment): # This if is just needed to avoid that tests ever skip way more than # they should for all Python versions. pytest.skip() + + +@pytest.fixture() +def skip_pre_python35(environment): + if environment.version_info < (3, 5): + # This if is just needed to avoid that tests ever skip way more than + # they should for all Python versions. + pytest.skip() diff --git a/jedi/evaluate/arguments.py b/jedi/evaluate/arguments.py index b96a6a01..cfa09c2b 100644 --- a/jedi/evaluate/arguments.py +++ b/jedi/evaluate/arguments.py @@ -11,6 +11,7 @@ from jedi.evaluate.lazy_context import LazyKnownContext, LazyKnownContexts, \ from jedi.evaluate.names import ParamName, TreeNameDefinition from jedi.evaluate.base_context import NO_CONTEXTS, ContextSet, ContextualizedNode from jedi.evaluate.context import iterable +from jedi.evaluate.cache import evaluator_as_method_param_cache from jedi.evaluate.param import get_executed_params_and_issues, ExecutedParam @@ -208,6 +209,11 @@ class TreeArguments(AbstractArguments): self._evaluator = evaluator self.trailer = trailer # Can be None, e.g. in a class definition. + @classmethod + @evaluator_as_method_param_cache() + def create_cached(cls, *args, **kwargs): + return cls(*args, **kwargs) + def unpack(self, funcdef=None): named_args = [] for star_count, el in unpack_arglist(self.argument_node): diff --git a/jedi/evaluate/names.py b/jedi/evaluate/names.py index eed5e5a6..563506b3 100644 --- a/jedi/evaluate/names.py +++ b/jedi/evaluate/names.py @@ -150,8 +150,18 @@ class TreeNameDefinition(AbstractTreeName): return self._API_TYPES.get(definition.type, 'statement') -class ParamNameInterface(object): - api_type = u'param' +class _ParamMixin(object): + def maybe_positional_argument(self, include_star=True): + options = [Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD] + if include_star: + options.append(Parameter.VAR_POSITIONAL) + return self.get_kind() in options + + def maybe_keyword_argument(self, include_stars=True): + options = [Parameter.KEYWORD_ONLY, Parameter.POSITIONAL_OR_KEYWORD] + if include_stars: + options.append(Parameter.VAR_KEYWORD) + return self.get_kind() in options def _kind_string(self): kind = self.get_kind() @@ -161,6 +171,10 @@ class ParamNameInterface(object): return '**' return '' + +class ParamNameInterface(_ParamMixin): + api_type = u'param' + def get_kind(self): raise NotImplementedError @@ -173,6 +187,15 @@ class ParamNameInterface(object): # clear what values would be allowed. return None + @property + def star_count(self): + kind = self.get_kind() + if kind == Parameter.VAR_POSITIONAL: + return 1 + if kind == Parameter.VAR_KEYWORD: + return 2 + return 0 + class BaseTreeParamName(ParamNameInterface, AbstractTreeName): annotation_node = None @@ -247,6 +270,17 @@ class ParamName(BaseTreeParamName): return params[param_node.position_index] +class ParamNameWrapper(_ParamMixin): + def __init__(self, param_name): + self._wrapped_param_name = param_name + + def __getattr__(self, name): + return getattr(self._wrapped_param_name, name) + + def __repr__(self): + return '<%s: %s>' % (self.__class__.__name__, self._wrapped_param_name) + + class ImportName(AbstractNameDefinition): start_pos = (1, 0) _level = 0 diff --git a/jedi/evaluate/signature.py b/jedi/evaluate/signature.py index 1f4cefbe..1b4d60dc 100644 --- a/jedi/evaluate/signature.py +++ b/jedi/evaluate/signature.py @@ -30,12 +30,6 @@ class _SignatureMixin(object): s += ' -> ' + annotation return s - def get_param_names(self): - param_names = self._function_context.get_param_names() - if self.is_bound: - return param_names[1:] - return param_names - class AbstractSignature(_SignatureMixin): def __init__(self, context, is_bound=False): @@ -50,6 +44,12 @@ class AbstractSignature(_SignatureMixin): def annotation_string(self): return '' + def get_param_names(self, resolve_stars=True): + param_names = self._function_context.get_param_names() + if self.is_bound: + return param_names[1:] + return param_names + def bind(self, context): raise NotImplementedError @@ -77,6 +77,13 @@ class TreeSignature(AbstractSignature): return '' return a.get_code(include_prefix=False) + def get_param_names(self, resolve_stars=True): + params = super(TreeSignature, self).get_param_names() + if resolve_stars: + from jedi.evaluate.star_args import process_params + params = process_params(params) + return params + class BuiltinSignature(AbstractSignature): def __init__(self, context, return_string, is_bound=False): diff --git a/jedi/evaluate/star_args.py b/jedi/evaluate/star_args.py new file mode 100644 index 00000000..4fc6cc8e --- /dev/null +++ b/jedi/evaluate/star_args.py @@ -0,0 +1,204 @@ +""" +This module is responsible for evaluating *args and **kwargs for signatures. + +This means for example in this case:: + + def foo(a, b, c): ... + + def bar(*args): + return foo(1, *args) + +The signature here for bar should be `bar(b, c)` instead of bar(*args). +""" + +from jedi._compatibility import Parameter +from jedi.evaluate.names import ParamNameWrapper + + +def _iter_nodes_for_param(param_name): + from parso.python.tree import search_ancestor + from jedi.evaluate.arguments import TreeArguments + + execution_context = param_name.parent_context + function_node = execution_context.tree_node + module_node = function_node.get_root_node() + start = function_node.children[-1].start_pos + end = function_node.children[-1].end_pos + for name in module_node.get_used_names().get(param_name.string_name): + if start <= name.start_pos < end: + # Is used in the function + argument = name.parent + if argument.type == 'argument' \ + and argument.children[0] == '*' * param_name.star_count: + # No support for Python <= 3.4 here, but they are end-of-life + # anyway + trailer = search_ancestor(argument, 'trailer') + if trailer is not None: # Make sure we're in a function + context = execution_context.create_context(trailer) + if _goes_to_param_name(param_name, context, name): + contexts = _to_callables(context, trailer) + + args = TreeArguments.create_cached( + execution_context.evaluator, + context=context, + argument_node=trailer.children[1], + trailer=trailer, + ) + for c in contexts: + yield c, args + else: + assert False + + +def _goes_to_param_name(param_name, context, potential_name): + if potential_name.type != 'name': + return False + from jedi.evaluate.names import TreeNameDefinition + found = TreeNameDefinition(context, potential_name).goto() + return any(param_name.parent_context == p.parent_context + and param_name.start_pos == p.start_pos + for p in found) + + +def _to_callables(context, trailer): + from jedi.evaluate.syntax_tree import eval_trailer + + atom_expr = trailer.parent + index = atom_expr.children[0] == 'await' + # Eval atom first + contexts = context.eval_node(atom_expr.children[index]) + for trailer2 in atom_expr.children[index + 1:]: + if trailer == trailer2: + break + contexts = eval_trailer(context, contexts, trailer2) + return contexts + + +def _remove_given_params(arguments, param_names): + count = 0 + used_keys = set() + for key, _ in arguments.unpack(): + if key is None: + count += 1 + else: + used_keys.add(key) + + for p in param_names: + if count and p.maybe_positional_argument(): + count -= 1 + continue + if p.string_name in used_keys and p.maybe_keyword_argument(): + continue + yield p + + +def process_params(param_names, star_count=3): # default means both * and ** + used_names = set() + arg_callables = [] + kwarg_callables = [] + + kw_only_names = [] + kwarg_names = [] + arg_names = [] + original_arg_name = None + original_kwarg_name = None + for p in param_names: + kind = p.get_kind() + if kind == Parameter.VAR_POSITIONAL: + if star_count & 1: + arg_callables = _iter_nodes_for_param(p) + original_arg_name = p + elif p.get_kind() == Parameter.VAR_KEYWORD: + if star_count & 2: + kwarg_callables = list(_iter_nodes_for_param(p)) + original_kwarg_name = p + elif kind == Parameter.KEYWORD_ONLY: + if star_count & 2: + kw_only_names.append(p) + elif kind == Parameter.POSITIONAL_ONLY: + if star_count & 1: + yield p + else: + if star_count == 1: + yield ParamNameFixedKind(p, Parameter.POSITIONAL_ONLY) + elif star_count == 2: + kw_only_names.append(ParamNameFixedKind(p, Parameter.KEYWORD_ONLY)) + else: + used_names.add(p.string_name) + yield p + + longest_param_names = () + found_arg_signature = False + found_kwarg_signature = False + for func_and_argument in arg_callables: + func, arguments = func_and_argument + new_star_count = star_count + if func_and_argument in kwarg_callables: + kwarg_callables.remove(func_and_argument) + else: + new_star_count = 1 + + for signature in func.get_signatures(): + found_arg_signature = True + if new_star_count == 3: + found_kwarg_signature = True + args_for_this_func = [] + for p in process_params( + list(_remove_given_params( + arguments, + signature.get_param_names(resolve_stars=False) + )), new_star_count): + if p.get_kind() == Parameter.VAR_KEYWORD: + kwarg_names.append(p) + elif p.get_kind() == Parameter.VAR_POSITIONAL: + arg_names.append(p) + elif p.get_kind() == Parameter.KEYWORD_ONLY: + kw_only_names.append(p) + else: + args_for_this_func.append(p) + if len(args_for_this_func) > len(longest_param_names): + longest_param_names = args_for_this_func + + for p in longest_param_names: + if star_count == 1 and p.get_kind() != Parameter.VAR_POSITIONAL: + yield ParamNameFixedKind(p, Parameter.POSITIONAL_ONLY) + else: + if p.get_kind() == Parameter.POSITIONAL_OR_KEYWORD: + used_names.add(p.string_name) + yield p + + if not found_arg_signature and original_arg_name is not None: + yield original_arg_name + elif arg_names: + yield arg_names[0] + + for p in kw_only_names: + if p.string_name in used_names: + continue + yield p + used_names.add(p.string_name) + + for func, arguments in kwarg_callables: + for signature in func.get_signatures(): + found_kwarg_signature = True + for p in process_params( + list(_remove_given_params( + arguments, + signature.get_param_names(resolve_stars=False) + )), star_count=2): + if p.get_kind() != Parameter.KEYWORD_ONLY or not kwarg_names: + yield p + + if not found_kwarg_signature and original_kwarg_name is not None: + yield original_kwarg_name + elif kwarg_names: + yield kwarg_names[0] + + +class ParamNameFixedKind(ParamNameWrapper): + def __init__(self, param_name, new_kind): + super(ParamNameFixedKind, self).__init__(param_name) + self._new_kind = new_kind + + def get_kind(self): + return self._new_kind diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index fc138cd2..d76edcaf 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -642,6 +642,46 @@ class ItemGetterCallable(ContextWrapper): return context_set +@argument_clinic('func, /') +def _functools_wraps(funcs): + return ContextSet(WrapsCallable(func) for func in funcs) + + +class WrapsCallable(ContextWrapper): + # XXX this is not the correct wrapped context, it should be a weird + # partials object, but it doesn't matter, because it's always used as a + # decorator anyway. + @repack_with_argument_clinic('func, /') + def py__call__(self, funcs): + return ContextSet({Wrapped(func, self._wrapped_context) for func in funcs}) + + +class Wrapped(ContextWrapper): + def __init__(self, func, original_function): + super(Wrapped, self).__init__(func) + self._original_function = original_function + + @property + def name(self): + return self._original_function.name + + def get_signatures(self): + return [ + ReplacedNameSignature(sig, self._original_function.name) + for sig in self._wrapped_context.get_signatures() + ] + + +class ReplacedNameSignature(SignatureWrapper): + def __init__(self, signature, name): + super(ReplacedNameSignature, self).__init__(signature) + self._name = name + + @property + def name(self): + return self._name + + @argument_clinic('*args, /', want_obj=True, want_arguments=True) def _operator_itemgetter(args_context_set, obj, arguments): return ContextSet([ @@ -675,7 +715,8 @@ _implemented = { }, 'functools': { 'partial': functools_partial, - 'wraps': _return_first_param, + 'wraps': _functools_wraps, + #'wraps': _return_first_param, }, '_weakref': { 'proxy': _return_first_param, diff --git a/test/test_evaluate/test_signature.py b/test/test_evaluate/test_signature.py index 8cc05109..5a4e2423 100644 --- a/test/test_evaluate/test_signature.py +++ b/test/test_evaluate/test_signature.py @@ -60,6 +60,7 @@ c = functools.partial(func, 1, c=2) d = functools.partial() ''' + @pytest.mark.parametrize( 'code, expected', [ ('def f(a, * args, x): pass\n f(', 'f(a, *args, x)'), @@ -91,6 +92,83 @@ def test_tree_signature(Script, environment, code, expected): assert expected == sig._signature.to_string() +@pytest.mark.parametrize( + 'combination, expected', [ + # Functions + ('full_redirect(simple)', 'b, *, c'), + ('full_redirect(simple4)', 'b, x: int'), + ('full_redirect(a)', 'b, *args'), + ('full_redirect(kw)', 'b, *, c, **kwargs'), + ('full_redirect(akw)', 'c, *args, **kwargs'), + + # Non functions + ('full_redirect(lambda x, y: ...)', 'y'), + ('full_redirect(C)', 'z, *c'), + ('full_redirect(C())', 'y'), + ('full_redirect()', '*args, **kwargs'), + ('full_redirect(1)', '*args, **kwargs'), + + # Merging + ('two_redirects(simple, simple)', 'a, b, *, c'), + ('two_redirects(simple2, simple2)', 'x'), + ('two_redirects(akw, kw)', 'a, c, *args, **kwargs'), + ('two_redirects(kw, akw)', 'a, b, *args, c, **kwargs'), + + ('combined_redirect(simple, simple2)', 'a, b, /, *, x'), + ('combined_redirect(simple, simple3)', 'a, b, /, *, a, x: int'), + ('combined_redirect(simple2, simple)', 'x, /, *, a, b, c'), + ('combined_redirect(simple3, simple)', 'a, x: int, /, *, a, b, c'), + + ('combined_redirect(simple, kw)', 'a, b, /, *, a, b, c, **kwargs'), + ('combined_redirect(kw, simple)', 'a, b, /, *, a, b, c'), + + ('combined_lot_of_args(kw, simple4)', '*, b'), + ('combined_lot_of_args(simple4, kw)', '*, b, c, **kwargs'), + + ('combined_redirect(combined_redirect(simple2, simple4), combined_redirect(kw, simple5))', + 'x, /, *, y'), + ('combined_redirect(combined_redirect(simple4, simple2), combined_redirect(simple5, kw))', + 'a, b, x: int, /, *, a, b, c, **kwargs'), + ('combined_redirect(combined_redirect(a, kw), combined_redirect(kw, simple5))', + 'a, b, /, *args, y'), + + ('no_redirect(kw)', '*args, **kwargs'), + ('no_redirect(akw)', '*args, **kwargs'), + ('no_redirect(simple)', '*args, **kwargs'), + ] +) +def test_nested_signatures(Script, environment, combination, expected, skip_pre_python35): + code = dedent(''' + def simple(a, b, *, c): ... + def simple2(x): ... + def simple3(a, x: int): ... + def simple4(a, b, x: int): ... + def simple5(y): ... + def a(a, b, *args): ... + def kw(a, b, *, c, **kwargs): ... + def akw(a, c, *args, **kwargs): ... + + def no_redirect(func): + return lambda *args, **kwargs: func(1) + def full_redirect(func): + return lambda *args, **kwargs: func(1, *args, **kwargs) + def two_redirects(func1, func2): + return lambda *args, **kwargs: func1(*args, **kwargs) + func2(1, *args, **kwargs) + def combined_redirect(func1, func2): + return lambda *args, **kwargs: func1(*args) + func2(**kwargs) + def combined_lot_of_args(func1, func2): + return lambda *args, **kwargs: func1(1, 2, 3, 4, *args) + func2(a=3, x=1, y=1, **kwargs) + + class C: + def __init__(self, a, z, *c): ... + def __call__(self, x, y): ... + ''') + code += 'z = ' + combination + '\nz(' + sig, = Script(code).call_signatures() + computed = sig._signature.to_string() + assert '(' + expected + ')' == computed + + def test_pow_signature(Script): # See github #1357 sigs = Script('pow(').call_signatures() @@ -101,6 +179,41 @@ def test_pow_signature(Script): 'pow(x: int, y: int, /) -> Any'} +@pytest.mark.parametrize( + 'code, signature', [ + [dedent(''' + import functools + def f(x): + pass + def x(f): + @functools.wraps(f) + def wrapper(*args): + # Have no arguments here, but because of wraps, the signature + # should still be f's. + return f(*args) + return wrapper + + x(f)('''), 'f(x, /)'], + [dedent(''' + import functools + def f(x): + pass + def x(f): + @functools.wraps(f) + def wrapper(): + # Have no arguments here, but because of wraps, the signature + # should still be f's. + return 1 + return wrapper + + x(f)('''), 'f()'], + ] +) +def test_wraps_signature(Script, code, signature, skip_pre_python35): + sigs = Script(code).call_signatures() + assert {sig._signature.to_string() for sig in sigs} == {signature} + + @pytest.mark.parametrize( 'start, start_params', [ ['@dataclass\nclass X:', []],