Repository: jupyter/jupyter_client
Pull request #240 (2017-02-10 16:59:04)

Add 'jupyter kernel' command
A simple lead in to the 'kernel nanny' work, this adds a command so you can do:

```
jupyter kernel --kernel python
```

I intend this for post-5.0.

rgbkrk: Could this be tested?
minrk: Very nice! I'd love a test to get basic exercise of the signal stuff.
rolweber: Is this related to issue #32?
takluyver: Yep, it is. I've basically stopped trying to work on everything related to the 'kernel nanny' proposal (https://github.com/jupyter/enhancement-proposals/pull/14) for now, though, because I don't have a good enough feel for the problem.

This will probably also need to be updated in light of #261, if that goes forward.

Patch:
diff --git a/jupyter_client/kernelapp.py b/jupyter_client/kernelapp.py
new file mode 100644
index 0000000..a2ab178
--- /dev/null
+++ b/jupyter_client/kernelapp.py
@@ -0,0 +1,81 @@
+import os
+import signal
+import uuid
+
+from jupyter_core.application import JupyterApp, base_flags
+from tornado.ioloop import IOLoop
+from traitlets import Unicode
+
+from . import __version__
+from .kernelspec import KernelSpecManager, NATIVE_KERNEL_NAME
+from .manager import KernelManager
+
+class KernelApp(JupyterApp):
+    """Launch a kernel by name in a local subprocess.
+    """
+    version = __version__
+    description = "Run a kernel locally in a subprocess"
+
+    classes = [KernelManager, KernelSpecManager]
+
+    aliases = {
+        'kernel': 'KernelApp.kernel_name',
+        'ip': 'KernelManager.ip',
+    }
+    flags = {'debug': base_flags['debug']}
+
+    kernel_name = Unicode(NATIVE_KERNEL_NAME,
+        help = 'The name of a kernel type to start'
+    ).tag(config=True)
+
+    def initialize(self, argv=None):
+        super(KernelApp, self).initialize(argv)
+        self.km = KernelManager(kernel_name=self.kernel_name,
+                                config=self.config)
+        cf_basename = 'kernel-%s.json' % uuid.uuid4()
+        self.km.connection_file = os.path.join(self.runtime_dir, cf_basename)
+        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)
+        self.km.shutdown_kernel()
+        self.loop.stop()
+
+    def log_connection_info(self):
+        cf = self.km.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)
+        try:
+            self.km.start_kernel()
+            self.log_connection_info()
+            self.setup_signals()
+            self.loop.start()
+        finally:
+            self.km.cleanup()
+
+
+main = KernelApp.launch_instance
diff --git a/scripts/jupyter-kernel b/scripts/jupyter-kernel
new file mode 100755
index 0000000..31144d4
--- /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 f042f00..233f83a 100644
--- a/setup.py
+++ b/setup.py
@@ -85,7 +85,9 @@ setup_args = dict(
         '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,
@@ -94,6 +96,7 @@ setup_args = dict(
         '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',


Test patch:
diff --git a/jupyter_client/tests/test_kernelapp.py b/jupyter_client/tests/test_kernelapp.py
new file mode 100644
index 0000000..2533472
--- /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)
+