Repository: ipython/ipython

Issue:
`post_execute` and `post_run_cell` hooks not always triggered
Due to various early returns in the code below, `post_execute` and `post_run_cell` hooks are not triggered in some cases. Since the pre hooks are always called, this is either a bug or the [docs](http://ipython.readthedocs.io/en/stable/config/callbacks.html#post-run-cell) should mention that post hooks are not guaranteed to be triggered.

https://github.com/ipython/ipython/blob/ca86b79bfd5cdd633ea6cb9c18bb2c1c493402a1/IPython/core/interactiveshell.py#L2694-L2739

/cc @craigcitro


Discussion:
Thanks for the report, you are right that we should split the post_execution hooks into hooks that execute after every requests, or only after every valid Python request. I don't think we should fix the current ones, but provide variants that also trigger even when the pre-execute fails. 
Hey @Carreau -- I'm curious about the details of what you're proposing.

* Do you mean that you want to allow users to set separate hooks for the various ways a cell can fail, eg `post_syntax_error_hook`, `post_preprocessing_error_hook`, that sort of thing? I feel like that's a lot of complexity. For my money, I'd rather provide a way to customize the `error_before_exec` function that gets used.

* Is the motivation for adding a new hook simply to avoid a subtle behavior change? As it is, I'm betting that most (if not all) users of the `post_*` hooks actually *expect* that they're always being executed, and would be surprised by the behavior @fniephaus ran into here. But of course I'm saying that based on 0 data. 😀 😀 😀  Do you know of a use case for the current behavior?

* If we do go the route of new hooks, do you imagine having something like a `post_execute_exception_hook`, where we know exactly one of that and `post_execute_hook` will be triggered? Or something more like a `finally_hook`, where it's executed no matter what?

Thinking it through, I could see an argument that the right answer here is to say "we will be explicit about documenting and sticking to the current behavior, because it's a subtle behavior change. If you want something else, you should override `run_cell` yourself." I think that would work for our use case, but I bet we aren't the first and won't be the last to stumble on this, so we wanted to look at fixing it at the root first. 😉 
> Do you mean that you want to allow users to set separate hooks for the various ways a cell can fail

Probably not _that_ granular. 

> Is the motivation for adding a new hook simply to avoid a subtle behavior change?

Yes. 

> But of course I'm saying that based on 0 data

I see you are 0-indexed. And so we have at least 1 more data point: I would have expected these hooks to not trigger on failure :-) 

> If we do go the route of new hooks, do you imagine having something like a post_execute_exception_hook, where we know exactly one of that and post_execute_hook will be triggered? Or something more like a finally_hook, where it's executed no matter what?

Yes, probably a `finally_hook`, that get a parameter with whether things failed and how. Then it's explicit to user when it is called and give you all the flexibility you can get.

We'll better document anyway !

Thanks !



Resolving patch:
diff --git a/IPython/core/events.py b/IPython/core/events.py
--- a/IPython/core/events.py
+++ b/IPython/core/events.py
@@ -13,6 +13,9 @@
    This API is experimental in IPython 2.0, and may be revised in future versions.
 """
 
+from backcall import callback_prototype
+
+
 class EventManager(object):
     """Manage a collection of events and a sequence of callbacks for each.
     
@@ -37,7 +40,7 @@ def __init__(self, shell, available_events):
         self.callbacks = {n:[] for n in available_events}
     
     def register(self, event, function):
-        """Register a new event callback
+        """Register a new event callback.
         
         Parameters
         ----------
@@ -56,12 +59,21 @@ def register(self, event, function):
         """
         if not callable(function):
             raise TypeError('Need a callable, got %r' % function)
-        self.callbacks[event].append(function)
+        callback_proto = available_events.get(event)
+        self.callbacks[event].append(callback_proto.adapt(function))
     
     def unregister(self, event, function):
         """Remove a callback from the given event."""
-        self.callbacks[event].remove(function)
-    
+        if function in self.callbacks[event]:
+            return self.callbacks[event].remove(function)
+
+        # Remove callback in case ``function`` was adapted by `backcall`.
+        for callback in self.callbacks[event]:
+            if callback.__wrapped__ is function:
+                return self.callbacks[event].remove(callback)
+
+        raise ValueError('Function {!r} is not registered as a {} callback'.format(function, event))
+
     def trigger(self, event, *args, **kwargs):
         """Call callbacks for ``event``.
         
@@ -78,8 +90,9 @@ def trigger(self, event, *args, **kwargs):
 # event_name -> prototype mapping
 available_events = {}
 
-def _define_event(callback_proto):
-    available_events[callback_proto.__name__] = callback_proto
+def _define_event(callback_function):
+    callback_proto = callback_prototype(callback_function)
+    available_events[callback_function.__name__] = callback_proto
     return callback_proto
 
 # ------------------------------------------------------------------------------
@@ -94,12 +107,19 @@ def pre_execute():
     """Fires before code is executed in response to user/frontend action.
     
     This includes comm and widget messages and silent execution, as well as user
-    code cells."""
+    code cells.
+    """
     pass
 
 @_define_event
-def pre_run_cell():
-    """Fires before user-entered code runs."""
+def pre_run_cell(info):
+    """Fires before user-entered code runs.
+
+    Parameters
+    ----------
+    info : :class:`~IPython.core.interactiveshell.ExecutionInfo`
+      An object containing information used for the code execution.
+    """
     pass
 
 @_define_event
@@ -107,12 +127,19 @@ def post_execute():
     """Fires after code is executed in response to user/frontend action.
     
     This includes comm and widget messages and silent execution, as well as user
-    code cells."""
+    code cells.
+    """
     pass
 
 @_define_event
-def post_run_cell():
-    """Fires after user-entered code runs."""
+def post_run_cell(result):
+    """Fires after user-entered code runs.
+
+    Parameters
+    ----------
+    result : :class:`~IPython.core.interactiveshell.ExecutionResult`
+      The object which will be returned as the execution result.
+    """
     pass
 
 @_define_event
diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py
--- a/IPython/core/interactiveshell.py
+++ b/IPython/core/interactiveshell.py
@@ -173,6 +173,30 @@ class DummyMod(object):
     pass
 
 
+class ExecutionInfo(object):
+    """The arguments used for 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
+    info = None
     result = None
 
+    def __init__(self, info):
+        self.info = info
+
     @property
     def success(self):
         return (self.error_before_exec is None) and (self.error_in_exec is None)
@@ -196,8 +224,8 @@ def raise_error(self):
 
     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 info=%s result=%s>' %\
+                (name, id(self), self.execution_count, self.error_before_exec, self.error_in_exec, repr(self.info), repr(self.result))
 
 
 class InteractiveShell(SingletonConfigurable):
@@ -1879,7 +1907,7 @@ def showsyntaxerror(self, filename=None, running_compiled_code=False):
     # This is overridden in TerminalInteractiveShell to show a message about
     # the %paste magic.
     def showindentationerror(self):
-        """Called by run_cell when there's an IndentationError in code entered
+        """Called by _run_cell when there's an IndentationError in code entered
         at the prompt.
 
         This is overridden in TerminalInteractiveShell to show a message about
@@ -2621,13 +2649,38 @@ def run_cell(self, raw_cell, store_history=False, silent=False, shell_futures=Tr
         -------
         result : :class:`ExecutionResult`
         """
-        result = ExecutionResult()
+        try:
+            result = self._run_cell(
+                raw_cell, store_history, silent, shell_futures)
+        finally:
+            self.events.trigger('post_execute')
+            if not silent:
+                self.events.trigger('post_run_cell', result)
+        return result
+
+    def _run_cell(self, raw_cell, store_history, silent, shell_futures):
+        """Internal method to run a complete IPython cell.
+
+        Parameters
+        ----------
+        raw_cell : str
+        store_history : bool
+        silent : bool
+        shell_futures : bool
+
+        Returns
+        -------
+        result : :class:`ExecutionResult`
+        """
+        info = ExecutionInfo(
+            raw_cell, store_history, silent, shell_futures)
+        result = ExecutionResult(info)
 
         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
 
@@ -2642,7 +2695,7 @@ def error_before_exec(value):
 
         self.events.trigger('pre_execute')
         if not silent:
-            self.events.trigger('pre_run_cell')
+            self.events.trigger('pre_run_cell', info)
 
         # If any of our input transformation (input_transformer_manager or
         # prefilter_manager) raises an exception, we store it in this variable
@@ -2723,7 +2776,7 @@ def error_before_exec(value):
                 self.displayhook.exec_result = result
 
                 # Execute the user code
-                interactivity = "none" if silent else self.ast_node_interactivity
+                interactivity = 'none' if silent else self.ast_node_interactivity
                 has_raised = self.run_ast_nodes(code_ast.body, cell_name,
                    interactivity=interactivity, compiler=compiler, result=result)
                 
@@ -2734,10 +2787,6 @@ def error_before_exec(value):
                 # ExecutionResult
                 self.displayhook.exec_result = None
 
-                self.events.trigger('post_execute')
-                if not silent:
-                    self.events.trigger('post_run_cell')
-
         if store_history:
             # Write output to the database. Does nothing unless
             # history output logging is enabled.
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -192,6 +192,7 @@
     'traitlets>=4.2',
     'prompt_toolkit>=1.0.4,<2.0.0',
     'pygments',
+    'backcall',
 ]
 
 # Platform-specific dependencies:


Test patch:
diff --git a/IPython/core/tests/test_events.py b/IPython/core/tests/test_events.py
--- a/IPython/core/tests/test_events.py
+++ b/IPython/core/tests/test_events.py
@@ -1,15 +1,24 @@
+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
 def ping_received():
     pass
 
+
+@events._define_event
+def event_with_argument(argument):
+    pass
+
+
 class CallbackTests(unittest.TestCase):
     def setUp(self):
-        self.em = events.EventManager(get_ipython(), {'ping_received': ping_received})
+        self.em = events.EventManager(get_ipython(), {'ping_received': ping_received, 'event_with_argument': event_with_argument})
     
     def test_register_unregister(self):
         cb = Mock()
@@ -49,3 +58,16 @@ def func3(*_):
         self.em.trigger('ping_received')
         self.assertEqual([True, True, False], invoked)
         self.assertEqual([func3], self.em.callbacks['ping_received'])
+    
+    def test_ignore_event_arguments_if_no_argument_required(self):
+        call_count = [0]
+        def event_with_no_argument():
+            call_count[0] += 1
+
+        self.em.register('event_with_argument', event_with_no_argument)
+        self.em.trigger('event_with_argument', 'the argument')
+        self.assertEqual(call_count[0], 1)
+        
+        self.em.unregister('event_with_argument', event_with_no_argument)
+        self.em.trigger('ping_received')
+        self.assertEqual(call_count[0], 1)
diff --git a/IPython/core/tests/test_interactiveshell.py b/IPython/core/tests/test_interactiveshell.py
--- a/IPython/core/tests/test_interactiveshell.py
+++ b/IPython/core/tests/test_interactiveshell.py
@@ -263,6 +263,7 @@ def test_silent_postexec(self):
         pre_always = mock.Mock()
         post_explicit = mock.Mock()
         post_always = mock.Mock()
+        all_mocks = [pre_explicit, pre_always, post_explicit, post_always]
         
         ip.events.register('pre_run_cell', pre_explicit)
         ip.events.register('pre_execute', pre_always)
@@ -280,6 +281,19 @@ def test_silent_postexec(self):
             ip.run_cell("1")
             assert pre_explicit.called
             assert post_explicit.called
+            info, = pre_explicit.call_args[0]
+            result, = post_explicit.call_args[0]
+            self.assertEqual(info, result.info)
+            # check that post hooks are always called
+            [m.reset_mock() for m in all_mocks]
+            ip.run_cell("syntax error")
+            assert pre_always.called
+            assert pre_explicit.called
+            assert post_always.called
+            assert post_explicit.called
+            info, = pre_explicit.call_args[0]
+            result, = post_explicit.call_args[0]
+            self.assertEqual(info, result.info)
         finally:
             # remove post-exec
             ip.events.unregister('pre_run_cell', pre_explicit)