Skip to content

Commit

Permalink
rename the annotations attribute from __annotations__ to `_zope_ann…
Browse files Browse the repository at this point in the history
…otations`
  • Loading branch information
d-maurer committed Dec 10, 2024
1 parent fbe0459 commit b8b41d0
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 14 deletions.
7 changes: 6 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/zopefoundation/zope.annotation/issues/15>`_.


5.0 (2023-03-27)
Expand Down
37 changes: 28 additions & 9 deletions src/zope/annotation/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

_EMPTY_STORAGE = _STORAGE()

ATTR = "_zope_annotations"


@interface.implementer(interfaces.IAnnotations)
@component.adapter(interfaces.IAttributeAnnotatable)
Expand All @@ -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.
Expand All @@ -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)

Expand Down
57 changes: 53 additions & 4 deletions src/zope/annotation/tests/test_attributeannotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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__)

0 comments on commit b8b41d0

Please sign in to comment.