diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..e7d546b8a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +Release 0.20.0 + +Date: 2021-09-18 + +New feature + +- #377 Added the ability to extract method to @staticmethod/@classmethod (@climbus) +- #374 Changed Organize import to keep variables listed in `__all__` +- Change default .ropeproject/config.py to ignore code in folders named + .venv and venv (@0x1e02) + +Syntax support + +- #372 Add extract method refactoring of code containing `exec` (@ceridwen) +- #389 Add extract method refactoring of code containing `async def`, `async for`, and `await` +- #365, #386 Support extract method of expressions containing inline assignment (walrus operator) + +Bug fixes + +- #380 Fix list of variables that are returned and/or turned into argument when extracting method in a loop diff --git a/docs/dev/issues.rst b/docs/dev/issues.rst index 62c7d9c3c..f90b708f7 100644 --- a/docs/dev/issues.rst +++ b/docs/dev/issues.rst @@ -122,35 +122,3 @@ Examples .. ... -Possible Module Renamings -========================= - -*First level*: - -These module names are somehow inconsistent. - -* change -> changes -* method_object -> methodobject -* default_config -> defaultconfig - -*Second level* - -Many modules use long names. They can be shortened without loss of -readability. - -* methodobject -> methobj or funcobj -* usefunction -> usefunc -* multiproject -> mulprj -* functionutils -> funcutils -* importutils -> imputils -* introduce_factory -> factory -* change_signature -> signature -* encapsulate_field -> encapsulate -* sourceutils -> srcutils -* resourceobserver -> observer - - -Getting Ready For Python 3.0 -============================ - -This has been moved to a separate branch. diff --git a/docs/dev/todo.rst b/docs/dev/todo.rst deleted file mode 100644 index 7395fc1b5..000000000 --- a/docs/dev/todo.rst +++ /dev/null @@ -1,8 +0,0 @@ -====== - TODO -====== - -See the `unresolved issues` section of ``issues.rst`` file for more. - - -> Public Release 1.0 diff --git a/docs/release-process.rst b/docs/release-process.rst index df4dc223d..9ce56c06a 100644 --- a/docs/release-process.rst +++ b/docs/release-process.rst @@ -1,4 +1,4 @@ 1. Increment version number in ``rope/__init__.py`` -2. Tag the release with the tag annotation containing the release information +2. Tag the release with the tag annotation containing the release information, e.g. ``git tag -s 0.21.0`` 3. ``python3 setup.py sdist`` 4. ``twine upload -s dist/rope-$VERSION.tar.gz*`` diff --git a/rope/__init__.py b/rope/__init__.py index 32b78cf75..109a4901c 100644 --- a/rope/__init__.py +++ b/rope/__init__.py @@ -1,7 +1,7 @@ """rope, a python refactoring library""" INFO = __doc__ -VERSION = '0.19.0' +VERSION = '0.20.0' COPYRIGHT = """\ Copyright (C) 2019-2021 Matej Cepl Copyright (C) 2015-2018 Nicholas Smith diff --git a/rope/refactor/extract.py b/rope/refactor/extract.py index 34690131b..923f47248 100644 --- a/rope/refactor/extract.py +++ b/rope/refactor/extract.py @@ -414,7 +414,7 @@ def base_conditions(self, info): if end_scope != info.scope and end_scope.get_end() != end_line: raise RefactoringError('Bad region selected for extract method') try: - extracted = info.source[info.region[0]:info.region[1]] + extracted = info.extracted if info.one_line: extracted = '(%s)' % extracted if _UnmatchedBreakOrContinueFinder.has_errors(extracted): @@ -432,17 +432,22 @@ def one_line_conditions(self, info): 'span multiple lines.') if usefunction._named_expr_count(info._parsed_extracted) - usefunction._namedexpr_last(info._parsed_extracted): raise RefactoringError('Extracted piece cannot ' - 'contain named expression (:=) statements.') + 'contain named expression (:= operator).') def multi_line_conditions(self, info): node = _parse_text(info.source[info.region[0]:info.region[1]]) count = usefunction._return_count(node) + extracted = info.extracted if count > 1: raise RefactoringError('Extracted piece can have only one ' 'return statement.') if usefunction._yield_count(node): raise RefactoringError('Extracted piece cannot ' 'have yield statements.') + if not hasattr(ast, 'PyCF_ALLOW_TOP_LEVEL_AWAIT') and _AsyncStatementFinder.has_errors(extracted): + raise RefactoringError('Extracted piece can only have async/await ' + 'statements if Rope is running on Python ' + '3.8 or higher') if count == 1 and not usefunction._returns_last(node): raise RefactoringError('Return should be the last statement.') if info.region != info.lines_region: @@ -782,6 +787,9 @@ def _FunctionDef(self, node): def _Global(self, node): self.globals_.add(*node.names) + def _AsyncFunctionDef(self, node): + self._FunctionDef(node) + def _Name(self, node): if isinstance(node.ctx, (ast.Store, ast.AugStore)): self._written_variable(node.id, node.lineno) @@ -894,7 +902,18 @@ def find_reads_for_one_liners(code): return visitor.read -class _UnmatchedBreakOrContinueFinder(object): +class _BaseErrorFinder(object): + @classmethod + def has_errors(cls, code): + if code.strip() == '': + return False + node = _parse_text(code) + visitor = cls() + ast.walk(node, visitor) + return visitor.error + + +class _UnmatchedBreakOrContinueFinder(_BaseErrorFinder): def __init__(self): self.error = False @@ -934,14 +953,23 @@ def _FunctionDef(self, node): def _ClassDef(self, node): pass - @staticmethod - def has_errors(code): - if code.strip() == '': - return False - node = _parse_text(code) - visitor = _UnmatchedBreakOrContinueFinder() - ast.walk(node, visitor) - return visitor.error + +class _AsyncStatementFinder(_BaseErrorFinder): + + def __init__(self): + self.error = False + + def _AsyncFor(self, node): + self.error = True + + def _AsyncWith(self, node): + self.error = True + + def _FunctionDef(self, node): + pass + + def _ClassDef(self, node): + pass class _GlobalFinder(object): @@ -962,7 +990,11 @@ def _parse_text(body): node = ast.parse(body) except SyntaxError: # needed to parse expression containing := operator - node = ast.parse('(' + body + ')') + try: + node = ast.parse('(' + body + ')') + except SyntaxError: + node = ast.parse('async def __rope_placeholder__():\n' + sourceutils.fix_indentation(body, 4)) + node.body = node.body[0].body return node diff --git a/rope/refactor/patchedast.py b/rope/refactor/patchedast.py index b89e1a443..78af8c9c2 100644 --- a/rope/refactor/patchedast.py +++ b/rope/refactor/patchedast.py @@ -468,14 +468,24 @@ def _ExtSlice(self, node): children.append(dim) self._handle(node, children) - def _For(self, node): - children = ['for', node.target, 'in', node.iter, ':'] + def _handle_for_loop_node(self, node, is_async): + if is_async: + children = ['async', 'for'] + else: + children = ['for'] + children.extend([node.target, 'in', node.iter, ':']) children.extend(node.body) if node.orelse: children.extend(['else', ':']) children.extend(node.orelse) self._handle(node, children) + def _For(self, node): + self._handle_for_loop_node(node, is_async=False) + + def _AsyncFor(self, node): + self._handle_for_loop_node(node, is_async=True) + def _ImportFrom(self, node): children = ['from'] if node.level: @@ -492,7 +502,7 @@ def _alias(self, node): children.extend(['as', node.asname]) self._handle(node, children) - def _FunctionDef(self, node): + def _handle_function_def_node(self, node, is_async): children = [] try: decorators = getattr(node, 'decorator_list') @@ -502,11 +512,21 @@ def _FunctionDef(self, node): for decorator in decorators: children.append('@') children.append(decorator) - children.extend(['def', node.name, '(', node.args]) + if is_async: + children.extend(['async', 'def']) + else: + children.extend(['def']) + children.extend([node.name, '(', node.args]) children.extend([')', ':']) children.extend(node.body) self._handle(node, children) + def _FunctionDef(self, node): + self._handle_function_def_node(node, is_async=False) + + def _AsyncFunctionDef(self, node): + self._handle_function_def_node(node, is_async=True) + def _arguments(self, node): children = [] args = list(node.args) @@ -793,6 +813,12 @@ def _UnaryOp(self, node): children.append(node.operand) self._handle(node, children) + def _Await(self, node): + children = ['await'] + if node.value: + children.append(node.value) + self._handle(node, children) + def _Yield(self, node): children = ['yield'] if node.value: diff --git a/ropetest/refactor/extracttest.py b/ropetest/refactor/extracttest.py index b7c029398..32005d9eb 100644 --- a/ropetest/refactor/extracttest.py +++ b/ropetest/refactor/extracttest.py @@ -1420,7 +1420,7 @@ def foo(a): ''') extract_target = 'a == (c := 5)' start, end = code.index(extract_target), code.index(extract_target) + len(extract_target) - with self.assertRaisesRegexp(rope.base.exceptions.RefactoringError, 'Extracted piece cannot contain named expression \\(:=\\) statements.'): + with self.assertRaisesRegexp(rope.base.exceptions.RefactoringError, 'Extracted piece cannot contain named expression \\(:= operator\\).'): self.do_extract_method(code, start, end, 'new_func') def test_extract_exec(self): @@ -1454,6 +1454,159 @@ def new_func(): ''') self.assertEqual(expected, refactored) + @testutils.only_for_versions_higher('3.5') + def test_extract_async_function(self): + code = dedent('''\ + async def my_func(my_list): + for x in my_list: + var = x + 1 + return var + ''') + start, end = self._convert_line_range_to_offset(code, 3, 3) + refactored = self.do_extract_method(code, start, end, 'new_func') + expected = dedent('''\ + async def my_func(my_list): + for x in my_list: + var = new_func(x) + return var + + def new_func(x): + var = x + 1 + return var + ''') + self.assertEqual(expected, refactored) + + @testutils.only_for_versions_higher('3.5') + def test_extract_inner_async_function(self): + code = dedent('''\ + def my_func(my_list): + async def inner_func(my_list): + for x in my_list: + var = x + 1 + return inner_func + ''') + start, end = self._convert_line_range_to_offset(code, 2, 4) + refactored = self.do_extract_method(code, start, end, 'new_func') + expected = dedent('''\ + def my_func(my_list): + inner_func = new_func(my_list) + return inner_func + + def new_func(my_list): + async def inner_func(my_list): + for x in my_list: + var = x + 1 + return inner_func + ''') + self.assertEqual(expected, refactored) + + @testutils.only_for_versions_higher('3.5') + def test_extract_around_inner_async_function(self): + code = dedent('''\ + def my_func(lst): + async def inner_func(obj): + for x in obj: + var = x + 1 + return map(inner_func, lst) + ''') + start, end = self._convert_line_range_to_offset(code, 5, 5) + refactored = self.do_extract_method(code, start, end, 'new_func') + expected = dedent('''\ + def my_func(lst): + async def inner_func(obj): + for x in obj: + var = x + 1 + return new_func(inner_func, lst) + + def new_func(inner_func, lst): + return map(inner_func, lst) + ''') + self.assertEqual(expected, refactored) + + @testutils.only_for_versions_higher('3.5') + def test_extract_refactor_around_async_for_loop(self): + code = dedent('''\ + async def my_func(my_list): + async for x in my_list: + var = x + 1 + return var + ''') + start, end = self._convert_line_range_to_offset(code, 3, 3) + refactored = self.do_extract_method(code, start, end, 'new_func') + expected = dedent('''\ + async def my_func(my_list): + async for x in my_list: + var = new_func(x) + return var + + def new_func(x): + var = x + 1 + return var + ''') + self.assertEqual(expected, refactored) + + @testutils.only_for_versions_higher('3.5') + @testutils.only_for_versions_lower('3.8') + def test_extract_refactor_containing_async_for_loop_should_error_before_py38(self): + """ + Refactoring async/await syntaxes is only supported in Python 3.8 and + higher because support for ast.PyCF_ALLOW_TOP_LEVEL_AWAIT was only + added to the standard library in Python 3.8. + """ + code = dedent('''\ + async def my_func(my_list): + async for x in my_list: + var = x + 1 + return var + ''') + start, end = self._convert_line_range_to_offset(code, 2, 3) + with self.assertRaisesRegexp(rope.base.exceptions.RefactoringError, 'Extracted piece can only have async/await statements if Rope is running on Python 3.8 or higher'): + self.do_extract_method(code, start, end, 'new_func') + + @testutils.only_for_versions_higher('3.8') + def test_extract_refactor_containing_async_for_loop_is_supported_after_py38(self): + code = dedent('''\ + async def my_func(my_list): + async for x in my_list: + var = x + 1 + return var + ''') + start, end = self._convert_line_range_to_offset(code, 2, 3) + refactored = self.do_extract_method(code, start, end, 'new_func') + expected = dedent('''\ + async def my_func(my_list): + var = new_func(my_list) + return var + + def new_func(my_list): + async for x in my_list: + var = x + 1 + return var + ''') + self.assertEqual(expected, refactored) + + @testutils.only_for_versions_higher('3.5') + def test_extract_await_expression(self): + code = dedent('''\ + async def my_func(my_list): + for url in my_list: + resp = await request(url) + return resp + ''') + selected = 'request(url)' + start, end = code.index(selected), code.index(selected) + len(selected) + refactored = self.do_extract_method(code, start, end, 'new_func') + expected = dedent('''\ + async def my_func(my_list): + for url in my_list: + resp = await new_func(url) + return resp + + def new_func(url): + return request(url) + ''') + self.assertEqual(expected, refactored) + def test_extract_to_staticmethod(self): code = dedent('''\ class A: diff --git a/ropetest/refactor/patchedasttest.py b/ropetest/refactor/patchedasttest.py index e4838ac79..b3846fbc0 100644 --- a/ropetest/refactor/patchedasttest.py +++ b/ropetest/refactor/patchedasttest.py @@ -3,6 +3,7 @@ except ImportError: import unittest import sys +from textwrap import dedent from rope.base import ast from rope.base.utils import pycompat @@ -541,6 +542,16 @@ def test_function_node(self): ['def', ' ', 'f', '', '(', '', 'arguments', '', ')', '', ':', '\n ', 'Pass']) + @testutils.only_for_versions_higher('3.5') + def test_async_function_node(self): + source = 'async def f():\n pass\n' + ast_frag = patchedast.get_patched_ast(source, True) + checker = _ResultChecker(self, ast_frag) + checker.check_region('AsyncFunction', 0, len(source) - 1) + checker.check_children('AsyncFunction', + ['async', ' ', 'def', ' ', 'f', '', '(', '', 'arguments', '', + ')', '', ':', '\n ', 'Pass']) + def test_function_node2(self): source = 'def f(p1, **p2):\n """docs"""\n pass\n' ast_frag = patchedast.get_patched_ast(source, True) @@ -622,7 +633,12 @@ def test_exec_node_with_parens(self): ' ', 'Call', '', ',', ' ', 'Call', '', ')']) def test_for_node(self): - source = 'for i in range(1):\n pass\nelse:\n pass\n' + source = dedent('''\ + for i in range(1): + pass + else: + pass + ''') ast_frag = patchedast.get_patched_ast(source, True) checker = _ResultChecker(self, ast_frag) checker.check_region('For', 0, len(source) - 1) @@ -631,6 +647,23 @@ def test_for_node(self): ':', '\n ', 'Pass', '\n', 'else', '', ':', '\n ', 'Pass']) + @testutils.only_for_versions_higher('3.5') + def test_async_for_node(self): + source = dedent('''\ + async def foo(): + async for i in range(1): + pass + else: + pass + ''') + ast_frag = patchedast.get_patched_ast(source, True) + checker = _ResultChecker(self, ast_frag) + checker.check_region('AsyncFor', source.index('async for'), len(source) - 1) + checker.check_children( + 'AsyncFor', ['async', ' ', 'for', ' ', 'Name', ' ', 'in', ' ', 'Call', '', + ':', '\n ', 'Pass', '\n ', + 'else', '', ':', '\n ', 'Pass']) + @testutils.only_for_versions_higher('3.8') def test_named_expr_node(self): source = 'if a := 10 == 10:\n pass\n' @@ -1169,6 +1202,16 @@ def test_starargs_after_keywords(self): 'Call', ['Name', '', '(', '', 'keyword', '', ',', ' *', 'Starred', '', ')']) + @testutils.only_for_versions_higher('3.5') + def test_await_node(self): + source = dedent('''\ + async def f(): + await sleep() + ''') + ast_frag = patchedast.get_patched_ast(source, True) + checker = _ResultChecker(self, ast_frag) + checker.check_children('Await', ['await', ' ', 'Call']) + class _ResultChecker(object): diff --git a/setup.py b/setup.py index c59558868..d3611ccdd 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ def get_version(): version=get_version(), description='a python refactoring library...', long_description=get_long_description(), + long_description_content_type='text/x-rst', author='Ali Gholami Rudi', author_email='aligrudi@users.sourceforge.net', url='https://github.com/python-rope/rope', @@ -64,7 +65,7 @@ def get_version(): 'rope.contrib', 'rope.refactor', 'rope.refactor.importutils'], - license='GNU GPL', + license='LGPL-3.0-or-later', classifiers=classifiers, extras_require={ 'dev': [