diff --git a/.travis.yml b/.travis.yml index 0a3a96915..820c3d19c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,21 @@ language: python python: - "nightly" - - '3.6-dev' + - 3.6 - 3.5 - 3.4 - - 3.3 - 2.7 sudo: false install: - pip install --upgrade setuptools pip - pip install --upgrade --pre -e .[test] pytest-cov pytest-warnings codecov script: - - py.test --cov jupyter_client jupyter_client + - | + if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then + py.test --cov jupyter_client jupyter_client --ignore jupyter_client/tests/test_async_manager.py + else + py.test --cov jupyter_client jupyter_client + fi after_success: - codecov matrix: diff --git a/docs/index.rst b/docs/index.rst index a0b8855cc..41e218ccc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,6 +23,7 @@ with Jupyter kernels. kernels wrapperkernels + kernel_providers .. toctree:: :maxdepth: 2 diff --git a/docs/kernel_providers.rst b/docs/kernel_providers.rst new file mode 100644 index 000000000..2e1b7e295 --- /dev/null +++ b/docs/kernel_providers.rst @@ -0,0 +1,156 @@ +================ +Kernel providers +================ + +.. note:: + This is a new interface under development, and may still change. + Not all Jupyter applications use this yet. + See :ref:`kernelspecs` for the established way of discovering kernel types. + +Creating a kernel provider +========================== + +By writing a kernel provider, you can extend how Jupyter applications discover +and start kernels. For example, you could find kernels in an environment system +like conda, or kernels on remote systems which you can access. + +To write a kernel provider, subclass +:class:`jupyter_client.discovery.KernelProviderBase`, giving your provider an ID +and overriding two methods. + +.. class:: MyKernelProvider + + .. attribute:: id + + A short string identifying this provider. Cannot contain forward slash + (``/``). + + .. method:: find_kernels() + + Get the available kernel types this provider knows about. + Return an iterable of 2-tuples: (name, attributes). + *name* is a short string identifying the kernel type. + *attributes* is a dictionary with information to allow selecting a kernel. + + .. method:: make_manager(name) + + Prepare and return a :class:`~jupyter_client.KernelManager` instance + ready to start a new kernel instance of the type identified by *name*. + The input will be one of the names given by :meth:`find_kernels`. + +For example, imagine we want to tell Jupyter about kernels for a new language +called *oblong*:: + + # oblong_provider.py + from jupyter_client.discovery import KernelProviderBase + from jupyter_client import KernelManager + from shutil import which + + class OblongKernelProvider(KernelProviderBase): + id = 'oblong' + + def find_kernels(self): + if not which('oblong-kernel'): + return # Check it's available + + # Two variants - for a real kernel, these could be something like + # different conda environments. + yield 'standard', { + 'display_name': 'Oblong (standard)', + 'language': {'name': 'oblong'}, + 'argv': ['oblong-kernel'], + } + yield 'rounded', { + 'display_name': 'Oblong (rounded)', + 'language': {'name': 'oblong'}, + 'argv': ['oblong-kernel'], + } + + def make_manager(self, name): + if name == 'standard': + return KernelManager(kernel_cmd=['oblong-kernel'], + extra_env={'ROUNDED': '0'}) + elif name == 'rounded': + return KernelManager(kernel_cmd=['oblong-kernel'], + extra_env={'ROUNDED': '1'}) + else: + raise ValueError("Unknown kernel %s" % name) + +You would then register this with an *entry point*. In your ``setup.py``, put +something like this:: + + setup(... + entry_points = { + 'jupyter_client.kernel_providers' : [ + # The name before the '=' should match the id attribute + 'oblong = oblong_provider:OblongKernelProvider', + ] + }) + +Finding kernel types +==================== + +To find and start kernels in client code, use +:class:`jupyter_client.discovery.KernelFinder`. This uses multiple kernel +providers to find available kernels. Like a kernel provider, it has methods +``find_kernels`` and ``make_manager``. The kernel names it works +with have the provider ID as a prefix, e.g. ``oblong/rounded`` (from the example +above). + +:: + + from jupyter_client.discovery import KernelFinder + kf = KernelFinder.from_entrypoints() + + ## Find available kernel types + for name, attributes in kf.find_kernels(): + print(name, ':', attributes['display_name']) + # oblong/standard : Oblong (standard) + # oblong/rounded : Oblong(rounded) + # ... + + ## Start a kernel by name + manager = kf.make_manager('oblong/standard') + manager.start_kernel() + +.. module:: jupyter_client.discovery + +.. autoclass:: KernelFinder + + .. automethod:: from_entrypoints + + .. automethod:: find_kernels + + .. automethod:: make_manager + +Kernel providers included in ``jupyter_client`` +=============================================== + +``jupyter_client`` includes two kernel providers: + +.. autoclass:: KernelSpecProvider + + .. seealso:: :ref:`kernelspecs` + +.. autoclass:: IPykernelProvider + +Glossary +======== + +Kernel instance + A running kernel, a process which can accept ZMQ connections from frontends. + Its state includes a namespace and an execution counter. + +Kernel type + The software to run a kernel instance, along with the context in which a + kernel starts. One kernel type allows starting multiple, initially similar + kernel instances. For instance, one kernel type may be associated with one + conda environment containing ``ipykernel``. The same kernel software in + another environment would be a different kernel type. Another software package + for a kernel, such as ``IRkernel``, would also be a different kernel type. + +Kernel provider + A Python class to discover kernel types and allow a client to start instances + of those kernel types. For instance, one kernel provider might find conda + environments containing ``ipykernel`` and allow starting kernel instances in + these environments. diff --git a/docs/kernels.rst b/docs/kernels.rst index 3319dda31..5308c603f 100644 --- a/docs/kernels.rst +++ b/docs/kernels.rst @@ -6,7 +6,7 @@ Making kernels for Jupyter A 'kernel' is a program that runs and introspects the user's code. IPython includes a kernel for Python code, and people have written kernels for -`several other languages `_. +`several other languages `_. When Jupyter starts a kernel, it passes it a connection file. This specifies how to set up communications with the frontend. @@ -132,6 +132,13 @@ JSON serialised dictionary containing the following keys and values: is found, a kernel with a matching `language` will be used. This allows a notebook written on any Python or Julia kernel to be properly associated with the user's Python or Julia kernel, even if they aren't listed under the same name as the author's. +- **interrupt_mode** (optional): May be either ``signal`` or ``message`` and + specifies how a client is supposed to interrupt cell execution on this kernel, + either by sending an interrupt ``signal`` via the operating system's + signalling facilities (e.g. `SIGINT` on POSIX systems), or by sending an + ``interrupt_request`` message on the control channel (see + :ref:`msging_interrupt`). If this is not specified + the client will default to ``signal`` mode. - **env** (optional): A dictionary of environment variables to set for the kernel. These will be added to the current environment variables before the kernel is started. diff --git a/docs/messaging.rst b/docs/messaging.rst index 776dda681..7c533a7de 100644 --- a/docs/messaging.rst +++ b/docs/messaging.rst @@ -21,7 +21,7 @@ Versioning The Jupyter message specification is versioned independently of the packages that use it. -The current version of the specification is 5.2. +The current version of the specification is 5.3. .. note:: *New in* and *Changed in* messages in this document refer to versions of the @@ -959,6 +959,27 @@ Message type: ``shutdown_reply``:: socket, they simply send a forceful process termination signal, since a dead process is unlikely to respond in any useful way to messages. +.. _msging_interrupt: + +Kernel interrupt +---------------- + +In case a kernel can not catch operating system interrupt signals (e.g. the used +runtime handles signals and does not allow a user program to define a callback), +a kernel can choose to be notified using a message instead. For this to work, +the kernels kernelspec must set `interrupt_mode` to ``message``. An interruption +will then result in the following message on the `control` channel: + +Message type: ``interrupt_request``:: + + content = {} + +Message type: ``interrupt_reply``:: + + content = {} + +.. versionadded:: 5.3 + Messages on the IOPub (PUB/SUB) channel ======================================= diff --git a/jupyter_client/_version.py b/jupyter_client/_version.py index 90dd2e93e..7f96345ae 100644 --- a/jupyter_client/_version.py +++ b/jupyter_client/_version.py @@ -1,5 +1,5 @@ version_info = (5, 1, 0) __version__ = '.'.join(map(str, version_info)) -protocol_version_info = (5, 2) +protocol_version_info = (5, 3) protocol_version = "%i.%i" % protocol_version_info diff --git a/jupyter_client/async_manager.py b/jupyter_client/async_manager.py new file mode 100644 index 000000000..19f27b350 --- /dev/null +++ b/jupyter_client/async_manager.py @@ -0,0 +1,204 @@ +"""Launch and control kernels using asyncio. +""" +# noinspection PyCompatibility +import asyncio +import os + +from traitlets.log import get_logger as get_app_logger + +from .launcher2 import ( + make_connection_file, build_popen_kwargs, prepare_interrupt_event +) +from .localinterfaces import is_local_ip, local_ips, localhost +from .manager2 import KernelManager2, KernelManager2ABC +from .util import inherit_docstring + +# noinspection PyCompatibility +class AsyncPopenKernelManager(KernelManager2): + """Run a kernel asynchronously in a subprocess. + + This is the async counterpart to PopenKernelLauncher. + After instantiating, call the launch() method to actually launch the + subprocess. This is necessary because the constructor cannot be async. + + Parameters + ---------- + + kernel_cmd : list of str + The Popen command template to launch the kernel + cwd : str + The working directory to launch the kernel in + extra_env : dict, optional + Dictionary of environment variables to update the existing environment + ip : str, optional + Set the kernel\'s IP address [default localhost]. + If the IP address is something other than localhost, then + Consoles on other machines will be able to connect + to the Kernel, so be careful! + """ + _exit_future = None + _win_interrupt_evt = None + kernel = None + + def __init__(self, kernel_cmd, cwd, extra_env=None, ip=None): + self.kernel_cmd = kernel_cmd + self.cwd = cwd + self.extra_env = extra_env + if ip is None: + ip = localhost() + self.ip = ip + self.log = get_app_logger() + + if self.transport == 'tcp' and not is_local_ip(ip): + raise RuntimeError("Can only launch a kernel on a local interface. " + "Make sure that the '*_address' attributes are " + "configured properly. " + "Currently valid addresses are: %s" % local_ips() + ) + + self.connection_file, self.connection_info = \ + make_connection_file(ip, self.transport) + + @asyncio.coroutine + def launch(self): + """Run this immediately after instantiation to launch the kernel process. + """ + kw = build_popen_kwargs(self.kernel_cmd, self.connection_file, + self.extra_env, self.cwd) + self._win_interrupt_evt = prepare_interrupt_event(kw['env']) + + # launch the kernel subprocess + args = kw.pop('args') + self.log.debug("Starting kernel: %s", args) + self.kernel = yield from asyncio.create_subprocess_exec(*args, **kw) + self.kernel.stdin.close() + self._exit_future = asyncio.ensure_future(self.kernel.wait()) + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def wait(self, timeout): + try: + yield from asyncio.wait_for(self.kernel.wait(), timeout) + return False + except asyncio.TimeoutError: + return True + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def is_alive(self): + return not self._exit_future.done() + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def signal(self, signum): + return super().signal(signum) + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def interrupt(self): + return super().interrupt() + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def kill(self): + return super().kill() + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def cleanup(self): + return super().cleanup() + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def get_connection_info(self): + return self.connection_info + + @inherit_docstring(KernelManager2) + @asyncio.coroutine + def relaunch(self): + kw = build_popen_kwargs(self.kernel_cmd, self.connection_file, + self.extra_env, self.cwd) + prepare_interrupt_event(kw['env'], self._win_interrupt_evt) + + # launch the kernel subprocess + args = kw.pop('args') + self.log.debug("Starting kernel: %s", args) + self.kernel = yield from asyncio.create_subprocess_exec(*args, **kw) + self.kernel.stdin.close() + self._exit_future = asyncio.ensure_future(self.kernel.wait()) + +# noinspection PyCompatibility +@asyncio.coroutine +def shutdown(client, manager, wait_time=5.0): + """Shutdown a kernel using a client and a manager. + + Attempts a clean shutdown by sending a shutdown message. If the kernel + hasn't exited in wait_time seconds, it will be killed. Set wait_time=None + to wait indefinitely. + """ + client.shutdown() + if (yield from manager.wait(wait_time)): + # OK, we've waited long enough. + manager.log.debug("Kernel is taking too long to finish, killing") + manager.kill() + manager.cleanup() + +# noinspection PyCompatibility +@asyncio.coroutine +def start_new_kernel(kernel_cmd, startup_timeout=60, cwd=None): + """Start a new kernel, and return its Manager and a blocking client""" + from .client2 import BlockingKernelClient2 + cwd = cwd or os.getcwd() + + km = AsyncPopenKernelManager(kernel_cmd, cwd=cwd) + yield from km.launch() + # TODO: asyncio client + kc = BlockingKernelClient2(km.connection_info, manager=km) + try: + kc.wait_for_ready(timeout=startup_timeout) + except RuntimeError: + yield from shutdown(kc, km) + raise + + return km, kc + +# noinspection PyCompatibility +class AsyncManagerWrapper(KernelManager2ABC): + """Wrap a blocking KernelLauncher to be used asynchronously. + + This calls the blocking methods in the event loop's default executor. + """ + def __init__(self, wrapped, loop=None): + self.wrapped = wrapped + self.loop = loop or asyncio.get_event_loop() + + def _exec(self, f, *args): + return self.loop.run_in_executor(None, f, *args) + + @asyncio.coroutine + def is_alive(self): + return (yield from self._exec(self.wrapped.is_alive)) + + @asyncio.coroutine + def wait(self, timeout): + return (yield from self._exec(self.wrapped.wait, timeout)) + + @asyncio.coroutine + def signal(self, signum): + return (yield from self._exec(self.wrapped.signal, signum)) + + @asyncio.coroutine + def interrupt(self): + return (yield from self._exec(self.wrapped.interrupt)) + + @asyncio.coroutine + def kill(self): + return (yield from self._exec(self.wrapped.kill)) + + @asyncio.coroutine + def cleanup(self): + return (yield from self._exec(self.wrapped.cleanup)) + + @asyncio.coroutine + def get_connection_info(self): + return (yield from self._exec(self.wrapped.get_connection_info)) diff --git a/jupyter_client/channels.py b/jupyter_client/channels.py index dd9906723..e24ba8311 100644 --- a/jupyter_client/channels.py +++ b/jupyter_client/channels.py @@ -193,9 +193,9 @@ def close(self): self.socket = None def call_handlers(self, since_last_heartbeat): - """This method is called in the ioloop thread when a message arrives. + """This method is called in the ioloop thread when the heartbeat fails. - Subclasses should override this method to handle incoming messages. + Subclasses should override this method to handle heartbeat failure. It is important to remember that this method is called in the thread so that some logic must be done to ensure that the application level handlers are called in the application thread. diff --git a/jupyter_client/client2.py b/jupyter_client/client2.py new file mode 100644 index 000000000..da798ecf3 --- /dev/null +++ b/jupyter_client/client2.py @@ -0,0 +1,712 @@ +"""Base class to manage the interaction with a running kernel""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from __future__ import absolute_import, print_function + +from functools import partial +from getpass import getpass +from six.moves import input +import sys +import time +import zmq + +from ipython_genutils.py3compat import string_types, iteritems +from traitlets.log import get_logger as get_app_logger +from .channels import major_protocol_version, HBChannel +from .session import Session +from .util import inherit_docstring + +try: + monotonic = time.monotonic +except AttributeError: + # py2 + monotonic = time.time # close enough + +try: + TimeoutError +except NameError: + # py2 + TimeoutError = RuntimeError + + +channel_socket_types = { + 'hb' : zmq.REQ, + 'shell' : zmq.DEALER, + 'iopub' : zmq.SUB, + 'stdin' : zmq.DEALER, + 'control': zmq.DEALER, +} + +# some utilities to validate message structure, these might get moved elsewhere +# if they prove to have more generic utility + +def validate_string_dict(dct): + """Validate that the input is a dict with string keys and values. + + Raises ValueError if not.""" + for k, v in iteritems(dct): + if not isinstance(k, string_types): + raise ValueError('key %r in dict must be a string' % k) + if not isinstance(v, string_types): + raise ValueError('value %r in dict must be a string' % v) + +class KernelClient2(object): + """Communicates with a single kernel on any host via zmq channels. + + The messages that can be sent are exposed as methods of the + client (KernelClient2.execute, complete, history, etc.). These methods only + send the message, they don't wait for a reply. To get results, use e.g. + :meth:`get_shell_msg` to fetch messages from the shell channel. + """ + hb_monitor = None + + def __init__(self, connection_info, manager=None, use_heartbeat=True): + self.connection_info = connection_info + self.manager = manager + self.using_heartbeat = use_heartbeat and (manager is not None) + self.context = zmq.Context.instance() + self.session = Session(key=connection_info['key'].encode('ascii'), + signature_scheme=connection_info['signature_scheme']) + self.log = get_app_logger() + + identity = self.session.bsession + self.iopub_socket = self._create_connected_socket('iopub', identity) + self.iopub_socket.setsockopt(zmq.SUBSCRIBE, b'') + self.shell_socket = self._create_connected_socket('shell', identity) + self.stdin_socket = self._create_connected_socket('stdin', identity) + self.control_socket = self._create_connected_socket('control', identity) + if self.using_heartbeat: + self.hb_monitor = HBChannel(context=self.context, + address=self._make_url('hb')) + self.hb_monitor.start() + + @property + def owned_kernel(self): + """True if this client 'owns' the kernel, i.e. started it.""" + return self.manager is not None + + def close(self): + """Close sockets of this client. + + After calling this, the client can no longer be used. + """ + self.iopub_socket.close() + self.shell_socket.close() + self.stdin_socket.close() + self.control_socket.close() + if self.hb_monitor: + self.hb_monitor.stop() + + # flag for whether execute requests should be allowed to call raw_input: + allow_stdin = True + + def _make_url(self, channel): + """Make a ZeroMQ URL for a given channel.""" + transport = self.connection_info['transport'] + ip = self.connection_info['ip'] + port = self.connection_info['%s_port' % channel] + + if transport == 'tcp': + return "tcp://%s:%i" % (ip, port) + else: + return "%s://%s-%s" % (transport, ip, port) + + def _create_connected_socket(self, channel, identity=None): + """Create a zmq Socket and connect it to the kernel.""" + url = self._make_url(channel) + socket_type = channel_socket_types[channel] + self.log.debug("Connecting to: %s" % url) + sock = self.context.socket(socket_type) + # set linger to 1s to prevent hangs at exit + sock.linger = 1000 + if identity: + sock.identity = identity + sock.connect(url) + return sock + + def is_alive(self): + if self.owned_kernel: + return self.manager.is_alive() + elif self.using_heartbeat: + return self.hb_monitor.is_beating() + else: + return True # Fingers crossed + + def _send(self, socket, msg): + self.session.send(socket, msg) + + # Methods to send specific messages on channels + def execute(self, code, silent=False, store_history=True, + user_expressions=None, allow_stdin=None, stop_on_error=True, + _header=None): + """Execute code in the kernel. + + Parameters + ---------- + code : str + A string of code in the kernel's language. + + silent : bool, optional (default False) + If set, the kernel will execute the code as quietly possible, and + will force store_history to be False. + + store_history : bool, optional (default True) + If set, the kernel will store command history. This is forced + to be False if silent is True. + + user_expressions : dict, optional + A dict mapping names to expressions to be evaluated in the user's + dict. The expression values are returned as strings formatted using + :func:`repr`. + + allow_stdin : bool, optional (default self.allow_stdin) + Flag for whether the kernel can send stdin requests to frontends. + + Some frontends (e.g. the Notebook) do not support stdin requests. + If raw_input is called from code executed from such a frontend, a + StdinNotImplementedError will be raised. + + stop_on_error: bool, optional (default True) + Flag whether to abort the execution queue, if an exception is encountered. + + Returns + ------- + The msg_id of the message sent. + """ + if user_expressions is None: + user_expressions = {} + if allow_stdin is None: + allow_stdin = self.allow_stdin + + # Don't waste network traffic if inputs are invalid + if not isinstance(code, string_types): + raise ValueError('code %r must be a string' % code) + validate_string_dict(user_expressions) + + # Create class for content/msg creation. Related to, but possibly + # not in Session. + content = dict(code=code, silent=silent, store_history=store_history, + user_expressions=user_expressions, + allow_stdin=allow_stdin, stop_on_error=stop_on_error + ) + msg = self.session.msg('execute_request', content, header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def complete(self, code, cursor_pos=None, _header=None): + """Tab complete text in the kernel's namespace. + + Parameters + ---------- + code : str + The context in which completion is requested. + Can be anything between a variable name and an entire cell. + cursor_pos : int, optional + The position of the cursor in the block of code where the completion was requested. + Default: ``len(code)`` + + Returns + ------- + The msg_id of the message sent. + """ + if cursor_pos is None: + cursor_pos = len(code) + content = dict(code=code, cursor_pos=cursor_pos) + msg = self.session.msg('complete_request', content, header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def inspect(self, code, cursor_pos=None, detail_level=0, _header=None): + """Get metadata information about an object in the kernel's namespace. + + It is up to the kernel to determine the appropriate object to inspect. + + Parameters + ---------- + code : str + The context in which info is requested. + Can be anything between a variable name and an entire cell. + cursor_pos : int, optional + The position of the cursor in the block of code where the info was requested. + Default: ``len(code)`` + detail_level : int, optional + The level of detail for the introspection (0-2) + + Returns + ------- + The msg_id of the message sent. + """ + if cursor_pos is None: + cursor_pos = len(code) + content = dict(code=code, cursor_pos=cursor_pos, + detail_level=detail_level, + ) + msg = self.session.msg('inspect_request', content, header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def history(self, raw=True, output=False, hist_access_type='range', + _header=None, **kwargs): + """Get entries from the kernel's history list. + + Parameters + ---------- + raw : bool + If True, return the raw input. + output : bool + If True, then return the output as well. + hist_access_type : str + 'range' (fill in session, start and stop params), 'tail' (fill in n) + or 'search' (fill in pattern param). + + session : int + For a range request, the session from which to get lines. Session + numbers are positive integers; negative ones count back from the + current session. + start : int + The first line number of a history range. + stop : int + The final (excluded) line number of a history range. + + n : int + The number of lines of history to get for a tail request. + + pattern : str + The glob-syntax pattern for a search request. + + Returns + ------- + The ID of the message sent. + """ + if hist_access_type == 'range': + kwargs.setdefault('session', 0) + kwargs.setdefault('start', 0) + content = dict(raw=raw, output=output, + hist_access_type=hist_access_type, + **kwargs) + msg = self.session.msg('history_request', content, header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def kernel_info(self, _header=None): + """Request kernel info + + Returns + ------- + The msg_id of the message sent + """ + msg = self.session.msg('kernel_info_request', header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def comm_info(self, target_name=None, _header=None): + """Request comm info + + Returns + ------- + The msg_id of the message sent + """ + if target_name is None: + content = {} + else: + content = dict(target_name=target_name) + msg = self.session.msg('comm_info_request', content, header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def _handle_kernel_info_reply(self, msg): + """handle kernel info reply + + sets protocol adaptation version. This might + be run from a separate thread. + """ + adapt_version = int(msg['content']['protocol_version'].split('.')[0]) + if adapt_version != major_protocol_version: + self.session.adapt_version = adapt_version + + def shutdown(self, restart=False, _header=None): + """Request an immediate kernel shutdown. + + Upon receipt of the (empty) reply, client code can safely assume that + the kernel has shut down and it's safe to forcefully terminate it if + it's still alive. + + The kernel will send the reply via a function registered with Python's + atexit module, ensuring it's truly done as the kernel is done with all + normal operation. + + Returns + ------- + The msg_id of the message sent + """ + # Send quit message to kernel. Once we implement kernel-side setattr, + # this should probably be done that way, but for now this will do. + msg = self.session.msg('shutdown_request', {'restart': restart}, + header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def is_complete(self, code, _header=None): + """Ask the kernel whether some code is complete and ready to execute.""" + msg = self.session.msg('is_complete_request', {'code': code}, + header=_header) + self._send(self.shell_socket, msg) + return msg['header']['msg_id'] + + def interrupt(self, _header=None): + """Send an interrupt message/signal to the kernel""" + mode = self.connection_info.get('interrupt_mode', 'signal') + if mode == 'message': + msg = self.session.msg("interrupt_request", content={}, + header=_header) + self._send(self.control_socket, msg) + return msg['header']['msg_id'] + elif self.owned_kernel: + self.manager.interrupt() + else: + self.log.warning("Can't send signal to non-owned kernel") + + def input(self, string, parent=None, _header=None): + """Send a string of raw input to the kernel. + + This should only be called in response to the kernel sending an + ``input_request`` message on the stdin channel. + """ + content = dict(value=string) + msg = self.session.msg('input_reply', content, + header=_header, parent=parent) + self._send(self.stdin_socket, msg) + + +def reqrep(meth): + def wrapped(self, *args, **kwargs): + reply = kwargs.pop('reply', False) + timeout = kwargs.pop('timeout', None) + msg_id = meth(self, *args, **kwargs) + if not reply: + return msg_id + + return self._recv_reply(msg_id, timeout=timeout) + + if not meth.__doc__: + # python -OO removes docstrings, + # so don't bother building the wrapped docstring + return wrapped + + basedoc, _ = meth.__doc__.split('Returns\n', 1) + parts = [basedoc.strip()] + if 'Parameters' not in basedoc: + parts.append(""" + Parameters + ---------- + """) + parts.append(""" + reply: bool (default: False) + Whether to wait for and return reply + timeout: float or None (default: None) + Timeout to use when waiting for a reply + + Returns + ------- + msg_id: str + The msg_id of the request sent, if reply=False (default) + reply: dict + The reply message for this request, if reply=True + """) + wrapped.__doc__ = '\n'.join(parts) + return wrapped + + +class BlockingKernelClient2(KernelClient2): + """A KernelClient with blocking APIs + + ``get_[channel]_msg()`` methods wait for and return messages on channels, + returning None if no message arrives within ``timeout`` seconds. + """ + + def _recv(self, socket): + """Receive and parse a message""" + msg = socket.recv_multipart() + ident,smsg = self.session.feed_identities(msg) + return self.session.deserialize(smsg) + + def _get_msg(self, socket, block=True, timeout=None): + if block: + if timeout is not None: + timeout *= 1000 # seconds to ms + ready = socket.poll(timeout) + else: + ready = socket.poll(timeout=0) + + if ready: + return self._recv(socket) + + def get_shell_msg(self, block=True, timeout=None): + """Get a message from the shell channel""" + return self._get_msg(self.shell_socket, block, timeout) + + def get_iopub_msg(self, block=True, timeout=None): + """Get a message from the iopub channel""" + return self._get_msg(self.iopub_socket, block, timeout) + + def get_stdin_msg(self, block=True, timeout=None): + """Get a message from the stdin channel""" + return self._get_msg(self.stdin_socket, block, timeout) + + def wait_for_ready(self, timeout=None): + """Waits for a response when a client is blocked + + - Sets future time for timeout + - Blocks on shell channel until a message is received + - Exit if the kernel has died + - If client times out before receiving a message from the kernel, send RuntimeError + - Flush the IOPub channel + """ + if timeout is None: + abs_timeout = float('inf') + else: + abs_timeout = time.time() + timeout + + if not self.owned_kernel: + # This Client was not created by a KernelManager, + # so wait for kernel to become responsive to heartbeats + # before checking for kernel_info reply + while not self.is_alive(): + if time.time() > abs_timeout: + raise RuntimeError( + "Kernel didn't respond to heartbeats in %d seconds and timed out" % timeout) + time.sleep(0.2) + + self.kernel_info(reply=False) + + # Wait for kernel info reply on shell channel + while True: + msg = self.get_shell_msg(timeout=1) + if msg and msg['msg_type'] == 'kernel_info_reply': + self._handle_kernel_info_reply(msg) + break + + if not self.is_alive(): + raise RuntimeError('Kernel died before replying to kernel_info') + + # Check if current time is ready check time plus timeout + if time.time() > abs_timeout: + raise RuntimeError( + "Kernel didn't respond in %d seconds" % timeout) + + # Flush IOPub channel + while True: + msg = self.get_iopub_msg(block=True, timeout=0.2) + if msg is None: + break + + def _recv_reply(self, msg_id, timeout=None): + """Receive and return the reply for a given request""" + deadline = None + if timeout is not None: + deadline = monotonic() + timeout + while True: + if timeout is not None: + timeout = max(0, deadline - monotonic()) + reply = self.get_shell_msg(timeout=timeout) + if reply is None: + raise TimeoutError("Timeout waiting for reply") + elif reply['parent_header'].get('msg_id') != msg_id: + # not my reply, someone may have forgotten to retrieve theirs + continue + return reply + + execute = reqrep(KernelClient2.execute) + history = reqrep(KernelClient2.history) + complete = reqrep(KernelClient2.complete) + inspect = reqrep(KernelClient2.inspect) + kernel_info = reqrep(KernelClient2.kernel_info) + comm_info = reqrep(KernelClient2.comm_info) + shutdown = reqrep(KernelClient2.shutdown) + + @inherit_docstring(KernelClient2) + def interrupt(self, reply=False, timeout=None): + msg_id = super(BlockingKernelClient2, self).interrupt() + if reply and msg_id: + return self._recv_reply(msg_id, timeout=timeout) + else: + return msg_id + + def _stdin_hook_default(self, msg): + """Handle an input request""" + content = msg['content'] + if content.get('password', False): + prompt = getpass + else: + prompt = input + + try: + raw_data = prompt(content["prompt"]) + except EOFError: + # turn EOFError into EOF character + raw_data = '\x04' + except KeyboardInterrupt: + sys.stdout.write('\n') + return + + # only send stdin reply if there *was not* another request + # or execution finished while we were reading. + if not (self.stdin_socket.poll(timeout=0) + or self.shell_socket.poll(timeout=0)): + self.input(raw_data) + + def _output_hook_default(self, msg): + """Default hook for redisplaying plain-text output""" + msg_type = msg['header']['msg_type'] + content = msg['content'] + if msg_type == 'stream': + stream = getattr(sys, content['name']) + stream.write(content['text']) + elif msg_type in ('display_data', 'execute_result'): + sys.stdout.write(content['data'].get('text/plain', '')) + elif msg_type == 'error': + print('\n'.join(content['traceback']), file=sys.stderr) + + def _output_hook_kernel(self, session, socket, parent_header, msg): + """Output hook when running inside an IPython kernel + + adds rich output support. + """ + msg_type = msg['header']['msg_type'] + if msg_type in ('display_data', 'execute_result', 'error'): + session.send(socket, msg_type, msg['content'], parent=parent_header) + else: + self._output_hook_default(msg) + + def execute_interactive(self, code, silent=False, store_history=True, + user_expressions=None, allow_stdin=None, + stop_on_error=True, + timeout=None, output_hook=None, stdin_hook=None, + ): + """Execute code in the kernel interactively + + Output will be redisplayed, and stdin prompts will be relayed as well. + If an IPython kernel is detected, rich output will be displayed. + + You can pass a custom output_hook callable that will be called + with every IOPub message that is produced instead of the default redisplay. + + .. versionadded:: 5.0 + + Parameters + ---------- + code : str + A string of code in the kernel's language. + + silent : bool, optional (default False) + If set, the kernel will execute the code as quietly possible, and + will force store_history to be False. + + store_history : bool, optional (default True) + If set, the kernel will store command history. This is forced + to be False if silent is True. + + user_expressions : dict, optional + A dict mapping names to expressions to be evaluated in the user's + dict. The expression values are returned as strings formatted using + :func:`repr`. + + allow_stdin : bool, optional (default self.allow_stdin) + Flag for whether the kernel can send stdin requests to frontends. + + Some frontends (e.g. the Notebook) do not support stdin requests. + If raw_input is called from code executed from such a frontend, a + StdinNotImplementedError will be raised. + + stop_on_error: bool, optional (default True) + Flag whether to abort the execution queue, if an exception is encountered. + + timeout: float or None (default: None) + Timeout to use when waiting for a reply + + output_hook: callable(msg) + Function to be called with output messages. + If not specified, output will be redisplayed. + + stdin_hook: callable(msg) + Function to be called with stdin_request messages. + If not specified, input/getpass will be called. + + Returns + ------- + reply: dict + The reply message for this request + """ + if allow_stdin is None: + allow_stdin = self.allow_stdin + msg_id = self.execute(code, + silent=silent, + store_history=store_history, + user_expressions=user_expressions, + allow_stdin=allow_stdin, + stop_on_error=stop_on_error, + ) + if stdin_hook is None: + stdin_hook = self._stdin_hook_default + if output_hook is None: + # detect IPython kernel + if 'IPython' in sys.modules: + from IPython import get_ipython + ip = get_ipython() + in_kernel = getattr(ip, 'kernel', False) + if in_kernel: + output_hook = partial( + self._output_hook_kernel, + ip.display_pub.session, + ip.display_pub.pub_socket, + ip.display_pub.parent_header, + ) + if output_hook is None: + # default: redisplay plain-text outputs + output_hook = self._output_hook_default + + # set deadline based on timeout + timeout_ms = None + if timeout is not None: + deadline = monotonic() + timeout + else: + deadline = None + + poller = zmq.Poller() + poller.register(self.iopub_socket, zmq.POLLIN) + if allow_stdin: + poller.register(self.stdin_socket, zmq.POLLIN) + + # wait for output and redisplay it + while True: + if deadline is not None: + timeout = max(0, deadline - monotonic()) + timeout_ms = 1e3 * timeout + events = dict(poller.poll(timeout_ms)) + if not events: + raise TimeoutError("Timeout waiting for output") + if self.stdin_socket in events: + req = self.get_stdin_msg(timeout=0) + stdin_hook(req) + continue + if self.iopub_socket not in events: + continue + + msg = self.get_iopub_msg(timeout=0) + + if msg['parent_header'].get('msg_id') != msg_id: + # not from my request + continue + output_hook(msg) + + # stop on idle + if msg['header']['msg_type'] == 'status' and \ + msg['content']['execution_state'] == 'idle': + break + + # output is done, get the reply + if timeout is not None: + timeout = max(0, deadline - monotonic()) + return self._recv_reply(msg_id, timeout=timeout) diff --git a/jupyter_client/client2_ioloop.py b/jupyter_client/client2_ioloop.py new file mode 100644 index 000000000..5e42045c0 --- /dev/null +++ b/jupyter_client/client2_ioloop.py @@ -0,0 +1,234 @@ +import atexit +import errno +from functools import partial +from threading import Thread, Event +from zmq import ZMQError +from zmq.eventloop import ioloop, zmqstream + +from .client2 import KernelClient2 +from .util import inherit_docstring + + +class IOLoopKernelClient2(KernelClient2): + """Uses a zmq/tornado IOLoop to handle received messages and fire callbacks. + + Use ClientInThread to run this in a separate thread alongside your + application. + """ + def __init__(self, connection_info, **kwargs): + super(IOLoopKernelClient2, self).__init__(connection_info, **kwargs) + self.ioloop = ioloop.IOLoop.current() + self.handlers = { + 'iopub': [], + 'shell': [self._auto_adapt], + 'stdin': [], + 'control': [], + } + self.shell_stream = zmqstream.ZMQStream(self.shell_socket, self.ioloop) + self.shell_stream.on_recv(partial(self._handle_recv, 'shell')) + self.iopub_stream = zmqstream.ZMQStream(self.iopub_socket, self.ioloop) + self.iopub_stream.on_recv(partial(self._handle_recv, 'iopub')) + self.stdin_stream = zmqstream.ZMQStream(self.stdin_socket, self.ioloop) + self.stdin_stream.on_recv(partial(self._handle_recv, 'stdin')) + self.control_stream = zmqstream.ZMQStream(self.control_socket, self.ioloop) + self.control_stream.on_recv(partial(self._handle_recv, 'control')) + + def close(self): + """Close the client's sockets & streams. + + This does not close the IOLoop. + """ + self.shell_stream.close() + self.iopub_stream.close() + self.stdin_stream.close() + self.control_stream.close() + if self.hb_monitor: + self.hb_monitor.stop() + + def _auto_adapt(self, msg): + """Use the first kernel_info_reply to set up protocol version adaptation + """ + if msg['header']['msg_type'] == 'kernel_info_reply': + self._handle_kernel_info_reply(msg) + self.remove_handler('shell', self._auto_adapt) + + def _handle_recv(self, channel, msg): + """Callback for stream.on_recv. + + Unpacks message, and calls handlers with it. + """ + ident, smsg = self.session.feed_identities(msg) + msg = self.session.deserialize(smsg) + self._call_handlers(channel, msg) + + def _call_handlers(self, channel, msg): + # [:] copies the list - handlers that remove themselves (or add other + # handlers) will not mess up iterating over it. + for handler in self.handlers[channel][:]: + try: + handler(msg) + except Exception as e: + self.log.error("Exception from message handler %r", handler, + exc_info=e) + + def add_handler(self, channel, handler): + """Add a callback for received messages on one channel. + + Parameters + ---------- + + channel : str + One of 'shell', 'iopub', 'stdin' or 'control' + handler : function + Will be called for each message received with the message dictionary + as a single argument. + """ + self.handlers[channel].append(handler) + + def remove_handler(self, channel, handler): + """Remove a previously registered callback.""" + self.handlers[channel].remove(handler) + +class ClientInThread(Thread): + """Run an IOLoopKernelClient2 in a separate thread. + + The main client methods (execute, complete, etc.) all pass their arguments + to the ioloop thread, which sends the messages. Handlers for received + messages will be called in the ioloop thread, so they should typically + use a signal or callback mechanism to interact with the application in + the main thread. + """ + client = None + _exiting = False + + def __init__(self, connection_info, manager=None, loop=None): + super(ClientInThread, self).__init__() + self.daemon = True + self.connection_info = connection_info + self.manager = manager + self.started = Event() + + @staticmethod + @atexit.register + def _notice_exit(): + ClientInThread._exiting = True + + def run(self): + """Run my loop, ignoring EINTR events in the poller""" + loop = ioloop.IOLoop(make_current=True) + self.client = IOLoopKernelClient2(self.connection_info, manager=self.manager) + self.client.ioloop.add_callback(self.started.set) + try: + self._run_loop() + finally: + self.client.close() + self.client.ioloop.close() + self.client = None + + def _run_loop(self): + while True: + try: + self.client.ioloop.start() + except ZMQError as e: + if e.errno == errno.EINTR: + continue + else: + raise + except Exception: + if self._exiting: + break + else: + raise + else: + break + + @property + def ioloop(self): + if self.client: + return self.client.ioloop + + def close(self): + """Shut down the client and wait for the thread to exit. + + This closes the client's sockets and ioloop, and joins its thread. + """ + if self.client is not None: + self.ioloop.add_callback(self.client.ioloop.stop) + self.join() + + @inherit_docstring(IOLoopKernelClient2) + def add_handler(self, channel, handler): + self.client.handlers[channel].append(handler) + + @inherit_docstring(IOLoopKernelClient2) + def remove_handler(self, channel, handler): + self.client.handlers[channel].remove(handler) + + # Client messaging methods -------------------------------- + # These send as much work as possible to the IO thread, but we generate + # the header in the calling thread so we can return the message ID. + + @inherit_docstring(KernelClient2) + def execute(self, *args, **kwargs): + hdr = self.client.session.msg_header('execute_request') + self.ioloop.add_callback(self.client.execute, *args, _header=hdr, **kwargs) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def complete(self, *args, **kwargs): + hdr = self.client.session.msg_header('complete_request') + self.ioloop.add_callback(self.client.complete, *args, _header=hdr, **kwargs) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def inspect(self, *args, **kwargs): + hdr = self.client.session.msg_header('inspect_request') + self.ioloop.add_callback(self.client.inspect, *args, _header=hdr, **kwargs) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def history(self, *args, **kwargs): + hdr = self.client.session.msg_header('history_request') + self.ioloop.add_callback(self.client.history, *args, _header=hdr, **kwargs) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def kernel_info(self, _header=None): + hdr = self.client.session.msg_header('kernel_info_request') + self.ioloop.add_callback(self.client.kernel_info, _header=hdr) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def comm_info(self, target_name=None, _header=None): + hdr = self.client.session.msg_header('comm_info_request') + self.ioloop.add_callback(self.client.comm_info, target_name, _header=hdr) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def shutdown(self, restart=False, _header=None): + hdr = self.client.session.msg_header('shutdown_request') + self.ioloop.add_callback(self.client.shutdown, restart, _header=hdr) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def is_complete(self, code, _header=None): + hdr = self.client.session.msg_header('is_complete_request') + self.ioloop.add_callback(self.client.is_complete, code, _header=hdr) + return hdr['msg_id'] + + @inherit_docstring(KernelClient2) + def interrupt(self, _header=None): + mode = self.connection_info.get('interrupt_mode', 'signal') + if mode == 'message': + hdr = self.client.session.msg_header('is_complete_request') + self.ioloop.add_callback(self.client.interrupt, _header=hdr) + return hdr['msg_id'] + else: + self.client.interrupt() + + @inherit_docstring(KernelClient2) + def input(self, string, parent=None): + hdr = self.client.session.msg_header('input_reply') + self.ioloop.add_callback(self.client.is_complete, string, + parent=parent, _header=hdr) + return hdr['msg_id'] diff --git a/jupyter_client/discovery.py b/jupyter_client/discovery.py new file mode 100644 index 000000000..f37bca8c7 --- /dev/null +++ b/jupyter_client/discovery.py @@ -0,0 +1,167 @@ +from abc import ABCMeta, abstractmethod +import entrypoints +import logging +import six + +from .kernelspec import KernelSpecManager +from .manager2 import KernelManager2 + +log = logging.getLogger(__name__) + +class KernelProviderBase(six.with_metaclass(ABCMeta, object)): + id = None # Should be a short string identifying the provider class. + + @abstractmethod + def find_kernels(self): + """Return an iterator of (kernel_name, kernel_info_dict) tuples.""" + pass + + @abstractmethod + def launch(self, name, cwd=None): + """Launch a kernel, return an object with the KernelManager2 interface. + + name will be one of the kernel names produced by find_kernels() + + This method launches and manages the kernel in a blocking manner. + """ + pass + + def launch_async(self, name, cwd=None): + """Launch a kernel asynchronously using asyncio. + + name will be one of the kernel names produced by find_kernels() + + This method should act as an asyncio coroutine, returning an object + with the AsyncKernelManager interface. This closely matches the + synchronous KernelManager2 interface, but all methods are coroutines. + """ + raise NotImplementedError() + +class KernelSpecProvider(KernelProviderBase): + """Offers kernel types from installed kernelspec directories. + """ + id = 'spec' + + def __init__(self): + self.ksm = KernelSpecManager() + + def find_kernels(self): + for name, resdir in self.ksm.find_kernel_specs().items(): + spec = self.ksm._get_kernel_spec_by_name(name, resdir) + yield name, { + # TODO: get full language info + 'language': {'name': spec.language}, + 'display_name': spec.display_name, + 'argv': spec.argv, + } + + def launch(self, name, cwd=None): + spec = self.ksm.get_kernel_spec(name) + return KernelManager2(kernel_cmd=spec.argv, extra_env=spec.env, cwd=cwd) + + def launch_async(self, name, cwd=None): + from .async_manager import AsyncPopenKernelManager + spec = self.ksm.get_kernel_spec(name) + return AsyncPopenKernelManager.launch( + kernel_cmd=spec.argv, extra_env=spec.env, cwd=cwd) + +class IPykernelProvider(KernelProviderBase): + """Offers a kernel type using the Python interpreter it's running in. + + This checks if ipykernel is importable first. + """ + id = 'pyimport' + + def _check_for_kernel(self): + try: + from ipykernel.kernelspec import RESOURCES, get_kernel_dict + from ipykernel.ipkernel import IPythonKernel + except ImportError: + return None + else: + return { + 'spec': get_kernel_dict(), + 'language_info': IPythonKernel.language_info, + 'resources_dir': RESOURCES, + } + + def find_kernels(self): + info = self._check_for_kernel() + + if info: + yield 'kernel', { + 'language': info['language_info'], + 'display_name': info['spec']['display_name'], + 'argv': info['spec']['argv'], + } + + def launch(self, name, cwd=None): + info = self._check_for_kernel() + if info is None: + raise Exception("ipykernel is not importable") + return KernelManager2(kernel_cmd=info['spec']['argv'], extra_env={}, + cwd=cwd) + + def launch_async(self, name, cwd=None): + from .async_manager import AsyncPopenKernelManager + info = self._check_for_kernel() + if info is None: + raise Exception("ipykernel is not importable") + return AsyncPopenKernelManager.launch( + kernel_cmd=info['spec']['argv'], extra_env={}, cwd=cwd) + +class KernelFinder(object): + """Manages a collection of kernel providers to find available kernel types + + *providers* should be a list of kernel provider instances. + """ + def __init__(self, providers): + self.providers = providers + + @classmethod + def from_entrypoints(cls): + """Load all kernel providers advertised by entry points. + + Kernel providers should use the "jupyter_client.kernel_providers" + entry point group. + + Returns an instance of KernelFinder. + """ + providers = [] + for ep in entrypoints.get_group_all('jupyter_client.kernel_providers'): + try: + provider = ep.load()() # Load and instantiate + except Exception: + log.error('Error loading kernel provider', exc_info=True) + else: + providers.append(provider) + + return cls(providers) + + def find_kernels(self): + """Iterate over available kernel types. + + Yields 2-tuples of (prefixed_name, attributes) + """ + for provider in self.providers: + for kid, attributes in provider.find_kernels(): + id = provider.id + '/' + kid + yield id, attributes + + def launch(self, name, cwd=None): + """Launch a kernel of a given kernel type. + """ + provider_id, kernel_id = name.split('/', 1) + for provider in self.providers: + if provider_id == provider.id: + return provider.launch(kernel_id, cwd) + raise KeyError(provider_id) + + def launch_async(self, name, cwd=None): + """Launch a kernel of a given kernel type, using asyncio. + """ + provider_id, kernel_id = name.split('/', 1) + for provider in self.providers: + if provider_id == provider.id: + return provider.launch_async(kernel_id, cwd) + raise KeyError(provider_id) diff --git a/jupyter_client/ioloop/manager.py b/jupyter_client/ioloop/manager.py index 511a73f55..cc285291b 100644 --- a/jupyter_client/ioloop/manager.py +++ b/jupyter_client/ioloop/manager.py @@ -1,15 +1,7 @@ """A kernel manager with a tornado IOLoop""" -#----------------------------------------------------------------------------- -# Copyright (c) The Jupyter Development Team -# -# Distributed under the terms of the BSD License. The full license is in -# the file COPYING, distributed as part of this software. -#----------------------------------------------------------------------------- - -#----------------------------------------------------------------------------- -# Imports -#----------------------------------------------------------------------------- +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. from __future__ import absolute_import @@ -24,10 +16,6 @@ from jupyter_client.manager import KernelManager from .restarter import IOLoopKernelRestarter -#----------------------------------------------------------------------------- -# Code -#----------------------------------------------------------------------------- - def as_zmqstream(f): def wrapped(self, *args, **kwargs): @@ -37,9 +25,9 @@ def wrapped(self, *args, **kwargs): class IOLoopKernelManager(KernelManager): - loop = Instance('zmq.eventloop.ioloop.IOLoop') + loop = Instance('tornado.ioloop.IOLoop') def _loop_default(self): - return ioloop.IOLoop.instance() + return ioloop.IOLoop.current() restarter_class = Type( default_value=IOLoopKernelRestarter, diff --git a/jupyter_client/ioloop/restarter.py b/jupyter_client/ioloop/restarter.py index 6f531744c..69079eecf 100644 --- a/jupyter_client/ioloop/restarter.py +++ b/jupyter_client/ioloop/restarter.py @@ -4,37 +4,28 @@ restarts the kernel if it dies. """ -#----------------------------------------------------------------------------- -# Copyright (c) The Jupyter Development Team -# -# Distributed under the terms of the BSD License. The full license is in -# the file COPYING, distributed as part of this software. -#----------------------------------------------------------------------------- - -#----------------------------------------------------------------------------- -# Imports -#----------------------------------------------------------------------------- +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. from __future__ import absolute_import +import warnings from zmq.eventloop import ioloop - from jupyter_client.restarter import KernelRestarter from traitlets import ( Instance, ) -#----------------------------------------------------------------------------- -# Code -#----------------------------------------------------------------------------- - class IOLoopKernelRestarter(KernelRestarter): """Monitor and autorestart a kernel.""" - loop = Instance('zmq.eventloop.ioloop.IOLoop') + loop = Instance('tornado.ioloop.IOLoop') def _loop_default(self): - return ioloop.IOLoop.instance() + warnings.warn("IOLoopKernelRestarter.loop is deprecated in jupyter-client 5.2", + DeprecationWarning, stacklevel=4, + ) + return ioloop.IOLoop.current() _pcallback = None @@ -42,7 +33,7 @@ def start(self): """Start the polling of the kernel.""" if self._pcallback is None: self._pcallback = ioloop.PeriodicCallback( - self.poll, 1000*self.time_to_dead, self.loop + self.poll, 1000*self.time_to_dead, ) self._pcallback.start() diff --git a/jupyter_client/kernelapp.py b/jupyter_client/kernelapp.py new file mode 100644 index 000000000..ee8167cc3 --- /dev/null +++ b/jupyter_client/kernelapp.py @@ -0,0 +1,80 @@ +import os +import signal + +from jupyter_core.application import JupyterApp, base_flags +from tornado.ioloop import IOLoop +from traitlets import Unicode + +from . import __version__ +from .discovery import KernelFinder +from .client2 import BlockingKernelClient2 +from .manager2 import shutdown + +class KernelApp(JupyterApp): + """Launch a kernel by name in a local subprocess. + """ + version = __version__ + description = "Run a kernel locally in a subprocess" + + aliases = { + 'kernel': 'KernelApp.kernel_name', + #'ip': 'KernelManager.ip', # TODO + } + flags = {'debug': base_flags['debug']} + + kernel_name = Unicode("pyimport/kernel", + help = 'The name of a kernel type to start' + ).tag(config=True) + + def initialize(self, argv=None): + super(KernelApp, self).initialize(argv) + self.kernel_finder = KernelFinder.from_entrypoints() + if '/' not in self.kernel_name: + self.kernel_name = 'spec/' + self.kernel_name + self.loop = IOLoop.current() + self.loop.add_callback(self._record_started) + + def setup_signals(self): + """Shutdown on SIGTERM or SIGINT (Ctrl-C)""" + if os.name == 'nt': + return + + def shutdown_handler(signo, frame): + self.loop.add_callback_from_signal(self.shutdown, signo) + for sig in [signal.SIGTERM, signal.SIGINT]: + signal.signal(sig, shutdown_handler) + + def shutdown(self, signo): + self.log.info('Shutting down on signal %d' % signo) + client = BlockingKernelClient2(self.manager.get_connection_info()) + shutdown(client, self.manager) + client.close() + self.loop.stop() + + def log_connection_info(self): + cf = self.manager.connection_file + self.log.info('Connection file: %s', cf) + self.log.info("To connect a client: --existing %s", os.path.basename(cf)) + + def _record_started(self): + """For tests, create a file to indicate that we've started + + Do not rely on this except in our own tests! + """ + fn = os.environ.get('JUPYTER_CLIENT_TEST_RECORD_STARTUP_PRIVATE') + if fn is not None: + with open(fn, 'wb'): + pass + + def start(self): + self.log.info('Starting kernel %r', self.kernel_name) + self.manager = self.kernel_finder.launch(self.kernel_name) + try: + self.log_connection_info() + self.setup_signals() + self.loop.start() + finally: + self.manager.cleanup() + + +main = KernelApp.launch_instance diff --git a/jupyter_client/kernelspec.py b/jupyter_client/kernelspec.py index 3465ac7a4..d2248cc58 100644 --- a/jupyter_client/kernelspec.py +++ b/jupyter_client/kernelspec.py @@ -13,7 +13,9 @@ pjoin = os.path.join from ipython_genutils.py3compat import PY3 -from traitlets import HasTraits, List, Unicode, Dict, Set, Bool, Type +from traitlets import ( + HasTraits, List, Unicode, Dict, Set, Bool, Type, CaselessStrEnum +) from traitlets.config import LoggingConfigurable from jupyter_core.paths import jupyter_data_dir, jupyter_path, SYSTEM_JUPYTER_PATH @@ -28,6 +30,9 @@ class KernelSpec(HasTraits): language = Unicode() env = Dict() resource_dir = Unicode() + interrupt_mode = CaselessStrEnum( + ['message', 'signal'], default_value='signal' + ) metadata = Dict() @classmethod @@ -46,6 +51,7 @@ def to_dict(self): env=self.env, display_name=self.display_name, language=self.language, + interrupt_mode=self.interrupt_mode, metadata=self.metadata, ) @@ -227,7 +233,7 @@ def get_all_specs(self): def remove_kernel_spec(self, name): """Remove a kernel spec directory by name. - + Returns the path that was deleted. """ save_native = self.ensure_native_kernel @@ -263,7 +269,7 @@ def install_kernel_spec(self, source_dir, kernel_name=None, user=False, If ``user`` is False, it will attempt to install into the systemwide kernel registry. If the process does not have appropriate permissions, an :exc:`OSError` will be raised. - + If ``prefix`` is given, the kernelspec will be installed to PREFIX/share/jupyter/kernels/KERNEL_NAME. This can be sys.prefix for installation inside virtual or conda envs. @@ -284,16 +290,16 @@ def install_kernel_spec(self, source_dir, kernel_name=None, user=False, DeprecationWarning, stacklevel=2, ) - + destination = self._get_destination_dir(kernel_name, user=user, prefix=prefix) self.log.debug('Installing kernelspec in %s', destination) - + kernel_dir = os.path.dirname(destination) if kernel_dir not in self.kernel_dirs: self.log.warning("Installing to %s, which is not in %s. The kernelspec may not be found.", kernel_dir, self.kernel_dirs, ) - + if os.path.isdir(destination): self.log.info('Removing existing kernelspec in %s', destination) shutil.rmtree(destination) diff --git a/jupyter_client/launcher2.py b/jupyter_client/launcher2.py new file mode 100644 index 000000000..a05cc2486 --- /dev/null +++ b/jupyter_client/launcher2.py @@ -0,0 +1,241 @@ +"""Machinery for launching a kernel locally. + +Used by jupyter_client.manager2. +""" +from binascii import b2a_hex +import errno +import json +import os +import re +import six +import socket +import stat +from subprocess import PIPE +import sys +import warnings + +from ipython_genutils.encoding import getdefaultencoding +from ipython_genutils.py3compat import cast_bytes_py2 +from jupyter_core.paths import jupyter_runtime_dir +from jupyter_core.utils import ensure_dir_exists +from .localinterfaces import localhost + +def new_key(): + """Generate a new random key string. + + Avoids problematic runtime import in stdlib uuid on Python 2. + + Returns + ------- + + id string (16 random bytes as hex-encoded text, chunks separated by '-') + """ + buf = os.urandom(16) + return u'-'.join(b2a_hex(x).decode('ascii') for x in ( + buf[:4], buf[4:] + )) + +def random_ports(ip, transport='tcp'): + """Pick a set of random, unused ports for the kernel to use. + """ + res = {} + port_names = ['shell_port', 'iopub_port', 'stdin_port', 'control_port', + 'hb_port'] + if transport == 'tcp': + # store sockets temporarily to avoid reusing a port number + tmp_socks = [] + for _ in port_names: + sock = socket.socket() + # struct.pack('ii', (0,0)) is 8 null bytes + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, b'\0' * 8) + sock.bind((ip, 0)) + tmp_socks.append(sock) + for name, sock in zip(port_names, tmp_socks): + port = sock.getsockname()[1] + sock.close() + res[name] = port + else: + N = 1 + for name in port_names: + while os.path.exists("%s-%s" % (ip, str(N))): + N += 1 + res[name] = N + N += 1 + return res + +def set_sticky_bit(fname): + """Set the sticky bit on the file and its parent directory. + + This stops it being deleted by periodic cleanup of XDG_RUNTIME_DIR. + """ + if not hasattr(stat, 'S_ISVTX'): + return + + paths = [fname] + runtime_dir = os.path.dirname(fname) + if runtime_dir: + paths.append(runtime_dir) + for path in paths: + permissions = os.stat(path).st_mode + new_permissions = permissions | stat.S_ISVTX + if new_permissions != permissions: + try: + os.chmod(path, new_permissions) + except OSError as e: + if e.errno == errno.EPERM and path == runtime_dir: + # suppress permission errors setting sticky bit on runtime_dir, + # which we may not own. + pass + else: + # failed to set sticky bit, probably not a big deal + warnings.warn( + "Failed to set sticky bit on %r: %s" + "\nProbably not a big deal, but runtime files may be cleaned up periodically." % (path, e), + RuntimeWarning, + ) + +def make_connection_file(ip=None, transport='tcp'): + """Generates a JSON config file, including the selection of random ports. + + Parameters + ---------- + + ip : str, optional + The ip address the kernel will bind to. + + transport : str, optional + The ZMQ transport to use: tcp or ipc + """ + if not ip: + ip = localhost() + + runtime_dir = jupyter_runtime_dir() + ensure_dir_exists(runtime_dir) + fname = os.path.join(runtime_dir, 'kernel-%s.json' % new_key()) + + cfg = random_ports(ip=ip, transport=transport) + cfg['ip'] = ip + cfg['key'] = new_key() + cfg['transport'] = transport + cfg['signature_scheme'] = 'hmac-sha256' + + with open(fname, 'w') as f: + f.write(json.dumps(cfg, indent=2)) + + set_sticky_bit(fname) + + return fname, cfg + +def format_kernel_cmd(cmd, connection_file, kernel_resource_dir=None): + """Replace templated args (e.g. {connection_file}) + """ + if cmd and cmd[0] == 'python': + # executable is 'python', use sys.executable. + # These will typically be the same, + # but if the current process is in an env + # and has been launched by abspath without + # activating the env, python on PATH may not be sys.executable, + # but it should be. + cmd[0] = sys.executable + + ns = dict(connection_file=connection_file, + prefix=sys.prefix, + ) + + if kernel_resource_dir: + ns["resource_dir"] = kernel_resource_dir + + pat = re.compile(r'\{([A-Za-z0-9_]+)\}') + def from_ns(match): + """Get the key out of ns if it's there, otherwise no change.""" + return ns.get(match.group(1), match.group()) + + return [ pat.sub(from_ns, arg) for arg in cmd ] + +def build_popen_kwargs(cmd_template, connection_file, extra_env=None, cwd=None): + """Build a dictionary of arguments to pass to Popen""" + kwargs = {} + # Popen will fail (sometimes with a deadlock) if stdin, stdout, and stderr + # are invalid. Unfortunately, there is in general no way to detect whether + # they are valid. The following two blocks redirect them to (temporary) + # pipes in certain important cases. + + # If this process has been backgrounded, our stdin is invalid. Since there + # is no compelling reason for the kernel to inherit our stdin anyway, we'll + # place this one safe and always redirect. + kwargs['stdin'] = PIPE + + # If this process in running on pythonw, we know that stdin, stdout, and + # stderr are all invalid. + redirect_out = sys.executable.endswith('pythonw.exe') + if redirect_out: + kwargs['stdout'] = kwargs['stderr'] = open(os.devnull, 'w') + + cmd = format_kernel_cmd(cmd_template, connection_file) + + kwargs['env'] = env = os.environ.copy() + # Don't allow PYTHONEXECUTABLE to be passed to kernel process. + # If set, it can bork all the things. + env.pop('PYTHONEXECUTABLE', None) + + if extra_env: + print(extra_env) + env.update(extra_env) + + # TODO: where is this used? + independent = False + + if sys.platform == 'win32': + # Popen on Python 2 on Windows cannot handle unicode args or cwd + encoding = getdefaultencoding(prefer_stream=False) + kwargs['args'] = [cast_bytes_py2(c, encoding) for c in cmd] + if cwd: + kwargs['cwd'] = cast_bytes_py2(cwd, + sys.getfilesystemencoding() or 'ascii') + + try: + # noinspection PyUnresolvedReferences + from _winapi import DuplicateHandle, GetCurrentProcess, \ + DUPLICATE_SAME_ACCESS, CREATE_NEW_PROCESS_GROUP + except: + # noinspection PyUnresolvedReferences + from _subprocess import DuplicateHandle, GetCurrentProcess, \ + DUPLICATE_SAME_ACCESS, CREATE_NEW_PROCESS_GROUP + # Launch the kernel process + if independent: + kwargs['creationflags'] = CREATE_NEW_PROCESS_GROUP + else: + pid = GetCurrentProcess() + handle = DuplicateHandle(pid, pid, pid, 0, + True, # Inheritable by new processes. + DUPLICATE_SAME_ACCESS) + env['JPY_PARENT_PID'] = str(int(handle)) + + else: + kwargs['args'] = cmd + kwargs['cwd'] = cwd + # Create a new session. + # This makes it easier to interrupt the kernel, + # because we want to interrupt the whole process group. + # We don't use setpgrp, which is known to cause problems for kernels starting + # certain interactive subprocesses, such as bash -i. + if six.PY3: + kwargs['start_new_session'] = True + else: + kwargs['preexec_fn'] = lambda: os.setsid() + if not independent: + env['JPY_PARENT_PID'] = str(os.getpid()) + + return kwargs + +def prepare_interrupt_event(env, interrupt_event=None): + if sys.platform == 'win32': + from .win_interrupt import create_interrupt_event + # Create a Win32 event for interrupting the kernel + # and store it in an environment variable. + if interrupt_event is None: + interrupt_event = create_interrupt_event() + env["JPY_INTERRUPT_EVENT"] = str(interrupt_event) + # deprecated old env name: + env["IPY_INTERRUPT_EVENT"] = env["JPY_INTERRUPT_EVENT"] + return interrupt_event diff --git a/jupyter_client/manager.py b/jupyter_client/manager.py index d50a5fbb8..2bcc1629a 100644 --- a/jupyter_client/manager.py +++ b/jupyter_client/manager.py @@ -22,7 +22,7 @@ from ipython_genutils.importstring import import_item from .localinterfaces import is_local_ip, local_ips from traitlets import ( - Any, Float, Instance, Unicode, List, Bool, Type, DottedObjectName + Any, Float, Instance, Unicode, List, Bool, Type, DottedObjectName, Dict ) from jupyter_client import ( launch_kernel, @@ -87,23 +87,13 @@ def kernel_spec(self): self._kernel_spec = self.kernel_spec_manager.get_kernel_spec(self.kernel_name) return self._kernel_spec - kernel_cmd = List(Unicode(), config=True, - help="""DEPRECATED: Use kernel_name instead. - - The Popen Command to launch the kernel. - Override this if you have a custom kernel. - If kernel_cmd is specified in a configuration file, - Jupyter does not pass any arguments to the kernel, - because it cannot make any assumptions about the - arguments that the kernel understands. In particular, - this means that the kernel does not receive the - option --debug if it given on the Jupyter command line. - """ + kernel_cmd = List(Unicode(), + help="""The Popen Command to launch the kernel.""" ) - def _kernel_cmd_changed(self, name, old, new): - warnings.warn("Setting kernel_cmd is deprecated, use kernel_spec to " - "start different kernels.") + extra_env = Dict( + help="""Extra environment variables to be set for the kernel.""" + ) @property def ipykernel(self): @@ -254,7 +244,9 @@ def start_kernel(self, **kw): # If kernel_cmd has been set manually, don't refer to a kernel spec # Environment variables from kernel spec are added to os.environ env.update(self.kernel_spec.env or {}) - + elif self.extra_env: + env.update(self.extra_env) + # launch the kernel subprocess self.log.debug("Starting kernel: %s", kernel_cmd) self.kernel = self._launch_kernel(kernel_cmd, env=env, @@ -411,11 +403,18 @@ def interrupt_kernel(self): platforms. """ if self.has_kernel: - if sys.platform == 'win32': - from .win_interrupt import send_interrupt - send_interrupt(self.kernel.win32_interrupt_event) - else: - self.signal_kernel(signal.SIGINT) + interrupt_mode = self.kernel_spec.interrupt_mode + if interrupt_mode == 'signal': + if sys.platform == 'win32': + from .win_interrupt import send_interrupt + send_interrupt(self.kernel.win32_interrupt_event) + else: + self.signal_kernel(signal.SIGINT) + + elif interrupt_mode == 'message': + msg = self.session.msg("interrupt_request", content={}) + self._connect_control_socket() + self.session.send(self._control_socket, msg) else: raise RuntimeError("Cannot interrupt kernel. No kernel is running!") diff --git a/jupyter_client/manager2.py b/jupyter_client/manager2.py new file mode 100644 index 000000000..81f9012ec --- /dev/null +++ b/jupyter_client/manager2.py @@ -0,0 +1,316 @@ +"""Base class to manage a running kernel""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from __future__ import absolute_import + +from abc import ABCMeta, abstractmethod +from contextlib import contextmanager +import logging +import os +import signal +import six +import subprocess +import sys +import time + +log = logging.getLogger(__name__) + +from traitlets.log import get_logger as get_app_logger + +from .launcher2 import ( + make_connection_file, build_popen_kwargs, prepare_interrupt_event +) +from .localinterfaces import is_local_ip, local_ips, localhost + + +class KernelManager2ABC(six.with_metaclass(ABCMeta, object)): + @abstractmethod + def is_alive(self): + """Check whether the kernel is currently alive (e.g. the process exists) + """ + pass + + @abstractmethod + def wait(self, timeout): + """Wait for the kernel process to exit. + + If timeout is a number, it is a maximum time in seconds to wait. + timeout=None means wait indefinitely. + + Returns True if the kernel is still alive after waiting, False if it + exited (like is_alive()). + """ + pass + + @abstractmethod + def signal(self, signum): + """Send a signal to the kernel.""" + pass + + @abstractmethod + def interrupt(self): + """Interrupt the kernel by sending it a signal or similar event + + Kernels can request to get interrupts as messages rather than signals. + The manager is *not* expected to handle this. + :meth:`.KernelClient2.interrupt` should send an interrupt_request or + call this method as appropriate. + """ + pass + + @abstractmethod + def kill(self): + """Forcibly terminate the kernel. + + This method may be used to dispose of a kernel that won't shut down. + Working kernels should usually be shut down by sending shutdown_request + from a client and giving it some time to clean up. + """ + pass + + def cleanup(self): + """Clean up any resources, such as files created by the manager.""" + pass + + @abstractmethod + def get_connection_info(self): + """Return a dictionary of connection information""" + pass + + @abstractmethod + def relaunch(self): + """Attempt to relaunch the kernel using the same ports. + + This is meant to be called after the managed kernel has died. Calling + it while the kernel is still alive has undefined behaviour. + + Returns True if this manager supports that. + """ + pass + + +class KernelManager2(KernelManager2ABC): + """Manages a single kernel in a subprocess on this host. + + This version starts kernels with Popen to listen on TCP sockets. + + Parameters + ---------- + + kernel_cmd : list of str + The Popen command template to launch the kernel + cwd : str + The working directory to launch the kernel in + extra_env : dict, optional + Dictionary of environment variables to update the existing environment + ip : str, optional + Set the kernel\'s IP address [default localhost]. + If the IP address is something other than localhost, then + Consoles on other machines will be able to connect + to the Kernel, so be careful! + """ + transport = 'tcp' + + def __init__(self, kernel_cmd, cwd, extra_env=None, ip=None): + self.kernel_cmd = kernel_cmd + self.cwd = cwd + self.extra_env = extra_env + if ip is None: + ip = localhost() + self.ip = ip + self.log = get_app_logger() + + if self.transport == 'tcp' and not is_local_ip(ip): + raise RuntimeError("Can only launch a kernel on a local interface. " + "Make sure that the '*_address' attributes are " + "configured properly. " + "Currently valid addresses are: %s" % local_ips() + ) + + self.connection_file, self.connection_info = \ + make_connection_file(ip, self.transport) + + kw = build_popen_kwargs(kernel_cmd, self.connection_file, + extra_env, cwd) + self._win_interrupt_evt = prepare_interrupt_event(kw['env']) + + # launch the kernel subprocess + self.log.debug("Starting kernel: %s", kw['args']) + self.kernel = subprocess.Popen(**kw) + self.kernel.stdin.close() + + def wait(self, timeout): + """""" + if timeout is None: + # Wait indefinitely + self.kernel.wait() + return False + + if six.PY3: + try: + self.kernel.wait(timeout) + return False + except subprocess.TimeoutExpired: + return True + else: + pollinterval = 0.1 + for i in range(int(timeout / pollinterval)): + if self.is_alive(): + time.sleep(pollinterval) + else: + return False + return self.is_alive() + + def cleanup(self): + """Clean up resources when the kernel is shut down""" + # cleanup connection files on full shutdown of kernel we started + try: + os.remove(self.connection_file) + except (IOError, OSError, AttributeError): + pass + + def relaunch(self): + """Attempt to relaunch the kernel using the same ports. + + This is meant to be called after the managed kernel has died. Calling + it while the kernel is still alive has undefined behaviour. + + Returns True if this manager supports that. + """ + kw = build_popen_kwargs(self.kernel_cmd, self.connection_file, + self.extra_env, self.cwd) + prepare_interrupt_event(kw['env'], self._win_interrupt_evt) + + # launch the kernel subprocess + self.log.debug("Starting kernel: %s", kw['args']) + self.kernel = subprocess.Popen(**kw) + self.kernel.stdin.close() + return True + + def kill(self): + """Kill the running kernel. + """ + # Signal the kernel to terminate (sends SIGKILL on Unix and calls + # TerminateProcess() on Win32). + try: + self.kernel.kill() + except OSError as e: + # In Windows, we will get an Access Denied error if the process + # has already terminated. Ignore it. + if sys.platform == 'win32': + if e.winerror != 5: + raise + # On Unix, we may get an ESRCH error if the process has already + # terminated. Ignore it. + else: + from errno import ESRCH + if e.errno != ESRCH: + raise + + # Block until the kernel terminates. + self.kernel.wait() + + def interrupt(self): + """Interrupts the kernel by sending it a signal. + + Unlike ``signal_kernel``, this operation is well supported on all + platforms. + + Kernels may ask for interrupts to be delivered by a message rather than + a signal. This method does *not* handle that. Use KernelClient.interrupt + to send a message or a signal as appropriate. + """ + if sys.platform == 'win32': + from .win_interrupt import send_interrupt + send_interrupt(self._win_interrupt_evt) + else: + self.signal(signal.SIGINT) + + def signal(self, signum): + """Sends a signal to the process group of the kernel (this + usually includes the kernel and any subprocesses spawned by + the kernel). + + Note that since only SIGTERM is supported on Windows, this function is + only useful on Unix systems. + """ + if hasattr(os, "getpgid") and hasattr(os, "killpg"): + try: + pgid = os.getpgid(self.kernel.pid) + os.killpg(pgid, signum) + return + except OSError: + pass + self.kernel.send_signal(signum) + + def is_alive(self): + """Is the kernel process still running?""" + return self.kernel.poll() is None + + def get_connection_info(self): + return self.connection_info + +class IPCKernelManager2(KernelManager2): + """Start a kernel on this machine to listen on IPC (filesystem) sockets""" + transport = 'ipc' + + def cleanup(self): + ports = [v for (k, v) in self.connection_info.items() + if k.endswith('_port')] + for port in ports: + ipcfile = "%s-%i" % (self.connection_info['ip'], port) + try: + os.remove(ipcfile) + except (IOError, OSError): + pass + + super(IPCKernelManager2, self).cleanup() + +def shutdown(client, manager, wait_time=5.0): + """Shutdown a kernel using a client and a manager. + + Attempts a clean shutdown by sending a shutdown message. If the kernel + hasn't exited in wait_time seconds, it will be killed. Set wait_time=None + to wait indefinitely. + """ + client.shutdown() + if manager.wait(wait_time): + # OK, we've waited long enough. + log.debug("Kernel is taking too long to finish, killing") + manager.kill() + manager.cleanup() + +def start_new_kernel(kernel_cmd, startup_timeout=60, cwd=None): + """Start a new kernel, and return its Manager and a blocking client""" + from .client2 import BlockingKernelClient2 + cwd = cwd or os.getcwd() + + km = KernelManager2(kernel_cmd, cwd=cwd) + kc = BlockingKernelClient2(km.connection_info, manager=km) + try: + kc.wait_for_ready(timeout=startup_timeout) + except RuntimeError: + shutdown(kc, km) + kc.close() + raise + + return km, kc + +@contextmanager +def run_kernel(kernel_cmd, **kwargs): + """Context manager to create a kernel in a subprocess. + + The kernel is shut down when the context exits. + + Returns + ------- + kernel_client: connected KernelClient instance + """ + km, kc = start_new_kernel(kernel_cmd, **kwargs) + try: + yield kc + finally: + shutdown(kc, km) + kc.close() diff --git a/jupyter_client/restarter2.py b/jupyter_client/restarter2.py new file mode 100644 index 000000000..ab7c4221b --- /dev/null +++ b/jupyter_client/restarter2.py @@ -0,0 +1,149 @@ +"""Machinery to monitor a KernelManager and restart the kernel if it dies +""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from tornado import ioloop +from traitlets.config.configurable import LoggingConfigurable +from traitlets import ( + Float, Bool, Integer, +) + +from .discovery import KernelFinder + + +class KernelRestarterBase(LoggingConfigurable): + """Monitor and autorestart a kernel.""" + + debug = Bool(False, config=True, + help="""Whether to include every poll event in debugging output. + + Has to be set explicitly, because there will be *a lot* of output. + """ + ) + + time_to_dead = Float(3.0, config=True, + help="""Kernel heartbeat interval in seconds.""" + ) + + restart_limit = Integer(5, config=True, + help="""The number of consecutive autorestarts before the kernel is presumed dead.""" + ) + + random_ports_until_alive = Bool(True, config=True, + help="""Whether to choose new random ports when restarting before the kernel is alive.""" + ) + _restarting = False + _restart_count = 0 + _initial_startup = True + + def __init__(self, kernel_manager, kernel_type, kernel_finder=None, **kw): + super(KernelRestarterBase, self).__init__(**kw) + self.kernel_manager = kernel_manager + self.kernel_type = kernel_type + self.kernel_finder = kernel_finder or KernelFinder.from_entrypoints() + self.callbacks = dict(restart=[], dead=[]) + + def start(self): + """Start monitoring the kernel.""" + raise NotImplementedError("Must be implemented in a subclass") + + def stop(self): + """Stop monitoring.""" + raise NotImplementedError("Must be implemented in a subclass") + + def add_callback(self, f, event='restart'): + """register a callback to fire on a particular event + + Possible values for event: + + 'restart' (default): kernel has died, and will be restarted. + 'dead': restart has failed, kernel will be left dead. + + """ + self.callbacks[event].append(f) + + def remove_callback(self, f, event='restart'): + """unregister a callback to fire on a particular event + + Possible values for event: + + 'restart' (default): kernel has died, and will be restarted. + 'dead': restart has failed, kernel will be left dead. + + """ + try: + self.callbacks[event].remove(f) + except ValueError: + pass + + def _fire_callbacks(self, event): + """fire our callbacks for a particular event""" + for callback in self.callbacks[event]: + try: + callback() + except Exception as e: + self.log.error("KernelRestarter: %s callback %r failed", event, callback, exc_info=True) + + def do_restart(self): + """Called when the kernel has died""" + if self._restarting: + self._restart_count += 1 + else: + self._restart_count = 1 + + if self._restart_count >= self.restart_limit: + self.log.warning("KernelRestarter: restart failed") + self._fire_callbacks('dead') + self._restarting = False + self._restart_count = 0 + self.stop() + else: + newports = self.random_ports_until_alive and self._initial_startup + self._fire_callbacks('restart') + if newports: + cwd = getattr(self.kernel_manager, 'cwd', None) # :-/ + self.log.info("KernelRestarter: starting new manager (%i/%i)", + self._restart_count, self.restart_limit) + self.kernel_manager.cleanup() + self.kernel_manager = self.kernel_finder.launch( + self.kernel_type, cwd) + else: + self.log.info( + 'KernelRestarter: restarting kernel (%i/%i), keeping ports', + self._restart_count, self.restart_limit) + self.kernel_manager.relaunch() + self._restarting = True + + def poll(self): + if self.debug: + self.log.debug('Polling kernel...') + if not self.kernel_manager.is_alive(): + self.do_restart() + else: + if self._initial_startup: + self._initial_startup = False + if self._restarting: + self.log.debug("KernelRestarter: restart apparently succeeded") + self._restarting = False + + +class TornadoKernelRestarter(KernelRestarterBase): + """Monitor a kernel using the tornado ioloop.""" + _pcallback = None + + def start(self): + """Start the polling of the kernel.""" + if self._pcallback is None: + self._pcallback = ioloop.PeriodicCallback( + self.poll, 1000*self.time_to_dead, + ) + self._pcallback.start() + + def stop(self): + """Stop the kernel polling.""" + if self._pcallback is not None: + self._pcallback.stop() + self._pcallback = None + diff --git a/jupyter_client/session.py b/jupyter_client/session.py index af60ac259..33b1c0b4a 100644 --- a/jupyter_client/session.py +++ b/jupyter_client/session.py @@ -191,9 +191,9 @@ def _context_default(self): session = Instance('jupyter_client.session.Session', allow_none=True) - loop = Instance('zmq.eventloop.ioloop.IOLoop') + loop = Instance('tornado.ioloop.IOLoop') def _loop_default(self): - return IOLoop.instance() + return IOLoop.current() def __init__(self, **kwargs): super(SessionFactory, self).__init__(**kwargs) diff --git a/jupyter_client/tests/test_async_manager.py b/jupyter_client/tests/test_async_manager.py new file mode 100644 index 000000000..c90b45adc --- /dev/null +++ b/jupyter_client/tests/test_async_manager.py @@ -0,0 +1,50 @@ +import os +import pytest +from unittest import TestCase + +asyncio = pytest.importorskip('asyncio') + +from ipykernel.kernelspec import make_ipkernel_cmd +from .utils import test_env, skip_win32 +from jupyter_client.async_manager import ( + AsyncPopenKernelManager, shutdown, start_new_kernel +) + +# noinspection PyCompatibility +class TestKernelManager(TestCase): + def setUp(self): + self.env_patch = test_env() + self.env_patch.start() + + def tearDown(self): + self.env_patch.stop() + + @asyncio.coroutine + def t_get_connect_info(self): + km = AsyncPopenKernelManager(make_ipkernel_cmd(), os.getcwd()) + yield from km.launch() + try: + info = yield from km.get_connection_info() + self.assertEqual(set(info.keys()), { + 'ip', 'transport', + 'hb_port', 'shell_port', 'stdin_port', 'iopub_port', 'control_port', + 'key', 'signature_scheme', + }) + finally: + yield from km.kill() + yield from km.cleanup() + + def test_get_connect_info(self): + asyncio.get_event_loop().run_until_complete(self.t_get_connect_info()) + + @asyncio.coroutine + def t_start_new_kernel(self): + km, kc = yield from start_new_kernel(make_ipkernel_cmd(), startup_timeout=5) + try: + self.assertTrue((yield from km.is_alive())) + self.assertTrue(kc.is_alive()) + finally: + yield from shutdown(kc, km) + + def test_start_new_kernel(self): + asyncio.get_event_loop().run_until_complete(self.t_start_new_kernel()) diff --git a/jupyter_client/tests/test_client2.py b/jupyter_client/tests/test_client2.py new file mode 100644 index 000000000..3cab7a2e8 --- /dev/null +++ b/jupyter_client/tests/test_client2.py @@ -0,0 +1,86 @@ +"""Tests for the KernelClient2""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + + +import os + +pjoin = os.path.join +from unittest import TestCase + +from ipykernel.kernelspec import make_ipkernel_cmd +from ..manager2 import start_new_kernel, shutdown +from .utils import test_env + +from ipython_genutils.py3compat import string_types +from IPython.utils.capture import capture_output + +TIMEOUT = 30 + + +class TestKernelClient(TestCase): + def setUp(self): + self.env_patch = test_env() + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + self.km, self.kc = start_new_kernel(kernel_cmd=make_ipkernel_cmd()) + self.addCleanup(self.kc.close) + self.addCleanup(shutdown, self.kc, self.km) + + def test_execute_interactive(self): + kc = self.kc + + with capture_output() as io: + reply = kc.execute_interactive("print('hello')", timeout=TIMEOUT) + assert 'hello' in io.stdout + assert reply['content']['status'] == 'ok' + + def _check_reply(self, reply_type, reply): + self.assertIsInstance(reply, dict) + self.assertEqual(reply['header']['msg_type'], reply_type + '_reply') + self.assertEqual(reply['parent_header']['msg_type'], + reply_type + '_request') + + def test_history(self): + kc = self.kc + msg_id = kc.history(session=0) + self.assertIsInstance(msg_id, string_types) + reply = kc.history(session=0, reply=True, timeout=TIMEOUT) + self._check_reply('history', reply) + + def test_inspect(self): + kc = self.kc + msg_id = kc.inspect('who cares') + self.assertIsInstance(msg_id, string_types) + reply = kc.inspect('code', reply=True, timeout=TIMEOUT) + self._check_reply('inspect', reply) + + def test_complete(self): + kc = self.kc + msg_id = kc.complete('who cares') + self.assertIsInstance(msg_id, string_types) + reply = kc.complete('code', reply=True, timeout=TIMEOUT) + self._check_reply('complete', reply) + + def test_kernel_info(self): + kc = self.kc + msg_id = kc.kernel_info() + self.assertIsInstance(msg_id, string_types) + reply = kc.kernel_info(reply=True, timeout=TIMEOUT) + self._check_reply('kernel_info', reply) + + def test_comm_info(self): + kc = self.kc + msg_id = kc.comm_info() + self.assertIsInstance(msg_id, string_types) + reply = kc.comm_info(reply=True, timeout=TIMEOUT) + self._check_reply('comm_info', reply) + + def test_shutdown(self): + kc = self.kc + msg_id = kc.shutdown() + self.assertIsInstance(msg_id, string_types) + reply = kc.shutdown(reply=True, timeout=TIMEOUT) + self._check_reply('shutdown', reply) diff --git a/jupyter_client/tests/test_client2_loop.py b/jupyter_client/tests/test_client2_loop.py new file mode 100644 index 000000000..72ea8bd64 --- /dev/null +++ b/jupyter_client/tests/test_client2_loop.py @@ -0,0 +1,95 @@ +"""Tests for the ioloop KernelClient running in a separate thread.""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + + +import os +try: + from queue import Queue, Empty +except ImportError: + from Queue import Queue, Empty + +pjoin = os.path.join +from unittest import TestCase + +from ipykernel.kernelspec import make_ipkernel_cmd +from jupyter_client.manager2 import KernelManager2, shutdown +from jupyter_client.client2_ioloop import ClientInThread +from .utils import test_env + +from ipython_genutils.py3compat import string_types +from IPython.utils.capture import capture_output + +TIMEOUT = 30 + + +class TestKernelClient(TestCase): + def setUp(self): + self.env_patch = test_env() + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + # Start a client in a new thread, put received messages in queues. + self.km = KernelManager2(make_ipkernel_cmd(), cwd='.') + self.kc = ClientInThread(self.km.get_connection_info(), manager=self.km) + self.received = {'shell': Queue(), 'iopub': Queue()} + self.kc.start() + if not self.kc.started.wait(10.0): + raise RuntimeError("Failed to start kernel client") + self.kc.add_handler('shell', self.received['shell'].put) + self.kc.add_handler('iopub', self.received['iopub'].put) + + + def tearDown(self): + shutdown(self.kc, self.km) + self.kc.close() + self.env_patch.stop() + + def _check_reply(self, reply_type, reply): + self.assertIsInstance(reply, dict) + self.assertEqual(reply['header']['msg_type'], reply_type + '_reply') + self.assertEqual(reply['parent_header']['msg_type'], + reply_type + '_request') + + def test_history(self): + kc = self.kc + msg_id = kc.history(session=0) + self.assertIsInstance(msg_id, string_types) + reply = self.received['shell'].get(timeout=TIMEOUT) + self._check_reply('history', reply) + + def test_inspect(self): + kc = self.kc + msg_id = kc.inspect('who cares') + self.assertIsInstance(msg_id, string_types) + reply = self.received['shell'].get(timeout=TIMEOUT) + self._check_reply('inspect', reply) + + def test_complete(self): + kc = self.kc + msg_id = kc.complete('who cares') + self.assertIsInstance(msg_id, string_types) + reply = self.received['shell'].get(timeout=TIMEOUT) + self._check_reply('complete', reply) + + def test_kernel_info(self): + kc = self.kc + msg_id = kc.kernel_info() + self.assertIsInstance(msg_id, string_types) + reply = self.received['shell'].get(timeout=TIMEOUT) + self._check_reply('kernel_info', reply) + + def test_comm_info(self): + kc = self.kc + msg_id = kc.comm_info() + self.assertIsInstance(msg_id, string_types) + reply = self.received['shell'].get(timeout=TIMEOUT) + self._check_reply('comm_info', reply) + + def test_shutdown(self): + kc = self.kc + msg_id = kc.shutdown() + self.assertIsInstance(msg_id, string_types) + reply = self.received['shell'].get(timeout=TIMEOUT) + self._check_reply('shutdown', reply) diff --git a/jupyter_client/tests/test_discovery.py b/jupyter_client/tests/test_discovery.py new file mode 100644 index 000000000..58d680a30 --- /dev/null +++ b/jupyter_client/tests/test_discovery.py @@ -0,0 +1,61 @@ +import sys + +from jupyter_client import discovery +from jupyter_client.manager2 import KernelManager2ABC + +def test_ipykernel_provider(): + import ipykernel # Fail clearly if ipykernel not installed + ikf = discovery.IPykernelProvider() + + res = list(ikf.find_kernels()) + assert len(res) == 1, res + id, info = res[0] + assert id == 'kernel' + assert info['argv'][0] == sys.executable + +class DummyKernelProvider(discovery.KernelProviderBase): + """A dummy kernel provider for testing KernelFinder""" + id = 'dummy' + + def find_kernels(self): + yield 'sample', {'argv': ['dummy_kernel']} + + def launch(self, name, cwd=None): + return DummyKernelManager() + +class DummyKernelManager(KernelManager2ABC): + _alive = True + def is_alive(self): + """Check whether the kernel is currently alive (e.g. the process exists) + """ + return self._alive + + def wait(self, timeout): + """Wait for the kernel process to exit. + """ + return False + + def signal(self, signum): + """Send a signal to the kernel.""" + pass + + def interrupt(self): + pass + + def kill(self): + self._alive = False + + def get_connection_info(self): + """Return a dictionary of connection information""" + return {} + + def relaunch(self): + return True + +def test_meta_kernel_finder(): + kf = discovery.KernelFinder(providers=[DummyKernelProvider()]) + assert list(kf.find_kernels()) == \ + [('dummy/sample', {'argv': ['dummy_kernel']})] + + launcher = kf.launch('dummy/sample') + assert isinstance(launcher, DummyKernelManager) diff --git a/jupyter_client/tests/test_kernelapp.py b/jupyter_client/tests/test_kernelapp.py new file mode 100644 index 000000000..2533472d4 --- /dev/null +++ b/jupyter_client/tests/test_kernelapp.py @@ -0,0 +1,67 @@ +from __future__ import division + +import os +import shutil +from subprocess import Popen, PIPE +import sys +from tempfile import mkdtemp +import time + +PY3 = sys.version_info[0] >= 3 + +def _launch(extra_env): + env = os.environ.copy() + env.update(extra_env) + return Popen([sys.executable, '-c', + 'from jupyter_client.kernelapp import main; main()'], + env=env, stderr=(PIPE if PY3 else None)) + +WAIT_TIME = 10 +POLL_FREQ = 10 + +def hacky_wait(p): + """Python 2 subprocess doesn't have timeouts :-(""" + for _ in range(WAIT_TIME * POLL_FREQ): + if p.poll() is not None: + return p.returncode + time.sleep(1 / POLL_FREQ) + else: + raise AssertionError("Process didn't exit in {} seconds" + .format(WAIT_TIME)) + +def test_kernelapp_lifecycle(): + # Check that 'jupyter kernel' starts and terminates OK. + runtime_dir = mkdtemp() + startup_dir = mkdtemp() + started = os.path.join(startup_dir, 'started') + try: + p = _launch({'JUPYTER_RUNTIME_DIR': runtime_dir, + 'JUPYTER_CLIENT_TEST_RECORD_STARTUP_PRIVATE': started, + }) + # Wait for start + for _ in range(WAIT_TIME * POLL_FREQ): + if os.path.isfile(started): + break + time.sleep(1 / POLL_FREQ) + else: + raise AssertionError("No started file created in {} seconds" + .format(WAIT_TIME)) + + # Connection file should be there by now + files = os.listdir(runtime_dir) + assert len(files) == 1 + cf = files[0] + assert cf.startswith('kernel') + assert cf.endswith('.json') + + # Send SIGTERM to shut down + p.terminate() + if PY3: + _, stderr = p.communicate(timeout=WAIT_TIME) + assert cf in stderr.decode('utf-8', 'replace') + else: + hacky_wait(p) + finally: + shutil.rmtree(runtime_dir) + shutil.rmtree(startup_dir) + diff --git a/jupyter_client/tests/test_manager2.py b/jupyter_client/tests/test_manager2.py new file mode 100644 index 000000000..cf38b1ec0 --- /dev/null +++ b/jupyter_client/tests/test_manager2.py @@ -0,0 +1,86 @@ +"""Tests for KernelManager2""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import os + +pjoin = os.path.join +import signal +import sys +import time +from unittest import TestCase + +from ipykernel.kernelspec import make_ipkernel_cmd +from jupyter_client.manager2 import ( + KernelManager2, run_kernel, start_new_kernel, shutdown +) +from .utils import test_env, skip_win32 + +TIMEOUT = 30 + +SIGNAL_KERNEL_CMD = [sys.executable, '-m', 'jupyter_client.tests.signalkernel', + '-f', '{connection_file}'] + +class TestKernelManager(TestCase): + def setUp(self): + self.env_patch = test_env() + self.env_patch.start() + + def tearDown(self): + self.env_patch.stop() + + def test_get_connect_info(self): + km = KernelManager2(make_ipkernel_cmd(), os.getcwd()) + try: + self.assertEqual(set(km.connection_info.keys()), { + 'ip', 'transport', + 'hb_port', 'shell_port', 'stdin_port', 'iopub_port', 'control_port', + 'key', 'signature_scheme', + }) + finally: + km.kill() + km.cleanup() + + @skip_win32 + def test_signal_kernel_subprocesses(self): + with run_kernel(SIGNAL_KERNEL_CMD, startup_timeout=5) as kc: + def execute(cmd): + reply = kc.execute(cmd, reply=True) + content = reply['content'] + self.assertEqual(content['status'], 'ok') + return content + + N = 5 + for i in range(N): + execute("start") + time.sleep(1) # make sure subprocs stay up + reply = execute('check') + self.assertEqual(reply['user_expressions']['poll'], [None] * N) + + # start a job on the kernel to be interrupted + kc.execute('sleep') + time.sleep(1) # ensure sleep message has been handled before we interrupt + kc.interrupt() + reply = kc.get_shell_msg(TIMEOUT) + content = reply['content'] + self.assertEqual(content['status'], 'ok') + self.assertEqual(content['user_expressions']['interrupted'], True) + # wait up to 5s for subprocesses to handle signal + for i in range(50): + reply = execute('check') + if reply['user_expressions']['poll'] != [-signal.SIGINT] * N: + time.sleep(0.1) + else: + break + # verify that subprocesses were interrupted + self.assertEqual(reply['user_expressions']['poll'], + [-signal.SIGINT] * N) + + def test_start_new_kernel(self): + km, kc = start_new_kernel(make_ipkernel_cmd(), startup_timeout=5) + try: + self.assertTrue(km.is_alive()) + self.assertTrue(kc.is_alive()) + finally: + shutdown(kc, km) diff --git a/jupyter_client/tests/test_restarter2.py b/jupyter_client/tests/test_restarter2.py new file mode 100644 index 000000000..2a8527d63 --- /dev/null +++ b/jupyter_client/tests/test_restarter2.py @@ -0,0 +1,37 @@ +from .test_discovery import DummyKernelManager, DummyKernelProvider + +from jupyter_client import discovery, restarter2 + +def test_reinstantiate(): + # If the kernel fails before the first poll, a new manager should be + # instantiated + kf = discovery.KernelFinder(providers=[DummyKernelProvider()]) + manager = kf.launch('dummy/sample') + manager.kill() + + restarter = restarter2.KernelRestarterBase(manager, 'dummy/sample', + kernel_finder=kf) + assert restarter.kernel_manager is manager + restarter.poll() + assert restarter.kernel_manager is not manager + assert restarter.kernel_manager.is_alive() + +def test_relaunch(): + # If the kernel fails after the first poll, its manager's relaunch() method + # should be called. + kf = discovery.KernelFinder(providers=[DummyKernelProvider()]) + manager = kf.launch('dummy/sample') + relaunch_count = [0] + def relaunch(): + relaunch_count[0] += 1 + manager.relaunch = relaunch + + restarter = restarter2.KernelRestarterBase(manager, 'dummy/sample', + kernel_finder=kf) + restarter.poll() + assert relaunch_count[0] == 0 + # Kernel dies after first poll + manager.kill() + restarter.poll() + assert relaunch_count[0] == 1 + assert restarter.kernel_manager is manager diff --git a/jupyter_client/tests/test_session.py b/jupyter_client/tests/test_session.py index 43819a898..e80274367 100644 --- a/jupyter_client/tests/test_session.py +++ b/jupyter_client/tests/test_session.py @@ -8,6 +8,10 @@ import sys import uuid from datetime import datetime +try: + from unittest import mock +except ImportError: + import mock import pytest @@ -34,6 +38,14 @@ def setUp(self): self.session = ss.Session() +@pytest.fixture +def no_copy_threshold(): + """Disable zero-copy optimizations in pyzmq >= 17""" + with mock.patch.object(zmq, 'COPY_THRESHOLD', 1): + yield + + +@pytest.mark.usefixtures('no_copy_threshold') class TestSession(SessionTestCase): def test_msg(self): diff --git a/jupyter_client/util.py b/jupyter_client/util.py new file mode 100644 index 000000000..4e4681f24 --- /dev/null +++ b/jupyter_client/util.py @@ -0,0 +1,6 @@ +def inherit_docstring(cls): + def decorator(func): + doc = getattr(cls, func.__name__).__doc__ + func.__doc__ = doc + return func + return decorator diff --git a/scripts/jupyter-kernel b/scripts/jupyter-kernel new file mode 100755 index 000000000..31144d405 --- /dev/null +++ b/scripts/jupyter-kernel @@ -0,0 +1,5 @@ +#!/usr/bin/env python +from jupyter_client.kernelapp import main + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index 341af7fb2..233f83a0e 100644 --- a/setup.py +++ b/setup.py @@ -82,9 +82,12 @@ def run(self): 'jupyter_core', 'pyzmq>=13', 'python-dateutil>=2.1', + 'entrypoints', ], extras_require = { - 'test': ['ipykernel', 'ipython', 'mock', 'pytest'], + 'test': ['ipykernel', 'ipython', 'mock'], + 'test:python_version == "3.3"': ['pytest<3.3.0'], + 'test:python_version >= "3.4" or python_version == "2.7"': ['pytest'], }, cmdclass = { 'bdist_egg': bdist_egg if 'bdist_egg' in sys.argv else bdist_egg_disabled, @@ -93,6 +96,11 @@ def run(self): 'console_scripts': [ 'jupyter-kernelspec = jupyter_client.kernelspecapp:KernelSpecApp.launch_instance', 'jupyter-run = jupyter_client.runapp:RunApp.launch_instance', + 'jupyter-kernel = jupyter_client.kernelapp:main', + ], + 'jupyter_client.kernel_providers' : [ + 'spec = jupyter_client.discovery:KernelSpecProvider', + 'pyimport = jupyter_client.discovery:IPykernelProvider', ] }, )