Skip to content

Instantly share code, notes, and snippets.

@AlexWaygood
Last active June 19, 2024 19:40
Show Gist options
  • Save AlexWaygood/29e386e092377fb2e288620df1765ed5 to your computer and use it in GitHub Desktop.
Save AlexWaygood/29e386e092377fb2e288620df1765ed5 to your computer and use it in GitHub Desktop.
Demo for an `__annotations__` solution
"""Proof of concept for how `__annotations__` issues with metaclasses could be solved under PEP 649.
See https://discuss.python.org/t/pep-749-implementing-pep-649/54974/28 for more context.
To experiment with this proof of concept:
1. Clone CPython
2. Create a fresh build of the main branch according to the instructions in the devguide.
3. Save this file to the repository root.
4. Run `./python.exe annotations-demo.py --test` to run tests,
or `PYTHON_BASIC_REPL=1 ./python.exe -i annotations-demo.py` to play with it in the REPL.
"""
from collections.abc import Mapping
class _AnnotationsDescriptor(Mapping):
def __init__(self, owner: type):
self._annotations_cache = None
self._owner = owner
def _materialized_annotations(self) -> dict:
if self._annotations_cache is None:
match self._owner.__annotate__:
case None:
self._annotations_cache = {}
case annotate_function:
self._annotations_cache = annotate_function(1)
return self._annotations_cache
# Must act as a descriptor if accessed via `.`
def __get__(self, obj, cls=None) -> dict:
# obj is None if the descriptor is accessed from the class object itself.
# If obj is not None, it means the descriptor is being accessed
# from an instance of the class
assert type(obj) is self._owner or cls is self._owner
return self._materialized_annotations()
# Must act as a mapping if accessed via `__dict__`:
def __getitem__(self, key: str):
return self._materialized_annotations()[key]
def __iter__(self):
yield from self._materialized_annotations()
def __len__(self) -> int:
return len(self._materialized_annotations())
# Add some dict-like methods that the Mapping ABC doesn't include, as well, for convenience:
def __repr__(self) -> str:
if self._annotations_cache is None:
return f"<annotations of {self._owner.__name__!r}>"
return repr(self._annotations_cache)
def __eq__(self, other):
return dict.__eq__(self._materialized_annotations(), other)
def __copy__(self):
return dict(self._materialized_annotations())
copy = __copy__
def __or__(self, other):
return dict.__or__(self._materialized_annotations(), other)
def __ror__(self, other):
return dict.__ror__(self._materialized_annotations(), other)
class Object:
"""Pretend this is how builtins.object would work"""
__annotate__ = None
annotations = {}
def __init_subclass__(cls):
cls.annotations = _AnnotationsDescriptor(cls)
if "__annotate__" not in cls.__dict__:
cls.__annotate__ = None
###################################################################
# Tests
##################################################################
import unittest
class SimpleWithoutAnnotations(Object): pass
class SimpleWithAnnotations(Object):
x: int
class Meta(Object, type):
x: int
class UsesMetaWithoutAnnotations(Object, metaclass=Meta): pass
class UsesMetaWithAnnotations(Object, metaclass=Meta):
x: str
class HasAnnotationsInstanceAttribute(Object):
x: int
def __init__(self):
self.annotations = 42
class AnnotationTests(unittest.TestCase):
def test_classes_without_annotations(self):
for cls in SimpleWithoutAnnotations, UsesMetaWithoutAnnotations:
with self.subTest(cls=cls.__name__):
self.assertEqual(cls.annotations, {})
# Accessing it via an instance works on Python <=3.13;
# it's arguable whether this is desirable or not but it seems better
# to preserve pre-existing behaviour here
self.assertEqual(cls.annotations, cls().annotations)
def test_classes_with_annotations(self):
for cls in SimpleWithAnnotations, Meta:
with self.subTest(cls=cls.__name__):
self.assertEqual(cls.annotations, {"x": int})
self.assertIs(cls.annotations["x"], int)
self.assertIs(cls.annotations.get("x", bytes), int)
self.assertEqual(UsesMetaWithAnnotations.annotations, {"x": str})
for cls in SimpleWithAnnotations, UsesMetaWithAnnotations:
with self.subTest(cls=cls.__name__):
# Accessing it via an instance works on Python <=3.13;
# it's arguable whether this is desirable or not but it seems better
# to preserve pre-existing behaviour here
self.assertEqual(cls.annotations, cls().annotations)
self.assertEqual(HasAnnotationsInstanceAttribute.annotations, {"x": int})
self.assertEqual(HasAnnotationsInstanceAttribute().annotations, 42)
def test_convenience_methods(self):
for cls in (
SimpleWithoutAnnotations, SimpleWithAnnotations, Meta,
UsesMetaWithoutAnnotations, UsesMetaWithAnnotations
):
with self.subTest(cls=cls.__name__):
annotations = cls.__dict__.get("annotations", {})
self.assertIsInstance(annotations, Mapping)
self.assertEqual(annotations, cls.annotations)
self.assertEqual(annotations, annotations.copy())
self.assertIsInstance(annotations.copy(), dict)
self.assertIsInstance(annotations | {}, dict)
self.assertIsInstance({} | annotations, dict)
with self.assertRaises(TypeError):
hash(annotations)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--test", action="store_true")
if parser.parse_args().test:
import sys
sys.argv[1:] = []
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment