From 3a287e3d42c88a265f020172095c28c9ce9c22a7 2014-08-04 21:22:16
From: Jonathan Frederic <jdfreder@calpoly.edu>
Date: 2014-08-04 21:22:16
Subject: [PATCH] Add EventfulList and EventfulDict trait types.

---

diff --git a/IPython/utils/eventful.py b/IPython/utils/eventful.py
new file mode 100644
index 0000000..b826498
--- /dev/null
+++ b/IPython/utils/eventful.py
@@ -0,0 +1,289 @@
+"""Contains eventful dict and list implementations."""
+
+class EventfulDict(dict):
+    """Eventful dictionary.
+
+    This class inherits from the Python intrinsic dictionary class, dict.  It
+    adds events to the get, set, and del actions and optionally allows you to
+    intercept and cancel these actions.  The eventfulness isn't recursive.  In
+    other words, if you add a dict as a child, the events of that dict won't be
+    listened to.  If you find you need something recursive, listen to the `add`
+    and `set` methods, and then cancel `dict` values from being set, and instead
+    set `EventfulDict`s that wrap those `dict`s.  Then you can wire the events
+    to the same handlers if necessary.
+
+    See the on_add, on_set, and on_del methods for registering an event
+    handler."""
+
+    def __init__(self, *args, **kwargs):
+        """Public constructor"""
+        self._add_callback = None
+        self._del_callback = None
+        self._set_callback = None
+        dict.__init__(self, *args, **kwargs)
+
+    def on_add(self, callback):
+        """Register a callback for when an item is added to the dict.
+
+        Allows the listener to detect when items are added to the dictionary and
+        optionally cancel the addition.
+
+        callback: callable or None
+            If you want to ignore the addition event, pass None as the callback.
+            The callback should have a signature of callback(key, value).  The
+            callback should return a boolean True if the additon should be
+            canceled, False or None otherwise."""
+        self._add_callback = callback
+
+    def on_del(self, callback):
+        """Register a callback for when an item is deleted from the dict.
+
+        Allows the listener to detect when items are deleted from the dictionary
+        and optionally cancel the deletion.
+
+        callback: callable or None
+            If you want to ignore the deletion event, pass None as the callback.
+            The callback should have a signature of callback(key).  The
+            callback should return a boolean True if the deletion should be
+            canceled, False or None otherwise."""
+        self._del_callback = callback
+
+    def on_set(self, callback):
+        """Register a callback for when an item is changed in the dict.
+
+        Allows the listener to detect when items are changed in the dictionary
+        and optionally cancel the change.
+
+        callback: callable or None
+            If you want to ignore the change event, pass None as the callback.
+            The callback should have a signature of callback(key, value).  The
+            callback should return a boolean True if the change should be
+            canceled, False or None otherwise."""
+        self._set_callback = callback
+
+    def _can_add(self, key, value):
+        """Check if the item can be added to the dict."""
+        if callable(self._add_callback):
+            return not bool(self._add_callback(key, value))
+        else:
+            return True
+
+    def _can_del(self, key):
+        """Check if the item can be deleted from the dict."""
+        if callable(self._del_callback):
+            return not bool(self._del_callback(key))
+        else:
+            return True
+
+    def _can_set(self, key, value):
+        """Check if the item can be changed in the dict."""
+        if callable(self._set_callback):
+            return not bool(self._set_callback(key, value))
+        else:
+            return True
+
+    def pop(self, key):
+        """Returns the value of an item in the dictionary and then deletes the
+        item from the dictionary."""
+        if self._can_del(key):
+            return dict.pop(self, key)
+        else:
+            raise Exception('Cannot `pop`, deletion of key "{}" failed.'.format(key))
+
+    def popitem(self):
+        """Pop the next key/value pair from the dictionary."""
+        key = dict.keys(self)[0]
+        return key, self.pop(key)
+
+    def update(self, other_dict):
+        """Copy the key/value pairs from another dictionary into this dictionary,
+        overwriting any conflicting keys in this dictionary."""
+        for (key, value) in other_dict.items():
+            self[key] = value
+            
+    def clear(self):
+        """Clear the dictionary."""
+        for key in list(self.keys()):
+            del self[key]
+
+    def __setitem__(self, key, value):
+        if (key in self and self._can_set(key, value)) or \
+        (key not in self and self._can_add(key, value)):
+            return dict.__setitem__(self, key, value)
+        
+    def __delitem__(self, key):
+        if self._can_del(key):
+            return dict.__delitem__(self, key)
+
+
+class EventfulList(list):
+    """Eventful list.
+
+    This class inherits from the Python intrinsic `list` class.  It adds events 
+    that allow you to listen for actions that modify the list.  You can 
+    optionally cancel the actions.
+
+    See the on_del, on_set, on_insert, on_sort, and on_reverse methods for
+    registering an event handler.
+
+    Some of the method docstrings were taken from the Python documentation at
+    https://docs.python.org/2/tutorial/datastructures.html"""
+
+    def __init__(self, *pargs, **kwargs):
+        """Public constructor"""
+        self._insert_callback = None
+        self._set_callback = None
+        self._del_callback = None
+        self._sort_callback = None
+        self._reverse_callback = None
+        list.__init__(self, *pargs, **kwargs)
+
+    def on_insert(self, callback):
+        """Register a callback for when an item is inserted into the list.
+
+        Allows the listener to detect when items are inserted into the list and
+        optionally cancel the insertion.
+
+        callback: callable or None
+            If you want to ignore the insertion event, pass None as the callback.
+            The callback should have a signature of callback(index, value).  The
+            callback should return a boolean True if the insertion should be
+            canceled, False or None otherwise."""
+        self._insert_callback = callback
+
+    def on_del(self, callback):
+        """Register a callback for item deletion.
+
+        Allows the listener to detect when items are deleted from the list and
+        optionally cancel the deletion.
+
+        callback: callable or None
+            If you want to ignore the deletion event, pass None as the callback.
+            The callback should have a signature of callback(index).  The
+            callback should return a boolean True if the deletion should be
+            canceled, False or None otherwise."""
+        self._del_callback = callback
+        
+    def on_set(self, callback):
+        """Register a callback for items are set.
+
+        Allows the listener to detect when items are set and optionally cancel 
+        the setting.  Note, `set` is also called when one or more items are
+        added to the end of the list.
+
+        callback: callable or None
+            If you want to ignore the set event, pass None as the callback.
+            The callback should have a signature of callback(index, value).  The
+            callback should return a boolean True if the set should be
+            canceled, False or None otherwise."""
+        self._set_callback = callback
+        
+    def on_reverse(self, callback):
+        """Register a callback for list reversal.
+
+        callback: callable or None
+            If you want to ignore the reverse event, pass None as the callback.
+            The callback should have a signature of callback().  The
+            callback should return a boolean True if the reverse should be
+            canceled, False or None otherwise."""
+        self._reverse_callback = callback
+        
+    def on_sort(self, callback):
+        """Register a callback for sortting of the list.
+
+        callback: callable or None
+            If you want to ignore the sort event, pass None as the callback.
+            The callback signature should match that of Python list's `.sort` 
+            method or `callback(*pargs, **kwargs)` as a catch all. The callback 
+            should return a boolean True if the reverse should be canceled, 
+            False or None otherwise."""
+        self._sort_callback = callback
+    
+    def _can_insert(self, index, value):
+        """Check if the item can be inserted."""
+        if callable(self._insert_callback):
+            return not bool(self._insert_callback(index, value))
+        else:
+            return True
+
+    def _can_del(self, index):
+        """Check if the item can be deleted."""
+        if callable(self._del_callback):
+            return not bool(self._del_callback(index))
+        else:
+            return True
+
+    def _can_set(self, index, value):
+        """Check if the item can be set."""
+        if callable(self._set_callback):
+            return not bool(self._set_callback(index, value))
+        else:
+            return True
+
+    def _can_reverse(self):
+        """Check if the list can be reversed."""
+        if callable(self._reverse_callback):
+            return not bool(self._reverse_callback())
+        else:
+            return True
+
+    def _can_sort(self, *pargs, **kwargs):
+        """Check if the list can be sorted."""
+        if callable(self._sort_callback):
+            return not bool(self._sort_callback(*pargs, **kwargs))
+        else:
+            return True
+        
+    def append(self, x):
+        """Add an item to the end of the list."""
+        self[len(self):] = [x]
+
+    def extend(self, L):
+        """Extend the list by appending all the items in the given list."""
+        self[len(self):] = L
+
+    def remove(self, x):
+        """Remove the first item from the list whose value is x. It is an error 
+        if there is no such item."""
+        del self[self.index(x)]
+        
+    def pop(self, i=None):
+        """Remove the item at the given position in the list, and return it. If 
+        no index is specified, a.pop() removes and returns the last item in the 
+        list."""
+        if i is None:
+            i = len(self) - 1
+        val = self[i]
+        del self[i]
+        return val
+
+    def reverse(self):
+        """Reverse the elements of the list, in place."""
+        if self._can_reverse():
+            list.reverse(self)
+    
+    def insert(self, index, value):
+        """Insert an item at a given position. The first argument is the index 
+        of the element before which to insert, so a.insert(0, x) inserts at the 
+        front of the list, and a.insert(len(a), x) is equivalent to 
+        a.append(x)."""
+        if self._can_insert(index, value):
+            list.insert(self, index, value)
+
+    def sort(self, *pargs, **kwargs):
+        """Sort the items of the list in place (the arguments can be used for 
+        sort customization, see Python's sorted() for their explanation)."""
+        if self._can_sort(*pargs, **kwargs):
+            list.sort(self, *pargs, **kwargs)
+
+    def __delitem__(self, index):
+        if self._can_del(index):
+            list.__delitem__(self, index)
+
+    def __setitem__(self, index, value):
+        if self._can_set(index, value):
+            list.__setitem__(self, index, value)
+
+    def __setslice__(self, start, end, value):
+        if self._can_set(slice(start, end), value):
+            list.__setslice__(self, start, end, value)
diff --git a/IPython/utils/tests/test_traitlets.py b/IPython/utils/tests/test_traitlets.py
index 8992345..760e22e 100644
--- a/IPython/utils/tests/test_traitlets.py
+++ b/IPython/utils/tests/test_traitlets.py
@@ -19,7 +19,7 @@ from IPython.utils.traitlets import (
     HasTraits, MetaHasTraits, TraitType, Any, CBytes, Dict,
     Int, Long, Integer, Float, Complex, Bytes, Unicode, TraitError,
     Undefined, Type, This, Instance, TCPAddress, List, Tuple,
-    ObjectName, DottedObjectName, CRegExp, link
+    ObjectName, DottedObjectName, CRegExp, link, EventfulList, EventfulDict
 )
 from IPython.utils import py3compat
 from IPython.testing.decorators import skipif
@@ -1034,7 +1034,7 @@ class TestCRegExp(TraitTestBase):
 
     _default_value = re.compile(r'')
     _good_values = [r'\d+', re.compile(r'\d+')]
-    _bad_values = [r'(', None, ()]
+    _bad_values = ['(', None, ()]
 
 class DictTrait(HasTraits):
     value = Dict()
@@ -1171,4 +1171,83 @@ def test_pickle_hastraits():
         c2 = pickle.loads(p)
         nt.assert_equal(c2.i, c.i)
         nt.assert_equal(c2.j, c.j)
-    
+
+class TestEventful(TestCase):
+
+    def test_list(self):
+        """Does the EventfulList work?"""
+        event_cache = []
+
+        class A(HasTraits):
+            x = EventfulList([c for c in 'abc'])
+        a = A()
+
+        def handle_insert(index, value):
+            event_cache.append('insert')
+        def handle_del(index):
+            event_cache.append('del')
+        def handle_set(index, value):
+            event_cache.append('set')
+        def handle_reverse():
+            event_cache.append('reverse')
+        def handle_sort(*pargs, **kwargs):
+            event_cache.append('sort')
+        a.x.on_insert(handle_insert)
+        a.x.on_del(handle_del)
+        a.x.on_set(handle_set)
+        a.x.on_reverse(handle_reverse)
+        a.x.on_sort(handle_sort)
+
+        a.x.remove('c')
+        # ab
+        a.x.insert(0, 'z')
+        # zab
+        del a.x[1]
+        # zb
+        a.x.reverse()
+        # bz 
+        a.x[1] = 'o'
+        # bo
+        a.x.append('a')
+        # boa
+        a.x.sort()
+        # abo
+
+        # Were the correct events captured?
+        self.assertEqual(event_cache, ['del', 'insert', 'del', 'reverse', 'set', 'set', 'sort'])
+
+        # Is the output correct?
+        self.assertEqual(a.x, [c for c in 'abo'])
+
+    def test_dict(self):
+        """Does the EventfulDict work?"""
+        event_cache = []
+
+        class A(HasTraits):
+            x = EventfulDict({c: c for c in 'abc'})
+        a = A()
+
+        def handle_add(key, value):
+            event_cache.append('add')
+        def handle_del(key):
+            event_cache.append('del')
+        def handle_set(key, value):
+            event_cache.append('set')
+        a.x.on_add(handle_add)
+        a.x.on_del(handle_del)
+        a.x.on_set(handle_set)
+
+        del a.x['c']
+        # ab
+        a.x['z'] = 1
+        # abz
+        a.x['z'] = 'z'
+        # abz
+        a.x.pop('a')
+        # bz 
+
+        # Were the correct events captured?
+        self.assertEqual(event_cache, ['del', 'add', 'set', 'del'])
+
+        # Is the output correct?
+        self.assertEqual(a.x, {c: c for c in 'bz'})
diff --git a/IPython/utils/traitlets.py b/IPython/utils/traitlets.py
index 1a68b04..20b1c8e 100644
--- a/IPython/utils/traitlets.py
+++ b/IPython/utils/traitlets.py
@@ -54,6 +54,7 @@ except:
 
 from .importstring import import_item
 from IPython.utils import py3compat
+from IPython.utils import eventful
 from IPython.utils.py3compat import iteritems
 from IPython.testing.skipdoctest import skip_doctest
 
@@ -1490,6 +1491,49 @@ class Dict(Instance):
         super(Dict,self).__init__(klass=dict, args=args,
                                   allow_none=allow_none, **metadata)
 
+
+class EventfulDict(Instance):
+    """An instance of an EventfulDict."""
+
+    def __init__(self, default_value=None, allow_none=True, **metadata):
+        """Create a EventfulDict trait type from a dict.
+
+        The default value is created by doing 
+        ``eventful.EvenfulDict(default_value)``, which creates a copy of the 
+        ``default_value``.
+        """
+        if default_value is None:
+            args = ((),)
+        elif isinstance(default_value, dict):
+            args = (default_value,)
+        elif isinstance(default_value, SequenceTypes):
+            args = (default_value,)
+        else:
+            raise TypeError('default value of EventfulDict was %s' % default_value)
+
+        super(EventfulDict, self).__init__(klass=eventful.EventfulDict, args=args,
+                                  allow_none=allow_none, **metadata)
+
+
+class EventfulList(Instance):
+    """An instance of an EventfulList."""
+
+    def __init__(self, default_value=None, allow_none=True, **metadata):
+        """Create a EventfulList trait type from a dict.
+
+        The default value is created by doing 
+        ``eventful.EvenfulList(default_value)``, which creates a copy of the 
+        ``default_value``.
+        """
+        if default_value is None:
+            args = ((),)
+        else:
+            args = (default_value,)
+
+        super(EventfulList, self).__init__(klass=eventful.EventfulList, args=args,
+                                  allow_none=allow_none, **metadata)
+
+
 class TCPAddress(TraitType):
     """A trait for an (ip, port) tuple.