From 21ebef5e53f5d56a9cf6b0f5ca56a6a24adaf713 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Wed, 22 Jul 2026 10:15:57 -0300 Subject: [PATCH 1/2] Fix _JoinedStr end-quote detection for ''' inside a "..." f-string end_quote_char() picked the closing delimiter by max(len(quote)) over every quote char found anywhere in the f-string body, so a ''' sequence inside a double-quoted f-string (e.g. f"cfg = '''\n{value}") was selected as the delimiter. That produced a reconstructed token consume_joined_string() could not locate, raising `ValueError: substring not found` -> MismatchedTokenError, which aborts rename/inline for the entire module. Pick the delimiter the f-string literally ends with (QUOTE_CHARS is ordered longest-first, so """/''' win over "/' correctly) so the closing quote matches the opening one regardless of quote characters appearing in the body. Adds a regression test; full patchedast, rename and inline suites stay green. --- rope/refactor/patchedast.py | 18 ++++++++++-------- ropetest/refactor/patchedasttest.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/rope/refactor/patchedast.py b/rope/refactor/patchedast.py index 4e842c046..65d942f04 100644 --- a/rope/refactor/patchedast.py +++ b/rope/refactor/patchedast.py @@ -383,14 +383,16 @@ def start_quote_char(): return self.source[start : quote_pos + len(quote_char)] def end_quote_char(): - possible_quotes = [ - (self.source.source.rfind(q, start, end), q) - for q in reversed(QUOTE_CHARS) - ] - _, quote_pos, quote_char = max( - (len(q), pos, q) for pos, q in possible_quotes if pos != -1 - ) - return self.source[end - len(quote_char) : end] + # The closing delimiter is the quote char the f-string literally + # ends with -- NOT the longest quote char anywhere in the body. + # A ''' appearing inside a "..." f-string body must not be picked + # as the delimiter (issue: end_quote_char used max(len(q)) and + # mis-matched, producing a token that consume_joined_string could + # not locate). + for quote_char in QUOTE_CHARS: + if self.source.source[end - len(quote_char) : end] == quote_char: + return self.source[end - len(quote_char) : end] + return self.source[end - 1 : end] QUOTE_CHARS = ['"""', "'''", '"', "'"] offset = self.source.offset diff --git a/ropetest/refactor/patchedasttest.py b/ropetest/refactor/patchedasttest.py index 3ac341f25..109d07c8f 100644 --- a/ropetest/refactor/patchedasttest.py +++ b/ropetest/refactor/patchedasttest.py @@ -272,6 +272,19 @@ def test_handling_format_strings_basic(self): checker.check_children("JoinedStr", ['f"', "abc", "FormattedValue", "", '"']) checker.check_children("FormattedValue", ["{", "", "Name", "", "}"]) + @testutils.only_for_versions_higher("3.6") + def test_handling_format_strings_with_triple_quote_in_body(self): + # A double-quoted f-string whose body contains a ''' sequence must not + # confuse the end-quote detection (end_quote_char picked the longest + # quote in the body instead of the matching delimiter). + source = 'f"abc = \'\'\'{a}"\n' + ast_frag = patchedast.get_patched_ast(source, True) + checker = _ResultChecker(self, ast_frag) + checker.check_children( + "JoinedStr", ['f"', "abc = '''", "FormattedValue", "", '"'] + ) + checker.check_children("FormattedValue", ["{", "", "Name", "", "}"]) + @testutils.only_for_versions_higher("3.6") def test_handling_format_strings_with_implicit_join(self): source = '''"1" + rf'abc{a}' f"""xxx{b} """\n''' From 307c1e1d31bf6d0320b6caebf9b8993c5e55e415 Mon Sep 17 00:00:00 2001 From: Marlon Costa Date: Wed, 22 Jul 2026 10:17:56 -0300 Subject: [PATCH 2/2] Add CHANGELOG entry for f-string triple-quote patchedast fix --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef219ac71..0dadd3875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # **Upcoming release** +- Fix `patchedast` aborting on a `'''` sequence inside a double-quoted + f-string (`end_quote_char` picked the longest quote in the body instead of + the matching delimiter), which raised `MismatchedTokenError` and broke + rename/inline for the whole module (@marlon-costa-dc) - ... # Release 1.14.0