interactiveshell.py
578 lines
| 23.0 KiB
| text/x-python
|
PythonLexer
|
r5600 | # -*- coding: utf-8 -*- | ||
|
r16568 | """terminal client to the IPython kernel""" | ||
|
r5600 | |||
|
r16568 | # Copyright (c) IPython Development Team. | ||
# Distributed under the terms of the Modified BSD License. | ||||
|
r5600 | |||
from __future__ import print_function | ||||
|
r16577 | import base64 | ||
|
r5600 | import bdb | ||
|
r5622 | import signal | ||
|
r8244 | import os | ||
|
r5600 | import sys | ||
|
r5616 | import time | ||
|
r8240 | import subprocess | ||
|
r16577 | from getpass import getpass | ||
|
r8239 | from io import BytesIO | ||
|
r5600 | |||
|
r13354 | try: | ||
from queue import Empty # Py 3 | ||||
except ImportError: | ||||
from Queue import Empty # Py 2 | ||||
|
r5600 | |||
|
r5616 | from IPython.core import page | ||
|
r16583 | from IPython.core import release | ||
|
r18846 | from IPython.terminal.console.zmqhistory import ZMQHistoryManager | ||
|
r11132 | from IPython.utils.warn import warn, error | ||
|
r5600 | from IPython.utils import io | ||
|
r13355 | from IPython.utils.py3compat import string_types, input | ||
|
r18373 | from IPython.utils.traitlets import List, Enum, Any, Instance, Unicode, Float, Bool | ||
|
r8246 | from IPython.utils.tempdir import NamedFileInTemporaryDirectory | ||
|
r5600 | |||
|
r11021 | from IPython.terminal.interactiveshell import TerminalInteractiveShell | ||
from IPython.terminal.console.completer import ZMQCompleter | ||||
|
r5600 | |||
class ZMQTerminalInteractiveShell(TerminalInteractiveShell): | ||||
|
r5616 | """A subclass of TerminalInteractiveShell that uses the 0MQ kernel""" | ||
_executing = False | ||||
|
r11670 | _execution_state = Unicode('') | ||
|
r16215 | _pending_clearoutput = False | ||
|
r16583 | kernel_banner = Unicode('') | ||
|
r11790 | kernel_timeout = Float(60, config=True, | ||
help="""Timeout for giving up on a kernel (in seconds). | ||||
On first connect and restart, the console tests whether the | ||||
kernel is running and responsive by sending kernel_info_requests. | ||||
This sets the timeout in seconds for how long the kernel can take | ||||
before being presumed dead. | ||||
""" | ||||
) | ||||
|
r8238 | |||
|
r8240 | image_handler = Enum(('PIL', 'stream', 'tempfile', 'callable'), | ||
config=True, help= | ||||
|
r8238 | """ | ||
|
r8240 | Handler for image type output. This is useful, for example, | ||
|
r8238 | when connecting to the kernel in which pylab inline backend is | ||
|
r8240 | activated. There are four handlers defined. 'PIL': Use | ||
Python Imaging Library to popup image; 'stream': Use an | ||||
external program to show the image. Image will be fed into | ||||
the STDIN of the program. You will need to configure | ||||
`stream_image_handler`; 'tempfile': Use an external program to | ||||
show the image. Image will be saved in a temporally file and | ||||
the program is called with the temporally file. You will need | ||||
to configure `tempfile_image_handler`; 'callable': You can set | ||||
any Python callable which is called with the image data. You | ||||
will need to configure `callable_image_handler`. | ||||
""" | ||||
) | ||||
stream_image_handler = List(config=True, help= | ||||
""" | ||||
Command to invoke an image viewer program when you are using | ||||
'stream' image handler. This option is a list of string where | ||||
the first element is the command itself and reminders are the | ||||
options for the command. Raw image data is given as STDIN to | ||||
the program. | ||||
""" | ||||
) | ||||
tempfile_image_handler = List(config=True, help= | ||||
""" | ||||
Command to invoke an image viewer program when you are using | ||||
'tempfile' image handler. This option is a list of string | ||||
where the first element is the command itself and reminders | ||||
|
r8242 | are the options for the command. You can use {file} and | ||
{format} in the string to represent the location of the | ||||
generated image file and image format. | ||||
|
r8240 | """ | ||
) | ||||
callable_image_handler = Any(config=True, help= | ||||
""" | ||||
Callable object called via 'callable' image handler with one | ||||
argument, `data`, which is `msg["content"]["data"]` where | ||||
`msg` is the message from iopub channel. For exmaple, you can | ||||
|
r8249 | find base64 encoded PNG data as `data['image/png']`. | ||
|
r8238 | """ | ||
) | ||||
|
r8243 | mime_preference = List( | ||
default_value=['image/png', 'image/jpeg', 'image/svg+xml'], | ||||
config=True, allow_none=False, help= | ||||
""" | ||||
Preferred object representation MIME type in order. First | ||||
matched MIME type will be used. | ||||
""" | ||||
) | ||||
|
r10331 | manager = Instance('IPython.kernel.KernelManager') | ||
client = Instance('IPython.kernel.KernelClient') | ||||
def _client_changed(self, name, old, new): | ||||
self.session_id = new.session.session | ||||
session_id = Unicode() | ||||
|
r5600 | def init_completer(self): | ||
"""Initialize the completion machinery. | ||||
This creates completion machinery that can be used by client code, | ||||
either interactively in-process (typically triggered by the readline | ||||
|
r13560 | library), programmatically (such as in test suites) or out-of-process | ||
|
r5600 | (typically over the network by remote frontends). | ||
""" | ||||
from IPython.core.completerlib import (module_completer, | ||||
magic_run_completer, cd_completer) | ||||
|
r11676 | self.Completer = ZMQCompleter(self, self.client, config=self.config) | ||
|
r5600 | |||
self.set_hook('complete_command', module_completer, str_key = 'import') | ||||
self.set_hook('complete_command', module_completer, str_key = 'from') | ||||
self.set_hook('complete_command', magic_run_completer, str_key = '%run') | ||||
self.set_hook('complete_command', cd_completer, str_key = '%cd') | ||||
# Only configure readline if we truly are using readline. IPython can | ||||
# do tab-completion over the network, in GUIs, etc, where readline | ||||
# itself may be absent | ||||
if self.has_readline: | ||||
self.set_readline_completer() | ||||
def run_cell(self, cell, store_history=True): | ||||
"""Run a complete IPython cell. | ||||
Parameters | ||||
---------- | ||||
cell : str | ||||
The code (including IPython code such as %magic functions) to run. | ||||
store_history : bool | ||||
If True, the raw and translated cell will be stored in IPython's | ||||
history. For user code calling back into IPython's machinery, this | ||||
should be set to False. | ||||
""" | ||||
if (not cell) or cell.isspace(): | ||||
|
r15590 | # pressing enter flushes any pending display | ||
self.handle_iopub() | ||||
|
r5600 | return | ||
|
r17325 | |||
|
r5616 | # flush stale replies, which could have been ignored, due to missed heartbeats | ||
|
r10287 | while self.client.shell_channel.msg_ready(): | ||
self.client.shell_channel.get_msg() | ||||
|
r5600 | # shell_channel.execute takes 'hidden', which is the inverse of store_hist | ||
|
r10287 | msg_id = self.client.shell_channel.execute(cell, not store_history) | ||
|
r11670 | |||
# first thing is wait for any side effects (output, stdin, etc.) | ||||
self._executing = True | ||||
self._execution_state = "busy" | ||||
while self._execution_state != 'idle' and self.client.is_alive(): | ||||
|
r5600 | try: | ||
|
r16577 | self.handle_input_request(msg_id, timeout=0.05) | ||
|
r5600 | except Empty: | ||
|
r5616 | # display intermediate print statements, etc. | ||
|
r11670 | self.handle_iopub(msg_id) | ||
# after all of that is done, wait for the execute reply | ||||
while self.client.is_alive(): | ||||
try: | ||||
self.handle_execute_reply(msg_id, timeout=0.05) | ||||
except Empty: | ||||
|
r5600 | pass | ||
|
r11670 | else: | ||
break | ||||
|
r5616 | self._executing = False | ||
|
r5600 | |||
#----------------- | ||||
# message handlers | ||||
#----------------- | ||||
|
r11670 | def handle_execute_reply(self, msg_id, timeout=None): | ||
msg = self.client.shell_channel.get_msg(block=False, timeout=timeout) | ||||
|
r5616 | if msg["parent_header"].get("msg_id", None) == msg_id: | ||
|
r11670 | self.handle_iopub(msg_id) | ||
|
r5616 | |||
content = msg["content"] | ||||
status = content['status'] | ||||
if status == 'aborted': | ||||
self.write('Aborted\n') | ||||
return | ||||
elif status == 'ok': | ||||
|
r16586 | # handle payloads | ||
|
r5616 | for item in content["payload"]: | ||
|
r16586 | source = item['source'] | ||
if source == 'page': | ||||
page.page(item['data']['text/plain']) | ||||
elif source == 'set_next_input': | ||||
self.set_next_input(item['text']) | ||||
|
r17325 | elif source == 'ask_exit': | ||
self.ask_exit() | ||||
|
r5600 | |||
|
r5616 | elif status == 'error': | ||
for frame in content["traceback"]: | ||||
|
r5600 | print(frame, file=io.stderr) | ||
|
r5616 | self.execution_count = int(content["execution_count"] + 1) | ||
|
r18373 | |||
include_other_output = Bool(False, config=True, | ||||
help="""Whether to include output from clients | ||||
other than this one sharing the same kernel. | ||||
Outputs are not displayed until enter is pressed. | ||||
""" | ||||
) | ||||
other_output_prefix = Unicode("[remote] ", config=True, | ||||
help="""Prefix to add to outputs coming from clients other than this one. | ||||
Only relevant if include_other_output is True. | ||||
""" | ||||
) | ||||
def from_here(self, msg): | ||||
"""Return whether a message is from this session""" | ||||
return msg['parent_header'].get("session", self.session_id) == self.session_id | ||||
def include_output(self, msg): | ||||
"""Return whether we should include a given output message""" | ||||
from_here = self.from_here(msg) | ||||
if msg['msg_type'] == 'execute_input': | ||||
# only echo inputs not from here | ||||
return self.include_other_output and not from_here | ||||
if self.include_other_output: | ||||
return True | ||||
else: | ||||
return from_here | ||||
|
r15590 | def handle_iopub(self, msg_id=''): | ||
"""Process messages on the IOPub channel | ||||
|
r5600 | |||
|
r11670 | This method consumes and processes messages on the IOPub channel, | ||
|
r16568 | such as stdout, stderr, execute_result and status. | ||
|
r11670 | |||
|
r15590 | It only displays output that is caused by this session. | ||
|
r5600 | """ | ||
|
r10287 | while self.client.iopub_channel.msg_ready(): | ||
sub_msg = self.client.iopub_channel.get_msg() | ||||
|
r5600 | msg_type = sub_msg['header']['msg_type'] | ||
|
r5631 | parent = sub_msg["parent_header"] | ||
|
r15590 | |||
|
r18373 | if self.include_output(sub_msg): | ||
|
r11670 | if msg_type == 'status': | ||
|
r15590 | self._execution_state = sub_msg["content"]["execution_state"] | ||
|
r11670 | elif msg_type == 'stream': | ||
|
r5600 | if sub_msg["content"]["name"] == "stdout": | ||
|
r16215 | if self._pending_clearoutput: | ||
print("\r", file=io.stdout, end="") | ||||
self._pending_clearoutput = False | ||||
|
r18104 | print(sub_msg["content"]["text"], file=io.stdout, end="") | ||
|
r5600 | io.stdout.flush() | ||
|
r18104 | elif sub_msg["content"]["name"] == "stderr": | ||
|
r16215 | if self._pending_clearoutput: | ||
print("\r", file=io.stderr, end="") | ||||
self._pending_clearoutput = False | ||||
|
r18104 | print(sub_msg["content"]["text"], file=io.stderr, end="") | ||
|
r5600 | io.stderr.flush() | ||
|
r5606 | |||
|
r16568 | elif msg_type == 'execute_result': | ||
|
r16215 | if self._pending_clearoutput: | ||
print("\r", file=io.stdout, end="") | ||||
self._pending_clearoutput = False | ||||
|
r5606 | self.execution_count = int(sub_msg["content"]["execution_count"]) | ||
|
r18373 | if not self.from_here(sub_msg): | ||
sys.stdout.write(self.other_output_prefix) | ||||
|
r5600 | format_dict = sub_msg["content"]["data"] | ||
|
r8238 | self.handle_rich_data(format_dict) | ||
|
r18373 | |||
|
r5600 | # taken from DisplayHook.__call__: | ||
hook = self.displayhook | ||||
hook.start_displayhook() | ||||
hook.write_output_prompt() | ||||
hook.write_format_data(format_dict) | ||||
hook.log_output(format_dict) | ||||
hook.finish_displayhook() | ||||
|
r8238 | elif msg_type == 'display_data': | ||
|
r13821 | data = sub_msg["content"]["data"] | ||
handled = self.handle_rich_data(data) | ||||
if not handled: | ||||
|
r18373 | if not self.from_here(sub_msg): | ||
sys.stdout.write(self.other_output_prefix) | ||||
|
r13821 | # if it was an image, we handled it by now | ||
if 'text/plain' in data: | ||||
print(data['text/plain']) | ||||
|
r18373 | |||
elif msg_type == 'execute_input': | ||||
content = sub_msg['content'] | ||||
self.execution_count = content['execution_count'] | ||||
if not self.from_here(sub_msg): | ||||
sys.stdout.write(self.other_output_prefix) | ||||
sys.stdout.write(self.prompt_manager.render('in')) | ||||
sys.stdout.write(content['code']) | ||||
|
r16215 | elif msg_type == 'clear_output': | ||
if sub_msg["content"]["wait"]: | ||||
self._pending_clearoutput = True | ||||
else: | ||||
print("\r", file=io.stdout, end="") | ||||
|
r8242 | _imagemime = { | ||
'image/png': 'png', | ||||
'image/jpeg': 'jpeg', | ||||
'image/svg+xml': 'svg', | ||||
} | ||||
|
r8238 | def handle_rich_data(self, data): | ||
|
r8243 | for mime in self.mime_preference: | ||
if mime in data and mime in self._imagemime: | ||||
|
r8240 | self.handle_image(data, mime) | ||
|
r13821 | return True | ||
|
r8240 | |||
def handle_image(self, data, mime): | ||||
handler = getattr( | ||||
self, 'handle_image_{0}'.format(self.image_handler), None) | ||||
if handler: | ||||
handler(data, mime) | ||||
def handle_image_PIL(self, data, mime): | ||||
if mime not in ('image/png', 'image/jpeg'): | ||||
return | ||||
|
r8258 | import PIL.Image | ||
|
r8252 | raw = base64.decodestring(data[mime].encode('ascii')) | ||
|
r8240 | img = PIL.Image.open(BytesIO(raw)) | ||
img.show() | ||||
def handle_image_stream(self, data, mime): | ||||
|
r8252 | raw = base64.decodestring(data[mime].encode('ascii')) | ||
|
r8242 | imageformat = self._imagemime[mime] | ||
fmt = dict(format=imageformat) | ||||
args = [s.format(**fmt) for s in self.stream_image_handler] | ||||
|
r8244 | with open(os.devnull, 'w') as devnull: | ||
proc = subprocess.Popen( | ||||
args, stdin=subprocess.PIPE, | ||||
stdout=devnull, stderr=devnull) | ||||
proc.communicate(raw) | ||||
|
r8240 | |||
def handle_image_tempfile(self, data, mime): | ||||
|
r8252 | raw = base64.decodestring(data[mime].encode('ascii')) | ||
|
r8242 | imageformat = self._imagemime[mime] | ||
|
r8248 | filename = 'tmp.{0}'.format(imageformat) | ||
|
r12148 | with NamedFileInTemporaryDirectory(filename) as f, \ | ||
open(os.devnull, 'w') as devnull: | ||||
|
r8240 | f.write(raw) | ||
f.flush() | ||||
|
r8242 | fmt = dict(file=f.name, format=imageformat) | ||
|
r8240 | args = [s.format(**fmt) for s in self.tempfile_image_handler] | ||
|
r8244 | subprocess.call(args, stdout=devnull, stderr=devnull) | ||
|
r8240 | |||
def handle_image_callable(self, data, mime): | ||||
self.callable_image_handler(data) | ||||
|
r8238 | |||
|
r16577 | def handle_input_request(self, msg_id, timeout=0.1): | ||
|
r5600 | """ Method to capture raw_input | ||
""" | ||||
|
r16577 | req = self.client.stdin_channel.get_msg(timeout=timeout) | ||
|
r5622 | # in case any iopub came while we were waiting: | ||
|
r11670 | self.handle_iopub(msg_id) | ||
|
r16577 | if msg_id == req["parent_header"].get("msg_id"): | ||
|
r5622 | # wrap SIGINT handler | ||
real_handler = signal.getsignal(signal.SIGINT) | ||||
def double_int(sig,frame): | ||||
# call real handler (forwards sigint to kernel), | ||||
# then raise local interrupt, stopping local raw_input | ||||
real_handler(sig,frame) | ||||
raise KeyboardInterrupt | ||||
signal.signal(signal.SIGINT, double_int) | ||||
|
r16577 | content = req['content'] | ||
read = getpass if content.get('password', False) else input | ||||
|
r5622 | try: | ||
|
r16577 | raw_data = read(content["prompt"]) | ||
|
r5622 | except EOFError: | ||
# turn EOFError into EOF character | ||||
raw_data = '\x04' | ||||
except KeyboardInterrupt: | ||||
sys.stdout.write('\n') | ||||
return | ||||
finally: | ||||
# restore SIGINT handler | ||||
signal.signal(signal.SIGINT, real_handler) | ||||
# only send stdin reply if there *was not* another request | ||||
# or execution finished while we were reading. | ||||
|
r10287 | if not (self.client.stdin_channel.msg_ready() or self.client.shell_channel.msg_ready()): | ||
self.client.stdin_channel.input(raw_data) | ||||
|
r5600 | |||
def mainloop(self, display_banner=False): | ||||
while True: | ||||
try: | ||||
self.interact(display_banner=display_banner) | ||||
|
r16583 | #self.interact_with_readline() | ||
|
r5600 | # XXX for testing of a readline-decoupled repl loop, call | ||
# interact_with_readline above | ||||
break | ||||
except KeyboardInterrupt: | ||||
# this should not be necessary, but KeyboardInterrupt | ||||
# handling seems rather unpredictable... | ||||
self.write("\nKeyboardInterrupt in interact()\n") | ||||
|
r18793 | |||
self.client.shell_channel.shutdown() | ||||
|
r5600 | |||
|
r16583 | def _banner1_default(self): | ||
return "IPython Console {version}\n".format(version=release.version) | ||||
def compute_banner(self): | ||||
super(ZMQTerminalInteractiveShell, self).compute_banner() | ||||
if self.client and not self.kernel_banner: | ||||
msg_id = self.client.kernel_info() | ||||
while True: | ||||
try: | ||||
reply = self.client.get_shell_msg(timeout=1) | ||||
except Empty: | ||||
break | ||||
else: | ||||
if reply['parent_header'].get('msg_id') == msg_id: | ||||
self.kernel_banner = reply['content'].get('banner', '') | ||||
break | ||||
self.banner += self.kernel_banner | ||||
|
r5616 | def wait_for_kernel(self, timeout=None): | ||
"""method to wait for a kernel to be ready""" | ||||
tic = time.time() | ||||
|
r10287 | self.client.hb_channel.unpause() | ||
|
r5616 | while True: | ||
|
r11785 | msg_id = self.client.kernel_info() | ||
reply = None | ||||
while True: | ||||
try: | ||||
reply = self.client.get_shell_msg(timeout=1) | ||||
except Empty: | ||||
break | ||||
else: | ||||
if reply['parent_header'].get('msg_id') == msg_id: | ||||
return True | ||||
if timeout is not None \ | ||||
and (time.time() - tic) > timeout \ | ||||
and not self.client.hb_channel.is_beating(): | ||||
|
r5616 | # heart failed | ||
|
r11785 | return False | ||
|
r5616 | return True | ||
|
r5600 | def interact(self, display_banner=None): | ||
"""Closely emulate the interactive Python console.""" | ||||
# batch run -> do not interact | ||||
if self.exit_now: | ||||
return | ||||
if display_banner is None: | ||||
display_banner = self.display_banner | ||||
|
r13353 | if isinstance(display_banner, string_types): | ||
|
r5600 | self.show_banner(display_banner) | ||
elif display_banner: | ||||
self.show_banner() | ||||
more = False | ||||
|
r5616 | # run a non-empty no-op, so that we don't get a prompt until | ||
# we know the kernel is ready. This keeps the connection | ||||
# message above the first prompt. | ||||
|
r11790 | if not self.wait_for_kernel(self.kernel_timeout): | ||
|
r5616 | error("Kernel did not respond\n") | ||
return | ||||
|
r5600 | if self.has_readline: | ||
self.readline_startup_hook(self.pre_readline) | ||||
|
r5617 | hlen_b4_cell = self.readline.get_current_history_length() | ||
else: | ||||
hlen_b4_cell = 0 | ||||
|
r5600 | # exit_now is set by a call to %Exit or %Quit, through the | ||
# ask_exit callback. | ||||
while not self.exit_now: | ||||
|
r10287 | if not self.client.is_alive(): | ||
|
r5616 | # kernel died, prompt for action or exit | ||
|
r10287 | |||
action = "restart" if self.manager else "wait for restart" | ||||
|
r5616 | ans = self.ask_yes_no("kernel died, %s ([y]/n)?" % action, default='y') | ||
if ans: | ||||
|
r10287 | if self.manager: | ||
self.manager.restart_kernel(True) | ||||
|
r11790 | self.wait_for_kernel(self.kernel_timeout) | ||
|
r5600 | else: | ||
|
r5616 | self.exit_now = True | ||
|
r5600 | continue | ||
try: | ||||
|
r5616 | # protect prompt block from KeyboardInterrupt | ||
# when sitting on ctrl-C | ||||
self.hooks.pre_prompt_hook() | ||||
if more: | ||||
try: | ||||
|
r5628 | prompt = self.prompt_manager.render('in2') | ||
|
r5616 | except Exception: | ||
self.showtraceback() | ||||
if self.autoindent: | ||||
self.rl_do_indent = True | ||||
else: | ||||
try: | ||||
|
r5628 | prompt = self.separate_in + self.prompt_manager.render('in') | ||
|
r5616 | except Exception: | ||
self.showtraceback() | ||||
|
r5600 | line = self.raw_input(prompt) | ||
if self.exit_now: | ||||
# quick exit on sys.std[in|out] close | ||||
break | ||||
if self.autoindent: | ||||
self.rl_do_indent = False | ||||
except KeyboardInterrupt: | ||||
#double-guard against keyboardinterrupts during kbdint handling | ||||
try: | ||||
|
r19114 | self.write('\n' + self.get_exception_only()) | ||
|
r13912 | source_raw = self.input_splitter.raw_reset() | ||
|
r5617 | hlen_b4_cell = self._replace_rlhist_multiline(source_raw, hlen_b4_cell) | ||
|
r5600 | more = False | ||
except KeyboardInterrupt: | ||||
pass | ||||
except EOFError: | ||||
if self.autoindent: | ||||
self.rl_do_indent = False | ||||
if self.has_readline: | ||||
self.readline_startup_hook(None) | ||||
self.write('\n') | ||||
self.exit() | ||||
except bdb.BdbQuit: | ||||
warn('The Python debugger has exited with a BdbQuit exception.\n' | ||||
'Because of how pdb handles the stack, it is impossible\n' | ||||
'for IPython to properly format this particular exception.\n' | ||||
'IPython will resume normal operation.') | ||||
except: | ||||
# exceptions here are VERY RARE, but they can be triggered | ||||
# asynchronously by signal handlers, for example. | ||||
self.showtraceback() | ||||
else: | ||||
|
r13913 | try: | ||
self.input_splitter.push(line) | ||||
more = self.input_splitter.push_accepts_more() | ||||
except SyntaxError: | ||||
# Run the code directly - run_cell takes care of displaying | ||||
# the exception. | ||||
more = False | ||||
|
r5600 | if (self.SyntaxTB.last_syntax_error and | ||
self.autoedit_syntax): | ||||
self.edit_syntax_error() | ||||
if not more: | ||||
|
r13912 | source_raw = self.input_splitter.raw_reset() | ||
|
r5617 | hlen_b4_cell = self._replace_rlhist_multiline(source_raw, hlen_b4_cell) | ||
|
r5600 | self.run_cell(source_raw) | ||
# Turn off the exit flag, so the mainloop can be restarted if desired | ||||
self.exit_now = False | ||||
|
r18815 | |||
def init_history(self): | ||||
"""Sets up the command history. """ | ||||
|
r18846 | self.history_manager = ZMQHistoryManager(client=self.client) | ||
|
r18815 | self.configurables.append(self.history_manager) | ||