Skip to content
Open
15 changes: 15 additions & 0 deletions Doc/library/annotationlib.rst
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,29 @@ Functions

* VALUE: :attr:`!object.__annotations__` is tried first; if that does not exist,
the :attr:`!object.__annotate__` function is called if it exists.

* FORWARDREF: If :attr:`!object.__annotations__` exists and can be evaluated successfully,
it is used; otherwise, the :attr:`!object.__annotate__` function is called. If it
does not exist either, :attr:`!object.__annotations__` is tried again and any error
from accessing it is re-raised.

* When calling :attr:`!object.__annotate__` it is first called with :attr:`~Format.FORWARDREF`,
if this is not implemented it will then check if :attr:`~Format.VALUE_WITH_FAKE_GLOBALS`
is supported and use that in the fake globals environment.
If neither of these formats are supported it will fall back to using :attr:`~Format.VALUE`.
If :attr:`~Format.VALUE` fails, the error from this call will be raised.

* STRING: If :attr:`!object.__annotate__` exists, it is called first;
otherwise, :attr:`!object.__annotations__` is used and stringified
using :func:`annotations_to_string`.

* When calling :attr:`!object.__annotate__` it is first called with :attr:`~Format.STRING`,
if this is not implemented it will then check if :attr:`~Format.VALUE_WITH_FAKE_GLOBALS`
is supported and use that in the fake globals environment.
If neither of these formats are supported it will fall back to using :attr:`~Format.VALUE`
with the result converted using :func:`annotations_to_string`.
If :attr:`~Format.VALUE` fails, the error from this call will be raised.

Returns a dict. :func:`!get_annotations` returns a new dict every time
it's called; calling it twice on the same object will return two
different but equivalent dicts.
Expand Down
15 changes: 15 additions & 0 deletions Lib/annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,18 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
# possibly constants if the annotate function uses them directly). We then
# convert each of those into a string to get an approximation of the
# original source.

# Attempt to call with VALUE_WITH_FAKE_GLOBALS to check if it is implemented
# See: https://github.com/python/cpython/issues/138764
# Only fail on NotImplementedError
try:
annotate(Format.VALUE_WITH_FAKE_GLOBALS)
except NotImplementedError:
# Both STRING and VALUE_WITH_FAKE_GLOBALS are not implemented fallback to VALUE
return annotations_to_string(annotate(Format.VALUE))
except Exception:
pass

globals = _StringifierDict({}, format=format)
is_class = isinstance(owner, type)
closure = _build_closure(
Expand Down Expand Up @@ -722,6 +734,9 @@ def call_annotate_function(annotate, format, *, owner=None, _is_evaluate=False):
)
try:
result = func(Format.VALUE_WITH_FAKE_GLOBALS)
except NotImplementedError:
# FORWARDREF and VALUE_WITH_FAKE_GLOBALS not supported, fall back to VALUE
return annotate(Format.VALUE)
except Exception:
pass
else:
Expand Down
176 changes: 176 additions & 0 deletions Lib/test/test_annotationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,25 @@ class RaisesAttributeError:
},
)

def test_raises_error_from_value(self):
# test that if VALUE is the only supported format, but raises an error
# that error is propagated from get_annotations
class DemoException(Exception): ...

def annotate(format, /):
if format == Format.VALUE:
raise DemoException()
else:
raise NotImplementedError(format)

def f(): ...

f.__annotate__ = annotate

for fmt in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
with self.assertRaises(DemoException):
get_annotations(f, format=fmt)


class TestCallEvaluateFunction(unittest.TestCase):
def test_evaluation(self):
Expand All @@ -1206,6 +1225,163 @@ def evaluate(format, exc=NotImplementedError):
)


class TestCallAnnotateFunction(unittest.TestCase):
# Tests for user defined annotate functions.

# Format and NotImplementedError are provided as arguments so they exist in
# the fake globals namespace.
# This avoids non-matching conditions passing by being converted to stringifiers.
# See: https://github.com/python/cpython/issues/138764

def test_user_annotate_value(self):
def annotate(format, /):
if format == Format.VALUE:
return {"x": str}
else:
raise NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.VALUE,
)

self.assertEqual(annotations, {"x": str})

def test_user_annotate_forwardref_supported(self):
# If Format.FORWARDREF is supported prefer it over Format.VALUE
def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {'x': str}
elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
return {'x': int}
elif format == __Format.FORWARDREF:
return {'x': float}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.FORWARDREF
)

self.assertEqual(annotations, {"x": float})

def test_user_annotate_forwardref_fakeglobals(self):
# If Format.FORWARDREF is not supported, use Format.VALUE_WITH_FAKE_GLOBALS
# before falling back to Format.VALUE
def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {'x': str}
elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
return {'x': int}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.FORWARDREF
)

self.assertEqual(annotations, {"x": int})

def test_user_annotate_forwardref_value_fallback(self):
# If Format.FORWARDREF and Format.VALUE_WITH_FAKE_GLOBALS are not supported
# use Format.VALUE
def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {"x": str}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.FORWARDREF,
)

self.assertEqual(annotations, {"x": str})

def test_user_annotate_string_supported(self):
# If Format.STRING is supported prefer it over Format.VALUE
def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {'x': str}
elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
return {'x': int}
elif format == __Format.STRING:
return {'x': "float"}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.STRING,
)

self.assertEqual(annotations, {"x": "float"})

def test_user_annotate_string_fakeglobals(self):
# If Format.STRING is not supported but Format.VALUE_WITH_FAKE_GLOBALS is
# prefer that over Format.VALUE
def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {'x': str}
elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
return {'x': int}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.STRING,
)

self.assertEqual(annotations, {"x": "int"})

def test_user_annotate_string_value_fallback(self):
# If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not
# supported fall back to Format.VALUE and convert to strings
def annotate(format, /, __Format=Format, __NotImplementedError=NotImplementedError):
if format == __Format.VALUE:
return {"x": str}
else:
raise __NotImplementedError(format)

annotations = annotationlib.call_annotate_function(
annotate,
Format.STRING,
)

self.assertEqual(annotations, {"x": "str"})

def test_condition_not_stringified(self):
# Make sure the first condition isn't evaluated as True by being converted
# to a _Stringifier
def annotate(format, /):
if format == Format.FORWARDREF:
return {"x": str}
else:
raise NotImplementedError(format)

with self.assertRaises(NotImplementedError):
annotationlib.call_annotate_function(annotate, Format.STRING)

def test_error_from_value_raised(self):
# Test that the error from format.VALUE is raised
# if all formats fail

class DemoException(Exception): ...

def annotate(format, /):
if format == Format.VALUE:
raise DemoException()
else:
raise NotImplementedError(format)

for fmt in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
with self.assertRaises(DemoException):
annotationlib.call_annotate_function(annotate, format=fmt)


class MetaclassTests(unittest.TestCase):
def test_annotated_meta(self):
class Meta(type):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Prevent ``annotationlib.call_annotate_function`` from calling ``annotate`` functions that don't support ``VALUE_WITH_FAKE_GLOBALS`` in a fake globals namespace with empty globals.
Make ``FORWARDREF`` and ``STRING`` annotations fall back to using ``VALUE`` annotations in the case that neither their own format, nor ``VALUE_WITH_FAKE_GLOBALS`` are supported.
Loading