Support Jupyter kernel servers

* jupyter-client.el (jupyter-initialize-connection): Elevate NOTE to FIXME.
(jupyter-kernel-info): Extend timeout.

* jupyter-kernel-manager.el (jupyter-shutdown-kernel):
(jupyter-interrupt-kernel): Allow any number of arguments.

* jupyter-kernelspec.el (jupyter-find-kernelspecs): Optionally allow specs.

* jupyter-messages.el (jupyter-encode-raw-message): New function.

* jupyter-rest-api.el (jupyter-api-auth-headers): New function.

* jupyter-server-ioloop.el: New file.

* jupyter-server.el: New file.

* test/jupyter-test.el (jupyter-server):
(jupyter-server-kernel-manager):
(jupyter-server-start-new-kernel): New tests.
This commit is contained in:
Nathaniel Nicandro 2019-05-31 14:55:00 -05:00 committed by Nathaniel Nicandro
parent 9df340bd0f
commit 67831c08fa
8 changed files with 1130 additions and 38 deletions

View file

@ -275,7 +275,7 @@ See `jupyter-initialize-connection'."
(list info-or-session (list info-or-session
'(or jupyter-session-p json-plist-p stringp)))))) '(or jupyter-session-p json-plist-p stringp))))))
;; NOTE: This requires that CLIENT is communicating with a kernel using a ;; FIXME: This requires that CLIENT is communicating with a kernel using a
;; `jupyter-channel-ioloop-comm' object. ;; `jupyter-channel-ioloop-comm' object.
(cl-defmethod jupyter-initialize-connection ((client jupyter-kernel-client) info-or-session) (cl-defmethod jupyter-initialize-connection ((client jupyter-kernel-client) info-or-session)
"Initialize CLIENT with connection INFO-OR-SESSION. "Initialize CLIENT with connection INFO-OR-SESSION.
@ -1859,6 +1859,9 @@ dispatching based on the kernel language."
(let* ((jupyter-inhibit-handlers t) (let* ((jupyter-inhibit-handlers t)
(req (jupyter-send-kernel-info-request client)) (req (jupyter-send-kernel-info-request client))
(msg (jupyter-wait-until-received :kernel-info-reply (msg (jupyter-wait-until-received :kernel-info-reply
;; Go to great lengths to ensure we have waited long
;; enough. When communicating with slow to start kernels
;; behind a kernel server this is necessary.
req (* 3 jupyter-long-timeout) "Requesting kernel info..."))) req (* 3 jupyter-long-timeout) "Requesting kernel info...")))
(unless msg (unless msg
(error "Kernel did not respond to kernel-info request")) (error "Kernel did not respond to kernel-info request"))

View file

@ -380,7 +380,7 @@ connect to MANAGER's kernel."
(jupyter-stop-channel channel) (jupyter-stop-channel channel)
(oset manager control-channel nil))) (oset manager control-channel nil)))
(cl-defgeneric jupyter-shutdown-kernel ((manager jupyter-kernel-manager) &optional restart timeout) (cl-defgeneric jupyter-shutdown-kernel ((manager jupyter-kernel-manager) &rest args)
"Shutdown MANAGER's kernel or restart instead if RESTART is non-nil. "Shutdown MANAGER's kernel or restart instead if RESTART is non-nil.
Wait until TIMEOUT before forcibly shutting down the kernel.") Wait until TIMEOUT before forcibly shutting down the kernel.")
@ -418,7 +418,7 @@ channel is stopped unless RESTART is non-nil."
(jupyter-start-kernel manager) (jupyter-start-kernel manager)
(jupyter-stop-channels manager))))) (jupyter-stop-channels manager)))))
(cl-defgeneric jupyter-interrupt-kernel ((manager jupyter-kernel-manager) &optional timeout) (cl-defgeneric jupyter-interrupt-kernel ((manager jupyter-kernel-manager) &rest args)
"Interrupt MANAGER's kernel. "Interrupt MANAGER's kernel.
When the kernel has an interrupt mode of \"message\" send an When the kernel has an interrupt mode of \"message\" send an
interrupt request and wait until TIMEOUT for a reply.") interrupt request and wait until TIMEOUT for a reply.")

View file

@ -92,7 +92,7 @@ REFRESH has the same meaning as in
`jupyter-available-kernelspecs'." `jupyter-available-kernelspecs'."
(cdr (assoc name (jupyter-available-kernelspecs refresh)))) (cdr (assoc name (jupyter-available-kernelspecs refresh))))
(defun jupyter-find-kernelspecs (re &optional refresh) (defun jupyter-find-kernelspecs (re &optional specs refresh)
"Find all specs of kernels that have names matching matching RE. "Find all specs of kernels that have names matching matching RE.
RE is a regular expression use to match the name of a kernel. RE is a regular expression use to match the name of a kernel.
Return an alist with elements of the form: Return an alist with elements of the form:
@ -104,15 +104,22 @@ DIRECTORY is the kernel's resource directory, and PLIST is the
kernelspec propery list read from the \"kernel.json\" file in the kernelspec propery list read from the \"kernel.json\" file in the
resource directory. resource directory.
If SPECS is non-nil search SPECS, otherwise search the specs
returned by `jupyter-available-kernelspecs'.
Optional argument REFRESH has the same meaning as in Optional argument REFRESH has the same meaning as in
`jupyter-available-kernelspecs'." `jupyter-available-kernelspecs'."
(delq nil (mapcar (lambda (s) (and (string-match-p re (car s)) s)) (cl-remove-if-not
(jupyter-available-kernelspecs refresh)))) (lambda (s) (string-match-p re (car s)))
(or specs (jupyter-available-kernelspecs refresh))))
(defun jupyter-guess-kernelspec (name) (defun jupyter-guess-kernelspec (name &optional specs refresh)
"Return the first kernelspec matching NAME. "Return the first kernelspec matching NAME.
Raise an error if no kernelspec could be found." Raise an error if no kernelspec could be found.
(or (car (jupyter-find-kernelspecs name))
SPECS and REFRESH have the same meaning as in
`jupyter-find-kernelspecs'."
(or (car (jupyter-find-kernelspecs name specs refresh))
(error "No valid kernelspec for kernel name (%s)" name))) (error "No valid kernelspec for kernel name (%s)" name)))
(defun jupyter-completing-read-kernelspec (&optional specs refresh) (defun jupyter-completing-read-kernelspec (&optional specs refresh)

View file

@ -239,6 +239,52 @@ The returned object has the same form as the object returned by
"Encode TIME into an ISO 8601 time string." "Encode TIME into an ISO 8601 time string."
(format-time-string "%FT%T.%6N" time t)) (format-time-string "%FT%T.%6N" time t))
(cl-defun jupyter-encode-raw-message (session
type
&rest plist
&key
content
(msg-id (jupyter-new-uuid))
parent-header
metadata
buffers
&allow-other-keys)
"Encode a message into a JSON string.
Similar to `jupyter-encode-message', but returns the JSON encoded
string instead of a list of the encoded parts.
PLIST is an extra property list added to the top level of the
message before encoding."
(declare (indent 2))
(cl-check-type session jupyter-session)
(cl-check-type metadata json-plist)
(cl-check-type content json-plist)
(cl-check-type parent-header json-plist)
(cl-check-type buffers list)
(or content (setq content jupyter--empty-dict))
(or parent-header (setq parent-header jupyter--empty-dict))
(or metadata (setq metadata jupyter--empty-dict))
(or buffers (setq buffers []))
(let (fplist)
(while plist
(cond
((memq (car plist)
'(:content :parent-header :metadata :buffers :msg-id))
(pop plist)
(pop plist))
(t
(push (prog1 (pop plist)
(push (pop plist) fplist))
fplist))))
(jupyter--encode
(cl-list*
:parent_header parent-header
:header (jupyter--message-header session type msg-id)
:content content
:metadata metadata
:buffers buffers
fplist))))
(cl-defun jupyter-encode-message (session (cl-defun jupyter-encode-message (session
type type
&key idents &key idents

View file

@ -39,6 +39,7 @@
(eval-when-compile (require 'subr-x)) (eval-when-compile (require 'subr-x))
(require 'jupyter-base) (require 'jupyter-base)
(require 'websocket) (require 'websocket)
(require 'url)
(declare-function jupyter-decode-time "jupyter-messages") (declare-function jupyter-decode-time "jupyter-messages")
@ -71,7 +72,9 @@ operation of a `jupyter-rest-client'."
`(progn `(progn
(require 'url) (require 'url)
(url-do-setup) (url-do-setup)
(url-cookie-parse-file) ;; Don't save any cookies or history in a subprocess
(ignore-errors (cancel-timer url-history-timer))
(ignore-errors (cancel-timer url-cookie-timer))
(push ,(file-name-directory (locate-library "jupyter-base")) load-path) (push ,(file-name-directory (locate-library "jupyter-base")) load-path)
(push ,(file-name-directory (locate-library "websocket")) load-path) (push ,(file-name-directory (locate-library "websocket")) load-path)
(require 'jupyter-rest-api) (require 'jupyter-rest-api)
@ -391,19 +394,22 @@ Raise an error on failure."
(cl-check-type client jupyter-rest-client) (cl-check-type client jupyter-rest-client)
(unless jupyter-api--authentication-in-progress-p (unless jupyter-api--authentication-in-progress-p
(let ((jupyter-api--authentication-in-progress-p t)) (let ((jupyter-api--authentication-in-progress-p t))
(url-do-setup)
(with-slots (auth url) client (with-slots (auth url) client
(unless (or (listp auth) (unless (or (listp auth)
(not (memq auth '(ask token password)))) (not (memq auth '(ask token password))))
(when (eq auth 'ask) (when (eq auth 'ask)
;; Check to see if the server is accessible first ;; Check to see if the server is accessible first by trying to
;; access the login page and checking if we can make an
;; authenticated request afterwards.
(let ((jupyter-api-max-authentication-attempts 1))
(jupyter-api-password-authenticator client
(lambda (_) "")))
(if (jupyter-api-server-accessible-p client) (if (jupyter-api-server-accessible-p client)
;; Get the _xsrf cookie if we don't have it already. (oset client auth t)
(progn (when noninteractive
(unless (jupyter-api-xsrf-header-from-cookies url) (signal 'jupyter-api-login-failed
(let ((jupyter-api-max-authentication-attempts 1)) (list "Can't authenticate non-interactively")))
(jupyter-api-password-authenticator client
(lambda (_) ""))))
(oset client auth t))
(cond (cond
((y-or-n-p (format "Token authenticated [%s]? " url)) ((y-or-n-p (format "Token authenticated [%s]? " url))
(oset client auth 'token)) (oset client auth 'token))
@ -486,26 +492,24 @@ As a special case, if METHOD is \"WS\", a websocket will be
opened using the REST api url and PLIST will be used in a call to opened using the REST api url and PLIST will be used in a call to
`websocket-open'." `websocket-open'."
(jupyter-api--ensure-authenticated client) (jupyter-api--ensure-authenticated client)
(let* ((xsrf (jupyter-api-xsrf-header-from-cookies (oref client url))) (let* ((url-request-extra-headers
(url-request-extra-headers url-request-extra-headers) (append (jupyter-api-auth-headers client)
(jupyter-api-xsrf-header-from-cookies (oref client url))
url-request-extra-headers))
(jupyter-api-url (if (equal method "WS") (jupyter-api-url (if (equal method "WS")
(oref client ws-url) (oref client ws-url)
(oref client url)))) (oref client url))))
(if (equal method "WS") (when (equal method "WS")
(let ((head plist)) (let ((head plist))
(while (and head (not (keywordp (car head)))) (while (and head (not (keywordp (car head))))
(pop head)) (pop head))
(setq head (or (plist-member head :custom-header-alist) (setq head (or (plist-member head :custom-header-alist)
(setcdr (last plist) (setcdr (last plist)
(list :custom-header-alist nil)))) (list :custom-header-alist nil))))
(let ((cur (plist-get head :custom-header-alist))) (plist-put head :custom-header-alist
(plist-put head :custom-header-alist (append
(append xsrf (plist-get head :custom-header-alist)
(jupyter-api-auth-headers client) url-request-extra-headers))))
cur))))
(cl-callf2 append
(append xsrf (jupyter-api-auth-headers client))
url-request-extra-headers))
(apply #'jupyter-api--request method plist))) (apply #'jupyter-api--request method plist)))
;;; Endpoints ;;; Endpoints

225
jupyter-server-ioloop.el Normal file
View file

@ -0,0 +1,225 @@
;;; jupyter-server-ioloop.el --- Kernel server communication -*- lexical-binding: t -*-
;; Copyright (C) 2019 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 03 Apr 2019
;; Version: 0.7.3
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 2, or (at
;; your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs; see the file COPYING. If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.
;;; Commentary:
;; A `jupyter-server-ioloop' launches websocket connections in order to
;; communicate with a kernel server via the Jupyter messaging protocol. You can
;; tell the ioloop to establish a websocket connection to a particular kernel
;; by sending a connect-channels event with the websocket URL and kernel ID.
;;
;; (jupyter-send ioloop 'connect-channels "id")
;;
;; A connect-channels event will be emitted back to the parent process with the
;; ID of the kernel in response.
;;
;; To stop a websocket connection, a disconnect-channels event can be sent,
;; passing the kernel ID.
;;
;; (jupyter-send ioloop 'disconnect-channels "id")
;;
;; A disconnect-channels event will also be emitted back to the parent process
;; with the ID of the kernel.
;;
;; Finally, a `jupyter-server-ioloop' behaves as a `jupyter-channel-ioloop'
;; when sent a `send' event. That is it will emit a `sent' event after every
;; `send' and when a message is received from the kernel will emit a `message'
;; event. When sending a `send' event, the format is the same as a
;; `jupyter-channel-ioloop' except that the kernel ID must be first argument.
;;
;; (jupyter-send ioloop 'send "id" ...)
;;
;; Similarly, when the parent process receives a `message' or `sent' event, the
;; first argument will be the kernel ID
;;
;; (message "id" ...) or (sent "id" ...)
;;; Code:
(require 'jupyter-ioloop)
(require 'jupyter-messages)
(require 'jupyter-rest-api)
(require 'websocket)
(defvar jupyter-server-recvd-messages nil)
(defvar jupyter-server-connected-kernels nil)
(defvar jupyter-server-rest-client nil)
(defclass jupyter-server-ioloop (jupyter-ioloop)
;; TODO: Clean this up by removing the need for these and just setting these
;; values in `jupyter-ioloop-start' similar to the `jupyter-channel-ioloop'.
((url :type string :initarg :url)
(ws-url
:type string
:initarg :ws-url
:documentation "The URL to connect websockets to.")
(ws-headers
:type (list-of cons)
:initform nil
:initarg :ws-headers
:documentation "Headers that will be passed to the websocket connections.
Has the same format as `url-request-extra-headers'."))
:documentation "A `jupyter-ioloop' configured for communication using websockets.
A websocket can be opened by sending the connect-channels event
with the websocket url and the kernel-id of the kernel to connect
to, e.g.
\(jupyter-send ioloop 'connect-channels \"kernel-id\")
Also implemented is the send event which takes the same arguments
as the send event of a `jupyter-channel-ioloop' except the
kernel-id must be the first element, e.g.
\(jupyter-send ioloop 'send \"kernel-id\" ...)
Events that are emitted to the parent process are the message
event, also the same as the event in `jupyter-channel-ioloop'
except with a kernel-id as the first element. And a
disconnected-channels event that occurs whenever a websocket is
closed, the event has the kernel-id of the associated with the
websocket.")
(cl-defmethod initialize-instance ((ioloop jupyter-server-ioloop) &optional _slots)
(cl-call-next-method)
(cl-callf append (oref ioloop setup)
`((jupyter-api-with-subprocess-setup
(require 'jupyter-server-ioloop)
(push 'jupyter-server-ioloop--recv-messages jupyter-ioloop-pre-hook)
(setq jupyter-server-rest-client (jupyter-rest-client
:url ,(oref ioloop url)
:ws-url ,(oref ioloop ws-url)
:auth (quote ,(oref ioloop ws-headers)))))))
(jupyter-server-ioloop-add-send-event ioloop)
(jupyter-server-ioloop-add-connect-channels-event ioloop)
(jupyter-server-ioloop-add-disconnect-channels-event ioloop))
;;; Receiving messages on a websocket
;; Added to `jupyter-ioloop-pre-hook'
(defun jupyter-server-ioloop--recv-messages ()
;; A negative value of seconds means to return immediately if there was
;; nothing that could be read from subprocesses. See `Faccept_process_output'
;; and `wait_reading_process_output'.
(accept-process-output nil -1)
(when jupyter-server-recvd-messages
(mapc (lambda (msg) (prin1 (cons 'message msg)))
(nreverse jupyter-server-recvd-messages))
(setq jupyter-server-recvd-messages nil)
(zmq-flush 'stdout)))
(defun jupyter-server-ioloop--on-message (ws frame)
(cl-case (websocket-frame-opcode frame)
((text binary)
(condition-case err
(let* ((msg (jupyter-read-plist-from-string
(websocket-frame-payload frame)))
(channel (intern (concat ":" (plist-get msg :channel))))
(msg-type (jupyter-message-type-as-keyword
(jupyter-message-type msg)))
(parent-header (plist-get msg :parent_header)))
;; Convert into keyword since that is what is expected
(plist-put msg :msg_type msg-type)
(plist-put parent-header :msg_type msg-type)
;; websocket-client-data = kernel-id
(push (cons (websocket-client-data ws)
;; NOTE: The nil is the identity field expected by a
;; `jupyter-channel-ioloop', it is mimicked here.
(cons channel (cons nil msg)))
jupyter-server-recvd-messages))
(error
(zmq-prin1 (cons 'error (list (car err)
(format "%S" (cdr err))))))))
(t (zmq-prin1 (cons 'error (format "Unhandled websocket frame %s"
(websocket-frame-opcode frame)))))))
(defun jupyter-server-ioloop--on-error (_ws type error)
(zmq-prin1 (cons 'error (list 'websocket-error type
(format "%S" (cdr error))))))
(defun jupyter-server-ioloop--disconnect (ws)
(websocket-close ws)
(cl-callf2 delq ws jupyter-server-connected-kernels))
(defun jupyter-server-ioloop--connect (kernel-id)
(let ((ws (jupyter-api-get-kernel-ws
jupyter-server-rest-client kernel-id
:on-error #'jupyter-server-ioloop--on-error
:on-message #'jupyter-server-ioloop--on-message)))
(push ws jupyter-server-connected-kernels)))
(defun jupyter-server-ioloop--kernel-ws (kernel-id)
(cl-find-if
(lambda (ws) (equal kernel-id (websocket-client-data ws)))
jupyter-server-connected-kernels))
;;; IOLoop events
(defvar jupyter-server--dummy-session (jupyter-session :id ""))
(defun jupyter-server-ioloop-add-send-event (ioloop)
(jupyter-ioloop-add-event
ioloop send (kernel-id channel msg-type msg msg-id)
(let ((ws (jupyter-server-ioloop--kernel-ws kernel-id)))
(unless ws
(error "Kernel with ID (%s) not connected" kernel-id))
(websocket-send-text
ws (jupyter-encode-raw-message
jupyter-server--dummy-session msg-type
:channel (substring (symbol-name channel) 1)
:msg-id msg-id
:content msg))
(jupyter-server-ioloop--recv-messages)
(list 'sent kernel-id channel msg-id))))
(defun jupyter-server-ioloop-add-connect-channels-event (ioloop)
(jupyter-ioloop-add-event ioloop connect-channels (kernel-id)
(let ((ws (jupyter-server-ioloop--kernel-ws kernel-id)))
(unless ws
;; NOTE: Authentication of the client happens in the parent process or
;; through the Authorization header set in the :auth slot of the client.
;; In the case of the parent process doing the authentication, cookies
;; are written to `url-cookie-file' and read from this subprocess by the
;; websocket code.
(url-cookie-parse-file)
(jupyter-server-ioloop--connect kernel-id)))
;; Ensure any pending messages are handled, since usually we synchronize on
;; connect-channels events, we want this event to be the
;; `jupyter-ioloop-last-event' so the waiting loop in the parent process
;; can capture it.
(jupyter-server-ioloop--recv-messages)
(list 'connect-channels kernel-id)))
(defun jupyter-server-ioloop-add-disconnect-channels-event (ioloop)
(jupyter-ioloop-add-event ioloop disconnect-channels (kernel-id)
(let ((ws (jupyter-server-ioloop--kernel-ws kernel-id)))
;; See the note at the end of
;; `jupyter-server-ioloop-add-connect-channels-event'
(jupyter-server-ioloop--recv-messages)
(when ws
(jupyter-server-ioloop--disconnect ws))
(list 'disconnect-channels kernel-id))))
(provide 'jupyter-server-ioloop)
;;; jupyter-server-ioloop.el ends here

701
jupyter-server.el Normal file
View file

@ -0,0 +1,701 @@
;;; jupyter-server.el --- Support for the Jupyter kernel servers -*- lexical-binding: t -*-
;; Copyright (C) 2019 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 02 Apr 2019
;; Version: 0.8.0
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 3, or (at
;; your option) any later version.
;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
;; General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs; see the file COPYING. If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.
;;; Commentary:
;; Overview of implementation
;;
;; A `jupyter-server' communicates with a Jupyter kernel server (either the
;; notebook or a kernel gateway) via the Jupyter REST API. Given the URL and
;; Websocket URL for the server, the `jupyter-server' object can launch kernels
;; using the function `jupyter-server-start-new-kernel'. The kernelspecs
;; available on the server can be accessed by calling
;; `jupyter-server-kernelspecs'.
;;
;; `jupyter-server-start-new-kernel' returns a list (KM KC) where KM is a
;; `jupyter-server-kernel-manager' and KC is a kernel client that can
;; communicate with the kernel managed by KM. `jupyter-server-kernel-manager'
;; sends requests to the server using the `jupyter-server' object to manage the
;; lifetime of the kernel and ensures that a websocket is opened so that kernel
;; clients created using `jupyter-make-client' can communicate with the kernel.
;;
;; Communication with the channels of the kernels that are launched on the
;; `jupyter-server' is established via a `jupyter-server-ioloop' which
;; multiplexes the channels of all the kernel servers. The kernel ID the server
;; associated with a kernel can then be used to filter messages for a
;; particular kernel and to send messages to a kernel through the
;; `jupyter-server-ioloop'.
;;
;; `jupyter-server-kernel-comm' is a `jupyter-comm-layer' that handles the
;; communication of a client with a server kernel. The job of the
;; `jupyter-server-kernel-comm' is to connect to the `jupyter-server's event
;; stream and filter the messages to handle those of a particular kernel
;; identified by kernel ID.
;;
;; Starting REPLs
;;
;; You can launch kernels without connecting clients to them by using
;; `jupyter-server-launch-kernel'. To connect a REPL to a launched kernel use
;; `jupyter-connect-server-repl'. To both launch and connect a REPL use
;; `jupyter-run-server-repl'. All of the previous commands determine the server
;; to use by using the `jupyter-current-server' function, which see.
;;
;; Managing kernels on a server
;;
;; To get an overview of all live kernels on a server you can call
;; `jupyter-server-list-kernels'. From the buffer displayed there are a number
;; of keys bound that enable you to manage the kernels on the server. See
;; `jupyter-server-kernel-list-mode-map'.
;;
;; TODO: Find where it would be appropriate to call `delete-instance' on a
;;`jupyter-server' that does not have any websockets open, clients connected,
;; or HTTP connections open, or is not bound to `jupyter-current-server' in any
;; buffer.
;;
;; TODO: Naming kernels in `jupyter-server-list-kernels' instead of using their
;; ID. The kernel ID is not very useful to quickly identify which kernel does
;; what, it would be more useful to be able to associate a name with a kernel
;; ID.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'jupyter-repl)
(require 'jupyter-rest-api)
(require 'jupyter-kernel-manager)
(require 'jupyter-ioloop-comm)
(require 'jupyter-server-ioloop)
(defgroup jupyter-server nil
"Support for the Jupyter kernel gateway"
:group 'jupyter)
(defvar-local jupyter-current-server nil
"The `jupyter-server' associated with the current buffer.
Used in, e.g. a `jupyter-server-kernel-list-mode' buffer.")
(put 'jupyter-current-server 'permanent-local t)
;;; Plumbing
(defvar jupyter--servers nil)
(defclass jupyter-server (jupyter-rest-client
jupyter-ioloop-comm
eieio-instance-tracker)
((tracking-symbol :initform 'jupyter--servers)
(kernelspecs
:type json-plist
:initform nil
:documentation "Kernelspecs for the kernels available behind this gateway.
Access should be done through `jupyter-available-kernelspecs'.")))
;; TODO: When to `delete-instance'? Or define a function so that the user can
;; do so.
(defun jupyter-servers ()
"Return a list of all `jupyter-server's."
jupyter--servers)
;; TODO: Add the server as a slot
(defclass jupyter-server-kernel (jupyter-meta-kernel)
((id
:type string
:initarg :id
:documentation "The kernel ID.")))
(cl-defmethod jupyter-kernel-alive-p ((kernel jupyter-server-kernel))
(slot-boundp kernel 'id))
(cl-defmethod jupyter-start-kernel ((kernel jupyter-server-kernel) server &rest _ignore)
(cl-check-type server jupyter-server)
(with-slots (spec) kernel
(jupyter-server--verify-kernelspec server spec)
(cl-destructuring-bind (&key id &allow-other-keys)
(jupyter-api-start-kernel server (car spec))
(oset kernel id id))))
(cl-defmethod jupyter-kill-kernel ((kernel jupyter-server-kernel))
(cl-call-next-method)
(slot-makeunbound kernel 'id))
(defclass jupyter-server-kernel-comm (jupyter-comm-layer)
((server :type jupyter-server :initarg :server)
(kernel :type jupyter-server-kernel :initarg :kernel)))
(cl-defmethod jupyter-comm-id ((comm jupyter-server-kernel-comm))
(format "kid=%s" (truncate-string-to-width
(thread-first comm
(oref kernel)
(oref id))
9 nil nil "")))
;;;; `jupyter-server' events
(cl-defmethod jupyter-event-handler ((comm jupyter-server)
(event (head disconnect-channels)))
(let ((kernel-id (cadr event)))
(jupyter-comm-client-loop comm client
(when (equal kernel-id (oref (oref client kernel) id))
(jupyter-comm-stop client)))
(with-slots (ioloop) comm
(cl-callf2 remove kernel-id
(process-get (oref ioloop process) :kernel-ids)))))
(cl-defmethod jupyter-event-handler ((comm jupyter-server)
(event (head connect-channels)))
(let ((kernel-id (cadr event)))
(with-slots (ioloop) comm
(cl-callf append (process-get (oref ioloop process) :kernel-ids)
(list kernel-id)))))
(cl-defmethod jupyter-event-handler ((comm jupyter-server) event)
"Send EVENT to all clients connected to COMM.
Each client must have a KERNEL slot which, in turn, must have an
ID slot. The second element of EVENT is expected to be a kernel
ID. Send EVENT, with the kernel ID excluded, to a client whose
kernel has a matching ID."
(let ((kernel-id (cadr event)))
(setq event (cons (car event) (cddr event)))
(jupyter-comm-client-loop comm client
(when (equal kernel-id (oref (oref client kernel) id))
;; TODO: Since the event handlers of CLIENT will eventually call the
;; `jupyter-handle-message' of a `jupyter-kernel-client' we really
;; don't need to do any filtering based off of a `jupyter-session-id',
;; but maybe should? The `jupyter-handle-message' method will only
;; handle messages that have a parent ID of a previous request so there
;; already is filtering at the kernel client level. I wonder if there
;; is any issue with having an empty session ID in the messages sent by
;; the `jupyter-server-ioloop', see `jupyter-server--dummy-session'.
(jupyter-event-handler client event)))))
;;;; `jupyter-server' methods
(cl-defmethod jupyter-comm-start ((comm jupyter-server))
(unless (and (slot-boundp comm 'ioloop)
(jupyter-ioloop-alive-p (oref comm ioloop)))
;; TODO: Is a write to the cookie file and then a read of the cookie file
;; whenever connecting a websocket in a subprocess good enough? If, e.g.
;; the notebook is restarted and it clears the login information, there are
;; sometimes error due to `jupyter-api-request' trying to ask for login
;; information which look like "wrong type argument listp, [http://...]".
;; They don't seem to happens with the changes mentioned, but is it enough?
(url-cookie-write-file)
(oset comm ioloop (jupyter-server-ioloop
:url (oref comm url)
:ws-url (oref comm ws-url)
:ws-headers (jupyter-api-auth-headers comm)))
(cl-call-next-method)))
(cl-defmethod jupyter-connect-client ((comm jupyter-server)
(kcomm jupyter-server-kernel-comm))
(with-slots (id) (oref kcomm kernel)
(cl-call-next-method)
(jupyter-send comm 'connect-channels id)
(unless (jupyter-ioloop-wait-until (oref comm ioloop)
'connect-channels #'identity)
(error "Timeout when connecting websocket to kernel id %s" id))))
(cl-defmethod jupyter-server-kernel-connected-p ((comm jupyter-server) id)
"Return non-nil if COMM has a WebSocket connection to a kernel with ID."
(and (jupyter-comm-alive-p comm)
(member id (process-get (oref (oref comm ioloop) process) :kernel-ids))))
(defun jupyter-server--verify-kernelspec (server spec)
(cl-destructuring-bind (name _ . kspec) spec
(let ((server-spec (assoc name (jupyter-server-kernelspecs server))))
(unless server-spec
(error "No kernelspec matching %s on server @ %s"
name (oref server url)))
(when (cl-loop
with sspec = (cddr server-spec)
for (k v) on sspec by #'cddr
thereis (not (equal (plist-get kspec k) v)))
(error "%s kernelspec doesn't match one on server @ %s"
name (oref server url))))))
(cl-defmethod jupyter-server-kernelspecs ((server jupyter-server) &optional refresh)
"Return the kernelspecs on SERVER.
By default the available kernelspecs are cached. To force an
update of the cached kernelspecs, give a non-nil value to
REFRESH.
The kernelspecs are returned in the same form as returned by
`jupyter-available-kernelspecs'."
(when (or refresh (null (oref server kernelspecs)))
(let ((specs (jupyter-api-get-kernelspec server)))
(unless specs
(error "Can't retrieve kernelspecs from server @ %s" (oref server url)))
(oset server kernelspecs specs)
(plist-put (oref server kernelspecs) :kernelspecs
(cl-loop
with specs = (plist-get specs :kernelspecs)
for (_ spec) on specs by #'cddr
;; Uses the same format as `jupyter-available-kernelspecs'
;; (name dir . spec)
collect (cons (plist-get spec :name)
(cons nil (plist-get spec :spec)))))))
(plist-get (oref server kernelspecs) :kernelspecs))
;;;; `jupyter-server-kernel-comm' methods
(cl-defmethod jupyter-comm-start ((comm jupyter-server-kernel-comm) &rest _ignore)
"Register COMM to receive server events.
If SERVER receives events that have the same kernel ID as the
kernel associated with COMM, then COMM's `jupyter-event-handler'
will receive those events."
(with-slots (server) comm
(jupyter-comm-start server)
(jupyter-connect-client server comm)))
(cl-defmethod jupyter-comm-stop ((comm jupyter-server-kernel-comm) &rest _ignore)
"Disconnect COMM from receiving server events."
(jupyter-disconnect-client (oref comm server) comm))
(cl-defmethod jupyter-send ((comm jupyter-server-kernel-comm) event-type &rest event)
"Use COMM to send an EVENT to the server with type, EVENT-TYPE.
SERVER will direct EVENT to the right kernel based on the kernel
ID of the kernel associated with COMM."
(with-slots (server kernel) comm
(apply #'jupyter-send server event-type (oref kernel id) event)))
(cl-defmethod jupyter-comm-alive-p ((comm jupyter-server-kernel-comm))
"Return non-nil if COMM can receive server events for its associated kernel."
(and (jupyter-server-kernel-connected-p
(oref comm server)
(oref (oref comm kernel) id))
(catch 'member
(jupyter-comm-client-loop (oref comm server) client
(when (eq client comm)
(throw 'member t))))))
;; TODO: Remove the need for these methods, they are remnants from an older
;; implementation. They will need to be removed from `jupyter-kernel-client'.
(cl-defmethod jupyter-channel-alive-p ((comm jupyter-server-kernel-comm) _channel)
(jupyter-comm-alive-p comm))
(cl-defmethod jupyter-channels-running-p ((comm jupyter-server-kernel-comm))
(jupyter-comm-alive-p comm))
;;;; `jupyter-server-kernel-manager'
(defclass jupyter-server-kernel-manager (jupyter-kernel-manager-base)
((server :type jupyter-server :initarg :server)
(kernel :type jupyter-server-kernel :initarg :kernel)
(comm :type jupyter-server-kernel-comm)))
(cl-defmethod jupyter-kernel-alive-p ((manager jupyter-server-kernel-manager))
(with-slots (server kernel) manager
(and (jupyter-kernel-alive-p kernel)
(ignore-errors (jupyter-api-get-kernel server (oref kernel id))))))
(cl-defmethod jupyter-start-kernel ((manager jupyter-server-kernel-manager) &rest _ignore)
"Ensure that the gateway can receive events from its kernel."
(with-slots (server kernel) manager
(jupyter-start-kernel kernel server)))
(cl-defmethod jupyter-interrupt-kernel ((manager jupyter-server-kernel-manager))
(with-slots (server kernel) manager
(jupyter-api-interrupt-kernel server (oref kernel id))))
(cl-defmethod jupyter-kill-kernel ((manager jupyter-server-kernel-manager))
(jupyter-shutdown-kernel manager))
;; TODO: Figure out if restarting a kernel keeps the kernel ID
(cl-defmethod jupyter-shutdown-kernel ((manager jupyter-server-kernel-manager) &optional restart _timeout)
(with-slots (server kernel comm) manager
(if restart (jupyter-api-restart-kernel server (oref kernel id))
(when (jupyter-comm-alive-p server)
;; Stop the communication of a `jupyter-server' with
;; `jupyter-server-kernel-comm's that have the associated kernel ID.
(jupyter-send server 'disconnect-channels (oref kernel id)))
(when (jupyter-kernel-alive-p manager)
(jupyter-api-shutdown-kernel server (oref kernel id))))))
(cl-defmethod jupyter-make-client ((manager jupyter-server-kernel-manager) _class &rest _slots)
(let ((client (cl-call-next-method)))
(prog1 client
(unless (slot-boundp manager 'comm)
(oset manager comm (jupyter-server-kernel-comm
:kernel (oref manager kernel)
:server (oref manager server)))
(jupyter-comm-start (oref manager comm)))
(oset client kcomm (oref manager comm)))))
;;; Finding exisisting kernel managers and servers
(defun jupyter-server-find-manager (server id)
"Return a kernel manager managing kernel with ID on SERVER.
Return nil if none could be found."
(cl-loop
for manager in (jupyter-kernel-managers)
thereis (and (cl-typep manager 'jupyter-server-kernel-manager)
(eq (oref manager server) server)
(jupyter-kernel-alive-p manager)
(equal (oref (oref manager kernel) id) id)
manager)))
(defun jupyter-find-server (url &optional ws-url)
"Return a live `jupyter-server' that lives at URL.
Finds a server that matches both URL and WS-URL. When WS-URL the
default set by `jupyter-rest-client' is used.
Return nil if no `jupyter-server' could be found."
(with-slots (url ws-url)
(apply #'make-instance 'jupyter-rest-client
(append (list :url url)
(when ws-url (list :ws-url ws-url))))
(cl-loop for server in (jupyter-servers)
thereis (and (equal (oref server url) url)
(equal (oref server ws-url) ws-url)
server))))
;;; Helpers for commands
(defun jupyter-completing-read-server-kernel (server)
"Use `completing-read' to select a kernel on SERVER.
A model of the kernel is returned as a property list and has at
least the following keys:
- :id :: The ID used to identify the kernel on the server
- :last_activity :: The last channel activity of the kernel
- :name :: The kernelspec name used to start the kernel
- :execution_state :: The status of the kernel
- :connections :: The number of websocket connections for the kernel"
(let* ((kernels (jupyter-api-get-kernel server))
(display-names
(if (null kernels) (error "No kernels @ %s" (oref server url))
(mapcar (lambda (k)
(cl-destructuring-bind
(&key name id last_activity &allow-other-keys) k
(concat name " (last activity: " last_activity ", id: " id ")")))
kernels)))
(name (completing-read "kernel: " display-names nil t)))
(when (equal name "")
(error "No kernel selected"))
(nth (- (length display-names)
(length (member name display-names)))
(append kernels nil))))
(defun jupyter-current-server (&optional ask)
"Return an existing `jupyter-server' or a new one.
If `jupyter-current-server' is non-nil, return its value.
Otherwise, return the most recently used server.
With a prefix argument, ASK to select one and set the selected
one as the most recently used.
If no servers exist, ask the user to create one and return its
value."
(interactive "P")
(let ((read-url-make-server
(lambda ()
(let ((url (read-string "Server URL: " "http://localhost:8888"))
(ws-url (read-string "Websocket URL: " "ws://localhost:8888")))
(or (jupyter-find-server url ws-url)
(jupyter-server :url url :ws-url ws-url))))))
(if ask (let ((server (funcall read-url-make-server)))
(prog1 server
(setq jupyter--servers
(cons server (delq server jupyter--servers)))))
(or jupyter-current-server
(and (file-remote-p default-directory)
(jupyter-tramp-file-name-p default-directory)
(jupyter-tramp-server-from-file-name default-directory))
(if (> (length jupyter--servers) 1)
(let ((server (cdr (completing-read
"Jupyter Server: "
(mapcar (lambda (x) (cons (oref x url) x))
jupyter--servers)))))
(prog1 server
(setq jupyter--servers
(cons server (delq server jupyter--servers)))))
(or (car jupyter--servers)
(funcall read-url-make-server)))))))
;;; Commands
;;;###autoload
(defun jupyter-server-launch-kernel (server)
"Start a kernel on SERVER.
With a prefix argument, ask to select a server if there are
mutiple to choose from, otherwise the most recently used server
is used as determined by `jupyter-current-server'."
(interactive (list (jupyter-current-server current-prefix-arg)))
(let* ((specs (jupyter-server-kernelspecs server))
(spec (jupyter-completing-read-kernelspec specs)))
(jupyter-api-start-kernel server (car spec))))
;;; REPL
;; TODO: When closing the REPL buffer and it is the last connected client as
;; shown by the :connections key of a `jupyter-api-get-kernel' call, ask to
;; also shutdown the kernel.
;;
;; TODO: When calling `jupyter-stop-channels' and there is only one client to a
;; `jupyter-server-kernel-comm', tell the `jupyter-server-ioloop' to disconnect
;; the channels.
(defun jupyter-server-start-new-kernel (server kernel-name &optional client-class)
"Start a managed Jupyter kernel on SERVER.
KERNEL-NAME is the name of the kernel to start. It can also be
the prefix of a valid kernel name, in which case the first kernel
in jupyter-server-kernelspecs that has KERNEL-NAME as a
prefix will be used.
Optional argument CLIENT-CLASS is a subclass
of jupyer-kernel-client and will be used to initialize a new
client connected to the kernel. CLIENT-CLASS defaults to the
symbol jupyter-kernel-client.
Return a list (KM KC) where KM is the kernel manager managing the
lifetime of the kernel on SERVER. KC is a new client connected to
the kernel whose class is CLIENT-CLASS. Note that the clients
manager slot will also be set to the kernel manager instance,
see jupyter-make-client."
(or client-class (setq client-class 'jupyter-kernel-client))
(let* ((specs (jupyter-server-kernelspecs server))
(kernel (jupyter-server-kernel
:spec (jupyter-guess-kernelspec kernel-name specs)))
(manager (jupyter-server-kernel-manager
:server server
:kernel kernel)))
;; Needs to be started before calling `jupyter-make-client' since that
;; method will send a request to start a websocket channel to the kernel.
;; FIXME: This should be done in a `jupyter-initialize-connection' method,
;; but first that method needs to be generalize in `jupyter-client.el'
(unless (jupyter-kernel-alive-p manager)
(jupyter-start-kernel manager))
(let ((client (jupyter-make-client manager client-class)))
(jupyter-start-channels client)
(list manager client))))
;;;###autoload
(defun jupyter-run-server-repl
(server kernel-name &optional repl-name associate-buffer client-class display)
"On SERVER start a kernel with KERNEL-NAME.
With a prefix argument, ask to select a server if there are
mutiple to choose from, otherwise the most recently used server
is used as determined by `jupyter-current-server'.
REPL-NAME, ASSOCIATE-BUFFER, CLIENT-CLASS, and DISPLAY all have
the same meaning as in `jupyter-run-repl'."
(interactive
(let ((server (jupyter-current-server current-prefix-arg)))
(list server
(car (jupyter-completing-read-kernelspec
(jupyter-server-kernelspecs server)))
;; FIXME: Ambiguity with `jupyter-current-server' and
;; `current-prefix-arg'
(when (and current-prefix-arg
(y-or-n-p "Name REPL? "))
(read-string "REPL Name: "))
t nil t)))
(or client-class (setq client-class 'jupyter-repl-client))
(jupyter-error-if-not-client-class-p client-class 'jupyter-repl-client)
(cl-destructuring-bind (_manager client)
(jupyter-server-start-new-kernel server kernel-name client-class)
(jupyter-bootstrap-repl client repl-name associate-buffer display)))
;;;###autoload
(defun jupyter-connect-server-repl
(server kernel-id &optional repl-name associate-buffer client-class display)
"On SERVER, connect to the kernel with KERNEL-ID.
With a prefix argument, ask to select a server if there are
mutiple to choose from, otherwise the most recently used server
is used as determined by `jupyter-current-server'.
REPL-NAME, ASSOCIATE-BUFFER, CLIENT-CLASS, and DISPLAY all have
the same meaning as in `jupyter-connect-repl'."
(interactive
(let ((server (jupyter-current-server current-prefix-arg)))
(list server
(plist-get (jupyter-completing-read-server-kernel server) :id)
;; FIXME: Ambiguity with `jupyter-current-server' and
;; `current-prefix-arg'
(when (and current-prefix-arg
(y-or-n-p "Name REPL? "))
(read-string "REPL Name: "))
t nil t)))
(or client-class (setq client-class 'jupyter-repl-client))
(jupyter-error-if-not-client-class-p client-class 'jupyter-repl-client)
(let* ((specs (jupyter-server-kernelspecs server))
(manager
(or (jupyter-server-find-manager server kernel-id)
(let* ((model (jupyter-api-get-kernel server kernel-id))
(kernel (jupyter-server-kernel
:id kernel-id
:spec (assoc (plist-get model :name) specs))))
(jupyter-server-kernel-manager
:server server
:kernel kernel))))
(client (jupyter-make-client manager client-class)))
(jupyter-start-channels client)
(jupyter-bootstrap-repl client repl-name associate-buffer display)))
;;; `jupyter-server-kernel-list'
(defun jupyter-server-kernel-list-do-shutdown ()
"Shutdown the kernel corresponding to the current entry."
(interactive)
(when-let* ((id (tabulated-list-get-id))
(really (yes-or-no-p
(format "Really shutdown %s kernel? "
(aref (tabulated-list-get-entry) 0)))))
(let ((manager (jupyter-server-find-manager jupyter-current-server id)))
(if manager (jupyter-shutdown-kernel manager)
(jupyter-api-shutdown-kernel jupyter-current-server id)))
(tabulated-list-delete-entry)))
(defun jupyter-server-kernel-list-do-restart ()
"Restart the kernel corresponding to the current entry."
(interactive)
(when-let* ((id (tabulated-list-get-id))
(really (yes-or-no-p "Really restart kernel? ")))
(let ((manager (jupyter-server-find-manager jupyter-current-server id)))
(if manager (jupyter-shutdown-kernel manager 'restart)
(jupyter-api-restart-kernel jupyter-current-server id)))
(revert-buffer)))
(defun jupyter-server-kernel-list-do-interrupt ()
"Interrupt the kernel corresponding to the current entry."
(interactive)
(when-let* ((id (tabulated-list-get-id)))
(jupyter-api-interrupt-kernel jupyter-current-server id)
(revert-buffer)))
(defun jupyter-server-kernel-list-new-repl ()
"Connect a REPL to the kernel corresponding to the current entry."
(interactive)
(when-let* ((id (tabulated-list-get-id)))
(let ((jupyter-current-client
(jupyter-connect-server-repl jupyter-current-server id)))
(revert-buffer)
(jupyter-repl-pop-to-buffer))))
(defun jupyter-server-kernel-list-launch-kernel ()
"Launch a new kernel on the server."
(interactive)
(jupyter-server-launch-kernel jupyter-current-server)
(revert-buffer))
(defvar jupyter-server-kernel-list-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-i") #'jupyter-server-kernel-list-do-interrupt)
(define-key map (kbd "d") #'jupyter-server-kernel-list-do-shutdown)
(define-key map (kbd "C-c C-d") #'jupyter-server-kernel-list-do-shutdown)
(define-key map (kbd "C-c C-r") #'jupyter-server-kernel-list-do-restart)
(define-key map [follow-link] nil) ;; allows mouse-1 to be activated
(define-key map [mouse-1] #'jupyter-server-kernel-list-new-repl)
(define-key map (kbd "RET") #'jupyter-server-kernel-list-new-repl)
(define-key map (kbd "C-RET") #'jupyter-server-kernel-list-launch-kernel)
(define-key map (kbd "<return>") #'jupyter-server-kernel-list-new-repl)
(define-key map (kbd "C-<return>") #'jupyter-server-kernel-list-launch-kernel)
(define-key map "r" #'revert-buffer)
(define-key map "g" #'revert-buffer)
map))
(define-derived-mode jupyter-server-kernel-list-mode
tabulated-list-mode "Jupyter Server Kernels"
"A list of live kernels on a Jupyter kernel server."
(tabulated-list-init-header)
(tabulated-list-print)
(let ((inhibit-read-only t)
(url (oref jupyter-current-server url)))
(overlay-put
(make-overlay 1 2)
'before-string
(concat (propertize url 'face '(fixed-pitch default)) "\n"))))
(defun jupyter-server--kernel-list-format ()
(let* ((get-time
(lambda (a)
(or (get-text-property 0 'jupyter-time a)
(let ((time (jupyter--decode-time a)))
(prog1 time
(put-text-property 0 1 'jupyter-time time a))))))
(time-sort
(lambda (a b)
(time-less-p
(funcall get-time (aref (nth 1 a) 2))
(funcall get-time (aref (nth 1 b) 2)))))
(conn-sort
(lambda (a b)
(< (string-to-number (aref (nth 1 a) 4))
(string-to-number (aref (nth 1 b) 4))))))
`[("Name" 17 t)
("ID" 38 nil)
("Activity" 20 ,time-sort)
("State" 10 nil)
("Conns." 6 ,conn-sort)]))
(defun jupyter-server--kernel-list-entries ()
(cl-loop
with names = nil
for kernel across (jupyter-api-get-kernel jupyter-current-server)
collect
(cl-destructuring-bind
(&key name id last_activity execution_state
connections &allow-other-keys)
kernel
(let* ((time (jupyter--decode-time last_activity))
(name
(let ((same (cl-remove-if-not
(lambda (x) (string-prefix-p name x)) names)))
(when same (setq name (format "%s<%d>" name (length same))))
(push name names)
(propertize name 'face 'font-lock-constant-face)))
(activity (propertize (format-time-string "%F %T" time)
'face 'font-lock-doc-face))
(conns (propertize (number-to-string connections)
'face 'shadow))
(state (propertize execution_state
'face (pcase execution_state
("busy" 'warning)
("idle" 'shadow)
("starting" 'success)))))
(list id (vector name id activity state conns))))))
;;;###autoload
(defun jupyter-server-list-kernels (server)
"Display a list of live kernels on SERVER."
(interactive (list (jupyter-current-server current-prefix-arg)))
(if (zerop (length (jupyter-api-get-kernel server)))
(when (yes-or-no-p (format "No kernels at %s; launch one? "
(oref server url)))
(jupyter-server-launch-kernel server)
(jupyter-server-list-kernels server))
(with-current-buffer
(jupyter-get-buffer-create (format "kernels[%s]" (oref server url)))
(setq jupyter-current-server server)
(if (eq major-mode 'jupyter-server-kernel-list-mode)
(revert-buffer)
(setq tabulated-list-format (jupyter-server--kernel-list-format)
tabulated-list-entries #'jupyter-server--kernel-list-entries)
(jupyter-server-kernel-list-mode))
(jupyter-display-current-buffer-reuse-window))))
(provide 'jupyter-server)
;;; jupyter-server.el ends here

View file

@ -34,6 +34,7 @@
(require 'jupyter-repl) (require 'jupyter-repl)
(require 'jupyter-org-client) (require 'jupyter-org-client)
(require 'jupyter-kernel-manager) (require 'jupyter-kernel-manager)
(require 'jupyter-server)
(require 'cl-lib) (require 'cl-lib)
(require 'ert) (require 'ert)
(require 'subr-x) ; string-trim (require 'subr-x) ; string-trim
@ -49,6 +50,13 @@
(message "system-configuration %s" system-configuration) (message "system-configuration %s" system-configuration)
(when noninteractive
(message "Starting up notebook process for tests")
(start-process "jupyter-notebook" nil "jupyter" "notebook"
"--no-browser"
"--NotebookApp.token=''"
"--NotebookApp.password=''"))
(declare-function org-babel-python-table-or-string "ob-python" (results)) (declare-function org-babel-python-table-or-string "ob-python" (results))
;; TODO: Required tests ;; TODO: Required tests
@ -704,6 +712,22 @@
(jupyter-runtime-directory) (jupyter-runtime-directory)
(should (equal jupyter-runtime-directory "foo"))))))) (should (equal jupyter-runtime-directory "foo")))))))
(ert-deftest jupyter-server-kernel ()
:tags '(kernel server)
(let ((kernel (jupyter-server-kernel)))
(should-not (slot-boundp kernel 'id))
(should-not (jupyter-kernel-alive-p kernel))
;; TODO: How should this work? Pass the server as an argument?
(should-error (jupyter-start-kernel kernel))
(oset kernel id "foobar")
;; FIXME: Since the kernel does not have access to the server and is just a
;; container for data, we suppose the kernel is alive when it has an ID
;; assigned to it.
(should (jupyter-kernel-alive-p kernel))
(jupyter-kill-kernel kernel)
(should-not (slot-boundp kernel 'id))
(should-not (jupyter-kernel-alive-p kernel))))
;;; Client ;;; Client
;; TODO: Different values of the session argument ;; TODO: Different values of the session argument
@ -783,15 +807,18 @@
(jupyter-initialize-connection client conn-info) (jupyter-initialize-connection client conn-info)
(cl-loop (cl-loop
for channel in '(:hb :shell :iopub :stdin) for channel in '(:hb :shell :iopub :stdin)
do (should-not (jupyter-channel-alive-p client channel))) for alive-p = (jupyter-channel-alive-p client channel)
do (should-not alive-p))
(jupyter-start-channels client) (jupyter-start-channels client)
(cl-loop (cl-loop
for channel in '(:hb :shell :iopub :stdin) for channel in '(:hb :shell :iopub :stdin)
do (should (jupyter-channel-alive-p client channel))) for alive-p = (jupyter-channel-alive-p client channel)
do (should alive-p))
(jupyter-stop-channels client) (jupyter-stop-channels client)
(cl-loop (cl-loop
for channel in '(:hb :shell :iopub :stdin) for channel in '(:hb :shell :iopub :stdin)
do (should-not (jupyter-channel-alive-p client channel)))))) for alive-p = (jupyter-channel-alive-p client channel)
do (should-not alive-p)))))
(ert-deftest jupyter-inhibited-handlers () (ert-deftest jupyter-inhibited-handlers ()
:tags '(client handlers) :tags '(client handlers)
@ -1921,6 +1948,85 @@ next(x"))))))
(websocket-close ws)) (websocket-close ws))
(jupyter-api-shutdown-kernel client id))))) (jupyter-api-shutdown-kernel client id)))))
;;; Server
;; And `jupyter-server-kernel-comm'
(ert-deftest jupyter-server ()
:tags '(server)
(let* ((server (jupyter-server))
(kernel (jupyter-server-kernel
:spec (jupyter-guess-kernelspec
"python" (jupyter-server-kernelspecs server)))))
(should-not (slot-boundp kernel 'id))
(let ((id (jupyter-start-kernel kernel server)))
(unwind-protect
(progn
(should (slot-boundp kernel 'id))
(should (equal id (oref kernel id)))
(should (jupyter-api-get-kernel server id))
(should-not (jupyter-server-kernel-connected-p server id))
(should-not (jupyter-comm-alive-p server))
(jupyter-comm-start server)
(should (jupyter-comm-alive-p server))
(unwind-protect
(progn
(ert-info ("Connecting kernel comm to server")
(let ((kcomm (jupyter-server-kernel-comm
:server server
:kernel kernel)))
(should-not (jupyter-server-kernel-connected-p server id))
(jupyter-connect-client server kcomm)
(should (jupyter-server-kernel-connected-p server id))
(should (jupyter-comm-alive-p kcomm))
(jupyter-comm-stop kcomm)
(should-not (jupyter-comm-alive-p kcomm))
(should (jupyter-comm-alive-p server))))
(ert-info ("Connecting kernel comm starts server comm if necessary")
(let ((kcomm (jupyter-server-kernel-comm
:server server
:kernel kernel)))
(jupyter-comm-stop server)
(should-not (jupyter-comm-alive-p server))
(jupyter-comm-start kcomm)
(should (jupyter-comm-alive-p server))
(should (jupyter-server-kernel-connected-p server id))
(should (jupyter-comm-alive-p kcomm))
(jupyter-comm-stop kcomm))))
(jupyter-comm-stop server)))
(jupyter-api-shutdown-kernel server id)))))
(ert-deftest jupyter-server-kernel-manager ()
:tags '(server)
(let* ((server (jupyter-server))
(kernel (jupyter-server-kernel
:spec (jupyter-guess-kernelspec
"python" (jupyter-server-kernelspecs server))))
(manager (jupyter-server-kernel-manager
:server server
:kernel kernel)))
(should-not (jupyter-kernel-alive-p manager))
(let ((id (jupyter-start-kernel manager)))
(unwind-protect
(progn
(should (jupyter-kernel-alive-p manager))
;; TODO: Does shutting down the kernel also send a
;; disconnect-channels event?
)
(jupyter-api-shutdown-kernel server id)))))
(ert-deftest jupyter-server-start-new-kernel ()
:tags '(server)
(let ((server (jupyter-server)))
(cl-destructuring-bind (manager client)
(jupyter-server-start-new-kernel server "python")
(unwind-protect
(let ((jupyter-current-client client))
;; TODO: Get rid of these sleep calls
(sleep-for 2)
(should (equal (jupyter-eval "1 + 1") "2")))
(jupyter-shutdown-kernel manager)
(jupyter-comm-stop server)))))
;;; `org-mode' ;;; `org-mode'
(defvar org-babel-jupyter-resource-directory nil) (defvar org-babel-jupyter-resource-directory nil)