From f866029c91e91f0eda74f8b14f8940b591ad1435 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 16:19:05 +0000 Subject: [PATCH 1/7] Improve annotationlib._template_to_ast() behavior --- Lib/annotationlib.py | 75 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index bee019cd51591e..15ec99c595babb 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -560,32 +560,81 @@ def unary_op(self): del _make_unary_op -def _template_to_ast(template): +def _conversion_ast_value(conversion): + """Convert a `conversion` character to an AST-friendly Constant.""" + return -1 if conversion is None else ord(conversion) + + +def _format(format_spec): + """Convert a `format_spec` string to an AST-friendly Constant.""" + value = format_spec or None # empty string -> None + return ast.Constant(value=value) + + +def _template_to_ast_constructor(template): + """Convert a `template` instance to a non-literal AST.""" + args = [] + for part in template: + match part: + case str(): + args.append(ast.Constant(value=part)) + case _: + interp = ast.Call( + func=ast.Name(id="Interpolation"), + args=[ + ast.Constant(value=part.value), + ast.Constant(value=part.expression), + ast.Constant(value=part.conversion), + ast.Constant(value=part.format_spec), + ] + ) + args.append(interp) + return ast.Call( + func=ast.Name(id="Template"), + args=args, + keywords=[], + ) + + +def _template_to_ast_literal(template, parsed): + """Convert a `template` instance to a t-string literal AST.""" values = [] + interp_count = 0 for part in template: match part: case str(): values.append(ast.Constant(value=part)) - # 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 - ), - format_spec=( - ast.Constant(value=part.format_spec) - if part.format_spec != "" - else None - ), + value=parsed[interp_count], + conversion=ord(part.conversion) if part.conversion else -1, + format_spec=part.format_spec or None, # "" -> None ) values.append(interp) + interp_count += 1 return ast.TemplateStr(values=values) +def _template_to_ast(template): + """Make a best-effort conversion of a `template` instance to an AST.""" + # gh-138558: Not all Template instances can be represented as t-string + # literals. Return the most accurate AST we can. See issue for details. + if any(part.expression == "" for part in template.interpolations): + return _template_to_ast_constructor(template) + + try: + # Wrap in parens to allow whitespace inside interpolation curly braces + parsed = tuple( + ast.parse(f"({part.expression})", mode="eval").body + for part in template.interpolations + ) + except SyntaxError: + return _template_to_ast_constructor(template) + + return _template_to_ast_literal(template, parsed) + + class _StringifierDict(dict): def __init__(self, namespace, *, globals=None, owner=None, is_class=False, format): super().__init__(namespace) From 2b794d96318b811c31b3f9e0133f5899b4c98827 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 17:15:19 +0000 Subject: [PATCH 2/7] Add new annotationlib tests and ensure they pass --- Lib/annotationlib.py | 4 +++- Lib/test/test_annotationlib.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 15ec99c595babb..83bf004e104a1a 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -609,7 +609,9 @@ def _template_to_ast_literal(template, parsed): str=part.expression, value=parsed[interp_count], conversion=ord(part.conversion) if part.conversion else -1, - format_spec=part.format_spec or None, # "" -> None + format_spec=ast.Constant(value=part.format_spec) + if part.format_spec + else None, ) values.append(interp) interp_count += 1 diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 88e0d611647f28..c5e87f8a6f2493 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -7,7 +7,7 @@ import functools import itertools import pickle -from string.templatelib import Template +from string.templatelib import Template, Interpolation import typing import unittest from annotationlib import ( @@ -282,6 +282,7 @@ def f( a: t"a{b}c{d}e{f}g", b: t"{a:{1}}", c: t"{a | b * c}", + gh138558: t"{ 0}", ): pass annos = get_annotations(f, format=Format.STRING) @@ -293,6 +294,7 @@ def f( # interpolations in the format spec are eagerly evaluated so we can't recover the source "b": "t'{a:1}'", "c": "t'{a | b * c}'", + "gh138558": "t'{ 0}'", }) def g( @@ -1350,6 +1352,20 @@ def nested(): self.assertEqual(type_repr("1"), "'1'") self.assertEqual(type_repr(Format.VALUE), repr(Format.VALUE)) self.assertEqual(type_repr(MyClass()), "my repr") + # gh138558 tests + self.assertEqual(type_repr(t'''{ 0 + & 1 + | 2 + }'''), 't"""{ 0\n & 1\n | 2}"""') + self.assertEqual( + type_repr(Template("hi", Interpolation(42, "42"))), "t'hi{42}'" + ) + self.assertEqual( + type_repr(Template("hi", Interpolation(42))), + "Template('hi', Interpolation(42, '', None, ''))", + ) + # gh138558: perhaps in the future, we can improve this behavior: + self.assertEqual(type_repr(Template(Interpolation(42, "99"))), "t'{99}'") class TestAnnotationsToString(unittest.TestCase): From 23d5140b6a5b98aa2c3fb52f8e8f2746e62de226 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 17:20:09 +0000 Subject: [PATCH 3/7] Add required NEWS blurb for gh-issue-138558 --- .../2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst new file mode 100644 index 00000000000000..1c4b7d7c623d25 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst @@ -0,0 +1 @@ +Fix handling of unusual t-string annotations in annotationlib. Path by Dave Peck. From 59fd0165a4f8896a9d9c53d3a9784ebfb817dd69 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 17:22:57 +0000 Subject: [PATCH 4/7] Remove vestigial code from annotationlib --- Lib/annotationlib.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 83bf004e104a1a..d834733e09b4a0 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -560,17 +560,6 @@ def unary_op(self): del _make_unary_op -def _conversion_ast_value(conversion): - """Convert a `conversion` character to an AST-friendly Constant.""" - return -1 if conversion is None else ord(conversion) - - -def _format(format_spec): - """Convert a `format_spec` string to an AST-friendly Constant.""" - value = format_spec or None # empty string -> None - return ast.Constant(value=value) - - def _template_to_ast_constructor(template): """Convert a `template` instance to a non-literal AST.""" args = [] From 68dba76439cafb58eaf4e750293cba66337d8234 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 17:32:28 +0000 Subject: [PATCH 5/7] "Patch", not "Path" --- .../2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst index 1c4b7d7c623d25..23c995d2452f7b 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-09-17-17-17-21.gh-issue-138558.0VbzCH.rst @@ -1 +1 @@ -Fix handling of unusual t-string annotations in annotationlib. Path by Dave Peck. +Fix handling of unusual t-string annotations in annotationlib. Patch by Dave Peck. From 8fb8420758fd4bd4ec3086a8c6ca90619acfa3dc Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 17:45:08 +0000 Subject: [PATCH 6/7] Fix and test one last interesting edge case in annotationlib --- Lib/annotationlib.py | 10 ++++------ Lib/test/test_annotationlib.py | 4 ++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index d834733e09b4a0..74a5cb08b2fa5c 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -578,11 +578,7 @@ def _template_to_ast_constructor(template): ] ) args.append(interp) - return ast.Call( - func=ast.Name(id="Template"), - args=args, - keywords=[], - ) + return ast.Call(func=ast.Name(id="Template"), args=args, keywords=[]) def _template_to_ast_literal(template, parsed): @@ -611,7 +607,9 @@ def _template_to_ast(template): """Make a best-effort conversion of a `template` instance to an AST.""" # gh-138558: Not all Template instances can be represented as t-string # literals. Return the most accurate AST we can. See issue for details. - if any(part.expression == "" for part in template.interpolations): + + # If any expr is empty or whitespace only, we cannot convert to a literal. + if any(not part.expression.strip() for part in template.interpolations): return _template_to_ast_constructor(template) try: diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index c5e87f8a6f2493..a8a8bcec76a429 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1364,6 +1364,10 @@ def nested(): type_repr(Template("hi", Interpolation(42))), "Template('hi', Interpolation(42, '', None, ''))", ) + self.assertEqual( + type_repr(Template("hi", Interpolation(42, " "))), + "Template('hi', Interpolation(42, ' ', None, ''))", + ) # gh138558: perhaps in the future, we can improve this behavior: self.assertEqual(type_repr(Template(Interpolation(42, "99"))), "t'{99}'") From 2e51a64e8fa62da0970364cc71348350a90bc233 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 17 Sep 2025 17:47:28 +0000 Subject: [PATCH 7/7] Clarify code intent. --- Lib/annotationlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 74a5cb08b2fa5c..43e1d51bc4b807 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -609,7 +609,7 @@ def _template_to_ast(template): # literals. Return the most accurate AST we can. See issue for details. # If any expr is empty or whitespace only, we cannot convert to a literal. - if any(not part.expression.strip() for part in template.interpolations): + if any(part.expression.strip() == "" for part in template.interpolations): return _template_to_ast_constructor(template) try: