diff --git a/HISTORY.rst b/HISTORY.rst index 0edf1d1..cfa9552 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ History ------- +0.3.0 (2018-02-16) +~~~~~~~~~~~~~~~~~~ + +* Added signals support - ``context_initialized``, ``pre_context_changed``, + ``post_context_changed`` and ``context_key_changed``. See README for examples. + 0.2.1 (2017-07-28) ~~~~~~~~~~~~~~~~~~ diff --git a/README.rst b/README.rst index b9a5cfe..74b29ef 100644 --- a/README.rst +++ b/README.rst @@ -28,12 +28,12 @@ Example :: - >>> context = Context({"user": "Fred", "city": "Bedrock"}) + >>> context = Context({'user': 'Fred', 'city': 'Bedrock'}) >>> context['user'] 'Fred' >>> context['city'] 'Bedrock' - >>> context.push({"user": "Barney"}) + >>> context.push({'user': 'Barney'}) >>> context['user'] 'Barney' >>> context['city'] @@ -43,6 +43,58 @@ Example >>> context['user'] 'Fred' +Context also supports signals. +Signal handler can be attached globally:: + + >>> @context_key_changed.connect + ... def handler(sender, context, key, new, old): + ... print(key, new, old) + + >>> context = Context() + >>> context['hello'] = 'world' + hello world + +Or to individual context instances:: + + >>> def handler(sender, context, key, new, old): + ... print(key, new, old) + >>> context = Context() + >>> context_key_changed.connect(handler, sender=context) + +Supported signals:: + + >>> @context_initialized.connect + ... def handler(sender, context): + ... pass + + >>> @pre_context_changed.connect + ... def handler(sender, context): + ... pass + + >>> @post_context_changed.connect + ... def handler(sender, context): + ... pass + + >>> @context_key_changed.connect + ... def handler(sender, context, key, new, old): + ... pass + +Additionally, ``ClassSignallingContext`` can be used to subscribe signals +by sender classes, not instances:: + + >>> class TestContext(ClassSignallingContext): + ... pass + >>> def context_key_changed_handler(sender, context, key, new, old): + ... print(key, new, old) + >>> _ = context_key_changed.connect(context_key_changed_handler, sender=TestContext) + + >>> context = Context() + >>> class_context = TestContext() + + >>> context['foo'] = 'bar' + >>> class_context['foo'] = 'bar' + foo bar + Testing ------- diff --git a/pycontext/__init__.py b/pycontext/__init__.py index ad1b214..fe855d0 100644 --- a/pycontext/__init__.py +++ b/pycontext/__init__.py @@ -3,5 +3,5 @@ __author__ = 'Miroslav Shubernetskiy' -__version__ = '0.2.1' +__version__ = '0.3.0' __description__ = 'Python dict with stacked context data' diff --git a/pycontext/context.py b/pycontext/context.py index c77ffda..fac6883 100644 --- a/pycontext/context.py +++ b/pycontext/context.py @@ -2,8 +2,51 @@ from __future__ import absolute_import, print_function, unicode_literals import copy from collections import Mapping, deque +from contextlib import contextmanager from itertools import chain +import six + +from .signals import ( + context_initialized, + context_key_changed, + post_context_changed, + pre_context_changed, +) + + +@six.python_2_unicode_compatible +class Missing(object): + """ + Helper object to distinguish missing keys from falsy keys. + + >>> def foo(a=MISSING): + ... print(a, bool(a)) + >>> foo() + False + >>> foo('Yes') + Yes True + """ + def __bool__(self): + return False + + __nonzero__ = __bool__ + + def __eq__(self, other): + return isinstance(other, Missing) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return '' + + def __str__(self): + return self.__repr__() + + +MISSING = Missing() + class ContextPushPopContextManager(object): """ @@ -40,14 +83,39 @@ class Context(Mapping): data that should only be available inside clause. When the statement terminates, that scope can get popped off the stack again. - >>> ctxt = Context({"user": "Fred", "city": "Bedrock"}) - >>> assert ctxt['user'] == 'Fred' - >>> assert ctxt['city'] == 'Bedrock' - >>> assert isinstance(ctxt.push({"user": "Barney"}), ContextPushPopContextManager) - >>> assert ctxt['user'] == 'Barney' - >>> assert ctxt['city'] == 'Bedrock' - >>> assert ctxt.pop() == {'user': 'Barney'} - >>> assert ctxt['user'] == 'Fred' + >>> context = Context({"user": "Fred", "city": "Bedrock"}) + >>> assert context['user'] == 'Fred' + >>> assert context['city'] == 'Bedrock' + >>> assert isinstance(context.push({"user": "Barney"}), ContextPushPopContextManager) + >>> assert context['user'] == 'Barney' + >>> assert context['city'] == 'Bedrock' + >>> assert context.pop() == {'user': 'Barney'} + >>> assert context['user'] == 'Fred' + + Signals are also supported: + + >>> def pre(sender, context): + ... print('pre') + >>> def post(sender, context): + ... print('post') + >>> def changed(sender, context, key, new, old): + ... print(key, new, old) + >>> _ = pre_context_changed.connect(pre, sender=context) + >>> _ = post_context_changed.connect(post, sender=context) + >>> _ = context_key_changed.connect(changed, sender=context) + + >>> context['foo'] = 'bar' + pre + foo bar + post + >>> context.update({'foo': 'haha'}) + pre + foo haha bar + post + >>> # only changes are signalled for context_key_changed + >>> _ = context.push({'foo': 'haha'}) + pre + post """ __slots__ = ('frames',) @@ -65,6 +133,7 @@ def __init__(self, context_data=None, **kwargs): context.update(context_data) self.frames = deque([context]) + self._send_initialized_signal() def _get_base_context(self): """ @@ -145,7 +214,8 @@ def __setitem__(self, key, value): :param key: the name of the variable :param value: the variable value """ - self.frames[0][key] = value + with self._with_changed_keys((key, value)): + self.frames[0][key] = value def __setattr__(self, key, value): """ @@ -245,6 +315,29 @@ def _find(self, key, default=None): return frame[key], frame return default, None + def _send_initialized_signal(self): + context_initialized.send(self, context=self) + + def _send_pre_changed_signal(self): + pre_context_changed.send(self, context=self) + + def _send_post_changed_signal(self): + post_context_changed.send(self, context=self) + + def _send_changed_key_signal(self, key, new, old): + if old != new: + context_key_changed.send(self, context=self, key=key, new=new, old=old) + + @contextmanager + def _with_changed_keys(self, *args): + self._send_pre_changed_signal() + for k, v in args: + self._send_changed_key_signal(k, v, self.get(k, MISSING)) + + yield + + self._send_post_changed_signal() + def setdefault(self, key, default): """ Same as dict's setdefault implementation. @@ -253,7 +346,8 @@ def setdefault(self, key, default): to default and return default value. """ if key not in self: - self[key] = default + with self._with_changed_keys((key, default)): + self[key] = default return self[key] @@ -327,11 +421,12 @@ def copy(self): """ return self.__copy__() - def update(self, mapping=None, **kwargs): + def update(self, *args, **kwargs): """ Update the context from the mapping provided. """ - self.frames[0].update(mapping, **kwargs) + with self._with_changed_keys(*chain(kwargs.items(), *(i.items() for i in args))): + self.frames[0].update(*args, **kwargs) def push(self, data): """ @@ -350,7 +445,11 @@ def push(self, data): # For simplicity need to normalize the data to dict # since otherwise data can be another context # which will cause undesired recursion - self.frames.appendleft(dict(data)) + data = dict(data) + + with self._with_changed_keys(*data.items()): + self.frames.appendleft(data) + return ContextPushPopContextManager(self) def pop(self): @@ -359,4 +458,46 @@ def pop(self): :return: the frame popped from frames """ - return self.frames.popleft() + self._send_pre_changed_signal() + + try: + popped = self.frames.popleft() + return popped + finally: + for k, v in popped.items(): + self._send_changed_key_signal(k, self.get(k, MISSING), v) + + self._send_post_changed_signal() + + +class ClassSignallingContext(Context): + """ + Same as ``Context`` except signal sender is a class, not an instance. + + Useful to connect signals to a ``Context`` subclass. + + >>> class TestContext(ClassSignallingContext): + ... pass + >>> def context_key_changed_handler(sender, context, key, new, old): + ... print(key, new, old) + >>> _ = context_key_changed.connect(context_key_changed_handler, sender=TestContext) + + >>> context = Context() + >>> class_context = TestContext() + + >>> context['foo'] = 'bar' + >>> class_context['foo'] = 'bar' + foo bar + """ + def _send_initialized_signal(self): + context_initialized.send(self.__class__, context=self) + + def _send_pre_changed_signal(self): + pre_context_changed.send(self.__class__, context=self) + + def _send_post_changed_signal(self): + post_context_changed.send(self.__class__, context=self) + + def _send_changed_key_signal(self, key, new, old): + if old != new: + context_key_changed.send(self.__class__, context=self, key=key, new=new, old=old) diff --git a/pycontext/signals.py b/pycontext/signals.py new file mode 100644 index 0000000..9f9ca6d --- /dev/null +++ b/pycontext/signals.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals + +from blinker import Namespace + + +signals = Namespace() + +context_initialized = signals.signal('context_initialized') +pre_context_changed = signals.signal('pre_context_changed') +post_context_changed = signals.signal('post_context_changed') +context_key_changed = signals.signal('context_key_changed') diff --git a/requirements.txt b/requirements.txt index e69de29..7fe00ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,2 @@ +blinker +six diff --git a/tests/test_context.py b/tests/test_context.py index ea53ad6..6dcce29 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -6,7 +6,12 @@ import mock -from pycontext.context import Context +from pycontext.context import MISSING, Context +from pycontext.signals import ( + context_key_changed, + post_context_changed, + pre_context_changed, +) class TestContext(unittest.TestCase): @@ -418,3 +423,45 @@ def test_push_pop_context_manager(self): def test_recursion(self): with self.assertRaises(ValueError): self.context.push(self.context) + + def _check_signals(self, k='foo', o=MISSING, n='bar', i=None): + context = Context(i or {}) + + def pre(sender, context): + op = self.assertNotIn if o == MISSING else self.assertIn + op(k, context) + + def post(sender, context): + op = self.assertIn if o == MISSING else self.assertNotIn + op(k, context) + + def change(sender, context, key, new, old): + self.assertEqual(key, k) + self.assertEqual(new, n) + self.assertEqual(old, o) + + pre_context_changed.connect(pre, sender=context) + post_context_changed.connect(post, sender=context) + context_key_changed.connect(change, sender=context) + + return context, pre, post, change + + def test_signals_setitem(self): + context, pre, post, change = self._check_signals() + context['foo'] = 'bar' + + def test_signals_setdefault(self): + context, pre, post, change = self._check_signals() + context.setdefault('foo', 'bar') + + def test_signals_update(self): + context, pre, post, change = self._check_signals() + context.update(foo='bar') + + def test_signals_push(self): + context, pre, post, change = self._check_signals() + context.push({'foo': 'bar'}) + + def test_signals_pop(self): + context, pre, post, change = self._check_signals(i={'foo': 'bar'}, n=MISSING, o='bar') + self.assertEqual(context.pop(), {'foo': 'bar'})