]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-124412: Add helpers for converting annotations to source format (#124551)
authorJelle Zijlstra <jelle.zijlstra@gmail.com>
Thu, 26 Sep 2024 00:01:09 +0000 (17:01 -0700)
committerGitHub <noreply@github.com>
Thu, 26 Sep 2024 00:01:09 +0000 (00:01 +0000)
Doc/library/annotationlib.rst
Lib/_collections_abc.py
Lib/annotationlib.py
Lib/test/test_annotationlib.py
Lib/typing.py

index 1e72c5421674bc1811b77ead0f0119cd7a2701eb..2219e37f6b06775afcd42f8758786393701d2a4c 100644 (file)
@@ -197,6 +197,27 @@ Classes
 Functions
 ---------
 
+.. function:: annotations_to_source(annotations)
+
+   Convert an annotations dict containing runtime values to a
+   dict containing only strings. If the values are not already strings,
+   they are converted using :func:`value_to_source`.
+   This is meant as a helper for user-provided
+   annotate functions that support the :attr:`~Format.SOURCE` format but
+   do not have access to the code creating the annotations.
+
+   For example, this is used to implement the :attr:`~Format.SOURCE` for
+   :class:`typing.TypedDict` classes created through the functional syntax:
+
+   .. doctest::
+
+       >>> from typing import TypedDict
+       >>> Movie = TypedDict("movie", {"name": str, "year": int})
+       >>> get_annotations(Movie, format=Format.SOURCE)
+       {'name': 'str', 'year': 'int'}
+
+   .. versionadded:: 3.14
+
 .. function:: call_annotate_function(annotate, format, *, owner=None)
 
    Call the :term:`annotate function` *annotate* with the given *format*,
@@ -347,3 +368,18 @@ Functions
       {'a': <class 'int'>, 'b': <class 'str'>, 'return': <class 'float'>}
 
    .. versionadded:: 3.14
+
+.. function:: value_to_source(value)
+
+   Convert an arbitrary Python value to a format suitable for use by the
+   :attr:`~Format.SOURCE` format. This calls :func:`repr` for most
+   objects, but has special handling for some objects, such as type objects.
+
+   This is meant as a helper for user-provided
+   annotate functions that support the :attr:`~Format.SOURCE` format but
+   do not have access to the code creating the annotations. It can also
+   be used to provide a user-friendly string representation for other
+   objects that contain values that are commonly encountered in annotations.
+
+   .. versionadded:: 3.14
+
index 75252b3a87f9c4d541a1f4db3ee5628106816906..4139cbadf93e135ec1d0f45736fd075c2c1fa4b5 100644 (file)
@@ -485,9 +485,10 @@ class _CallableGenericAlias(GenericAlias):
     def __repr__(self):
         if len(self.__args__) == 2 and _is_param_expr(self.__args__[0]):
             return super().__repr__()
+        from annotationlib import value_to_source
         return (f'collections.abc.Callable'
-                f'[[{", ".join([_type_repr(a) for a in self.__args__[:-1]])}], '
-                f'{_type_repr(self.__args__[-1])}]')
+                f'[[{", ".join([value_to_source(a) for a in self.__args__[:-1]])}], '
+                f'{value_to_source(self.__args__[-1])}]')
 
     def __reduce__(self):
         args = self.__args__
@@ -524,23 +525,6 @@ def _is_param_expr(obj):
     names = ('ParamSpec', '_ConcatenateGenericAlias')
     return obj.__module__ == 'typing' and any(obj.__name__ == name for name in names)
 
-def _type_repr(obj):
-    """Return the repr() of an object, special-casing types (internal helper).
-
-    Copied from :mod:`typing` since collections.abc
-    shouldn't depend on that module.
-    (Keep this roughly in sync with the typing version.)
-    """
-    if isinstance(obj, type):
-        if obj.__module__ == 'builtins':
-            return obj.__qualname__
-        return f'{obj.__module__}.{obj.__qualname__}'
-    if obj is Ellipsis:
-        return '...'
-    if isinstance(obj, FunctionType):
-        return obj.__name__
-    return repr(obj)
-
 
 class Callable(metaclass=ABCMeta):
 
index 20c9542efac2d8bef923fc061db5b4bf9249ba42..a027f4de3dfed6bec2f566636422d782e187d5c6 100644 (file)
@@ -15,6 +15,8 @@ __all__ = [
     "call_evaluate_function",
     "get_annotate_function",
     "get_annotations",
+    "annotations_to_source",
+    "value_to_source",
 ]
 
 
@@ -693,7 +695,7 @@ def get_annotations(
                 return ann
             # But if we didn't get it, we use __annotations__ instead.
             ann = _get_dunder_annotations(obj)
-            return ann
+            return annotations_to_source(ann)
         case _:
             raise ValueError(f"Unsupported format {format!r}")
 
@@ -762,6 +764,33 @@ def get_annotations(
     return return_value
 
 
+def value_to_source(value):
+    """Convert a Python value to a format suitable for use with the SOURCE format.
+
+    This is inteded as a helper for tools that support the SOURCE format but do
+    not have access to the code that originally produced the annotations. It uses
+    repr() for most objects.
+
+    """
+    if isinstance(value, type):
+        if value.__module__ == "builtins":
+            return value.__qualname__
+        return f"{value.__module__}.{value.__qualname__}"
+    if value is ...:
+        return "..."
+    if isinstance(value, (types.FunctionType, types.BuiltinFunctionType)):
+        return value.__name__
+    return repr(value)
+
+
+def annotations_to_source(annotations):
+    """Convert an annotation dict containing values to approximately the SOURCE format."""
+    return {
+        n: t if isinstance(t, str) else value_to_source(t)
+        for n, t in annotations.items()
+    }
+
+
 def _get_and_call_annotate(obj, format):
     annotate = get_annotate_function(obj)
     if annotate is not None:
index 5b052dab5007d64e96c2d4ff6df27fd197db8c37..dc1106aee1e2f197a30f6ac8eab4da408a93fbb4 100644 (file)
@@ -7,7 +7,14 @@ import functools
 import itertools
 import pickle
 import unittest
-from annotationlib import Format, ForwardRef, get_annotations, get_annotate_function
+from annotationlib import (
+    Format,
+    ForwardRef,
+    get_annotations,
+    get_annotate_function,
+    annotations_to_source,
+    value_to_source,
+)
 from typing import Unpack
 
 from test import support
@@ -25,6 +32,11 @@ def times_three(fn):
     return wrapper
 
 
+class MyClass:
+    def __repr__(self):
+        return "my repr"
+
+
 class TestFormat(unittest.TestCase):
     def test_enum(self):
         self.assertEqual(annotationlib.Format.VALUE.value, 1)
@@ -324,7 +336,10 @@ class TestForwardRefClass(unittest.TestCase):
         # namespaces without going through eval()
         self.assertIs(ForwardRef("int").evaluate(), int)
         self.assertIs(ForwardRef("int").evaluate(locals={"int": str}), str)
-        self.assertIs(ForwardRef("int").evaluate(locals={"int": float}, globals={"int": str}), float)
+        self.assertIs(
+            ForwardRef("int").evaluate(locals={"int": float}, globals={"int": str}),
+            float,
+        )
         self.assertIs(ForwardRef("int").evaluate(globals={"int": str}), str)
         with support.swap_attr(builtins, "int", dict):
             self.assertIs(ForwardRef("int").evaluate(), dict)
@@ -788,9 +803,8 @@ class TestGetAnnotations(unittest.TestCase):
             annotationlib.get_annotations(ha, format=Format.FORWARDREF), {"x": int}
         )
 
-        # TODO(gh-124412): This should return {'x': 'int'} instead.
         self.assertEqual(
-            annotationlib.get_annotations(ha, format=Format.SOURCE), {"x": int}
+            annotationlib.get_annotations(ha, format=Format.SOURCE), {"x": "int"}
         )
 
     def test_raising_annotations_on_custom_object(self):
@@ -1078,6 +1092,29 @@ class TestGetAnnotateFunction(unittest.TestCase):
         self.assertEqual(get_annotate_function(C)(Format.VALUE), {"a": int})
 
 
+class TestToSource(unittest.TestCase):
+    def test_value_to_source(self):
+        self.assertEqual(value_to_source(int), "int")
+        self.assertEqual(value_to_source(MyClass), "test.test_annotationlib.MyClass")
+        self.assertEqual(value_to_source(len), "len")
+        self.assertEqual(value_to_source(value_to_source), "value_to_source")
+        self.assertEqual(value_to_source(times_three), "times_three")
+        self.assertEqual(value_to_source(...), "...")
+        self.assertEqual(value_to_source(None), "None")
+        self.assertEqual(value_to_source(1), "1")
+        self.assertEqual(value_to_source("1"), "'1'")
+        self.assertEqual(value_to_source(Format.VALUE), repr(Format.VALUE))
+        self.assertEqual(value_to_source(MyClass()), "my repr")
+
+    def test_annotations_to_source(self):
+        self.assertEqual(annotations_to_source({}), {})
+        self.assertEqual(annotations_to_source({"x": int}), {"x": "int"})
+        self.assertEqual(annotations_to_source({"x": "int"}), {"x": "int"})
+        self.assertEqual(
+            annotations_to_source({"x": int, "y": str}), {"x": "int", "y": "str"}
+        )
+
+
 class TestAnnotationLib(unittest.TestCase):
     def test__all__(self):
         support.check__all__(self, annotationlib)
index 9377e771d60f4bf18f1c9c039e8007b7b0366fad..252eef32cd88a490a86c5eb4e9fb20b91f3eb270 100644 (file)
@@ -242,21 +242,10 @@ def _type_repr(obj):
     typically enough to uniquely identify a type.  For everything
     else, we fall back on repr(obj).
     """
-    # When changing this function, don't forget about
-    # `_collections_abc._type_repr`, which does the same thing
-    # and must be consistent with this one.
-    if isinstance(obj, type):
-        if obj.__module__ == 'builtins':
-            return obj.__qualname__
-        return f'{obj.__module__}.{obj.__qualname__}'
-    if obj is ...:
-        return '...'
-    if isinstance(obj, types.FunctionType):
-        return obj.__name__
     if isinstance(obj, tuple):
         # Special case for `repr` of types with `ParamSpec`:
         return '[' + ', '.join(_type_repr(t) for t in obj) + ']'
-    return repr(obj)
+    return annotationlib.value_to_source(obj)
 
 
 def _collect_type_parameters(args, *, enforce_default_ordering: bool = True):
@@ -2948,14 +2937,10 @@ def _make_eager_annotate(types):
         if format in (annotationlib.Format.VALUE, annotationlib.Format.FORWARDREF):
             return checked_types
         else:
-            return _convert_to_source(types)
+            return annotationlib.annotations_to_source(types)
     return annotate
 
 
-def _convert_to_source(types):
-    return {n: t if isinstance(t, str) else _type_repr(t) for n, t in types.items()}
-
-
 # attributes prohibited to set in NamedTuple class syntax
 _prohibited = frozenset({'__new__', '__init__', '__slots__', '__getnewargs__',
                          '_fields', '_field_defaults',
@@ -3241,7 +3226,7 @@ class _TypedDictMeta(type):
                         for n, tp in own.items()
                     }
             elif format == annotationlib.Format.SOURCE:
-                own = _convert_to_source(own_annotations)
+                own = annotationlib.annotations_to_source(own_annotations)
             else:
                 own = own_checked_annotations
             annos.update(own)