Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New events system #5188

Merged
merged 20 commits into from
Mar 4, 2014
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ before_install:
# workaround for https://github.com/travis-ci/travis-cookbooks/issues/155
- sudo rm -rf /dev/shm && sudo ln -s /run/shm /dev/shm
- easy_install -q pyzmq
- pip install jinja2 sphinx pygments tornado requests
- pip install jinja2 sphinx pygments tornado requests mock
# Pierre Carrier's PPA for PhantomJS and CasperJS
- sudo add-apt-repository -y ppa:pcarrier/ppa
- sudo apt-get update
Expand Down
139 changes: 139 additions & 0 deletions IPython/core/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Infrastructure for registering and firing callbacks on application events.

Unlike :mod:`IPython.core.hooks`, which lets end users set single functions to
be called at specific times, or a collection of alternative methods to try,
callbacks are designed to be used by extension authors. A number of callbacks
can be registered for the same event without needing to be aware of one another.

The functions defined in this module are no-ops indicating the names of available
events and the arguments which will be passed to them.

.. note::

This API is experimental in IPython 2.0, and may be revised in future versions.
"""
from __future__ import print_function

class EventManager(object):
"""Manage a collection of events and a sequence of callbacks for each.

This is attached to :class:`~IPython.core.interactiveshell.InteractiveShell`
instances as an ``events`` attribute.

.. note::

This API is experimental in IPython 2.0, and may be revised in future versions.
"""
def __init__(self, shell, available_events):
"""Initialise the :class:`CallbackManager`.

Parameters
----------
shell
The :class:`~IPython.core.interactiveshell.InteractiveShell` instance
available_callbacks
An iterable of names for callback events.
"""
self.shell = shell
self.callbacks = {n:[] for n in available_events}

def register(self, event, function):
"""Register a new event callback

Parameters
----------
event : str
The event for which to register this callback.
function : callable
A function to be called on the given event. It should take the same
parameters as the appropriate callback prototype.

Raises
------
TypeError
If ``function`` is not callable.
KeyError
If ``event`` is not one of the known events.
"""
if not callable(function):
raise TypeError('Need a callable, got %r' % function)
self.callbacks[event].append(function)

def unregister(self, event, function):
"""Remove a callback from the given event."""
self.callbacks[event].remove(function)

def reset(self, event):
"""Clear all callbacks for the given event."""
self.callbacks[event] = []

def reset_all(self):
"""Clear all callbacks for all events."""
self.callbacks = {n:[] for n in self.callbacks}

def trigger(self, event, *args, **kwargs):
"""Call callbacks for ``event``.

Any additional arguments are passed to all callbacks registered for this
event. Exceptions raised by callbacks are caught, and a message printed.
"""
for func in self.callbacks[event]:
try:
func(*args, **kwargs)
except Exception:
print("Error in callback {} (for {}):".format(func, event))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

print to stderr, or no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showtraceback() goes to stdout, looking at the implementation. Possibly it shouldn't, but this should go to the same stream, and changing showtraceback() is more invasive.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense.

self.shell.showtraceback()

# event_name -> prototype mapping
available_events = {}

def _define_event(callback_proto):
available_events[callback_proto.__name__] = callback_proto
return callback_proto

# ------------------------------------------------------------------------------
# Callback prototypes
#
# No-op functions which describe the names of available events and the
# signatures of callbacks for those events.
# ------------------------------------------------------------------------------

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To the reader who is not very familiar with how the internals of IPython work, I think the names of these events are still very confusing (pre_execute, post_execute, pre_run_cell, post_run_cell) and the descriptions don't really help. It is also not clear the order in which these are triggered. Even if we don't rename them we should add better comments to clarify when they are triggered.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also clarify how silent affects these things.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had a long discussion about this over lunch on Friday, and those were the clearest set of names we could come up with. Any alternative suggestions will be duly considered.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

possible clarifications:

  • execute events fire before/after any code might be executed (interactive code, silent execution, widget interaction, comm messages).
  • run_cell events fire only before/after explicit "regular" execution, e.g. shift-enter or interactive typing at the terminal.

For those interested in the internal implementation, this means that silent=False execution is the only circumstance to trigger run_cell events. All potential execution triggers the execute events, so when run_cell fires, execute will also fire.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the wording a bit tricky - after all, the internal kernel code is running all the time, so it's not before/after 'any' code runs. I want to call it 'user defined' code, but the frontend can send code for execution without the user writing it, as is the case in most silent execution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with the names, but let's add a comment about how silent affects which are called.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a mention.

@_define_event
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."""
pass

@_define_event
def pre_run_cell():
"""Fires before user-entered code runs."""
pass

@_define_event
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."""
pass

@_define_event
def post_run_cell():
"""Fires after user-entered code runs."""
pass

@_define_event
def shell_initialized(ip):
"""Fires after initialisation of :class:`~IPython.core.interactiveshell.InteractiveShell`.

This is before extensions and startup scripts are loaded, so it can only be
set by subclassing.

Parameters
----------
ip : :class:`~IPython.core.interactiveshell.InteractiveShell`
The newly initialised shell.
"""
pass
5 changes: 5 additions & 0 deletions IPython/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ def load_ipython_extension(ip):
'show_in_pager','pre_prompt_hook',
'pre_run_code_hook', 'clipboard_get']

deprecated = {'pre_run_code_hook': "a callback for the 'pre_execute' or 'pre_run_cell' event",
'late_startup_hook': "a callback for the 'shell_initialized' event",
'shutdown_hook': "the atexit module",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to make an event for shutdown?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, I'm making a deliberately minimal set of events because @fperez wants to consider this API experimental for 2.x. atexit works for the obvious case, and we can work out other hooks for e.g. embedded IPython later on.

}

def editor(self, filename, linenum=None, wait=True):
"""Open the default editor at the given filename and linenumber.

Expand Down
60 changes: 32 additions & 28 deletions IPython/core/interactiveshell.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from IPython.core.alias import AliasManager, AliasError
from IPython.core.autocall import ExitAutocall
from IPython.core.builtin_trap import BuiltinTrap
from IPython.core.events import EventManager, available_events
from IPython.core.compilerop import CachingCompiler, check_linecache_ipython
from IPython.core.display_trap import DisplayTrap
from IPython.core.displayhook import DisplayHook
Expand Down Expand Up @@ -467,6 +468,7 @@ def __init__(self, ipython_dir=None, profile_dir=None,

self.init_syntax_highlighting()
self.init_hooks()
self.init_events()
self.init_pushd_popd_magic()
# self.init_traceback_handlers use to be here, but we moved it below
# because it and init_io have to come after init_readline.
Expand Down Expand Up @@ -510,6 +512,7 @@ def __init__(self, ipython_dir=None, profile_dir=None,
self.init_payload()
self.init_comms()
self.hooks.late_startup_hook()
self.events.trigger('shell_initialized', self)
atexit.register(self.atexit_operations)

def get_ipython(self):
Expand Down Expand Up @@ -785,9 +788,10 @@ def init_hooks(self):
for hook_name in hooks.__all__:
# default hooks have priority 100, i.e. low; user hooks should have
# 0-100 priority
self.set_hook(hook_name,getattr(hooks,hook_name), 100)
self.set_hook(hook_name,getattr(hooks,hook_name), 100, _warn_deprecated=False)

def set_hook(self,name,hook, priority = 50, str_key = None, re_key = None):
def set_hook(self,name,hook, priority=50, str_key=None, re_key=None,
_warn_deprecated=True):
"""set_hook(name,hook) -> sets an internal IPython hook.

IPython exposes some of its internal API as user-modifiable hooks. By
Expand Down Expand Up @@ -816,6 +820,11 @@ def set_hook(self,name,hook, priority = 50, str_key = None, re_key = None):
if name not in IPython.core.hooks.__all__:
print("Warning! Hook '%s' is not one of %s" % \
(name, IPython.core.hooks.__all__ ))

if _warn_deprecated and (name in IPython.core.hooks.deprecated):
alternative = IPython.core.hooks.deprecated[name]
warn("Hook {} is deprecated. Use {} instead.".format(name, alternative))

if not dp:
dp = IPython.core.hooks.CommandChainDispatcher()

Expand All @@ -827,12 +836,21 @@ def set_hook(self,name,hook, priority = 50, str_key = None, re_key = None):

setattr(self.hooks,name, dp)

#-------------------------------------------------------------------------
# Things related to callbacks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/callbacks/events/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

#-------------------------------------------------------------------------

def init_events(self):
self.events = EventManager(self, available_events)

def register_post_execute(self, func):
"""Register a function for calling after code execution.
"""DEPRECATED: Use ip.events.register('post_run_cell', func)

Register a function for calling after code execution.
"""
if not callable(func):
raise ValueError('argument %s must be callable' % func)
self._post_execute[func] = True
warn("ip.register_post_execute is deprecated, use "
"ip.events.register('post_run_cell', func) instead.")
self.events.register('post_run_cell', func)

#-------------------------------------------------------------------------
# Things related to the "main" module
Expand Down Expand Up @@ -2649,6 +2667,10 @@ def run_cell(self, raw_cell, store_history=False, silent=False, shell_futures=Tr
if silent:
store_history = False

self.events.trigger('pre_execute')
if not silent:
self.events.trigger('pre_run_cell')

# If any of our input transformation (input_transformer_manager or
# prefilter_manager) raises an exception, we store it in this variable
# so that we can display the error after logging the input and storing
Expand Down Expand Up @@ -2717,28 +2739,10 @@ def run_cell(self, raw_cell, store_history=False, silent=False, shell_futures=Tr
interactivity = "none" if silent else self.ast_node_interactivity
self.run_ast_nodes(code_ast.body, cell_name,
interactivity=interactivity, compiler=compiler)

# Execute any registered post-execution functions.
# unless we are silent
post_exec = [] if silent else iteritems(self._post_execute)

for func, status in post_exec:
if self.disable_failing_post_execute and not status:
continue
try:
func()
except KeyboardInterrupt:
print("\nKeyboardInterrupt", file=io.stderr)
except Exception:
# register as failing:
self._post_execute[func] = False
self.showtraceback()
print('\n'.join([
"post-execution function %r produced an error." % func,
"If this problem persists, you can disable failing post-exec functions with:",
"",
" get_ipython().disable_failing_post_execute = True"
]), file=io.stderr)

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
Expand Down
8 changes: 5 additions & 3 deletions IPython/core/pylabtools.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ def configure_inline_support(shell, backend):

if backend == backends['inline']:
from IPython.kernel.zmq.pylab.backend_inline import flush_figures
shell.register_post_execute(flush_figures)
shell.events.register('post_execute', flush_figures)

# Save rcParams that will be overwrittern
shell._saved_rcParams = dict()
Expand All @@ -363,8 +363,10 @@ def configure_inline_support(shell, backend):
pyplot.rcParams.update(cfg.rc)
else:
from IPython.kernel.zmq.pylab.backend_inline import flush_figures
if flush_figures in shell._post_execute:
shell._post_execute.pop(flush_figures)
try:
shell.events.unregister('post_execute', flush_figures)
except ValueError:
pass
if hasattr(shell, '_saved_rcParams'):
pyplot.rcParams.update(shell._saved_rcParams)
del shell._saved_rcParams
Expand Down
46 changes: 46 additions & 0 deletions IPython/core/tests/test_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import unittest
try: # Python 3.3 +
from unittest.mock import Mock
except ImportError:
from mock import Mock

from IPython.core import events
import IPython.testing.tools as tt

def ping_received():
pass

class CallbackTests(unittest.TestCase):
def setUp(self):
self.em = events.EventManager(get_ipython(), {'ping_received': ping_received})

def test_register_unregister(self):
cb = Mock()

self.em.register('ping_received', cb)
self.em.trigger('ping_received')
self.assertEqual(cb.call_count, 1)

self.em.unregister('ping_received', cb)
self.em.trigger('ping_received')
self.assertEqual(cb.call_count, 1)

def test_reset(self):
cb = Mock()
self.em.register('ping_received', cb)
self.em.reset('ping_received')
self.em.trigger('ping_received')
assert not cb.called

def test_reset_all(self):
cb = Mock()
self.em.register('ping_received', cb)
self.em.reset_all()
self.em.trigger('ping_received')
assert not cb.called

def test_cb_error(self):
cb = Mock(side_effect=ValueError)
self.em.register('ping_received', cb)
with tt.AssertPrints("Error in callback"):
self.em.trigger('ping_received')