Commits

Olemis Lang committed e19e1df

Trac #11148 : Remove 'extends_with_prefix' decorator function

  • Participants
  • Parent commits 420ff8f
  • Branches trac_t11148

Comments (0)

Files changed (3)

-t11148/t11148_r11782_IEntityChangeListener_extends_with_prefix.diff
+t11148/t11148_r11782_IEntityChangeListener_v2.diff
 #t11148/t11148_r11767_IResourceChangeListener.diff

File t11148/t11148_r11782_IEntityChangeListener_extends_with_prefix.diff

-# HG changeset patch
-# Parent 8d0c3223a81860370202a5184fa812081129fe43
-An alternative proposal is based on ticket comments and google groups discussion. The proposal introduces IEntityChangeListener interface and extends_with_prefix instruction.
-
-diff -r 8d0c3223a818 trac/core.py
---- a/trac/core.py	Thu Apr 18 14:30:21 2013 +0000
-+++ b/trac/core.py	Tue Apr 23 13:09:41 2013 -0500
-@@ -165,8 +165,30 @@
- 
-         locals_.setdefault('_implements', []).extend(interfaces)
- 
-+implements = Component.implements
- 
--implements = Component.implements
-+# FIXME : Move to e.g. trac.util ?
-+# FIXME : extends in function name does not apply anymore
-+def extends_with_prefix(base, prefix):
-+    from functools import update_wrapper
-+    from types import MethodType
-+
-+    def prefix_decorator(class_):
-+        class_._extends_with_prefix = prefix
-+        for name in dir(base):
-+            if not name.startswith('__') and not hasattr(class_, name):
-+                target = getattr(base, name)
-+                if isinstance(target, MethodType):
-+                    parts = name.split('_', 1)
-+                    gen_name = '_'.join([prefix, parts[-1]])
-+                    # TODO: Cosmetic improvements in doc et al.
-+                    gen_method = update_wrapper(lambda *args, **kwargs: None,
-+                                                target)
-+                    gen_method.__name__ = gen_method.func_name = gen_name
-+                    setattr(class_, gen_name, gen_method)
-+        return class_
-+
-+    return prefix_decorator
- 
- 
- class ComponentManager(object):
-@@ -236,3 +258,68 @@
-         with the given class will not be available.
-         """
-         return True
-+
-+class NotificationChangeInfo(object):
-+    def __init__(self, comment, author):
-+        self.comment = comment
-+        self.author = author
-+
-+class IEntityChangeListener(Interface):
-+    """Extension point interface for components that require notification
-+    when model entities are created, modified, or deleted.
-+
-+    :param     entity: parameter is instance of the a entity e.g. ticket,
-+                       milestone, etc.
-+    :param changeinfo: is instance of NotificationChangeInfo. The changeinfo
-+                       parameter content depends on an entity type and may be
-+                       None.
-+    :param     kwargs: receives in the form of excess keyword arguments 
-+                       custom event data supported by resource-specific
-+                       interfaces. 
-+    """
-+
-+    def entity_created(entity, changeinfo = None, **kwargs):
-+        """
-+        Called when an entity is created.
-+        """
-+
-+    def entity_changed(entity, old_values, changeinfo = None, **kwargs):
-+        """Called when an entity is modified.
-+
-+        :param old_values: is a dictionary containing the previous values of
-+                           the entity properties that changed. Properties
-+                           specific for entity type.
-+        """
-+
-+    def entity_deleted(entity, changeinfo = None, **kwargs):
-+        """Called when an entity is deleted."""
-+
-+    def entity_reparented(entity, changeinfo = None, **kwargs):
-+        """Called when an entity has been re-parented."""
-+
-+class ListenerNotifier(Component):
-+    required = True
-+
-+    def notify(self, method, **kwargs):
-+        interface = method.im_class
-+        method_name = method.__name__
-+        if interface is None:
-+            raise TracError(
-+                "Notification interface can not be None. " +
-+                "Target method name is %s" % method_name)
-+
-+        xp = ExtensionPoint(interface)
-+        # FIXME : Sender component rather than self. Change method signature
-+        for listener in xp.extensions(self):
-+            # FIXME : Do not copy kwargs
-+            getattr(listener, method_name)(**kwargs.copy())
-+
-+        xp = ExtensionPoint(IEntityChangeListener)
-+        generic_listeners = xp.extensions(self)
-+        if generic_listeners:
-+            #TODO : Include `interface` and `method_name` in kwargs ?
-+            parts = method_name.split('_', 1)
-+            method_name = 'entity_' + parts[-1]
-+            for listener in generic_listeners:
-+                # FIXME : Do not copy kwargs
-+                getattr(listener, method_name)(**kwargs.copy())
-diff -r 8d0c3223a818 trac/ticket/model.py
---- a/trac/ticket/model.py	Thu Apr 18 14:30:21 2013 +0000
-+++ b/trac/ticket/model.py	Tue Apr 23 13:09:41 2013 -0500
-@@ -25,7 +25,8 @@
- from trac.attachment import Attachment
- from trac import core
- from trac.cache import cached
--from trac.core import TracError
-+from trac.core import (extends_with_prefix, Interface, IEntityChangeListener, 
-+                       ListenerNotifier, TracError)
- from trac.resource import Resource, ResourceNotFound
- from trac.ticket.api import TicketSystem
- from trac.util import embedded_numbers, partition
-@@ -718,6 +719,7 @@
- class AbstractEnum(object):
-     type = None
-     ticket_col = None
-+    change_listener_interface = None
- 
-     def __init__(self, env, name=None, db=None):
-         if not self.ticket_col:
-@@ -761,6 +763,11 @@
-                 except ValueError:
-                     pass # Ignore cast error for this non-essential operation
-             TicketSystem(self.env).reset_ticket_fields()
-+
-+        interface = self.change_listener_interface
-+        ListenerNotifier(self.env).notify(getattr(interface, 
-+                    interface._extends_with_prefix + '_deleted'),
-+                    entity=self)
-         self.value = self._old_value = None
-         self.name = self._old_name = None
- 
-@@ -788,6 +795,10 @@
- 
-         self._old_name = self.name
-         self._old_value = self.value
-+        interface = self.change_listener_interface
-+        ListenerNotifier(self.env).notify(getattr(interface, 
-+                    interface._extends_with_prefix + '_created'),
-+                    entity=self)
- 
-     def update(self, db=None):
-         """Update the enum value.
-@@ -811,8 +822,17 @@
-                    (self.name, self._old_name))
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        old_values = dict()
-+        if self.name != self._old_name:
-+            old_values["name"] = self._old_name
-+        if self.value != self._old_value:
-+            old_values["value"] = self._old_value
-         self._old_name = self.name
-         self._old_value = self.value
-+        interface = self.change_listener_interface
-+        ListenerNotifier(self.env).notify(getattr(interface, 
-+                    interface._extends_with_prefix + '_changed'),
-+                    entity=self, old_values=old_values)
- 
-     @classmethod
-     def select(cls, env, db=None):
-@@ -831,9 +851,15 @@
-                 yield obj
- 
- 
-+@extends_with_prefix(IEntityChangeListener, "type")
-+class ITypeChangeListener(Interface):
-+    pass
-+
-+
- class Type(AbstractEnum):
-     type = 'ticket_type'
-     ticket_col = 'type'
-+    change_listener_interface = ITypeChangeListener
- 
- 
- class Status(object):
-@@ -848,16 +874,39 @@
-             yield status
- 
- 
-+@extends_with_prefix(IEntityChangeListener, "resolution")
-+class IResolutionChangeListener(Interface):
-+    pass
-+
-+
- class Resolution(AbstractEnum):
-     type = 'resolution'
-+    change_listener_interface = IResolutionChangeListener
-+
-+
-+@extends_with_prefix(IEntityChangeListener, "priority")
-+class IPriorityChangeListener(Interface):
-+    pass
- 
- 
- class Priority(AbstractEnum):
-     type = 'priority'
-+    change_listener_interface = IPriorityChangeListener
-+
-+
-+@extends_with_prefix(IEntityChangeListener, "severity")
-+class ISeverityChangeListener(Interface):
-+    pass
- 
- 
- class Severity(AbstractEnum):
-     type = 'severity'
-+    change_listener_interface = ISeverityChangeListener
-+
-+
-+@extends_with_prefix(IEntityChangeListener, "component")
-+class IComponentChangeListener(Interface):
-+   pass 
- 
- 
- class Component(object):
-@@ -893,9 +942,13 @@
-         with self.env.db_transaction as db:
-             self.env.log.info("Deleting component %s", self.name)
-             db("DELETE FROM component WHERE name=%s", (self.name,))
--            self.name = self._old_name = None
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        ListenerNotifier(self.env).notify(
-+                    IComponentChangeListener.component_deleted, entity=self)
-+
-+        self.name = self._old_name = None
-+
-     def insert(self, db=None):
-         """Insert a new component.
- 
-@@ -915,6 +968,9 @@
-             self._old_name = self.name
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        ListenerNotifier(self.env).notify(
-+                    IComponentChangeListener.component_created, entity=self)
-+
-     def update(self, db=None):
-         """Update the component.
- 
-@@ -926,6 +982,7 @@
-         if not self.name:
-             raise TracError(_("Invalid component name."))
- 
-+        old_name = self._old_name
-         with self.env.db_transaction as db:
-             self.env.log.info("Updating component '%s'", self.name)
-             db("""UPDATE component SET name=%s,owner=%s, description=%s
-@@ -939,6 +996,14 @@
-                 self._old_name = self.name
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        #todo:add support of old_values for owner and description fields
-+        old_values = dict()
-+        if self.name != old_name:
-+            old_values["name"] = old_name
-+        ListenerNotifier(self.env).notify(
-+                    IComponentChangeListener.component_changed, 
-+                    entity=self, old_values=old_values)
-+
-     @classmethod
-     def select(cls, env, db=None):
-         """
-@@ -1170,6 +1235,11 @@
-     return groups
- 
- 
-+@extends_with_prefix(IEntityChangeListener, "version")
-+class IVersionChangeListener(Interface):
-+    pass
-+
-+
- class Version(object):
-     def __init__(self, env, name=None, db=None):
-         self.env = env
-@@ -1199,9 +1269,12 @@
-         with self.env.db_transaction as db:
-             self.env.log.info("Deleting version %s", self.name)
-             db("DELETE FROM version WHERE name=%s", (self.name,))
--            self.name = self._old_name = None
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        ListenerNotifier(self.env).notify(
-+            IVersionChangeListener.version_deleted, entity=self)
-+        self.name = self._old_name = None
-+
-     def insert(self, db=None):
-         """Insert a new version.
- 
-@@ -1220,6 +1293,9 @@
-             self._old_name = self.name
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        ListenerNotifier(self.env).notify(
-+            IVersionChangeListener.version_created, entity=self)
-+
-     def update(self, db=None):
-         """Update the version.
- 
-@@ -1231,6 +1307,7 @@
-         if not self.name:
-             raise TracError(_("Invalid version name."))
- 
-+        old_name=self._old_name
-         with self.env.db_transaction as db:
-             self.env.log.info("Updating version '%s'", self.name)
-             db("""UPDATE version
-@@ -1244,6 +1321,15 @@
-                 self._old_name = self.name
-             TicketSystem(self.env).reset_ticket_fields()
- 
-+        #todo: add support of old_values for time and description fields
-+        old_values = dict()
-+        if self.name != old_name:
-+            old_values["name"] = old_name
-+
-+        ListenerNotifier(self.env).notify(
-+            IVersionChangeListener.version_changed, 
-+            entity=self, old_values=old_values)
-+
-     @classmethod
-     def select(cls, env, db=None):
-         """
-diff -r 8d0c3223a818 trac/ticket/tests/model.py
---- a/trac/ticket/tests/model.py	Thu Apr 18 14:30:21 2013 +0000
-+++ b/trac/ticket/tests/model.py	Tue Apr 23 13:09:41 2013 -0500
-@@ -9,11 +9,11 @@
- 
- from trac import core
- from trac.attachment import Attachment
--from trac.core import TracError, implements
-+from trac.core import implements, TracError
- from trac.resource import ResourceNotFound
- from trac.ticket.model import (
--    Ticket, Component, Milestone, Priority, Type, Version
--)
-+    Ticket, Component, Milestone, Priority, Type, Version,
-+    IComponentChangeListener, IVersionChangeListener, IPriorityChangeListener)
- from trac.ticket.api import (
-     IMilestoneChangeListener, ITicketChangeListener, TicketSystem
- )
-@@ -1097,6 +1097,390 @@
-         self.assertEqual([('Test', 0, 'Some text')], self.env.db_query(
-             "SELECT name, time, description FROM version WHERE name='Test'"))
- 
-+class EntityChangeListenerMock(core.Component):
-+ 
-+    def callback(self, action, entity, changeinfo, old_values = None, kwargs=None):
-+        pass
-+
-+    def _entity_created(self, entity, changeinfo = None, **kwargs):
-+        self.action = "created"
-+        self.entity = entity
-+        self.changeinfo = changeinfo
-+        self.kwargs = kwargs
-+        self.callback(self.action, entity, changeinfo, kwargs)
-+
-+    def _entity_changed(self, entity, old_values, changeinfo = None, **kwargs):
-+        self.action = "changed"
-+        self.entity = entity
-+        self.old_values = old_values
-+        self.changeinfo = changeinfo
-+        self.kwargs = kwargs
-+        self.callback(
-+            self.action, entity, changeinfo, old_values, kwargs)
-+
-+    def _entity_deleted(self, entity, changeinfo = None, **kwargs):
-+        self.action = "deleted"
-+        self.entity = entity
-+        self.changeinfo = changeinfo
-+        self.kwargs = kwargs
-+        self.callback(self.action, entity, changeinfo, kwargs)
-+
-+    def _entity_reparented(self, entity, changeinfo = None, **kwargs):
-+        self.action = "reparented"
-+        self.entity = entity
-+        self.changeinfo = changeinfo
-+        self.kwargs = kwargs
-+        self.callback(self.action, entity, changeinfo, kwargs)
-+
-+class BaseEntityChangeListenerTestCase(unittest.TestCase):
-+    DUMMY_ENTITY_NAME = "Entity 1"
-+    name_field = "name"
-+    entityChangeListener = None
-+    entity_type = None
-+
-+    def setUp(self):
-+        self.env = EnvironmentStub(default_data=True)
-+        self.listener = self.entityChangeListener(self.env)
-+        self.listener.callback = self.listener_callback
-+
-+    def tearDown(self):
-+        self.env.reset_db()
-+
-+    def test_change_listener_created(self):
-+        self._create_entity(self.DUMMY_ENTITY_NAME)
-+        self.assertEqual('created', self.listener.action)
-+        self.assertTrue(isinstance(self.listener.entity, self.entity_type))
-+        self.assertEqual(
-+            self.DUMMY_ENTITY_NAME,
-+            self.entity_name)
-+
-+    def test_change_listener_changed(self):
-+        entity = self._create_entity(self.DUMMY_ENTITY_NAME)
-+        self._rename_entity(entity, "UpdatedName")
-+        self.assertEqual('changed', self.listener.action)
-+        self.assertTrue(isinstance(self.listener.entity, self.entity_type))
-+        self.assertEqual("UpdatedName", self.entity_name)
-+        self.assertEqual(
-+            self.DUMMY_ENTITY_NAME,
-+            self.listener.old_values[self.name_field])
-+
-+    def test_change_listener_deleted(self):
-+        entity = self._create_entity(self.DUMMY_ENTITY_NAME)
-+        entity.delete()
-+        self.assertEqual('deleted', self.listener.action)
-+        self.assertTrue(isinstance(self.listener.entity, self.entity_type))
-+        self.assertEqual(self.DUMMY_ENTITY_NAME, self.entity_name)
-+
-+    def _create_entity(self, name):
-+        entity = self.entity_type(self.env)
-+        entity.name = name
-+        entity.insert()
-+        return entity
-+
-+    def _rename_entity(self, entity, new_name):
-+        entity.name = new_name
-+        entity.update()
-+        return entity
-+
-+    def _get_entity_name(self, enity):
-+        return enity.name
-+
-+    def listener_callback(self, action, entity, changeinfo, old_values=None, kwargs=None):
-+        self.entity_name = self._get_entity_name(entity)
-+
-+
-+class ComponentChangeListenerMock(EntityChangeListenerMock):
-+    implements(IComponentChangeListener)
-+
-+    def component_created(self, entity, changeinfo = None):
-+        return self._entity_created(entity, changeinfo)
-+
-+    def component_changed(self, entity, old_values, changeinfo = None):
-+        return self._entity_changed(entity, old_values, changeinfo)
-+
-+    def component_deleted(self, entity, changeinfo = None):
-+        return self._entity_deleted(entity, changeinfo)
-+
-+    def component_reparented(self, entity, changeinfo = None):
-+        return self._entity_reparented(entity, changeinfo)
-+
-+
-+class ComponentEntityChangeListenerTestCase(
-+    BaseEntityChangeListenerTestCase):
-+    entity_type = Component
-+    entityChangeListener = ComponentChangeListenerMock
-+
-+    def test_all_methods(self):
-+        self.assertTrue(hasattr(IComponentChangeListener, 'component_created'))
-+        self.assertTrue(hasattr(IComponentChangeListener, 'component_changed'))
-+        self.assertTrue(hasattr(IComponentChangeListener, 'component_deleted'))
-+        self.assertTrue(hasattr(IComponentChangeListener, 'component_reparented'))
-+
-+
-+class VersionChangeListenerMock(EntityChangeListenerMock):
-+    implements(IVersionChangeListener)
-+    entity_type = Component
-+
-+    def version_created(self, entity, changeinfo = None):
-+        return self._entity_created(entity, changeinfo)
-+
-+    def version_changed(self, entity, old_values, changeinfo = None):
-+        return self._entity_changed(entity, old_values, changeinfo)
-+
-+    def version_deleted(self, entity, changeinfo = None):
-+        return self._entity_deleted(entity, changeinfo)
-+
-+    def version_reparented(self, entity, changeinfo = None):
-+        return self._entity_reparented(entity, changeinfo)
-+
-+
-+class VersionEntityChangeListenerTestCase(
-+    BaseEntityChangeListenerTestCase):
-+    entity_type = Version
-+    entityChangeListener = VersionChangeListenerMock
-+
-+    def test_all_methods(self):
-+        self.assertTrue(hasattr(IVersionChangeListener, 'version_created'))
-+        self.assertTrue(hasattr(IVersionChangeListener, 'version_changed'))
-+        self.assertTrue(hasattr(IVersionChangeListener, 'version_deleted'))
-+        self.assertTrue(hasattr(IVersionChangeListener, 'version_reparented'))
-+
-+class PriorityChangeListenerMock(EntityChangeListenerMock):
-+    implements(IPriorityChangeListener)
-+
-+    def priority_created(self, entity, changeinfo = None):
-+        return self._entity_created(entity, changeinfo)
-+
-+    def priority_changed(self, entity, old_values, changeinfo = None):
-+        return self._entity_changed(entity, old_values, changeinfo)
-+
-+    def priority_deleted(self, entity, changeinfo = None):
-+        return self._entity_deleted(entity, changeinfo)
-+
-+    def priority_reparented(self, entity, changeinfo = None):
-+        return self._entity_reparented(entity, changeinfo)
-+
-+
-+class PriorityEntityChangeListenerTestCase(
-+    BaseEntityChangeListenerTestCase):
-+    entity_type = Priority
-+    entityChangeListener = PriorityChangeListenerMock
-+
-+    def test_all_methods(self):
-+        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_created'))
-+        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_changed'))
-+        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_deleted'))
-+        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_reparented'))
-+
-+
-+class MultipleEntitiesChangeListenerMock(core.Component):
-+    implements(IComponentChangeListener, IVersionChangeListener)
-+
-+    def __init__(self):
-+        self.action = []
-+
-+    def component_created(self, entity, changeinfo = None):
-+        self.action.append("component_created")
-+
-+    def component_changed(self, entity, old_values, changeinfo = None):
-+        pass
-+
-+    def component_deleted(self, entity, changeinfo = None):
-+        pass
-+
-+    def component_reparented(self, entity, changeinfo = None):
-+        pass
-+
-+    def version_created(self, entity, changeinfo = None):
-+        self.action.append("version_created")
-+
-+    def version_changed(self, entity, old_values, changeinfo = None):
-+        pass
-+
-+    def version_deleted(self, entity, changeinfo = None):
-+        pass
-+
-+    def version_reparented(self, entity, changeinfo = None):
-+        pass
-+
-+
-+class MultipleEntitiesChangeListenerTestCase(unittest.TestCase):
-+
-+    def setUp(self):
-+        self.env = EnvironmentStub(default_data=True)
-+        self.listener = MultipleEntitiesChangeListenerMock(self.env)
-+
-+    def tearDown(self):
-+        self.env.reset_db()
-+
-+    def test_can_receive_events_from_different_entities(self):
-+        self._create_entity(Component, "Component1")
-+        self._create_entity(Version, "Version1")
-+        self.assertEqual(["component_created", "version_created"], 
-+                         self.listener.action)
-+
-+    def _create_entity(self, entity_type, name):
-+        entity = entity_type(self.env)
-+        entity.name = name
-+        entity.insert()
-+        return entity
-+
-+
-+class GenericEntitiesChangeListenerMock(core.Component):
-+    implements(core.IEntityChangeListener)
-+
-+    def __init__(self):
-+        self.details = []
-+        self.action = []
-+
-+    def _handle(self, kwargs):
-+        cls = kwargs['entity'].__class__
-+        self.action.append(cls.__name__.lower() + '_' + kwargs['action'])
-+        self.details.append(kwargs)
-+
-+    def entity_created(self, entity, changeinfo = None, **kwargs):
-+        kwargs.update(entity=entity, changeinfo=changeinfo, action='created')
-+        self._handle(kwargs)
-+
-+    def entity_changed(self, entity, old_values, changeinfo = None, **kwargs):
-+        kwargs.update(entity=entity, changeinfo=changeinfo, action='changed',
-+                      old_values=old_values)
-+        self._handle(kwargs)
-+
-+    def entity_deleted(self, entity, changeinfo = None, **kwargs):
-+        kwargs.update(entity=entity, changeinfo=changeinfo, action='deleted')
-+        self._handle(kwargs)
-+
-+    def entity_reparented(self, entity, changeinfo = None, **kwargs):
-+        kwargs.update(entity=entity, changeinfo=changeinfo, action='reparented')
-+        self._handle(kwargs)
-+
-+
-+class GenericEntitiesChangeListenerTestCase(MultipleEntitiesChangeListenerTestCase):
-+
-+    def setUp(self):
-+        self.env = EnvironmentStub(default_data=True)
-+        self.listener = GenericEntitiesChangeListenerMock(self.env)
-+
-+    def test_custom_event_args(self):
-+
-+        class ISomethingListener(core.Interface):
-+            """Listener interface written from scratch
-+            """
-+            def something_created(entity, changeinfo = None):
-+                """Called when an entity is created."""
-+
-+            def something_changed(entity, old_values, required_arg,
-+                                  changeinfo=None, optional_arg=None):
-+                """Update event with required and optional custom args."""
-+
-+            def something_deleted(entity, changeinfo = None):
-+                """Called when an entity is deleted."""
-+
-+            def something_reparented(entity, changeinfo = None):
-+                """Never invoked. May be removed."""
-+
-+        class Something(object):
-+            def __init__(self, env):
-+                self.env = env
-+                self.required_arg = 999
-+
-+            # Just trigger notifications
-+            def delete(self, db=None):
-+                notifier = core.ListenerNotifier(self.env)
-+                notifier.notify(ISomethingListener.something_deleted, 
-+                                entity=self)
-+
-+            def insert(self, db=None):
-+                notifier = core.ListenerNotifier(self.env)
-+                notifier.notify(ISomethingListener.something_created,
-+                                entity=self)
-+
-+            def update(self, db=None):
-+                notifier = core.ListenerNotifier(self.env)
-+                optional_args = dict(optional_arg=self.optional_arg) \
-+                                if hasattr(self, 'optional_arg') \
-+                                else {}
-+                notifier.notify(ISomethingListener.something_changed,
-+                                entity=self,
-+                                old_values=dict(),
-+                                required_arg=self.required_arg,
-+                                **optional_args)
-+
-+        class SomethingListener(core.Component):
-+            """Replicate the same behavior of generic listener
-+            """
-+            implements(ISomethingListener)
-+
-+            def __init__(self):
-+                self.details = []
-+
-+            def _handle(self, kwargs):
-+                if 'optional_arg' in kwargs and kwargs['optional_arg'] is None:
-+                    del kwargs['optional_arg']
-+                self.details.append(kwargs)
-+
-+            def something_created(self, entity, changeinfo=None):
-+                args = locals()
-+                del args['self']
-+                args['action'] = 'created'
-+                self._handle(args)
-+
-+            def something_changed(self, entity, old_values, required_arg,
-+                                  changeinfo=None, optional_arg=None):
-+                args = locals()
-+                del args['self']
-+                args['action'] = 'changed'
-+                self._handle(args)
-+
-+            def something_deleted(self, entity, changeinfo=None):
-+                args = locals()
-+                del args['self']
-+                args['action'] = 'deleted'
-+                self._handle(args)
-+
-+            def something_reparented(self, entity, changeinfo=None):
-+                args = locals()
-+                del args['self']
-+                args['action'] = 'reparented'
-+                self._handle(args)
-+
-+        resource_listener = SomethingListener(self.env)
-+
-+        # CRUD operations
-+        something = Something(self.env)
-+        something.insert()
-+        something.update()
-+        something.required_arg = 7
-+        something.optional_arg = 44
-+        something.update()
-+        something.delete()
-+
-+        expected = [
-+                    {'action' : 'created',
-+                     'entity' : something,
-+                     'changeinfo' : None},
-+                    {'action' : 'changed',
-+                     'entity' : something,
-+                     'old_values' : {},
-+                     'required_arg' : 999,
-+                     'changeinfo' : None},
-+                    {'action' : 'changed',
-+                     'entity' : something,
-+                     'old_values' : {},
-+                     'required_arg' : 7,
-+                     'optional_arg' : 44,
-+                     'changeinfo' : None},
-+                    {'action' : 'deleted',
-+                     'entity' : something,
-+                     'changeinfo' : None},
-+                    ]
-+
-+        self.assertEquals(expected, self.listener.details,
-+                          'Generic listener event sequence')
-+        self.assertEquals(expected, resource_listener.details,
-+                          'Resource-specific listener event sequence')
-+
- 
- def suite():
-     suite = unittest.TestSuite()
-@@ -1107,6 +1491,17 @@
-     suite.addTest(unittest.makeSuite(MilestoneTestCase, 'test'))
-     suite.addTest(unittest.makeSuite(ComponentTestCase, 'test'))
-     suite.addTest(unittest.makeSuite(VersionTestCase, 'test'))
-+    suite.addTest(unittest.makeSuite(
-+        ComponentEntityChangeListenerTestCase, 'test'))
-+    suite.addTest(unittest.makeSuite(
-+        VersionEntityChangeListenerTestCase, 'test'))
-+    suite.addTest(unittest.makeSuite(
-+        PriorityEntityChangeListenerTestCase, 'test'))
-+    suite.addTest(unittest.makeSuite(
-+        MultipleEntitiesChangeListenerTestCase, 'test'))
-+    suite.addTest(unittest.makeSuite(
-+        GenericEntitiesChangeListenerTestCase, 'test'))
-+
-     return suite
- 
- if __name__ == '__main__':

File t11148/t11148_r11782_IEntityChangeListener_v2.diff

+# HG changeset patch
+# Parent 8d0c3223a81860370202a5184fa812081129fe43
+An alternative proposal is based on ticket comments and google groups discussion. The proposal introduces IEntityChangeListener interface .
+
+diff -r 8d0c3223a818 trac/core.py
+--- a/trac/core.py	Thu Apr 18 14:30:21 2013 +0000
++++ b/trac/core.py	Wed Apr 24 17:51:53 2013 -0500
+@@ -165,7 +165,6 @@
+ 
+         locals_.setdefault('_implements', []).extend(interfaces)
+ 
+-
+ implements = Component.implements
+ 
+ 
+@@ -236,3 +235,68 @@
+         with the given class will not be available.
+         """
+         return True
++
++class NotificationChangeInfo(object):
++    def __init__(self, comment, author):
++        self.comment = comment
++        self.author = author
++
++class IEntityChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when model entities are created, modified, or deleted.
++
++    :param     entity: parameter is instance of the a entity e.g. ticket,
++                       milestone, etc.
++    :param changeinfo: is instance of NotificationChangeInfo. The changeinfo
++                       parameter content depends on an entity type and may be
++                       None.
++    :param     kwargs: receives in the form of excess keyword arguments 
++                       custom event data supported by resource-specific
++                       interfaces. 
++    """
++
++    def entity_created(entity, changeinfo = None, **kwargs):
++        """
++        Called when an entity is created.
++        """
++
++    def entity_changed(entity, old_values, changeinfo = None, **kwargs):
++        """Called when an entity is modified.
++
++        :param old_values: is a dictionary containing the previous values of
++                           the entity properties that changed. Properties
++                           specific for entity type.
++        """
++
++    def entity_deleted(entity, changeinfo = None, **kwargs):
++        """Called when an entity is deleted."""
++
++    def entity_reparented(entity, changeinfo = None, **kwargs):
++        """Called when an entity has been re-parented."""
++
++class ListenerNotifier(Component):
++    required = True
++
++    def notify(self, method, **kwargs):
++        interface = method.im_class
++        method_name = method.__name__
++        if interface is None:
++            raise TracError(
++                "Notification interface can not be None. " +
++                "Target method name is %s" % method_name)
++
++        xp = ExtensionPoint(interface)
++        # FIXME : Sender component rather than self. Change method signature
++        for listener in xp.extensions(self):
++            # FIXME : Do not copy kwargs
++            getattr(listener, method_name)(**kwargs.copy())
++
++        xp = ExtensionPoint(IEntityChangeListener)
++        generic_listeners = xp.extensions(self)
++        if generic_listeners:
++            #TODO : Include `interface` and `method_name` in kwargs ?
++            parts = method_name.split('_', 1)
++            method_name = 'entity_' + parts[-1]
++            for listener in generic_listeners:
++                # FIXME : Do not copy kwargs
++                getattr(listener, method_name)(**kwargs.copy())
+diff -r 8d0c3223a818 trac/ticket/model.py
+--- a/trac/ticket/model.py	Thu Apr 18 14:30:21 2013 +0000
++++ b/trac/ticket/model.py	Wed Apr 24 17:51:53 2013 -0500
+@@ -25,7 +25,8 @@
+ from trac.attachment import Attachment
+ from trac import core
+ from trac.cache import cached
+-from trac.core import TracError
++from trac.core import (Interface, IEntityChangeListener, ListenerNotifier,
++                       TracError)
+ from trac.resource import Resource, ResourceNotFound
+ from trac.ticket.api import TicketSystem
+ from trac.util import embedded_numbers, partition
+@@ -718,6 +719,7 @@
+ class AbstractEnum(object):
+     type = None
+     ticket_col = None
++    change_listener_interface = None
+ 
+     def __init__(self, env, name=None, db=None):
+         if not self.ticket_col:
+@@ -761,6 +763,11 @@
+                 except ValueError:
+                     pass # Ignore cast error for this non-essential operation
+             TicketSystem(self.env).reset_ticket_fields()
++
++        interface = self.change_listener_interface
++        ListenerNotifier(self.env).notify(getattr(interface, 
++                    interface._entity_listener_prefix + '_deleted'),
++                    entity=self)
+         self.value = self._old_value = None
+         self.name = self._old_name = None
+ 
+@@ -788,6 +795,10 @@
+ 
+         self._old_name = self.name
+         self._old_value = self.value
++        interface = self.change_listener_interface
++        ListenerNotifier(self.env).notify(getattr(interface, 
++                    interface._entity_listener_prefix + '_created'),
++                    entity=self)
+ 
+     def update(self, db=None):
+         """Update the enum value.
+@@ -811,8 +822,17 @@
+                    (self.name, self._old_name))
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        old_values = dict()
++        if self.name != self._old_name:
++            old_values["name"] = self._old_name
++        if self.value != self._old_value:
++            old_values["value"] = self._old_value
+         self._old_name = self.name
+         self._old_value = self.value
++        interface = self.change_listener_interface
++        ListenerNotifier(self.env).notify(getattr(interface, 
++                    interface._entity_listener_prefix + '_changed'),
++                    entity=self, old_values=old_values)
+ 
+     @classmethod
+     def select(cls, env, db=None):
+@@ -831,9 +851,33 @@
+                 yield obj
+ 
+ 
++class ITypeChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when ticket types are created, modified, or deleted.
++
++    :param     entity: target ticket type.
++    :param changeinfo: always set to None.
++    """
++    _entity_listener_prefix = 'type'
++
++    def type_created(entity, changeinfo = None):
++        """Called when an ticket type is created."""
++
++    def type_changed(entity, old_values, changeinfo = None):
++        """Called when a ticket type is modified.
++
++        :param old_values: is a dictionary containing the previous 
++                           `name` and `value` of target ticket type
++        """
++
++    def type_deleted(entity, changeinfo = None):
++        """Called when a ticket type is deleted."""
++
++
+ class Type(AbstractEnum):
+     type = 'ticket_type'
+     ticket_col = 'type'
++    change_listener_interface = ITypeChangeListener
+ 
+ 
+ class Status(object):
+@@ -848,16 +892,112 @@
+             yield status
+ 
+ 
++class IResolutionChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when ticket resolution are created, modified, or deleted.
++
++    :param     entity: target ticket resolution.
++    :param changeinfo: always set to None.
++    """
++    _entity_listener_prefix = 'resolution'
++
++    def resolution_created(entity, changeinfo = None):
++        """Called when an ticket resolution is created."""
++
++    def resolution_changed(entity, old_values, changeinfo = None):
++        """Called when a ticket resolution is modified.
++
++        :param old_values: is a dictionary containing the previous 
++                           `name` and `value` of target ticket resolution
++        """
++
++    def resolution_deleted(entity, changeinfo = None):
++        """Called when a ticket resolution is deleted."""
++
++
+ class Resolution(AbstractEnum):
+     type = 'resolution'
++    change_listener_interface = IResolutionChangeListener
++
++
++class IPriorityChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when ticket priority are created, modified, or deleted.
++
++    :param     entity: target ticket priority.
++    :param changeinfo: always set to None.
++    """
++    _entity_listener_prefix = 'priority'
++
++    def priority_created(entity, changeinfo = None):
++        """Called when an ticket priority is created."""
++
++    def priority_changed(entity, old_values, changeinfo = None):
++        """Called when a ticket priority is modified.
++
++        :param old_values: is a dictionary containing the previous 
++                           `name` and `value` of target ticket priority
++        """
++
++    def priority_deleted(entity, changeinfo = None):
++        """Called when a ticket priority is deleted."""
+ 
+ 
+ class Priority(AbstractEnum):
+     type = 'priority'
++    change_listener_interface = IPriorityChangeListener
++
++
++class ISeverityChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when ticket severity are created, modified, or deleted.
++
++    :param     entity: target ticket severity.
++    :param changeinfo: always set to None.
++    """
++    _entity_listener_prefix = 'severity'
++
++    def severity_created(entity, changeinfo = None):
++        """Called when an ticket severity is created."""
++
++    def severity_changed(entity, old_values, changeinfo = None):
++        """Called when a ticket severity is modified.
++
++        :param old_values: is a dictionary containing the previous 
++                           `name` and `value` of target ticket severity
++        """
++
++    def severity_deleted(entity, changeinfo = None):
++        """Called when a ticket severity is deleted."""
+ 
+ 
+ class Severity(AbstractEnum):
+     type = 'severity'
++    change_listener_interface = ISeverityChangeListener
++
++
++class IComponentChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when ticket component are created, modified, or deleted.
++
++    :param     entity: target ticket component.
++    :param changeinfo: always set to None.
++    """
++    _entity_listener_prefix = 'component'
++
++    def component_created(entity, changeinfo = None):
++        """Called when an ticket component is created."""
++
++    def component_changed(entity, old_values, changeinfo = None):
++        """Called when a ticket component is modified.
++
++        :param old_values: is a dictionary containing the previous 
++                           `name`, `owner` and `description` of
++                           target ticket component
++        """
++
++    def component_deleted(entity, changeinfo = None):
++        """Called when a ticket component is deleted."""
+ 
+ 
+ class Component(object):
+@@ -893,9 +1033,13 @@
+         with self.env.db_transaction as db:
+             self.env.log.info("Deleting component %s", self.name)
+             db("DELETE FROM component WHERE name=%s", (self.name,))
+-            self.name = self._old_name = None
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        ListenerNotifier(self.env).notify(
++                    IComponentChangeListener.component_deleted, entity=self)
++
++        self.name = self._old_name = None
++
+     def insert(self, db=None):
+         """Insert a new component.
+ 
+@@ -915,6 +1059,9 @@
+             self._old_name = self.name
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        ListenerNotifier(self.env).notify(
++                    IComponentChangeListener.component_created, entity=self)
++
+     def update(self, db=None):
+         """Update the component.
+ 
+@@ -926,6 +1073,7 @@
+         if not self.name:
+             raise TracError(_("Invalid component name."))
+ 
++        old_name = self._old_name
+         with self.env.db_transaction as db:
+             self.env.log.info("Updating component '%s'", self.name)
+             db("""UPDATE component SET name=%s,owner=%s, description=%s
+@@ -939,6 +1087,14 @@
+                 self._old_name = self.name
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        #todo:add support of old_values for owner and description fields
++        old_values = dict()
++        if self.name != old_name:
++            old_values["name"] = old_name
++        ListenerNotifier(self.env).notify(
++                    IComponentChangeListener.component_changed, 
++                    entity=self, old_values=old_values)
++
+     @classmethod
+     def select(cls, env, db=None):
+         """
+@@ -1170,6 +1326,30 @@
+     return groups
+ 
+ 
++class IVersionChangeListener(Interface):
++    """Extension point interface for components that require notification
++    when ticket version are created, modified, or deleted.
++
++    :param     entity: target ticket version.
++    :param changeinfo: always set to None.
++    """
++    _entity_listener_prefix = 'version'
++
++    def version_created(entity, changeinfo = None):
++        """Called when an ticket version is created."""
++
++    def version_changed(entity, old_values, changeinfo = None):
++        """Called when a ticket version is modified.
++
++        :param old_values: is a dictionary containing the previous 
++                           `name`, `time` and `description` of
++                           target ticket version
++        """
++
++    def version_deleted(entity, changeinfo = None):
++        """Called when a ticket version is deleted."""
++
++
+ class Version(object):
+     def __init__(self, env, name=None, db=None):
+         self.env = env
+@@ -1199,9 +1379,12 @@
+         with self.env.db_transaction as db:
+             self.env.log.info("Deleting version %s", self.name)
+             db("DELETE FROM version WHERE name=%s", (self.name,))
+-            self.name = self._old_name = None
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        ListenerNotifier(self.env).notify(
++            IVersionChangeListener.version_deleted, entity=self)
++        self.name = self._old_name = None
++
+     def insert(self, db=None):
+         """Insert a new version.
+ 
+@@ -1220,6 +1403,9 @@
+             self._old_name = self.name
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        ListenerNotifier(self.env).notify(
++            IVersionChangeListener.version_created, entity=self)
++
+     def update(self, db=None):
+         """Update the version.
+ 
+@@ -1231,6 +1417,7 @@
+         if not self.name:
+             raise TracError(_("Invalid version name."))
+ 
++        old_name=self._old_name
+         with self.env.db_transaction as db:
+             self.env.log.info("Updating version '%s'", self.name)
+             db("""UPDATE version
+@@ -1244,6 +1431,15 @@
+                 self._old_name = self.name
+             TicketSystem(self.env).reset_ticket_fields()
+ 
++        #todo: add support of old_values for time and description fields
++        old_values = dict()
++        if self.name != old_name:
++            old_values["name"] = old_name
++
++        ListenerNotifier(self.env).notify(
++            IVersionChangeListener.version_changed, 
++            entity=self, old_values=old_values)
++
+     @classmethod
+     def select(cls, env, db=None):
+         """
+diff -r 8d0c3223a818 trac/ticket/tests/model.py
+--- a/trac/ticket/tests/model.py	Thu Apr 18 14:30:21 2013 +0000
++++ b/trac/ticket/tests/model.py	Wed Apr 24 17:51:53 2013 -0500
+@@ -9,11 +9,11 @@
+ 
+ from trac import core
+ from trac.attachment import Attachment
+-from trac.core import TracError, implements
++from trac.core import implements, TracError
+ from trac.resource import ResourceNotFound
+ from trac.ticket.model import (
+-    Ticket, Component, Milestone, Priority, Type, Version
+-)
++    Ticket, Component, Milestone, Priority, Type, Version,
++    IComponentChangeListener, IVersionChangeListener, IPriorityChangeListener)
+ from trac.ticket.api import (
+     IMilestoneChangeListener, ITicketChangeListener, TicketSystem
+ )
+@@ -1097,6 +1097,376 @@
+         self.assertEqual([('Test', 0, 'Some text')], self.env.db_query(
+             "SELECT name, time, description FROM version WHERE name='Test'"))
+ 
++class EntityChangeListenerMock(core.Component):
++ 
++    def callback(self, action, entity, changeinfo, old_values = None, kwargs=None):
++        pass
++
++    def _entity_created(self, entity, changeinfo = None, **kwargs):
++        self.action = "created"
++        self.entity = entity
++        self.changeinfo = changeinfo
++        self.kwargs = kwargs
++        self.callback(self.action, entity, changeinfo, kwargs)
++
++    def _entity_changed(self, entity, old_values, changeinfo = None, **kwargs):
++        self.action = "changed"
++        self.entity = entity
++        self.old_values = old_values
++        self.changeinfo = changeinfo
++        self.kwargs = kwargs
++        self.callback(
++            self.action, entity, changeinfo, old_values, kwargs)
++
++    def _entity_deleted(self, entity, changeinfo = None, **kwargs):
++        self.action = "deleted"
++        self.entity = entity
++        self.changeinfo = changeinfo
++        self.kwargs = kwargs
++        self.callback(self.action, entity, changeinfo, kwargs)
++
++    def _entity_reparented(self, entity, changeinfo = None, **kwargs):
++        self.action = "reparented"
++        self.entity = entity
++        self.changeinfo = changeinfo
++        self.kwargs = kwargs
++        self.callback(self.action, entity, changeinfo, kwargs)
++
++class BaseEntityChangeListenerTestCase(unittest.TestCase):
++    DUMMY_ENTITY_NAME = "Entity 1"
++    name_field = "name"
++    entityChangeListener = None
++    entity_type = None
++
++    def setUp(self):
++        self.env = EnvironmentStub(default_data=True)
++        self.listener = self.entityChangeListener(self.env)
++        self.listener.callback = self.listener_callback
++
++    def tearDown(self):
++        self.env.reset_db()
++
++    def test_change_listener_created(self):
++        self._create_entity(self.DUMMY_ENTITY_NAME)
++        self.assertEqual('created', self.listener.action)
++        self.assertTrue(isinstance(self.listener.entity, self.entity_type))
++        self.assertEqual(
++            self.DUMMY_ENTITY_NAME,
++            self.entity_name)
++
++    def test_change_listener_changed(self):
++        entity = self._create_entity(self.DUMMY_ENTITY_NAME)
++        self._rename_entity(entity, "UpdatedName")
++        self.assertEqual('changed', self.listener.action)
++        self.assertTrue(isinstance(self.listener.entity, self.entity_type))
++        self.assertEqual("UpdatedName", self.entity_name)
++        self.assertEqual(
++            self.DUMMY_ENTITY_NAME,
++            self.listener.old_values[self.name_field])
++
++    def test_change_listener_deleted(self):
++        entity = self._create_entity(self.DUMMY_ENTITY_NAME)
++        entity.delete()
++        self.assertEqual('deleted', self.listener.action)
++        self.assertTrue(isinstance(self.listener.entity, self.entity_type))
++        self.assertEqual(self.DUMMY_ENTITY_NAME, self.entity_name)
++
++    def _create_entity(self, name):
++        entity = self.entity_type(self.env)
++        entity.name = name
++        entity.insert()
++        return entity
++
++    def _rename_entity(self, entity, new_name):
++        entity.name = new_name
++        entity.update()
++        return entity
++
++    def _get_entity_name(self, enity):
++        return enity.name
++
++    def listener_callback(self, action, entity, changeinfo, old_values=None, kwargs=None):
++        self.entity_name = self._get_entity_name(entity)
++
++
++class ComponentChangeListenerMock(EntityChangeListenerMock):
++    implements(IComponentChangeListener)
++
++    def component_created(self, entity, changeinfo = None):
++        return self._entity_created(entity, changeinfo)
++
++    def component_changed(self, entity, old_values, changeinfo = None):
++        return self._entity_changed(entity, old_values, changeinfo)
++
++    def component_deleted(self, entity, changeinfo = None):
++        return self._entity_deleted(entity, changeinfo)
++
++    def component_reparented(self, entity, changeinfo = None):
++        return self._entity_reparented(entity, changeinfo)
++
++
++class ComponentEntityChangeListenerTestCase(
++    BaseEntityChangeListenerTestCase):
++    entity_type = Component
++    entityChangeListener = ComponentChangeListenerMock
++
++    def test_all_methods(self):
++        self.assertTrue(hasattr(IComponentChangeListener, 'component_created'))
++        self.assertTrue(hasattr(IComponentChangeListener, 'component_changed'))
++        self.assertTrue(hasattr(IComponentChangeListener, 'component_deleted'))
++
++
++class VersionChangeListenerMock(EntityChangeListenerMock):
++    implements(IVersionChangeListener)
++    entity_type = Component
++
++    def version_created(self, entity, changeinfo = None):
++        return self._entity_created(entity, changeinfo)
++
++    def version_changed(self, entity, old_values, changeinfo = None):
++        return self._entity_changed(entity, old_values, changeinfo)
++
++    def version_deleted(self, entity, changeinfo = None):
++        return self._entity_deleted(entity, changeinfo)
++
++
++class VersionEntityChangeListenerTestCase(
++    BaseEntityChangeListenerTestCase):
++    entity_type = Version
++    entityChangeListener = VersionChangeListenerMock
++
++    def test_all_methods(self):
++        self.assertTrue(hasattr(IVersionChangeListener, 'version_created'))
++        self.assertTrue(hasattr(IVersionChangeListener, 'version_changed'))
++        self.assertTrue(hasattr(IVersionChangeListener, 'version_deleted'))
++
++
++class PriorityChangeListenerMock(EntityChangeListenerMock):
++    implements(IPriorityChangeListener)
++
++    def priority_created(self, entity, changeinfo = None):
++        return self._entity_created(entity, changeinfo)
++
++    def priority_changed(self, entity, old_values, changeinfo = None):
++        return self._entity_changed(entity, old_values, changeinfo)
++
++    def priority_deleted(self, entity, changeinfo = None):
++        return self._entity_deleted(entity, changeinfo)
++
++
++class PriorityEntityChangeListenerTestCase(
++    BaseEntityChangeListenerTestCase):
++    entity_type = Priority
++    entityChangeListener = PriorityChangeListenerMock
++
++    def test_all_methods(self):
++        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_created'))
++        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_changed'))
++        self.assertTrue(hasattr(IPriorityChangeListener, 'priority_deleted'))
++
++
++class MultipleEntitiesChangeListenerMock(core.Component):
++    implements(IComponentChangeListener, IVersionChangeListener)
++
++    def __init__(self):
++        self.action = []
++
++    def component_created(self, entity, changeinfo = None):
++        self.action.append("component_created")
++
++    def component_changed(self, entity, old_values, changeinfo = None):
++        pass
++
++    def component_deleted(self, entity, changeinfo = None):
++        pass
++
++    def version_created(self, entity, changeinfo = None):
++        self.action.append("version_created")
++
++    def version_changed(self, entity, old_values, changeinfo = None):
++        pass
++
++    def version_deleted(self, entity, changeinfo = None):
++        pass
++
++
++class MultipleEntitiesChangeListenerTestCase(unittest.TestCase):
++
++    def setUp(self):
++        self.env = EnvironmentStub(default_data=True)
++        self.listener = MultipleEntitiesChangeListenerMock(self.env)
++
++    def tearDown(self):
++        self.env.reset_db()
++
++    def test_can_receive_events_from_different_entities(self):
++        self._create_entity(Component, "Component1")
++        self._create_entity(Version, "Version1")
++        self.assertEqual(["component_created", "version_created"], 
++                         self.listener.action)
++
++    def _create_entity(self, entity_type, name):
++        entity = entity_type(self.env)
++        entity.name = name
++        entity.insert()
++        return entity
++
++
++class GenericEntitiesChangeListenerMock(core.Component):
++    implements(core.IEntityChangeListener)
++
++    def __init__(self):
++        self.details = []
++        self.action = []
++
++    def _handle(self, kwargs):
++        cls = kwargs['entity'].__class__
++        self.action.append(cls.__name__.lower() + '_' + kwargs['action'])
++        self.details.append(kwargs)
++
++    def entity_created(self, entity, changeinfo = None, **kwargs):
++        kwargs.update(entity=entity, changeinfo=changeinfo, action='created')
++        self._handle(kwargs)
++
++    def entity_changed(self, entity, old_values, changeinfo = None, **kwargs):
++        kwargs.update(entity=entity, changeinfo=changeinfo, action='changed',
++                      old_values=old_values)
++        self._handle(kwargs)
++
++    def entity_deleted(self, entity, changeinfo = None, **kwargs):
++        kwargs.update(entity=entity, changeinfo=changeinfo, action='deleted')
++        self._handle(kwargs)
++
++    def entity_reparented(self, entity, changeinfo = None, **kwargs):
++        kwargs.update(entity=entity, changeinfo=changeinfo, action='reparented')
++        self._handle(kwargs)
++
++
++class GenericEntitiesChangeListenerTestCase(MultipleEntitiesChangeListenerTestCase):
++
++    def setUp(self):
++        self.env = EnvironmentStub(default_data=True)
++        self.listener = GenericEntitiesChangeListenerMock(self.env)
++
++    def test_custom_event_args(self):
++
++        class ISomethingListener(core.Interface):
++            """Listener interface written from scratch
++            """
++            def something_created(entity, changeinfo = None):
++                """Called when an entity is created."""
++
++            def something_changed(entity, old_values, required_arg,
++                                  changeinfo=None, optional_arg=None):
++                """Update event with required and optional custom args."""
++
++            def something_deleted(entity, changeinfo = None):
++                """Called when an entity is deleted."""
++
++            def something_reparented(entity, changeinfo = None):
++                """Never invoked. May be removed."""
++
++        class Something(object):
++            def __init__(self, env):
++                self.env = env
++                self.required_arg = 999
++
++            # Just trigger notifications
++            def delete(self, db=None):
++                notifier = core.ListenerNotifier(self.env)
++                notifier.notify(ISomethingListener.something_deleted, 
++                                entity=self)
++
++            def insert(self, db=None):
++                notifier = core.ListenerNotifier(self.env)
++                notifier.notify(ISomethingListener.something_created,
++                                entity=self)
++
++            def update(self, db=None):
++                notifier = core.ListenerNotifier(self.env)
++                optional_args = dict(optional_arg=self.optional_arg) \
++                                if hasattr(self, 'optional_arg') \
++                                else {}
++                notifier.notify(ISomethingListener.something_changed,
++                                entity=self,
++                                old_values=dict(),
++                                required_arg=self.required_arg,
++                                **optional_args)
++
++        class SomethingListener(core.Component):
++            """Replicate the same behavior of generic listener
++            """
++            implements(ISomethingListener)
++
++            def __init__(self):
++                self.details = []
++
++            def _handle(self, kwargs):
++                if 'optional_arg' in kwargs and kwargs['optional_arg'] is None:
++                    del kwargs['optional_arg']
++                self.details.append(kwargs)
++
++            def something_created(self, entity, changeinfo=None):
++                args = locals()
++                del args['self']
++                args['action'] = 'created'
++                self._handle(args)
++
++            def something_changed(self, entity, old_values, required_arg,
++                                  changeinfo=None, optional_arg=None):
++                args = locals()
++                del args['self']
++                args['action'] = 'changed'
++                self._handle(args)
++
++            def something_deleted(self, entity, changeinfo=None):
++                args = locals()
++                del args['self']
++                args['action'] = 'deleted'
++                self._handle(args)
++
++            def something_reparented(self, entity, changeinfo=None):
++                args = locals()
++                del args['self']
++                args['action'] = 'reparented'
++                self._handle(args)
++
++        resource_listener = SomethingListener(self.env)
++
++        # CRUD operations
++        something = Something(self.env)
++        something.insert()
++        something.update()
++        something.required_arg = 7
++        something.optional_arg = 44
++        something.update()
++        something.delete()
++
++        expected = [
++                    {'action' : 'created',
++                     'entity' : something,
++                     'changeinfo' : None},
++                    {'action' : 'changed',
++                     'entity' : something,
++                     'old_values' : {},
++                     'required_arg' : 999,
++                     'changeinfo' : None},
++                    {'action' : 'changed',
++                     'entity' : something,
++                     'old_values' : {},
++                     'required_arg' : 7,
++                     'optional_arg' : 44,
++                     'changeinfo' : None},
++                    {'action' : 'deleted',
++                     'entity' : something,
++                     'changeinfo' : None},
++                    ]
++
++        self.assertEquals(expected, self.listener.details,
++                          'Generic listener event sequence')
++        self.assertEquals(expected, resource_listener.details,
++                          'Resource-specific listener event sequence')
++
+ 
+ def suite():
+     suite = unittest.TestSuite()
+@@ -1107,6 +1477,17 @@
+     suite.addTest(unittest.makeSuite(MilestoneTestCase, 'test'))
+     suite.addTest(unittest.makeSuite(ComponentTestCase, 'test'))
+     suite.addTest(unittest.makeSuite(VersionTestCase, 'test'))
++    suite.addTest(unittest.makeSuite(
++        ComponentEntityChangeListenerTestCase, 'test'))
++    suite.addTest(unittest.makeSuite(
++        VersionEntityChangeListenerTestCase, 'test'))
++    suite.addTest(unittest.makeSuite(
++        PriorityEntityChangeListenerTestCase, 'test'))
++    suite.addTest(unittest.makeSuite(
++        MultipleEntitiesChangeListenerTestCase, 'test'))
++    suite.addTest(unittest.makeSuite(
++        GenericEntitiesChangeListenerTestCase, 'test'))
++
+     return suite
+ 
+ if __name__ == '__main__':