Skip to content

gh-124412: Add helpers for converting annotations to source format #124551

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Doc/library/annotationlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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*,
Expand Down Expand Up @@ -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

22 changes: 3 additions & 19 deletions Lib/_collections_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,10 @@ def __new__(cls, origin, args):
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__
Expand Down Expand Up @@ -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):

Expand Down
110 changes: 88 additions & 22 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"call_evaluate_function",
"get_annotate_function",
"get_annotations",
"annotations_to_source",
"value_to_source",
]


Expand Down Expand Up @@ -664,28 +666,38 @@ def get_annotations(
if eval_str and format != Format.VALUE:
raise ValueError("eval_str=True is only supported with format=Format.VALUE")

# For VALUE format, we look at __annotations__ directly.
if format != Format.VALUE:
annotate = get_annotate_function(obj)
if annotate is not None:
ann = call_annotate_function(annotate, format, owner=obj)
if not isinstance(ann, dict):
raise ValueError(f"{obj!r}.__annotate__ returned a non-dict")
return dict(ann)

if isinstance(obj, type):
try:
ann = _BASE_GET_ANNOTATIONS(obj)
except AttributeError:
# For static types, the descriptor raises AttributeError.
return {}
else:
ann = getattr(obj, "__annotations__", None)
if ann is None:
return {}

if not isinstance(ann, dict):
raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None")
match format:
case Format.VALUE:
# For VALUE, we only look at __annotations__
ann = _get_dunder_annotations(obj)
case Format.FORWARDREF:
# For FORWARDREF, we use __annotations__ if it exists
try:
ann = _get_dunder_annotations(obj)
except NameError:
pass
else:
return dict(ann)

# But if __annotations__ threw a NameError, we try calling __annotate__
ann = _get_and_call_annotate(obj, format)
if ann is not None:
return ann

# If that didn't work either, we have a very weird object: evaluating
# __annotations__ threw NameError and there is no __annotate__. In that case,
# we fall back to trying __annotations__ again.
return dict(_get_dunder_annotations(obj))
case Format.SOURCE:
# For SOURCE, we try to call __annotate__
ann = _get_and_call_annotate(obj, format)
if ann is not None:
return ann
# But if we didn't get it, we use __annotations__ instead.
ann = _get_dunder_annotations(obj)
return annotations_to_source(ann)
case _:
raise ValueError(f"Unsupported format {format!r}")

if not ann:
return {}
Expand Down Expand Up @@ -750,3 +762,57 @@ def get_annotations(
for key, value in ann.items()
}
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."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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:
ann = call_annotate_function(annotate, format, owner=obj)
if not isinstance(ann, dict):
raise ValueError(f"{obj!r}.__annotate__ returned a non-dict")
return dict(ann)
return None


def _get_dunder_annotations(obj):
if isinstance(obj, type):
try:
ann = _BASE_GET_ANNOTATIONS(obj)
except AttributeError:
# For static types, the descriptor raises AttributeError.
return {}
else:
ann = getattr(obj, "__annotations__", None)
if ann is None:
return {}

if not isinstance(ann, dict):
raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None")
return dict(ann)
129 changes: 123 additions & 6 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
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
Expand All @@ -25,6 +32,11 @@ def wrapper(a, b):
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)
Expand Down Expand Up @@ -324,7 +336,10 @@ def test_name_lookup_without_eval(self):
# 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)
Expand Down Expand Up @@ -740,17 +755,96 @@ def f(x: int):

self.assertEqual(annotationlib.get_annotations(f), {"x": int})
self.assertEqual(
annotationlib.get_annotations(f, format=annotationlib.Format.FORWARDREF),
annotationlib.get_annotations(f, format=Format.FORWARDREF),
{"x": int},
)

f.__annotations__["x"] = str
# The modification is reflected in VALUE (the default)
self.assertEqual(annotationlib.get_annotations(f), {"x": str})
# ... but not in FORWARDREF, which uses __annotate__
# ... and also in FORWARDREF, which tries __annotations__ if available
self.assertEqual(
annotationlib.get_annotations(f, format=annotationlib.Format.FORWARDREF),
{"x": int},
annotationlib.get_annotations(f, format=Format.FORWARDREF),
{"x": str},
)
# ... but not in SOURCE which always uses __annotate__
self.assertEqual(
annotationlib.get_annotations(f, format=Format.SOURCE),
{"x": "int"},
)

def test_non_dict_annotations(self):
class WeirdAnnotations:
@property
def __annotations__(self):
return "not a dict"

wa = WeirdAnnotations()
for format in Format:
with (
self.subTest(format=format),
self.assertRaisesRegex(
ValueError, r".*__annotations__ is neither a dict nor None"
),
):
annotationlib.get_annotations(wa, format=format)

def test_annotations_on_custom_object(self):
class HasAnnotations:
@property
def __annotations__(self):
return {"x": int}

ha = HasAnnotations()
self.assertEqual(
annotationlib.get_annotations(ha, format=Format.VALUE), {"x": int}
)
self.assertEqual(
annotationlib.get_annotations(ha, format=Format.FORWARDREF), {"x": int}
)

self.assertEqual(
annotationlib.get_annotations(ha, format=Format.SOURCE), {"x": "int"}
)

def test_raising_annotations_on_custom_object(self):
class HasRaisingAnnotations:
@property
def __annotations__(self):
return {"x": undefined}

hra = HasRaisingAnnotations()

with self.assertRaises(NameError):
annotationlib.get_annotations(hra, format=Format.VALUE)

with self.assertRaises(NameError):
annotationlib.get_annotations(hra, format=Format.FORWARDREF)

undefined = float
self.assertEqual(
annotationlib.get_annotations(hra, format=Format.VALUE), {"x": float}
)

def test_forwardref_prefers_annotations(self):
class HasBoth:
@property
def __annotations__(self):
return {"x": int}

@property
def __annotate__(self):
return lambda format: {"x": str}

hb = HasBoth()
self.assertEqual(
annotationlib.get_annotations(hb, format=Format.VALUE), {"x": int}
)
self.assertEqual(
annotationlib.get_annotations(hb, format=Format.FORWARDREF), {"x": int}
)
self.assertEqual(
annotationlib.get_annotations(hb, format=Format.SOURCE), {"x": str}
)

def test_pep695_generic_class_with_future_annotations(self):
Expand Down Expand Up @@ -998,6 +1092,29 @@ class C:
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)
Loading
Loading