-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
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
New events system #5188
Changes from 18 commits
e3e8fef
b4d5a82
823ffca
6b0a1bb
e5a269a
5898f70
934bce7
6de7fab
adb71c3
1cca64b
49fa2c5
cb9d4aa
d56c61c
13506a8
bc20f65
1b3e35d
25522b5
09e41c6
89392c3
d39747e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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)) | ||
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. | ||
# ------------------------------------------------------------------------------ | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should also clarify how silent affects these things. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. possible clarifications:
For those interested in the internal implementation, this means that There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to make an event for shutdown? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
} | ||
|
||
def editor(self, filename, linenum=None, wait=True): | ||
"""Open the default editor at the given filename and linenumber. | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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. | ||
|
@@ -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): | ||
|
@@ -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 | ||
|
@@ -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() | ||
|
||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. s/callbacks/events/ There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
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') |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 changingshowtraceback()
is more invasive.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense.