From 992e404786f196ab9d173dd2c8b7b36642d54264 Mon Sep 17 00:00:00 2001 From: Tomasz Pytel Date: Mon, 3 Feb 2025 11:33:30 -0500 Subject: [PATCH 1/7] gh-129598: allow multi stmts for ast single with ';' --- Lib/ast.py | 63 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index 0937c27bdf8a11..e321d416aae30d 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -674,6 +674,7 @@ def __init__(self): self._type_ignores = {} self._indent = 0 self._in_try_star = False + self._in_interactive = False def interleave(self, inter, f, seq): """Call f on each item in seq, calling inter() in between.""" @@ -702,11 +703,20 @@ def maybe_newline(self): if self._source: self.write("\n") - def fill(self, text=""): + def maybe_semicolon(self): + """Adds a "; " delimiter if it isn't the start of generated source""" + if self._source: + self.write("; ") + + def fill(self, text="", allowsemi=True): """Indent a piece of text and append it, according to the current - indentation level""" - self.maybe_newline() - self.write(" " * self._indent + text) + indentation level, or only delineate with semicolon if applicable""" + if self._in_interactive and not self._indent and allowsemi: + self.maybe_semicolon() + self.write(text) + else: + self.maybe_newline() + self.write(" " * self._indent + text) def write(self, *text): """Add new source parts""" @@ -815,6 +825,11 @@ def visit_Module(self, node): self._write_docstring_and_traverse_body(node) self._type_ignores.clear() + def visit_Interactive(self, node): + self._in_interactive = True + self._write_docstring_and_traverse_body(node) + self._in_interactive = False + def visit_FunctionType(self, node): with self.delimit("(", ")"): self.interleave( @@ -945,17 +960,17 @@ def visit_Raise(self, node): self.traverse(node.cause) def do_visit_try(self, node): - self.fill("try") + self.fill("try", allowsemi=False) with self.block(): self.traverse(node.body) for ex in node.handlers: self.traverse(ex) if node.orelse: - self.fill("else") + self.fill("else", allowsemi=False) with self.block(): self.traverse(node.orelse) if node.finalbody: - self.fill("finally") + self.fill("finally", allowsemi=False) with self.block(): self.traverse(node.finalbody) @@ -976,7 +991,7 @@ def visit_TryStar(self, node): self._in_try_star = prev_in_try_star def visit_ExceptHandler(self, node): - self.fill("except*" if self._in_try_star else "except") + self.fill("except*" if self._in_try_star else "except", allowsemi=False) if node.type: self.write(" ") self.traverse(node.type) @@ -989,9 +1004,9 @@ def visit_ExceptHandler(self, node): def visit_ClassDef(self, node): self.maybe_newline() for deco in node.decorator_list: - self.fill("@") + self.fill("@", allowsemi=False) self.traverse(deco) - self.fill("class " + node.name) + self.fill("class " + node.name, allowsemi=False) if hasattr(node, "type_params"): self._type_params_helper(node.type_params) with self.delimit_if("(", ")", condition = node.bases or node.keywords): @@ -1021,10 +1036,10 @@ def visit_AsyncFunctionDef(self, node): def _function_helper(self, node, fill_suffix): self.maybe_newline() for deco in node.decorator_list: - self.fill("@") + self.fill("@", allowsemi=False) self.traverse(deco) def_str = fill_suffix + " " + node.name - self.fill(def_str) + self.fill(def_str, allowsemi=False) if hasattr(node, "type_params"): self._type_params_helper(node.type_params) with self.delimit("(", ")"): @@ -1075,7 +1090,7 @@ def visit_AsyncFor(self, node): self._for_helper("async for ", node) def _for_helper(self, fill, node): - self.fill(fill) + self.fill(fill, allowsemi=False) self.set_precedence(_Precedence.TUPLE, node.target) self.traverse(node.target) self.write(" in ") @@ -1083,46 +1098,46 @@ def _for_helper(self, fill, node): with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) if node.orelse: - self.fill("else") + self.fill("else", allowsemi=False) with self.block(): self.traverse(node.orelse) def visit_If(self, node): - self.fill("if ") + self.fill("if ", allowsemi=False) self.traverse(node.test) with self.block(): self.traverse(node.body) # collapse nested ifs into equivalent elifs. while node.orelse and len(node.orelse) == 1 and isinstance(node.orelse[0], If): node = node.orelse[0] - self.fill("elif ") + self.fill("elif ", allowsemi=False) self.traverse(node.test) with self.block(): self.traverse(node.body) # final else if node.orelse: - self.fill("else") + self.fill("else", allowsemi=False) with self.block(): self.traverse(node.orelse) def visit_While(self, node): - self.fill("while ") + self.fill("while ", allowsemi=False) self.traverse(node.test) with self.block(): self.traverse(node.body) if node.orelse: - self.fill("else") + self.fill("else", allowsemi=False) with self.block(): self.traverse(node.orelse) def visit_With(self, node): - self.fill("with ") + self.fill("with ", allowsemi=False) self.interleave(lambda: self.write(", "), self.traverse, node.items) with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) def visit_AsyncWith(self, node): - self.fill("async with ") + self.fill("async with ", allowsemi=False) self.interleave(lambda: self.write(", "), self.traverse, node.items) with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) @@ -1264,7 +1279,7 @@ def visit_Name(self, node): self.write(node.id) def _write_docstring(self, node): - self.fill() + self.fill(allowsemi=False) if node.kind == "u": self.write("u") self._write_str_avoiding_backslashes(node.value, quote_types=_MULTI_QUOTES) @@ -1558,7 +1573,7 @@ def visit_Slice(self, node): self.traverse(node.step) def visit_Match(self, node): - self.fill("match ") + self.fill("match ", allowsemi=False) self.traverse(node.subject) with self.block(): for case in node.cases: @@ -1652,7 +1667,7 @@ def visit_withitem(self, node): self.traverse(node.optional_vars) def visit_match_case(self, node): - self.fill("case ") + self.fill("case ", allowsemi=False) self.traverse(node.pattern) if node.guard: self.write(" if ") From fb200e47865b1e796ad0f556d227a04f016be863 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 16:27:15 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst diff --git a/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst b/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst new file mode 100644 index 00000000000000..b4ae70dc7f0faa --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst @@ -0,0 +1 @@ +Fix of :func:`ast.unparse` when :class:`ast.Interactive` contains multiple statements. From 766f2bc50be9f08136d079c4415c748ea600ab4e Mon Sep 17 00:00:00 2001 From: Tomasz Pytel Date: Mon, 3 Feb 2025 12:41:37 -0500 Subject: [PATCH 3/7] allowsemi -> allow_semi and test --- Lib/ast.py | 44 +++++++++++++++++------------------ Lib/test/test_ast/test_ast.py | 11 +++++++++ 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index e321d416aae30d..814b6dc0ef6085 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -708,10 +708,10 @@ def maybe_semicolon(self): if self._source: self.write("; ") - def fill(self, text="", allowsemi=True): + def fill(self, text="", allow_semi=True): """Indent a piece of text and append it, according to the current indentation level, or only delineate with semicolon if applicable""" - if self._in_interactive and not self._indent and allowsemi: + if self._in_interactive and not self._indent and allow_semi: self.maybe_semicolon() self.write(text) else: @@ -960,17 +960,17 @@ def visit_Raise(self, node): self.traverse(node.cause) def do_visit_try(self, node): - self.fill("try", allowsemi=False) + self.fill("try", allow_semi=False) with self.block(): self.traverse(node.body) for ex in node.handlers: self.traverse(ex) if node.orelse: - self.fill("else", allowsemi=False) + self.fill("else", allow_semi=False) with self.block(): self.traverse(node.orelse) if node.finalbody: - self.fill("finally", allowsemi=False) + self.fill("finally", allow_semi=False) with self.block(): self.traverse(node.finalbody) @@ -991,7 +991,7 @@ def visit_TryStar(self, node): self._in_try_star = prev_in_try_star def visit_ExceptHandler(self, node): - self.fill("except*" if self._in_try_star else "except", allowsemi=False) + self.fill("except*" if self._in_try_star else "except", allow_semi=False) if node.type: self.write(" ") self.traverse(node.type) @@ -1004,9 +1004,9 @@ def visit_ExceptHandler(self, node): def visit_ClassDef(self, node): self.maybe_newline() for deco in node.decorator_list: - self.fill("@", allowsemi=False) + self.fill("@", allow_semi=False) self.traverse(deco) - self.fill("class " + node.name, allowsemi=False) + self.fill("class " + node.name, allow_semi=False) if hasattr(node, "type_params"): self._type_params_helper(node.type_params) with self.delimit_if("(", ")", condition = node.bases or node.keywords): @@ -1036,10 +1036,10 @@ def visit_AsyncFunctionDef(self, node): def _function_helper(self, node, fill_suffix): self.maybe_newline() for deco in node.decorator_list: - self.fill("@", allowsemi=False) + self.fill("@", allow_semi=False) self.traverse(deco) def_str = fill_suffix + " " + node.name - self.fill(def_str, allowsemi=False) + self.fill(def_str, allow_semi=False) if hasattr(node, "type_params"): self._type_params_helper(node.type_params) with self.delimit("(", ")"): @@ -1090,7 +1090,7 @@ def visit_AsyncFor(self, node): self._for_helper("async for ", node) def _for_helper(self, fill, node): - self.fill(fill, allowsemi=False) + self.fill(fill, allow_semi=False) self.set_precedence(_Precedence.TUPLE, node.target) self.traverse(node.target) self.write(" in ") @@ -1098,46 +1098,46 @@ def _for_helper(self, fill, node): with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) if node.orelse: - self.fill("else", allowsemi=False) + self.fill("else", allow_semi=False) with self.block(): self.traverse(node.orelse) def visit_If(self, node): - self.fill("if ", allowsemi=False) + self.fill("if ", allow_semi=False) self.traverse(node.test) with self.block(): self.traverse(node.body) # collapse nested ifs into equivalent elifs. while node.orelse and len(node.orelse) == 1 and isinstance(node.orelse[0], If): node = node.orelse[0] - self.fill("elif ", allowsemi=False) + self.fill("elif ", allow_semi=False) self.traverse(node.test) with self.block(): self.traverse(node.body) # final else if node.orelse: - self.fill("else", allowsemi=False) + self.fill("else", allow_semi=False) with self.block(): self.traverse(node.orelse) def visit_While(self, node): - self.fill("while ", allowsemi=False) + self.fill("while ", allow_semi=False) self.traverse(node.test) with self.block(): self.traverse(node.body) if node.orelse: - self.fill("else", allowsemi=False) + self.fill("else", allow_semi=False) with self.block(): self.traverse(node.orelse) def visit_With(self, node): - self.fill("with ", allowsemi=False) + self.fill("with ", allow_semi=False) self.interleave(lambda: self.write(", "), self.traverse, node.items) with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) def visit_AsyncWith(self, node): - self.fill("async with ", allowsemi=False) + self.fill("async with ", allow_semi=False) self.interleave(lambda: self.write(", "), self.traverse, node.items) with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) @@ -1279,7 +1279,7 @@ def visit_Name(self, node): self.write(node.id) def _write_docstring(self, node): - self.fill(allowsemi=False) + self.fill(allow_semi=False) if node.kind == "u": self.write("u") self._write_str_avoiding_backslashes(node.value, quote_types=_MULTI_QUOTES) @@ -1573,7 +1573,7 @@ def visit_Slice(self, node): self.traverse(node.step) def visit_Match(self, node): - self.fill("match ", allowsemi=False) + self.fill("match ", allow_semi=False) self.traverse(node.subject) with self.block(): for case in node.cases: @@ -1667,7 +1667,7 @@ def visit_withitem(self, node): self.traverse(node.optional_vars) def visit_match_case(self, node): - self.fill("case ", allowsemi=False) + self.fill("case ", allow_semi=False) self.traverse(node.pattern) if node.guard: self.write(" if ") diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index c268a1f00f938e..ae53fb4df86979 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -814,6 +814,17 @@ def test_repr_large_input_crash(self): r"Exceeds the limit \(\d+ digits\)"): repr(ast.Constant(value=eval(source))) + def test_unparse_interactive(self): + # gh-129598: Fix of ast.unparse() when ast.Interactive contains multiple statements + source = "i = 1; 'expr'; raise Exception" + self.assertEqual(source, ast.unparse(ast.parse(source, mode='single'))) + source = "if i:\n 'expr'\nelse:\n raise Exception" + unparsed = "if i:\n 'expr'\nelse:\n raise Exception" + self.assertEqual(unparsed, ast.unparse(ast.parse(source, mode='single'))) + source = "@decorator\ndef func():\n 'docstring'\n i = 1; 'expr'; raise Exception" + unparsed = '''@decorator\ndef func():\n """docstring"""\n i = 1\n 'expr'\n raise Exception''' + self.assertEqual(unparsed, ast.unparse(ast.parse(source, mode='single'))) + class CopyTests(unittest.TestCase): """Test copying and pickling AST nodes.""" From fe48e7ecd6c8e5a7362760268da7b15602ae168f Mon Sep 17 00:00:00 2001 From: Tomasz Pytel Date: Mon, 3 Feb 2025 13:41:40 -0500 Subject: [PATCH 4/7] requested changes --- Lib/ast.py | 44 ++++++------- Lib/test/test_ast/test_ast.py | 11 ---- Lib/test/test_unparse.py | 114 ++++++++++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 37 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index 814b6dc0ef6085..e1cc57230011ed 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -708,10 +708,10 @@ def maybe_semicolon(self): if self._source: self.write("; ") - def fill(self, text="", allow_semi=True): + def fill(self, text="", *, allow_semicolon=True): """Indent a piece of text and append it, according to the current indentation level, or only delineate with semicolon if applicable""" - if self._in_interactive and not self._indent and allow_semi: + if self._in_interactive and not self._indent and allow_semicolon: self.maybe_semicolon() self.write(text) else: @@ -960,17 +960,17 @@ def visit_Raise(self, node): self.traverse(node.cause) def do_visit_try(self, node): - self.fill("try", allow_semi=False) + self.fill("try", allow_semicolon=False) with self.block(): self.traverse(node.body) for ex in node.handlers: self.traverse(ex) if node.orelse: - self.fill("else", allow_semi=False) + self.fill("else", allow_semicolon=False) with self.block(): self.traverse(node.orelse) if node.finalbody: - self.fill("finally", allow_semi=False) + self.fill("finally", allow_semicolon=False) with self.block(): self.traverse(node.finalbody) @@ -991,7 +991,7 @@ def visit_TryStar(self, node): self._in_try_star = prev_in_try_star def visit_ExceptHandler(self, node): - self.fill("except*" if self._in_try_star else "except", allow_semi=False) + self.fill("except*" if self._in_try_star else "except", allow_semicolon=False) if node.type: self.write(" ") self.traverse(node.type) @@ -1004,9 +1004,9 @@ def visit_ExceptHandler(self, node): def visit_ClassDef(self, node): self.maybe_newline() for deco in node.decorator_list: - self.fill("@", allow_semi=False) + self.fill("@", allow_semicolon=False) self.traverse(deco) - self.fill("class " + node.name, allow_semi=False) + self.fill("class " + node.name, allow_semicolon=False) if hasattr(node, "type_params"): self._type_params_helper(node.type_params) with self.delimit_if("(", ")", condition = node.bases or node.keywords): @@ -1036,10 +1036,10 @@ def visit_AsyncFunctionDef(self, node): def _function_helper(self, node, fill_suffix): self.maybe_newline() for deco in node.decorator_list: - self.fill("@", allow_semi=False) + self.fill("@", allow_semicolon=False) self.traverse(deco) def_str = fill_suffix + " " + node.name - self.fill(def_str, allow_semi=False) + self.fill(def_str, allow_semicolon=False) if hasattr(node, "type_params"): self._type_params_helper(node.type_params) with self.delimit("(", ")"): @@ -1090,7 +1090,7 @@ def visit_AsyncFor(self, node): self._for_helper("async for ", node) def _for_helper(self, fill, node): - self.fill(fill, allow_semi=False) + self.fill(fill, allow_semicolon=False) self.set_precedence(_Precedence.TUPLE, node.target) self.traverse(node.target) self.write(" in ") @@ -1098,46 +1098,46 @@ def _for_helper(self, fill, node): with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) if node.orelse: - self.fill("else", allow_semi=False) + self.fill("else", allow_semicolon=False) with self.block(): self.traverse(node.orelse) def visit_If(self, node): - self.fill("if ", allow_semi=False) + self.fill("if ", allow_semicolon=False) self.traverse(node.test) with self.block(): self.traverse(node.body) # collapse nested ifs into equivalent elifs. while node.orelse and len(node.orelse) == 1 and isinstance(node.orelse[0], If): node = node.orelse[0] - self.fill("elif ", allow_semi=False) + self.fill("elif ", allow_semicolon=False) self.traverse(node.test) with self.block(): self.traverse(node.body) # final else if node.orelse: - self.fill("else", allow_semi=False) + self.fill("else", allow_semicolon=False) with self.block(): self.traverse(node.orelse) def visit_While(self, node): - self.fill("while ", allow_semi=False) + self.fill("while ", allow_semicolon=False) self.traverse(node.test) with self.block(): self.traverse(node.body) if node.orelse: - self.fill("else", allow_semi=False) + self.fill("else", allow_semicolon=False) with self.block(): self.traverse(node.orelse) def visit_With(self, node): - self.fill("with ", allow_semi=False) + self.fill("with ", allow_semicolon=False) self.interleave(lambda: self.write(", "), self.traverse, node.items) with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) def visit_AsyncWith(self, node): - self.fill("async with ", allow_semi=False) + self.fill("async with ", allow_semicolon=False) self.interleave(lambda: self.write(", "), self.traverse, node.items) with self.block(extra=self.get_type_comment(node)): self.traverse(node.body) @@ -1279,7 +1279,7 @@ def visit_Name(self, node): self.write(node.id) def _write_docstring(self, node): - self.fill(allow_semi=False) + self.fill(allow_semicolon=False) if node.kind == "u": self.write("u") self._write_str_avoiding_backslashes(node.value, quote_types=_MULTI_QUOTES) @@ -1573,7 +1573,7 @@ def visit_Slice(self, node): self.traverse(node.step) def visit_Match(self, node): - self.fill("match ", allow_semi=False) + self.fill("match ", allow_semicolon=False) self.traverse(node.subject) with self.block(): for case in node.cases: @@ -1667,7 +1667,7 @@ def visit_withitem(self, node): self.traverse(node.optional_vars) def visit_match_case(self, node): - self.fill("case ", allow_semi=False) + self.fill("case ", allow_semicolon=False) self.traverse(node.pattern) if node.guard: self.write(" if ") diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index ae53fb4df86979..c268a1f00f938e 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -814,17 +814,6 @@ def test_repr_large_input_crash(self): r"Exceeds the limit \(\d+ digits\)"): repr(ast.Constant(value=eval(source))) - def test_unparse_interactive(self): - # gh-129598: Fix of ast.unparse() when ast.Interactive contains multiple statements - source = "i = 1; 'expr'; raise Exception" - self.assertEqual(source, ast.unparse(ast.parse(source, mode='single'))) - source = "if i:\n 'expr'\nelse:\n raise Exception" - unparsed = "if i:\n 'expr'\nelse:\n raise Exception" - self.assertEqual(unparsed, ast.unparse(ast.parse(source, mode='single'))) - source = "@decorator\ndef func():\n 'docstring'\n i = 1; 'expr'; raise Exception" - unparsed = '''@decorator\ndef func():\n """docstring"""\n i = 1\n 'expr'\n raise Exception''' - self.assertEqual(unparsed, ast.unparse(ast.parse(source, mode='single'))) - class CopyTests(unittest.TestCase): """Test copying and pickling AST nodes.""" diff --git a/Lib/test/test_unparse.py b/Lib/test/test_unparse.py index f6c4f1f3f6476a..22184bee8b36ad 100644 --- a/Lib/test/test_unparse.py +++ b/Lib/test/test_unparse.py @@ -141,13 +141,13 @@ def check_invalid(self, node, raises=ValueError): with self.subTest(node=node): self.assertRaises(raises, ast.unparse, node) - def get_source(self, code1, code2=None): + def get_source(self, code1, code2=None, **kwargs): code2 = code2 or code1 - code1 = ast.unparse(ast.parse(code1)) + code1 = ast.unparse(ast.parse(code1, **kwargs)) return code1, code2 - def check_src_roundtrip(self, code1, code2=None): - code1, code2 = self.get_source(code1, code2) + def check_src_roundtrip(self, code1, code2=None, **kwargs): + code1, code2 = self.get_source(code1, code2, **kwargs) with self.subTest(code1=code1, code2=code2): self.assertEqual(code2, code1) @@ -466,6 +466,112 @@ def test_type_ignore(self): ): self.check_ast_roundtrip(statement, type_comments=True) + def test_unparse_interactive(self): + # gh-129598: Fix of ast.unparse() when ast.Interactive contains multiple statements + self.check_src_roundtrip("i = 1; 'expr'; raise Exception", mode='single') + self.check_src_roundtrip("i: int = 1; j: float = 0; k += l", mode='single') + combinable = ( + "'expr'", + "(i := 1)", + "import foo", + "from foo import bar", + "i = 1", + "i += 1", + "i: int = 1", + "return i", + "pass", + "break", + "continue", + "del i", + "assert i", + "global i", + "nonlocal j", + "await i", + "yield i", + "yield from i", + "raise i", + "type t[T] = ...", + "i", + ) + for a in combinable: + for b in combinable: + self.check_src_roundtrip(f"{a}; {b}", mode='single') + + # rest of the tests just make sure mode='single' parse and unparse didn't break + self.check_src_roundtrip( + "if i:\n 'expr'\nelse:\n raise Exception", + "if i:\n 'expr'\nelse:\n raise Exception", + mode='single' + ) + self.check_src_roundtrip( + "@decorator1\n@decorator2\ndef func():\n 'docstring'\n i = 1; 'expr'; raise Exception", + '''@decorator1\n@decorator2\ndef func():\n """docstring"""\n i = 1\n 'expr'\n raise Exception''', + mode='single' + ) + self.check_src_roundtrip( + "@decorator1\n@decorator2\nclass cls:\n 'docstring'\n i = 1; 'expr'; raise Exception", + '''@decorator1\n@decorator2\nclass cls:\n """docstring"""\n i = 1\n 'expr'\n raise Exception''', + mode='single' + ) + for statement in ( + "def x():\n pass", + "def x(y):\n pass", + "async def x():\n pass", + "async def x(y):\n pass", + "for x in y:\n pass", + "async for x in y:\n pass", + "with x():\n pass", + "async with x():\n pass", + "def f():\n pass", + "def f(a):\n pass", + "def f(b=2):\n pass", + "def f(a, b):\n pass", + "def f(a, b=2):\n pass", + "def f(a=5, b=2):\n pass", + "def f(*, a=1, b=2):\n pass", + "def f(*, a=1, b):\n pass", + "def f(*, a, b=2):\n pass", + "def f(a, b=None, *, c, **kwds):\n pass", + "def f(a=2, *args, c=5, d, **kwds):\n pass", + "def f(*args, **kwargs):\n pass", + "class cls:\n\n def f(self):\n pass", + "class cls:\n\n def f(self, a):\n pass", + "class cls:\n\n def f(self, b=2):\n pass", + "class cls:\n\n def f(self, a, b):\n pass", + "class cls:\n\n def f(self, a, b=2):\n pass", + "class cls:\n\n def f(self, a=5, b=2):\n pass", + "class cls:\n\n def f(self, *, a=1, b=2):\n pass", + "class cls:\n\n def f(self, *, a=1, b):\n pass", + "class cls:\n\n def f(self, *, a, b=2):\n pass", + "class cls:\n\n def f(self, a, b=None, *, c, **kwds):\n pass", + "class cls:\n\n def f(self, a=2, *args, c=5, d, **kwds):\n pass", + "class cls:\n\n def f(self, *args, **kwargs):\n pass", + ): + self.check_src_roundtrip(statement, mode='single') + for statement in ( + "def x():", + "def x(y):", + "async def x():", + "async def x(y):", + "for x in y:", + "async for x in y:", + "with x():", + "async with x():", + "def f():", + "def f(a):", + "def f(b=2):", + "def f(a, b):", + "def f(a, b=2):", + "def f(a=5, b=2):", + "def f(*, a=1, b=2):", + "def f(*, a=1, b):", + "def f(*, a, b=2):", + "def f(a, b=None, *, c, **kwds):", + "def f(a=2, *args, c=5, d, **kwds):", + "def f(*args, **kwargs):", + ): + self.check_src_roundtrip(statement + '\n i=1;j=2', statement + '\n i = 1\n j = 2', mode='single') + class CosmeticTestCase(ASTTestCase): """Test if there are cosmetic issues caused by unnecessary additions""" From e8f2896ee1d9fea0304e7263328fe39fced9a23a Mon Sep 17 00:00:00 2001 From: Tomasz Pytel Date: Tue, 4 Feb 2025 08:08:01 -0500 Subject: [PATCH 5/7] test cleanup --- Lib/test/test_unparse.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_unparse.py b/Lib/test/test_unparse.py index 22184bee8b36ad..6095436fce1da0 100644 --- a/Lib/test/test_unparse.py +++ b/Lib/test/test_unparse.py @@ -570,7 +570,10 @@ def test_unparse_interactive(self): "def f(a=2, *args, c=5, d, **kwds):", "def f(*args, **kwargs):", ): - self.check_src_roundtrip(statement + '\n i=1;j=2', statement + '\n i = 1\n j = 2', mode='single') + src = statement + '\n i=1;j=2' + out = statement + '\n i = 1\n j = 2' + + self.check_src_roundtrip(src, out, mode='single') class CosmeticTestCase(ASTTestCase): From 9c525cdd5a674bb9b17b9f06d55e3711461eae0b Mon Sep 17 00:00:00 2001 From: Tomasz Pytel Date: Tue, 4 Feb 2025 08:35:19 -0500 Subject: [PATCH 6/7] add try-finally to top level ast.mod unparse --- Lib/ast.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index e1cc57230011ed..cb1f8dfe128ead 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -822,13 +822,17 @@ def visit_Module(self, node): ignore.lineno: f"ignore{ignore.tag}" for ignore in node.type_ignores } - self._write_docstring_and_traverse_body(node) - self._type_ignores.clear() + try: + self._write_docstring_and_traverse_body(node) + finally: + self._type_ignores.clear() def visit_Interactive(self, node): self._in_interactive = True - self._write_docstring_and_traverse_body(node) - self._in_interactive = False + try: + self._write_docstring_and_traverse_body(node) + finally: + self._in_interactive = False def visit_FunctionType(self, node): with self.delimit("(", ")"): From 70102d6b372d7a38e87fc23dad274c1d5344259f Mon Sep 17 00:00:00 2001 From: Tomasz Pytel Date: Sat, 1 Mar 2025 07:11:43 -0500 Subject: [PATCH 7/7] split up test --- Lib/test/test_unparse.py | 11 ++++++++--- .../2025-02-03-16-27-14.gh-issue-129598.0js33I.rst | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_unparse.py b/Lib/test/test_unparse.py index 11fb00cdf6d8de..21f81dde49dc6e 100644 --- a/Lib/test/test_unparse.py +++ b/Lib/test/test_unparse.py @@ -466,8 +466,8 @@ def test_type_ignore(self): ): self.check_ast_roundtrip(statement, type_comments=True) - def test_unparse_interactive(self): - # gh-129598: Fix of ast.unparse() when ast.Interactive contains multiple statements + def test_unparse_interactive_semicolons(self): + # gh-129598: Fix ast.unparse() when ast.Interactive contains multiple statements self.check_src_roundtrip("i = 1; 'expr'; raise Exception", mode='single') self.check_src_roundtrip("i: int = 1; j: float = 0; k += l", mode='single') combinable = ( @@ -497,7 +497,8 @@ def test_unparse_interactive(self): for b in combinable: self.check_src_roundtrip(f"{a}; {b}", mode='single') - # rest of the tests just make sure mode='single' parse and unparse didn't break + def test_unparse_interactive_integrity_1(self): + # rest of unparse_interactive_integrity tests just make sure mode='single' parse and unparse didn't break self.check_src_roundtrip( "if i:\n 'expr'\nelse:\n raise Exception", "if i:\n 'expr'\nelse:\n raise Exception", @@ -513,6 +514,8 @@ def test_unparse_interactive(self): '''@decorator1\n@decorator2\nclass cls:\n """docstring"""\n i = 1\n 'expr'\n raise Exception''', mode='single' ) + + def test_unparse_interactive_integrity_2(self): for statement in ( "def x():\n pass", "def x(y):\n pass", @@ -548,6 +551,8 @@ def test_unparse_interactive(self): "class cls:\n\n def f(self, *args, **kwargs):\n pass", ): self.check_src_roundtrip(statement, mode='single') + + def test_unparse_interactive_integrity_3(self): for statement in ( "def x():", "def x(y):", diff --git a/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst b/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst index b4ae70dc7f0faa..f59eeb236e24a2 100644 --- a/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst +++ b/Misc/NEWS.d/next/Library/2025-02-03-16-27-14.gh-issue-129598.0js33I.rst @@ -1 +1 @@ -Fix of :func:`ast.unparse` when :class:`ast.Interactive` contains multiple statements. +Fix :func:`ast.unparse` when :class:`ast.Interactive` contains multiple statements.