Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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,3 @@
Prevent :func:`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