diff --git a/IPython/utils/eventful.py b/IPython/utils/eventful.py new file mode 100644 index 0000000..d02be7b --- /dev/null +++ b/IPython/utils/eventful.py @@ -0,0 +1,299 @@ +"""Contains eventful dict and list implementations.""" + +# void function used as a callback placeholder. +def _void(*p, **k): return None + +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_events, on_add, on_set, and on_del methods for registering + event handlers.""" + + def __init__(self, *args, **kwargs): + """Public constructor""" + self._add_callback = _void + self._del_callback = _void + self._set_callback = _void + dict.__init__(self, *args, **kwargs) + + def on_events(self, add_callback=None, set_callback=None, del_callback=None): + """Register callbacks for add, set, and del actions. + + See the doctstrings for on_(add/set/del) for details about each + callback. + + add_callback: [callback = None] + set_callback: [callback = None] + del_callback: [callback = None]""" + self.on_add(add_callback) + self.on_set(set_callback) + self.on_del(del_callback) + + 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 if callable(callback) else _void + + 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 if callable(callback) else _void + + 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 if callable(callback) else _void + + 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 = next(iter(self)) + 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) + + def _can_add(self, key, value): + """Check if the item can be added to the dict.""" + return not bool(self._add_callback(key, value)) + + def _can_del(self, key): + """Check if the item can be deleted from the dict.""" + return not bool(self._del_callback(key)) + + def _can_set(self, key, value): + """Check if the item can be changed in the dict.""" + return not bool(self._set_callback(key, value)) + + +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 = _void + self._set_callback = _void + self._del_callback = _void + self._sort_callback = _void + self._reverse_callback = _void + list.__init__(self, *pargs, **kwargs) + + def on_events(self, insert_callback=None, set_callback=None, + del_callback=None, reverse_callback=None, sort_callback=None): + """Register callbacks for add, set, and del actions. + + See the doctstrings for on_(insert/set/del/reverse/sort) for details + about each callback. + + insert_callback: [callback = None] + set_callback: [callback = None] + del_callback: [callback = None] + reverse_callback: [callback = None] + sort_callback: [callback = None]""" + self.on_insert(insert_callback) + self.on_set(set_callback) + self.on_del(del_callback) + self.on_reverse(reverse_callback) + self.on_sort(sort_callback) + + 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 if callable(callback) else _void + + 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 if callable(callback) else _void + + 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 if callable(callback) else _void + + 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 if callable(callback) else _void + + 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 if callable(callback) else _void + + 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) + + def _can_insert(self, index, value): + """Check if the item can be inserted.""" + return not bool(self._insert_callback(index, value)) + + def _can_del(self, index): + """Check if the item can be deleted.""" + return not bool(self._del_callback(index)) + + def _can_set(self, index, value): + """Check if the item can be set.""" + return not bool(self._set_callback(index, value)) + + def _can_reverse(self): + """Check if the list can be reversed.""" + return not bool(self._reverse_callback()) + + def _can_sort(self, *pargs, **kwargs): + """Check if the list can be sorted.""" + return not bool(self._sort_callback(*pargs, **kwargs)) diff --git a/IPython/utils/tests/test_traitlets.py b/IPython/utils/tests/test_traitlets.py index 8992345..4acd3ec 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,65 @@ 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() + a.x.on_events(lambda i, x: event_cache.append('insert'), \ + lambda i, x: event_cache.append('set'), \ + lambda i: event_cache.append('del'), \ + lambda: event_cache.append('reverse'), \ + lambda *p, **k: event_cache.append('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() + a.x.on_events(lambda k, v: event_cache.append('add'), \ + lambda k, v: event_cache.append('set'), \ + lambda k: event_cache.append('del')) + + 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.