From 1cfdd2d2ea818b5d46aee753652788fd99ac6938 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 6 May 2025 22:55:26 -0700 Subject: [PATCH 1/4] gh-133551: Support t-strings in annotationlib I don't know why you'd use t-strings in annotations, but now if you do, the STRING format will do a great job of recovering the source code. --- Lib/annotationlib.py | 26 ++++++++++++++ Lib/test/.ruff.toml | 1 + Lib/test/test_annotationlib.py | 36 +++++++++++++++++++ ...-05-06-22-54-37.gh-issue-133551.rfy1tJ.rst | 2 ++ 4 files changed, 65 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-05-06-22-54-37.gh-issue-133551.rfy1tJ.rst diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c0b1d4395d14ed..b75cc00d0c5d9a 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -305,6 +305,9 @@ def __repr__(self): return f"ForwardRef({self.__forward_arg__!r}{''.join(extra)})" +_Template = type(t"{1}") + + class _Stringifier: # Must match the slots on ForwardRef, so we can turn an instance of one into an # instance of the other in place. @@ -341,6 +344,8 @@ def __convert_to_ast(self, other): if isinstance(other.__ast_node__, str): return ast.Name(id=other.__ast_node__), other.__extra_names__ return other.__ast_node__, other.__extra_names__ + elif type(other) is _Template: + return _template_to_ast(other), None elif ( # In STRING format we don't bother with the create_unique_name() dance; # it's better to emit the repr() of the object instead of an opaque name. @@ -560,6 +565,22 @@ def unary_op(self): del _make_unary_op +def _template_to_ast(template): + values = [] + for part in template: + match part: + case str(): + values.append(ast.Constant(value=part)) + case _: # Interpolation, but we don't want to import the string module + interp = ast.Interpolation( + value=ast.parse(part.expression), + conversion=ord(part.conversion) if part.conversion is not None else -1, + format_spec=ast.Constant(value=part.format_spec) if part.format_spec != "" else None, + ) + values.append(interp) + return ast.TemplateStr(values=values) + + class _StringifierDict(dict): def __init__(self, namespace, *, globals=None, owner=None, is_class=False, format): super().__init__(namespace) @@ -784,6 +805,8 @@ def _stringify_single(anno): # We have to handle str specially to support PEP 563 stringified annotations. elif isinstance(anno, str): return anno + elif isinstance(anno, _Template): + return ast.unparse(_template_to_ast(anno)) else: return repr(anno) @@ -976,6 +999,9 @@ def type_repr(value): if value.__module__ == "builtins": return value.__qualname__ return f"{value.__module__}.{value.__qualname__}" + elif isinstance(value, _Template): + tree = _template_to_ast(value) + return ast.unparse(tree) if value is ...: return "..." return repr(value) diff --git a/Lib/test/.ruff.toml b/Lib/test/.ruff.toml index a1eac32a83aae3..c582a7f94ae9c9 100644 --- a/Lib/test/.ruff.toml +++ b/Lib/test/.ruff.toml @@ -11,6 +11,7 @@ extend-exclude = [ # SyntaxError because of t-strings "test_tstring.py", "test_string/test_templatelib.py", + "test_annotationlib.py", # New grammar constructions may not yet be recognized by Ruff, # and tests re-use the same names as only the grammar is being checked. "test_grammar.py", diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index c3c245ddaf86d1..bbea1665b0377c 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -7,6 +7,7 @@ import functools import itertools import pickle +from string.templatelib import Interpolation, Template import typing import unittest from annotationlib import ( @@ -273,6 +274,41 @@ def f( }, ) + def test_template_str(self): + def f( + x: t"{a}", + y: list[t"{a}"], + z: t"{a:b} {c!r} {d!s:t}", + a: t"a{b}c{d}e{f}g", + b: t"{a:{1}}", + ): pass + + annos = get_annotations(f, format=Format.STRING) + self.assertEqual(annos, { + "x": "t'{a}'", + "y": "list[t'{a}']", + "z": "t'{a:b} {c!r} {d!s:t}'", + "a": "t'a{b}c{d}e{f}g'", + # interpolations in the format spec are eagerly evaluated so we can't recover the source + "b": "t'{a:1}'", + }) + + def g( + x: t"{a}", + ): ... + + annos = get_annotations(g, format=Format.FORWARDREF) + templ = annos["x"] + # Template and Interpolation don't have __eq__ so we have to compare manually + self.assertIsInstance(templ, Template) + self.assertEqual(templ.strings, ("", "")) + self.assertEqual(len(templ.interpolations), 1) + interp = templ.interpolations[0] + self.assertEqual(interp.value, support.EqualToForwardRef("a", owner=g)) + self.assertEqual(interp.expression, "a") + self.assertIsNone(interp.conversion) + self.assertEqual(interp.format_spec, "") + def test_getitem(self): def f(x: undef1[str, undef2]): pass diff --git a/Misc/NEWS.d/next/Library/2025-05-06-22-54-37.gh-issue-133551.rfy1tJ.rst b/Misc/NEWS.d/next/Library/2025-05-06-22-54-37.gh-issue-133551.rfy1tJ.rst new file mode 100644 index 00000000000000..7fedc0818dc469 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-05-06-22-54-37.gh-issue-133551.rfy1tJ.rst @@ -0,0 +1,2 @@ +Support t-strings (:pep:`750`) in :mod:`annotationlib`. Patch by Jelle +Zijlstra. From 242387cd5cfddafbcceb28d55ea1f6546e5c8cd1 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 7 May 2025 06:35:47 -0700 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> --- Lib/annotationlib.py | 8 ++++++-- Lib/test/.ruff.toml | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index b75cc00d0c5d9a..b46c590451d8b7 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -305,7 +305,7 @@ def __repr__(self): return f"ForwardRef({self.__forward_arg__!r}{''.join(extra)})" -_Template = type(t"{1}") +_Template = type(t"") class _Stringifier: @@ -575,7 +575,11 @@ def _template_to_ast(template): interp = ast.Interpolation( value=ast.parse(part.expression), conversion=ord(part.conversion) if part.conversion is not None else -1, - format_spec=ast.Constant(value=part.format_spec) if part.format_spec != "" else None, + format_spec=( + ast.Constant(value=part.format_spec) + if part.format_spec != "" + else None + ), ) values.append(interp) return ast.TemplateStr(values=values) diff --git a/Lib/test/.ruff.toml b/Lib/test/.ruff.toml index c582a7f94ae9c9..7aa8a4785d6844 100644 --- a/Lib/test/.ruff.toml +++ b/Lib/test/.ruff.toml @@ -9,9 +9,9 @@ extend-exclude = [ "encoded_modules/module_iso_8859_1.py", "encoded_modules/module_koi8_r.py", # SyntaxError because of t-strings - "test_tstring.py", - "test_string/test_templatelib.py", "test_annotationlib.py", + "test_string/test_templatelib.py", + "test_tstring.py", # New grammar constructions may not yet be recognized by Ruff, # and tests re-use the same names as only the grammar is being checked. "test_grammar.py", From 84bc5b498c265e67d9ca6f4a4375359236db7c35 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 7 May 2025 06:54:57 -0700 Subject: [PATCH 3/4] fix warning, one more test --- Lib/annotationlib.py | 1 + Lib/test/test_annotationlib.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index b46c590451d8b7..f914d4417a9e5c 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -573,6 +573,7 @@ def _template_to_ast(template): values.append(ast.Constant(value=part)) case _: # Interpolation, but we don't want to import the string module interp = ast.Interpolation( + str=part.expression, value=ast.parse(part.expression), conversion=ord(part.conversion) if part.conversion is not None else -1, format_spec=( diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index bbea1665b0377c..4af97c82de9d46 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -281,6 +281,7 @@ def f( z: t"{a:b} {c!r} {d!s:t}", a: t"a{b}c{d}e{f}g", b: t"{a:{1}}", + c: t"{a | b * c}", ): pass annos = get_annotations(f, format=Format.STRING) @@ -291,6 +292,7 @@ def f( "a": "t'a{b}c{d}e{f}g'", # interpolations in the format spec are eagerly evaluated so we can't recover the source "b": "t'{a:1}'", + "c": "t'{a | b * c}'", }) def g( From c1c18004174a29550472b0ee89df67861a7f3b67 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 7 May 2025 07:32:23 -0700 Subject: [PATCH 4/4] line length --- Lib/annotationlib.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index f914d4417a9e5c..32b8553458930c 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -571,11 +571,16 @@ def _template_to_ast(template): match part: case str(): values.append(ast.Constant(value=part)) - case _: # Interpolation, but we don't want to import the string module + # Interpolation, but we don't want to import the string module + case _: interp = ast.Interpolation( str=part.expression, value=ast.parse(part.expression), - conversion=ord(part.conversion) if part.conversion is not None else -1, + conversion=( + ord(part.conversion) + if part.conversion is not None + else -1 + ), format_spec=( ast.Constant(value=part.format_spec) if part.format_spec != ""