From 93082f7dd69e91ac3903cea0fbabb2e2ba9e5ee9 Mon Sep 17 00:00:00 2001 From: Gadi Evron Date: Sun, 16 Aug 2026 19:48:27 +0300 Subject: [PATCH] Recognise async def method headers in worder The predicate only matched the canonical `def`/`class` prefixes, so an `async def` method header was not treated as a definition: find_definition on a call via self resolved to the call site instead of the def, and Rename mis-handled it. Normalize the whitespace run before comparing so `async def`, `async def` and `async\tdef` are all recognised. Version independent. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + rope/base/worder.py | 3 +- ropetest/contrib/findittest.py | 107 +++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b517051..851d5c9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # **Upcoming release** +- #859 Fix `find_definition`/Rename not resolving `async def` methods called via `self` (@gadievron) - #850 Update and pin black version in pre-commit and Github Actions - #851 Bump supported python version to up to Python 3.14 - #852 Implement patchedast handlers for TypeAlias diff --git a/rope/base/worder.py b/rope/base/worder.py index 94089b5a8..c49ebfe6c 100644 --- a/rope/base/worder.py +++ b/rope/base/worder.py @@ -310,7 +310,8 @@ def is_a_class_or_function_name_in_header(self, offset): word_start = self._find_word_start(offset - 1) line_start = self._get_line_start(word_start) prev_word = self.code[line_start:word_start].strip() - return prev_word in ["def", "class"] + # normalize whitespace so `async def` / `async\tdef` (valid Python) match too + return " ".join(prev_word.split()) in ["def", "class", "async def"] def _find_first_non_space_char(self, offset): if offset >= len(self.code): diff --git a/ropetest/contrib/findittest.py b/ropetest/contrib/findittest.py index c436428b4..af2b5d3f0 100644 --- a/ropetest/contrib/findittest.py +++ b/ropetest/contrib/findittest.py @@ -3,6 +3,7 @@ from rope.base import exceptions from rope.contrib.findit import find_definition, find_implementations, find_occurrences +from rope.refactor.rename import Rename from ropetest import testutils @@ -145,3 +146,109 @@ def test_find_definition_in_other_modules(self): result = find_definition(self.project, code, code.index("var")) self.assertEqual(mod1, result.resource) self.assertEqual(0, result.offset) + + # worder's is_a_class_or_function_name_in_header only recognised the + # canonical `def`/`class` prefixes, so an `async def` method header was + # not treated as a definition and find_definition resolved a call via + # self to the call site itself (never the def). + + def test_find_definition_of_async_method(self): + code = dedent("""\ + class C(object): + async def target(self): + pass + + async def run(self): + await self.target() + """) + call_offset = code.rindex("target") + result = find_definition(self.project, code, call_offset) + def_offset = code.index("target") + self.assertIsNotNone(result) + self.assertEqual(def_offset, result.offset) + + def test_find_definition_of_async_method_extra_whitespace(self): + # `async def` (two spaces) / `async\tdef` are valid Python and must + # be recognised too, not just canonical single-space `async def`. + code = dedent("""\ + class C(object): + async def target(self): + pass + + async def run(self): + await self.target() + """) + call_offset = code.rindex("target") + result = find_definition(self.project, code, call_offset) + def_offset = code.index("target") + self.assertIsNotNone(result) + self.assertEqual(def_offset, result.offset) + + def test_find_definition_of_async_method_tab_separated(self): + # `async\tdef` (tab-separated) is valid Python; the normalised + # predicate must recognise it too. + code = dedent("""\ + class C(object): + async def target(self): + pass + + async def run(self): + await self.target() + """).replace("async def", "async\tdef") + call_offset = code.rindex("target") + result = find_definition(self.project, code, call_offset) + def_offset = code.index("target") + self.assertIsNotNone(result) + self.assertEqual(def_offset, result.offset) + + def test_rename_heals_async_method(self): + # Rename from the call must reach the async def, not orphan it -- + # the damaging end-to-end symptom of the unrecognised header. + mod = testutils.create_module(self.project, "mod") + code = dedent("""\ + class C(object): + async def target(self): + pass + + async def run(self): + await self.target() + """) + mod.write(code) + call_offset = code.rindex("target") + changes = Rename(self.project, mod, call_offset).get_changes("renamed") + self.project.do(changes) + result = mod.read() + # both the call and the def must be renamed -- not silently orphaned + self.assertEqual(0, result.count("target")) + self.assertEqual(2, result.count("renamed")) + + def test_find_definition_of_sync_method_still_resolves(self): + # regression control: sync methods must keep resolving + code = dedent("""\ + class C(object): + def target(self): + pass + + def run(self): + self.target() + """) + call_offset = code.rindex("target") + result = find_definition(self.project, code, call_offset) + def_offset = code.index("target") + self.assertIsNotNone(result) + self.assertEqual(def_offset, result.offset) + + def test_find_definition_of_module_level_async_function(self): + # control: module-level async defs already resolve correctly + code = dedent("""\ + async def target(): + pass + + async def run(): + await target() + """) + call_offset = code.rindex("target") + result = find_definition(self.project, code, call_offset) + def_offset = code.index("target") + self.assertIsNotNone(result) + self.assertEqual(def_offset, result.offset)