Last active
June 19, 2024 19:40
-
-
Save AlexWaygood/29e386e092377fb2e288620df1765ed5 to your computer and use it in GitHub Desktop.
Demo for an `__annotations__` solution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""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