Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# **Upcoming release**

- Preserve positional-only and keyword-only parameter bindings, call inference,
and default-expression scope; refuse unsupported signature rewrites
(@yangfan-yf-yf)
- #895 patchedast Starred and keyword now consumes their syntactically expected and ** (@lieryan)
- #896 patchedast cleanup and refactoring (@lieryan)

Expand Down
37 changes: 37 additions & 0 deletions docs/library.rst
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,43 @@ Note, however, that the use of ``automatic_soa`` is discouraged, because it may
slow down saving considerably.


Function Parameter Information
------------------------------

For ordinary functions and methods, ``PyFunction.get_parameters()`` includes
positional-only and keyword-only bindings. A ``ParameterName.index`` is an
object slot, not a position in the source signature. The regular slots are
ordered as positional-only, positional-or-keyword, then keyword-only;
``*args`` and ``**kwargs`` bindings follow them. Signature separators do not
occupy slots.

``get_param_names(special_args=False)`` returns the regular names in this
order. ``get_parameter_layout()`` returns a tuple of ``(kind, name)`` pairs
for all bindings, with kinds ``posonly``, ``positional``, ``kwonly``,
``vararg``, and ``kwarg``. Use ``get_positional_param_names()`` and
``get_keyword_param_names()`` when matching positional and keyword
arguments. In particular, a keyword with the same name as a positional-only
parameter can belong to ``**kwargs`` and is not a reference to that parameter.

``get_parameter_defaults()`` maps names to their default-expression AST
nodes. Required parameters are absent from this mapping; an explicit
``=None`` has an AST node. Defaults belong to the definition's enclosing
scope. These APIs describe bindings and do not serialize a modified signature.
They do not provide a general annotation-expression scope model; existing
same-name annotation shadowing remains a separate limitation.

Change Signature, Introduce Parameter, Inline Parameter, and Move Method
raise ``RefactoringError`` for signatures containing positional-only or
keyword-only parameters before generating changes. Use Function refuses
keyword-only parameters. Existing positional-only Inline Method calls remain
supported, while keyword-only Inline Method calls are refused. Call inference
keeps unknown ``*args`` and ``**kwargs`` expansions conservative.

Local to Field requires a named positional receiver. It refuses methods with
no positional parameters and static methods, while retaining instance-method
and class-method field conversions.


Closing The Project
-------------------

Expand Down
71 changes: 66 additions & 5 deletions rope/base/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ class Arguments:

"""

def __init__(self, args, scope):
def __init__(self, args, scope, pyfunction=None):
self.args = args
self.scope = scope
self.pyfunction = pyfunction
self.instance = None

def get_arguments(self, parameters):
Expand All @@ -25,6 +26,8 @@ def get_arguments(self, parameters):
return result

def get_pynames(self, parameters):
if isinstance(self.pyfunction, rope.base.pyobjects.PyFunction):
return self._get_function_pynames(parameters)
result = [None] * max(len(parameters), len(self.args))
for index, arg in enumerate(self.args):
if isinstance(arg, ast.keyword) and arg.arg in parameters:
Expand All @@ -33,6 +36,44 @@ def get_pynames(self, parameters):
result[index] = self._evaluate(arg)
return result

def _get_function_pynames(self, parameters):
positional = [
name
for name in self.pyfunction.get_positional_param_names()
if name in parameters
]
keywords = set(self.pyfunction.get_keyword_param_names()) & set(parameters)
result = dict.fromkeys(parameters)
provided = set()
position = 0
unknown_positions = False
unknown_keywords = False
for arg in self.args:
if isinstance(arg, ast.Starred):
unknown_positions = True
elif isinstance(arg, ast.keyword):
if arg.arg is None:
unknown_keywords = True
elif arg.arg in keywords:
result[arg.arg] = self._evaluate(arg.value)
provided.add(arg.arg)
elif not unknown_positions:
if position < len(positional):
result[positional[position]] = self._evaluate(arg)
provided.add(positional[position])
position += 1
for name, default in self.pyfunction.get_parameter_defaults().items():
if name not in result or name in provided:
continue
if unknown_positions and name in positional:
continue
if unknown_keywords and name in keywords:
continue
result[name] = rope.base.evaluate.eval_node(
self.pyfunction.parent.get_scope(), default
)
return [result[name] for name in parameters]

def get_instance_pyname(self):
if self.args:
return self._evaluate(self.args[0])
Expand All @@ -41,15 +82,28 @@ def _evaluate(self, ast_node):
return rope.base.evaluate.eval_node(self.scope, ast_node)


def create_arguments(primary, pyfunction, call_node, scope):
def create_arguments(primary, pyfunction, call_node, scope, ignore_instance=False):
"""A factory for creating `Arguments`"""
args = list(call_node.args)
args.extend(call_node.keywords)
called = call_node.func
# XXX: Handle constructors
if _is_method_call(primary, pyfunction) and isinstance(called, ast.Attribute):
result = Arguments(args, scope, pyfunction)
if ignore_instance or not isinstance(called, ast.Attribute):
return result
if isinstance(pyfunction, rope.base.pyobjects.PyFunction) and primary is not None:
kind = pyfunction.get_kind()
receiver = primary.get_object()
if kind == "classmethod":
if not isinstance(receiver, rope.base.pyobjects.AbstractClass):
receiver = receiver.get_type()
return MixedArguments(
rope.base.pynames.UnboundName(receiver), result, scope
)
if kind == "method" and _is_method_call(primary, pyfunction):
return MixedArguments(primary, result, scope)
elif _is_method_call(primary, pyfunction):
args.insert(0, called.value)
return Arguments(args, scope)
return result


class ObjectArguments:
Expand Down Expand Up @@ -79,6 +133,13 @@ def __init__(self, pyname, arguments, scope):
self.args = arguments

def get_pynames(self, parameters):
if not parameters:
return []
function = getattr(self.args, "pyfunction", None)
if isinstance(function, rope.base.pyobjects.PyFunction):
positional = function.get_positional_param_names()
if not positional or parameters[0] != positional[0]:
return self.args.get_pynames(parameters)
return [self.pyname] + self.args.get_pynames(parameters[1:])

def get_arguments(self, parameters):
Expand Down
43 changes: 38 additions & 5 deletions rope/base/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,17 @@ def get_primary_and_pyname_at(
) -> Tuple[Optional[rope.base.pynames.PyName], Optional[rope.base.pynames.PyName]]:
lineno = self.lines.get_line_number(offset)
holding_scope = self.module_scope.get_inner_scope_for_offset(offset)
parameter = self._get_parameter_at(holding_scope, offset)
if parameter is not None:
return (None, parameter)
# function keyword parameter
if self.worder.is_function_keyword_parameter(offset):
keyword_name = self.worder.get_word_at(offset)
pyobject = self.get_enclosing_function(offset)
if isinstance(pyobject, pyobjectsdef.PyFunction):
parameter_name = pyobject.get_parameters().get(keyword_name, None)
parameter_name = None
if keyword_name in pyobject.get_keyword_param_names():
parameter_name = pyobject.get_parameters().get(keyword_name)
return (None, parameter_name)
elif isinstance(pyobject, pyobjects.AbstractFunction):
parameter_name = rope.base.pynames.ParameterName()
Expand Down Expand Up @@ -130,6 +135,24 @@ def get_primary_and_pyname_at(
name = self.worder.get_primary_at(offset)
return eval_str2(holding_scope, name)

def _get_parameter_at(self, scope, offset):
if scope.get_kind() != "Function":
return None
function = scope.pyobject
args = function.arguments
nodes = (
args.posonlyargs + args.args + args.kwonlyargs + [args.vararg, args.kwarg]
)
for node in nodes:
if node is None:
continue
prefix = self.lines.get_line(node.lineno).encode("utf-8")[: node.col_offset]
start = self.lines.get_line_start(node.lineno) + len(prefix.decode("utf-8"))
_, end = self.worder.get_word_range(start)
if start <= offset < end:
return function.get_parameters()[node.arg]
return None

def get_enclosing_function(self, offset):
function_parens = self.worder.find_parens_start_from_inside(offset)
try:
Expand Down Expand Up @@ -182,8 +205,16 @@ def _Call(self, node):
if pyobject is None:
return

def _get_returned(pyobject):
args = arguments.create_arguments(primary, pyobject, node, self.scope)
def _get_returned(pyobject, receiver=None):
args = arguments.create_arguments(
primary,
pyobject,
node,
self.scope,
ignore_instance=receiver is not None,
)
if receiver is not None:
args = arguments.MixedArguments(receiver, args, self.scope)
return pyobject.get_returned_object(args)

if isinstance(pyobject, rope.base.pyobjects.AbstractClass):
Expand All @@ -197,13 +228,15 @@ def _get_returned(pyobject):
return

pyfunction = None
receiver = None
if isinstance(pyobject, rope.base.pyobjects.AbstractFunction):
pyfunction = pyobject
elif "__call__" in pyobject:
pyfunction = pyobject["__call__"].get_object()
receiver = rope.base.pynames.UnboundName(pyobject)
if pyfunction is not None:
self.result = rope.base.pynames.UnboundName(
pyobject=_get_returned(pyfunction)
pyobject=_get_returned(pyfunction, receiver)
)

def _Str(self, node):
Expand Down Expand Up @@ -350,7 +383,7 @@ def _call_function(self, node, function_name, other_args=None):
args = [node]
if other_args:
args += other_args
arguments_ = arguments.Arguments(args, self.scope)
arguments_ = arguments.Arguments(args, self.scope, called)
self.result = rope.base.pynames.UnboundName(
pyobject=called.get_returned_object(arguments_)
)
Expand Down
Loading
Loading