From b8b41d048b8ff1a6cdf8bc39e15840f78d6f12c2 Mon Sep 17 00:00:00 2001 From: dieter Date: Tue, 10 Dec 2024 14:26:24 +0100 Subject: [PATCH] rename the annotations attribute from `__annotations__` to `_zope_annotations` --- CHANGES.rst | 7 ++- src/zope/annotation/attribute.py | 37 +++++++++--- .../tests/test_attributeannotations.py | 57 +++++++++++++++++-- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 614bc49..cc873d9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,12 @@ 5.1 (unreleased) ================ -- Nothing changed yet. +- Rename the annotations attribute from ``__annotations__`` to + ``_zope_annotations`` and provide migration code. + Introduce the optional callback ``notify_object_changed`` + into ``attribute.AttributeAnnotations`` to notify + object changes to interested observers (e.g. ``plone.protect``). + See `#15 `_. 5.0 (2023-03-27) diff --git a/src/zope/annotation/attribute.py b/src/zope/annotation/attribute.py index 6f714f2..a76a014 100644 --- a/src/zope/annotation/attribute.py +++ b/src/zope/annotation/attribute.py @@ -30,6 +30,8 @@ _EMPTY_STORAGE = _STORAGE() +ATTR = "_zope_annotations" + @interface.implementer(interfaces.IAnnotations) @component.adapter(interfaces.IAttributeAnnotatable) @@ -40,6 +42,10 @@ class AttributeAnnotations(DictMixin): `IAttributeAnnotatable` object. """ + # optional callback to notify that the object was changed + # can be used e.g. to inform ``plone.protect`` + notify_object_changed = None + # Yes, there's a lot of repetition of the `getattr` call, # but that turns out to be the most efficient for the ways # instances are typically used without sacrificing any semantics. @@ -50,48 +56,61 @@ class AttributeAnnotations(DictMixin): def __init__(self, obj, context=None): self.obj = obj + if getattr(obj, ATTR, None) is None: + # try to migrate + ann = getattr(obj, "__annotations__", None) + if ann is not None \ + and ann is not getattr(type(obj), "__annotations__", None): + # migrate + setattr(obj, ATTR, ann) + delattr(obj, "__annotations__") + if self.notify_object_changed is not None: + self.notify_object_changed(obj) @property def __parent__(self): return self.obj def __bool__(self): - return bool(getattr(self.obj, '__annotations__', 0)) + return bool(getattr(self.obj, ATTR, 0)) def get(self, key, default=None): """See zope.annotation.interfaces.IAnnotations""" - annotations = getattr(self.obj, '__annotations__', _EMPTY_STORAGE) + annotations = getattr(self.obj, ATTR, _EMPTY_STORAGE) return annotations.get(key, default) def __getitem__(self, key): - annotations = getattr(self.obj, '__annotations__', _EMPTY_STORAGE) + annotations = getattr(self.obj, ATTR, _EMPTY_STORAGE) return annotations[key] def keys(self): - annotations = getattr(self.obj, '__annotations__', _EMPTY_STORAGE) + annotations = getattr(self.obj, ATTR, _EMPTY_STORAGE) return annotations.keys() def __iter__(self): - annotations = getattr(self.obj, '__annotations__', _EMPTY_STORAGE) + annotations = getattr(self.obj, ATTR, _EMPTY_STORAGE) return iter(annotations) def __len__(self): - annotations = getattr(self.obj, '__annotations__', _EMPTY_STORAGE) + annotations = getattr(self.obj, ATTR, _EMPTY_STORAGE) return len(annotations) def __setitem__(self, key, value): """See zope.annotation.interfaces.IAnnotations""" try: - annotations = self.obj.__annotations__ + annotations = getattr(self.obj, ATTR) except AttributeError: - annotations = self.obj.__annotations__ = _STORAGE() + annotations = _STORAGE() + setattr(self.obj, ATTR, annotations) + if self.notify_object_changed is not None: + self.notify_object_changed(self.obj) annotations[key] = value def __delitem__(self, key): """See zope.app.interfaces.annotation.IAnnotations""" try: - annotation = self.obj.__annotations__ + annotation = getattr(self.obj, ATTR) except AttributeError: raise KeyError(key) diff --git a/src/zope/annotation/tests/test_attributeannotations.py b/src/zope/annotation/tests/test_attributeannotations.py index 9b0db50..21dfcf1 100644 --- a/src/zope/annotation/tests/test_attributeannotations.py +++ b/src/zope/annotation/tests/test_attributeannotations.py @@ -13,18 +13,18 @@ ############################################################################## import unittest +from zope.interface import implementer + +from zope.annotation.attribute import AttributeAnnotations +from zope.annotation.interfaces import IAttributeAnnotatable from zope.annotation.tests.annotations import AnnotationsTestBase class AttributeAnnotationsTest(AnnotationsTestBase, unittest.TestCase): def setUp(self): - from zope.interface import implementer from zope.testing import cleanup - from zope.annotation.attribute import AttributeAnnotations - from zope.annotation.interfaces import IAttributeAnnotatable - cleanup.setUp() @implementer(IAttributeAnnotatable) @@ -42,6 +42,55 @@ def testInterfaceVerifies(self): super().testInterfaceVerifies() self.assertIs(self.obj, self.annotations.__parent__) + def testMigration(self): + obj = self.obj + obj.__annotations__ = dict(a=1) + od = obj.__dict__ + annotations = AttributeAnnotations(obj) + self.assertEqual(annotations["a"], 1) + self.assertNotIn("__annotations__", od) + + def testTypeHints(self): + + @implementer(IAttributeAnnotatable) + class WithTypeHints: + a: int + + obj = WithTypeHints() + hints = obj.__annotations__ + annotations = AttributeAnnotations(obj) + self.assertFalse(annotations) + annotations["a"] = 1 + self.assertEqual(annotations["a"], 1) + self.assertIs(obj.__annotations__, hints) + + def test_notification(self): + from copy import copy + + class Counter: + c = 0 + + def __call__(self, unused): + self.c += 1 + + counter = Counter() + + class NotifyingAttributeAnnotations(AttributeAnnotations): + notify_object_changed = counter + + # initial annotation + obj = copy(self.obj) + ann = NotifyingAttributeAnnotations(obj) + self.assertEqual(counter.c, 0) + ann["x"] = 1 + self.assertEqual(counter.c, 1) + + # migration + obj = copy(self.obj) + obj.__annotations__ = dict(a=1) + ann = NotifyingAttributeAnnotations(obj) + self.assertEqual(counter.c, 2) + def test_suite(): return unittest.defaultTestLoader.loadTestsFromName(__name__)