diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 8204c762cce8a2b..cf81ca6216560f3 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -750,13 +750,22 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False): argdefs=annotate.__defaults__, kwdefaults=annotate.__kwdefaults__, ) - annos = func(Format.VALUE_WITH_FAKE_GLOBALS) + try: + annos = func(Format.VALUE_WITH_FAKE_GLOBALS) + except ValueError as exc: + # Dict comprehensions such as `{k: v for k, v in items}` unpack + # each iterated element. Fake-globals iteration yields a single + # starred stringifier, so unpacking raises ValueError. Recover + # the original annotation text from source when we can. + # Changing _Stringifier.__iter__ would break [*a] stringification. + if _is_unpack_value_error(exc): + sourced = _string_annotations_from_source(owner) + if sourced is not None: + return sourced + raise if _is_evaluate: return _stringify_single(annos) - return { - key: _stringify_single(val) - for key, val in annos.items() - } + return _stringify_annotation_dict(annos, owner) elif format == Format.FORWARDREF: # FORWARDREF is implemented similarly to STRING, but there are two changes, # at the beginning and the end of the process. @@ -878,6 +887,175 @@ def _build_closure(annotate, owner, is_class, stringifier_dict, *, allow_evaluat return tuple(new_closure), cell_dict +def _string_annotations_from_source(obj): + """Best-effort STRING annotations reconstructed from *obj*'s source AST. + + Used when fake-globals evaluation cannot stringify an annotation (dict + comprehensions, lambdas, generator expressions). Returns None if source + is unavailable. inspect is imported lazily because it imports this module. + """ + if obj is None: + return None + try: + import inspect + import textwrap + source = inspect.getsource(obj) + except (OSError, TypeError, RecursionError): + return None + source = textwrap.dedent(source) + try: + tree = ast.parse(source) + except SyntaxError: + return None + if not tree.body: + return None + node = tree.body[0] + result = {} + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for arg in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ): + if arg.annotation is not None: + result[arg.arg] = _annotation_ast_to_string(arg.annotation) + if node.args.vararg is not None and node.args.vararg.annotation is not None: + result[node.args.vararg.arg] = _annotation_ast_to_string( + node.args.vararg.annotation + ) + if node.args.kwarg is not None and node.args.kwarg.annotation is not None: + result[node.args.kwarg.arg] = _annotation_ast_to_string( + node.args.kwarg.annotation + ) + if node.returns is not None: + result["return"] = _annotation_ast_to_string(node.returns) + return result + if isinstance(node, ast.ClassDef): + _collect_class_annassigns(node.body, result) + return result + return None + + +def _annotation_ast_to_string(node): + # Match fake-globals STRING output for quoted annotations: a: "int" -> "int". + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return ast.unparse(node) + + +def _collect_class_annassigns(body, result): + """Collect class annotations, including those inside simple control flow. + + Nested functions and classes are skipped so their annotations are not + attributed to the enclosing class. + """ + for stmt in body: + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + result[stmt.target.id] = _annotation_ast_to_string(stmt.annotation) + elif isinstance(stmt, ast.If): + _collect_class_annassigns(stmt.body, result) + _collect_class_annassigns(stmt.orelse, result) + elif isinstance(stmt, ast.Try): + _collect_class_annassigns(stmt.body, result) + for handler in stmt.handlers: + _collect_class_annassigns(handler.body, result) + _collect_class_annassigns(stmt.orelse, result) + _collect_class_annassigns(stmt.finalbody, result) + elif isinstance(stmt, (ast.With, ast.AsyncWith)): + _collect_class_annassigns(stmt.body, result) + elif isinstance(stmt, (ast.For, ast.AsyncFor, ast.While)): + _collect_class_annassigns(stmt.body, result) + _collect_class_annassigns(stmt.orelse, result) + elif isinstance(stmt, ast.Match): + for case in stmt.cases: + _collect_class_annassigns(case.body, result) + + +def _is_unpack_value_error(exc): + if not exc.args: + return False + msg = exc.args[0] + return isinstance(msg, str) and "values to unpack" in msg + + +def _is_runtime_constructed(value): + """True if *value* was created at annotation-eval time and has no AST. + + Lambdas and generator expressions are syntax, not name lookups, so the + fake-globals stringifier never sees them. Their repr() embeds a memory + address and is not a valid annotation string. + """ + return isinstance(value, ( + types.FunctionType, + types.BuiltinFunctionType, + types.MethodType, + types.GeneratorType, + types.AsyncGeneratorType, + types.CoroutineType, + )) + + +def _contains_runtime_constructed(value): + if _is_runtime_constructed(value): + return True + if isinstance(value, dict): + return any( + _contains_runtime_constructed(k) or _contains_runtime_constructed(v) + for k, v in value.items() + ) + if isinstance(value, (list, tuple, set, frozenset)): + return any(_contains_runtime_constructed(v) for v in value) + return False + + +def _stringify_annotation_dict(annos, owner): + # Only read source when fake-globals produced a runtime object we cannot + # stringify (lambda, genexpr, or a container holding one), or when the + # fallback text still embeds a memory address. + sourced = _sentinel + result = {} + for key, val in annos.items(): + text = _stringify_single(val) + needs_source = ( + _contains_runtime_constructed(val) + or "0x" in text.lower() + ) + if needs_source: + if sourced is _sentinel: + sourced = _string_annotations_from_source(owner) + if sourced is not None and key in sourced: + result[key] = sourced[key] + continue + result[key] = text + return result + + +def _stringify_container(anno): + if isinstance(anno, list): + inner = ", ".join(_stringify_single(v) for v in anno) + return f"[{inner}]" + if isinstance(anno, tuple): + inner = ", ".join(_stringify_single(v) for v in anno) + if len(anno) == 1: + inner += "," + return f"({inner})" + if isinstance(anno, set): + if not anno: + return "set()" + inner = ", ".join(_stringify_single(v) for v in anno) + return f"{{{inner}}}" + if isinstance(anno, frozenset): + if not anno: + return "frozenset()" + inner = ", ".join(_stringify_single(v) for v in anno) + return f"frozenset({{{inner}}})" + inner = ", ".join( + f"{_stringify_single(k)}: {_stringify_single(v)}" + for k, v in anno.items() + ) + return f"{{{inner}}}" + + def _stringify_single(anno): if anno is ...: return "..." @@ -886,6 +1064,15 @@ def _stringify_single(anno): return anno elif isinstance(anno, _Template): return ast.unparse(_template_to_ast(anno)) + elif _is_runtime_constructed(anno): + # Lambdas and generator expressions are syntax, not name lookups. + # repr() embeds a memory address; type_repr() is stable. + return type_repr(anno) + elif ( + isinstance(anno, (list, tuple, set, frozenset, dict)) + and _contains_runtime_constructed(anno) + ): + return _stringify_container(anno) else: return repr(anno) @@ -1089,10 +1276,25 @@ def type_repr(value): repr() for most objects. """ - if isinstance(value, (type, types.FunctionType, types.BuiltinFunctionType)): - if value.__module__ == "builtins": + if isinstance(value, ( + type, + types.FunctionType, + types.BuiltinFunctionType, + types.MethodType, + )): + if getattr(value, "__module__", None) == "builtins": return value.__qualname__ - return f"{value.__module__}.{value.__qualname__}" + module = getattr(value, "__module__", None) + if module: + return f"{module}.{value.__qualname__}" + return value.__qualname__ + elif isinstance(value, ( + types.GeneratorType, + types.AsyncGeneratorType, + types.CoroutineType, + )): + # repr() of these objects embeds a memory address. + return value.__qualname__ elif isinstance(value, _Template): tree = _template_to_ast(value) return ast.unparse(tree) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 530114161701b5a..312ddd8a7623cef 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -545,6 +545,152 @@ def f(x: x | (1).__class__, y: (1).__class__): {"x": "x | ", "y": ""}, ) + def test_comprehensions(self): + # gh-157056: pair-unpacking comprehensions raised ValueError, and + # generator expressions were stringified with a memory address. + def f( + dictcomp: {k: v for k, v in items}, + listcomp: [k for k, v in items], + setcomp: {k for k, v in items}, + genexpr: (w for w in seq), + ): + pass + + anno = get_annotations(f, format=Format.STRING) + self.assertEqual( + anno, + { + "dictcomp": "{k: v for k, v in items}", + "listcomp": "[k for k, v in items]", + "setcomp": "{k for k, v in items}", + "genexpr": "(w for w in seq)", + }, + ) + self.assertNotIn("0x", anno["genexpr"].lower()) + + def test_lambda(self): + def f( + lam: lambda q: q, + nested_list: [lambda q: q], + nested_dict: {"h": lambda q: q}, + nested_frozenset: frozenset({lambda q: q}), + ): + pass + + anno = get_annotations(f, format=Format.STRING) + self.assertEqual( + anno, + { + "lam": "lambda q: q", + "nested_list": "[lambda q: q]", + "nested_dict": "{'h': lambda q: q}", + "nested_frozenset": "frozenset({lambda q: q})", + }, + ) + for value in anno.values(): + self.assertNotIn("0x", value.lower()) + + def test_quoted_string_with_unpack_fallback(self): + def f(a: "int", b: {k: v for k, v in items}): + pass + + anno = get_annotations(f, format=Format.STRING) + self.assertEqual(anno, {"a": "int", "b": "{k: v for k, v in items}"}) + + def test_return_varargs_kwargs(self): + def f( + *xs: {k: v for k, v in items}, + **kw: lambda q: q, + ) -> {k: v for k, v in items}: + pass + + anno = get_annotations(f, format=Format.STRING) + self.assertEqual( + anno, + { + "xs": "{k: v for k, v in items}", + "kw": "lambda q: q", + "return": "{k: v for k, v in items}", + }, + ) + + def test_nested_and_decorated(self): + def deco(fn): + return fn + + def outer(): + @deco + def inner(x: {k: v for k, v in items}, y: lambda q: q): + pass + return inner + + anno = get_annotations(outer(), format=Format.STRING) + self.assertEqual( + anno, + {"x": "{k: v for k, v in items}", "y": "lambda q: q"}, + ) + + def test_async_function(self): + async def f(x: {k: v for k, v in items}) -> int: + pass + + anno = get_annotations(f, format=Format.STRING) + self.assertEqual(anno, {"x": "{k: v for k, v in items}", "return": "int"}) + + def test_method_and_staticmethod(self): + class C: + def meth(self, x: {k: v for k, v in items}) -> str: + pass + + @staticmethod + def sm(x: lambda q: q): + pass + + self.assertEqual( + get_annotations(C.meth, format=Format.STRING), + {"x": "{k: v for k, v in items}", "return": "str"}, + ) + self.assertEqual( + get_annotations(C.sm, format=Format.STRING), + {"x": "lambda q: q"}, + ) + + def test_class_with_conditional_annotation(self): + class C: + a: int + b: {k: v for k, v in items} + if True: + c: str + + anno = get_annotations(C, format=Format.STRING) + self.assertEqual( + anno, + { + "a": "int", + "b": "{k: v for k, v in items}", + "c": "str", + }, + ) + + def test_string_format_does_not_read_source(self): + import inspect + + def f(x: int) -> str: + pass + + def boom(*args, **kwargs): + raise AssertionError("inspect.getsource should not be consulted") + + with support.swap_attr(inspect, "getsource", boom): + anno = get_annotations(f, format=Format.STRING) + self.assertEqual(anno, {"x": "int", "return": "str"}) + + def test_exec_without_source_still_raises(self): + ns = {} + exec("def f(x: {k: v for k, v in items}): pass", ns) + with self.assertRaisesRegex(ValueError, "values to unpack"): + get_annotations(ns["f"], format=Format.STRING) + class TestGetAnnotations(unittest.TestCase): def test_builtin_type(self): @@ -1859,6 +2005,12 @@ def nested(): self.assertEqual(type_repr(len), "len") self.assertEqual(type_repr(type_repr), "annotationlib.type_repr") self.assertEqual(type_repr(times_three), f"{__name__}.times_three") + lam = (lambda q: q) + self.assertTrue(type_repr(lam).endswith("")) + self.assertNotIn("0x", type_repr(lam).lower()) + gen = (w for w in ()) + self.assertTrue(type_repr(gen).endswith("")) + self.assertNotIn("0x", type_repr(gen).lower()) self.assertEqual(type_repr(...), "...") self.assertEqual(type_repr(None), "None") self.assertEqual(type_repr(1), "1") diff --git a/Misc/NEWS.d/next/Library/2026-09-08-01-00-00.gh-issue-157056.annotstr.rst b/Misc/NEWS.d/next/Library/2026-09-08-01-00-00.gh-issue-157056.annotstr.rst new file mode 100644 index 000000000000000..4436d5aaabc24f0 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-08-01-00-00.gh-issue-157056.annotstr.rst @@ -0,0 +1,4 @@ +:func:`annotationlib.get_annotations` with ``format=Format.STRING`` no +longer fails on dict-comprehension annotations, and no longer emits +non-deterministic strings that embed a memory address for ``lambda`` and +generator-expression annotations.