From 2c81acc259111c09654ee2047844ba8aee862e82 2017-10-12 16:07:31 From: Fabio Niephaus Date: 2017-10-12 16:07:31 Subject: [PATCH] Use `backcall` and introduce `ExecutionRequest` --- diff --git a/IPython/core/events.py b/IPython/core/events.py index 8dfc59b..7b897bb 100644 --- a/IPython/core/events.py +++ b/IPython/core/events.py @@ -13,27 +13,8 @@ events and the arguments which will be passed to them. This API is experimental in IPython 2.0, and may be revised in future versions. """ -from functools import wraps -from inspect import isfunction -try: - from inspect import getfullargspec -except: - from inspect import getargspec as getfullargspec # for Python2 compatibility. - -# original function -> wrapper function mapping -compatibility_wrapper_functions = {} - -def _compatibility_wrapper_for(function): - """Returns a wrapper for a function without args that accepts any args.""" - if len(getfullargspec(function).args) > 0: - raise TypeError('%s cannot have arguments' % function) - if function in compatibility_wrapper_functions: - return compatibility_wrapper_functions[function] - @wraps(function) - def wrapper(*args, **kwargs): - function() - compatibility_wrapper_functions[function] = wrapper - return wrapper +from backcall import callback_prototype + class EventManager(object): """Manage a collection of events and a sequence of callbacks for each. @@ -78,24 +59,11 @@ class EventManager(object): """ if not callable(function): raise TypeError('Need a callable, got %r' % function) - - callback_proto = available_events.get(event) - if (isfunction(callback_proto) and isfunction(function) and - len(getfullargspec(callback_proto).args) > 0 and - len(getfullargspec(function).args) == 0): - # `callback_proto` has args but `function` does not, so a - # compatibility wrapper is needed. - self.callbacks[event].append(_compatibility_wrapper_for(function)) - else: - self.callbacks[event].append(function) + self.callbacks[event].append(_adapt_function(event, function)) def unregister(self, event, function): """Remove a callback from the given event.""" - wrapper = compatibility_wrapper_functions.get(function) - if wrapper: - self.callbacks[event].remove(wrapper) - else: - self.callbacks[event].remove(function) + self.callbacks[event].remove(_adapt_function(event, function)) def trigger(self, event, *args, **kwargs): """Call callbacks for ``event``. @@ -113,10 +81,30 @@ class EventManager(object): # event_name -> prototype mapping available_events = {} +# (event, function) -> adapted function mapping +adapted_functions = {} + + def _define_event(callback_proto): available_events[callback_proto.__name__] = callback_proto return callback_proto + +def _adapt_function(event, function): + """Adapts and caches a function using `backcall` to provide compatibility. + + Function adaptations depend not only on the function but also on the event, + as events may expect different arguments (e.g. `request` vs. `result`). + Hence, `(event, function)` is used as the cache key. + """ + if (event, function) in adapted_functions: + return adapted_functions[(event, function)] + callback_proto = available_events.get(event) + adapted_function = callback_proto.adapt(function) + adapted_functions[(event, function)] = adapted_function + return adapted_function + + # ------------------------------------------------------------------------------ # Callback prototypes # @@ -125,7 +113,8 @@ def _define_event(callback_proto): # ------------------------------------------------------------------------------ @_define_event -def pre_execute(result): +@callback_prototype +def pre_execute(request): """Fires before code is executed in response to user/frontend action. This includes comm and widget messages and silent execution, as well as user @@ -133,23 +122,25 @@ def pre_execute(result): Parameters ---------- - result : :class:`~IPython.core.interactiveshell.ExecutionResult` - The object which will be returned as the execution result. + request : :class:`~IPython.core.interactiveshell.ExecutionRequest` + The object representing the code execution request. """ pass @_define_event -def pre_run_cell(result): +@callback_prototype +def pre_run_cell(request): """Fires before user-entered code runs. Parameters ---------- - result : :class:`~IPython.core.interactiveshell.ExecutionResult` - The object which will be returned as the execution result. + request : :class:`~IPython.core.interactiveshell.ExecutionRequest` + The object representing the code execution request. """ pass @_define_event +@callback_prototype def post_execute(result): """Fires after code is executed in response to user/frontend action. @@ -164,6 +155,7 @@ def post_execute(result): pass @_define_event +@callback_prototype def post_run_cell(result): """Fires after user-entered code runs. diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 4f2d037..601c4e3 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -173,6 +173,30 @@ class DummyMod(object): pass +class ExecutionRequest(object): + """The request of a call to :meth:`InteractiveShell.run_cell` + + Stores information about what is going to happen. + """ + raw_cell = None + store_history = False + silent = False + shell_futures = True + + def __init__(self, raw_cell, store_history, silent, shell_futures): + self.raw_cell = raw_cell + self.store_history = store_history + self.silent = silent + self.shell_futures = shell_futures + + def __repr__(self): + name = self.__class__.__qualname__ + raw_cell = ((self.raw_cell[:50] + '..') + if len(self.raw_cell) > 50 else self.raw_cell) + return '<%s object at %x, raw_cell="%s" store_history=%s silent=%s shell_futures=%s result=%s>' %\ + (name, id(self), raw_cell, store_history, silent, shell_futures, repr(self.result)) + + class ExecutionResult(object): """The result of a call to :meth:`InteractiveShell.run_cell` @@ -181,8 +205,12 @@ class ExecutionResult(object): execution_count = None error_before_exec = None error_in_exec = None + request = None result = None + def __init__(self, request): + self.request = request + @property def success(self): return (self.error_before_exec is None) and (self.error_in_exec is None) @@ -196,8 +224,8 @@ class ExecutionResult(object): def __repr__(self): name = self.__class__.__qualname__ - return '<%s object at %x, execution_count=%s error_before_exec=%s error_in_exec=%s result=%s>' %\ - (name, id(self), self.execution_count, self.error_before_exec, self.error_in_exec, repr(self.result)) + return '<%s object at %x, execution_count=%s error_before_exec=%s error_in_exec=%s request=%s result=%s>' %\ + (name, id(self), self.execution_count, self.error_before_exec, self.error_in_exec, repr(self.request), repr(self.result)) class InteractiveShell(SingletonConfigurable): @@ -866,7 +894,7 @@ class InteractiveShell(SingletonConfigurable): "ip.events.register('post_run_cell', func) instead.", stacklevel=2) self.events.register('post_run_cell', func) - def _clear_warning_registry(self): + def _clear_warning_registry(self, request): # clear the warning registry, so that different code blocks with # overlapping line number ranges don't cause spurious suppression of # warnings (see gh-6611 for details) @@ -2644,13 +2672,15 @@ class InteractiveShell(SingletonConfigurable): ------- result : :class:`ExecutionResult` """ - result = ExecutionResult() + request = ExecutionRequest( + raw_cell, store_history, silent, shell_futures) + result = ExecutionResult(request) if (not raw_cell) or raw_cell.isspace(): self.last_execution_succeeded = True self.last_execution_result = result return result - + if silent: store_history = False @@ -2663,9 +2693,9 @@ class InteractiveShell(SingletonConfigurable): self.last_execution_result = result return result - self.events.trigger('pre_execute') + self.events.trigger('pre_execute', request) if not silent: - self.events.trigger('pre_run_cell') + self.events.trigger('pre_run_cell', request) # If any of our input transformation (input_transformer_manager or # prefilter_manager) raises an exception, we store it in this variable diff --git a/IPython/core/tests/test_events.py b/IPython/core/tests/test_events.py index 6201a67..fbf38cd 100644 --- a/IPython/core/tests/test_events.py +++ b/IPython/core/tests/test_events.py @@ -1,17 +1,23 @@ +from backcall import callback_prototype import unittest from unittest.mock import Mock from IPython.core import events import IPython.testing.tools as tt + @events._define_event +@callback_prototype def ping_received(): pass + @events._define_event +@callback_prototype def event_with_argument(argument): pass + class CallbackTests(unittest.TestCase): def setUp(self): self.em = events.EventManager(get_ipython(), {'ping_received': ping_received, 'event_with_argument': event_with_argument}) @@ -60,7 +66,7 @@ class CallbackTests(unittest.TestCase): def event_with_no_argument(): call_count[0] += 1 - self.em.register('event_with_argument', event_with_no_argument) + self.em.register('event_with_argument', event_with_no_argument) self.em.trigger('event_with_argument', 'the argument') self.assertEqual(call_count[0], 1) diff --git a/IPython/core/tests/test_interactiveshell.py b/IPython/core/tests/test_interactiveshell.py index b30d6e9..07bdef2 100644 --- a/IPython/core/tests/test_interactiveshell.py +++ b/IPython/core/tests/test_interactiveshell.py @@ -276,6 +276,9 @@ class InteractiveShellTestCase(unittest.TestCase): assert not pre_explicit.called assert post_always.called assert not post_explicit.called + request, = pre_always.call_args[0] + result, = post_always.call_args[0] + self.assertEqual(request, result.request) # double-check that non-silent exec did what we expected # silent to avoid ip.run_cell("1") @@ -288,6 +291,9 @@ class InteractiveShellTestCase(unittest.TestCase): assert pre_explicit.called assert post_always.called assert post_explicit.called + request, = pre_always.call_args[0] + result, = post_always.call_args[0] + self.assertEqual(request, result.request) finally: # remove post-exec ip.events.unregister('pre_run_cell', pre_explicit) diff --git a/docs/source/config/callbacks.rst b/docs/source/config/callbacks.rst index e15d136..2ff6936 100644 --- a/docs/source/config/callbacks.rst +++ b/docs/source/config/callbacks.rst @@ -18,10 +18,12 @@ For example:: self.shell = ip self.last_x = None - def pre_execute(self): + def pre_execute(self, request): + print('Cell code: "%s"' % request.raw_cell) self.last_x = self.shell.user_ns.get('x', None) def post_execute(self, result): + print('Cell code: "%s"' % result.request.raw_cell) if result.error_before_exec: print('Error before execution: %s' % result.error_before_exec) if self.shell.user_ns.get('x', None) != self.last_x: @@ -55,8 +57,7 @@ pre_run_cell ``pre_run_cell`` fires prior to interactive execution (e.g. a cell in a notebook). It can be used to note the state prior to execution, and keep track of changes. -The object which will be returned as the execution result is provided as an -argument, even though the actual result is not yet available. +The object representing the code execution request is provided as an argument. pre_execute ----------- @@ -64,8 +65,7 @@ pre_execute ``pre_execute`` is like ``pre_run_cell``, but is triggered prior to *any* execution. Sometimes code can be executed by libraries, etc. which skipping the history/display mechanisms, in which cases ``pre_run_cell`` will not fire. -The object which will be returned as the execution result is provided as an -argument, even though the actual result is not yet available. +The object representing the code execution request is provided as an argument. post_run_cell ------------- diff --git a/docs/source/whatsnew/pr/event-callbacks-updates.rst b/docs/source/whatsnew/pr/event-callbacks-updates.rst index 73c248a..d00c85e 100644 --- a/docs/source/whatsnew/pr/event-callbacks-updates.rst +++ b/docs/source/whatsnew/pr/event-callbacks-updates.rst @@ -1,6 +1,7 @@ The *post* event callbacks are now always called, even when the execution failed (for example because of a ``SyntaxError``). -Additionally, the execution result object is now made available in both *pre* -and *post* event callbacks in a backward compatible manner. +Additionally, the execution request and result objects are now made available in +the corresponding *pre* or *post* event callbacks in a backward compatible +manner. * `Related GitHub issue `__ diff --git a/setup.py b/setup.py index 2ec6cbc..b750086 100755 --- a/setup.py +++ b/setup.py @@ -192,6 +192,7 @@ install_requires = [ 'traitlets>=4.2', 'prompt_toolkit>=1.0.4,<2.0.0', 'pygments', + 'backcall', ] # Platform-specific dependencies: