From 07a9e25c56e4a153488d2173236f2cd4a06065be Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 12 May 2025 21:29:43 -0700 Subject: [PATCH 1/2] Support Python 3.14 --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 3 ++ ast_decompiler/decompiler.py | 71 ++++++++++++++++++++++++++++++------ pyproject.toml | 5 ++- tests/test_pep695.py | 10 +++++ tests/test_py3_syntax.py | 11 +++++- tox.ini | 17 +++++---- 7 files changed, 95 insertions(+), 24 deletions(-) Index: ast_decompiler-0.8.0/ast_decompiler/decompiler.py =================================================================== --- ast_decompiler-0.8.0.orig/ast_decompiler/decompiler.py +++ ast_decompiler-0.8.0/ast_decompiler/decompiler.py @@ -879,7 +879,10 @@ class Decompiler(ast.NodeVisitor): self.write(delimiter) def visit_FormattedValue(self, node: ast.FormattedValue) -> None: - has_parent = isinstance(self.get_parent_node(), ast.JoinedStr) + has_parent = isinstance(self.get_parent_node(), ast.JoinedStr) or ( + sys.version_info >= (3, 14) + and isinstance(self.get_parent_node(), ast.TemplateStr) + ) with self.f_literalise_if(not has_parent): self.write("{") if isinstance(node.value, ast.JoinedStr): @@ -911,20 +914,64 @@ class Decompiler(ast.NodeVisitor): self.write("}") def visit_JoinedStr(self, node: ast.JoinedStr) -> None: - has_parent = isinstance(self.get_parent_node(), ast.FormattedValue) + has_parent = isinstance(self.get_parent_node(), ast.FormattedValue) or ( + sys.version_info >= (3, 14) + and isinstance(self.get_parent_node(), ast.Interpolation) + ) with self.f_literalise_if(not has_parent): for value in node.values: - if isinstance(value, ast.Constant) and isinstance(value.value, str): - # always escape ' - self.write( - value.value.encode("unicode-escape") - .decode("ascii") - .replace("'", r"\'") - .replace("{", "{{") - .replace("}", "}}") - ) + self._write_tf_string_part(value) + + def _write_tf_string_part(self, value: ast.expr) -> None: + if isinstance(value, ast.Constant) and isinstance(value.value, str): + # always escape ' + self.write( + value.value.encode("unicode-escape") + .decode("ascii") + .replace("'", r"\'") + .replace("{", "{{") + .replace("}", "}}") + ) + else: + self.visit(value) + + if sys.version_info >= (3, 14): + + def visit_TemplateStr(self, node: ast.TemplateStr) -> None: + self.write("t'") + for value in node.values: + self._write_tf_string_part(value) + self.write("'") + + def visit_Interpolation(self, node: ast.Interpolation) -> None: + self.write("{") + if isinstance(node.value, ast.JoinedStr): + raise NotImplementedError( + "ast_decompiler does not support nested f-strings yet" + ) + add_space = isinstance( + node.value, (ast.Set, ast.Dict, ast.SetComp, ast.DictComp) + ) + if add_space: + self.write(" ") + self.write(node.str) + if node.conversion != -1: + self.write(f"!{chr(node.conversion)}") + if node.format_spec is not None: + self.write(":") + if isinstance(node.format_spec, ast.JoinedStr): + self.visit(node.format_spec) + elif isinstance(node.format_spec, ast.Constant) and isinstance( + node.format_spec.value, str + ): + self.write(node.format_spec.value) else: - self.visit(value) + raise TypeError( + f"format spec must be a string, not {node.format_spec}" + ) + if add_space: + self.write(" ") + self.write("}") def visit_Constant(self, node: ast.Constant) -> None: if isinstance(node.value, str): Index: ast_decompiler-0.8.0/pyproject.toml =================================================================== --- ast_decompiler-0.8.0.orig/pyproject.toml +++ ast_decompiler-0.8.0/pyproject.toml @@ -21,12 +21,12 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development", ] @@ -40,7 +40,7 @@ email = "jelle.zijlstra@gmail.com" include = ["CHANGELOG", "README.rst", "*/test*.py"] exclude = [] -[tool.pyanalyze] +[tool.pycroscope] paths = ["ast_decompiler", "tests"] import_paths = ["."] @@ -55,7 +55,7 @@ suggested_return_type = true incompatible_override = true [tool.black] -target_version = ['py36'] +target-version = ['py38', 'py39', 'py310', 'py311', 'py312', 'py313'] skip-magic-trailing-comma = true preview = true Index: ast_decompiler-0.8.0/tests/test_pep695.py =================================================================== --- ast_decompiler-0.8.0.orig/tests/test_pep695.py +++ ast_decompiler-0.8.0/tests/test_pep695.py @@ -29,3 +29,13 @@ type X = int type Y[T: (int, str), *Ts, *P] = T """ ) + + +@skip_before((3, 13)) +def test_type_var_default() -> None: + check( + """ +def f[T=int, *Ts=(int, str), **P=()]() -> None: + pass +""" + ) Index: ast_decompiler-0.8.0/tests/test_py3_syntax.py =================================================================== --- ast_decompiler-0.8.0.orig/tests/test_py3_syntax.py +++ ast_decompiler-0.8.0/tests/test_py3_syntax.py @@ -1,4 +1,4 @@ -from tests import check +from tests import check, skip_after, skip_before def test_MatMult() -> None: @@ -276,6 +276,7 @@ f({}) ) +@skip_after((3, 14)) def test_finally_continue() -> None: check( """ @@ -335,3 +336,11 @@ def f(x, /): def test_fstring_debug_specifier() -> None: check("f'{user=} {member_since=}'") check("f'{user=!s} {delta.days=:,d}'") + + +@skip_before((3, 14)) +def test_tstring() -> None: + check("t'a'") + check("t'{a}'") + check("t'{a!s}'") + check("t'{a:b}'")