change python config, add jupyter and ein

This commit is contained in:
2024-05-05 20:36:39 +02:00
parent b18d02d8d5
commit 8b80ceda39
168 changed files with 177127 additions and 46 deletions

50
lisp/jupyter/Makefile Normal file
View File

@@ -0,0 +1,50 @@
EMACS ?= emacs
ELDEV ?= $(shell command -v eldev)
FILES = $(wildcard *.el)
ELCFILES = $(FILES:.el=.elc)
TESTFILES = $(foreach file,$(wildcard test/*.el),-l $(file))
TESTSELECTORS =
ifneq ($(TAGS),)
comma := ,
TESTSELECTORS := $(foreach tag,$(subst $(comma), ,$(TAGS)),"(tag $(tag))")
endif
ifneq ($(PATTERN),)
TESTSELECTORS := $(TESTSELECTORS) \"$(PATTERN)\"
endif
# ifneq ($(TESTSELECTORS),)
# TESTSELECTORS := (quote (or $(TESTSELECTORS)))
# endif
.PHONY: all
all: compile
.PHONY: eldev
eldev:
ifeq ($(ELDEV),)
$(error "Install eldev (https://github.com/doublep/eldev)")
endif
.PHONY: test
test:
$(ELDEV) test $(TESTSELECTORS)
.PHONY: clean
clean:
make -C js clean
@rm $(ELCFILES) 2>/dev/null || true
.PHONY: clean-eldev
clean-eldev:
@rm -rf .eldev/ 2>/dev/null || true
.PHONY: widgets
widgets:
make -C js
.PHONY: compile
compile:
$(ELDEV) compile

28
lisp/jupyter/js/Makefile Normal file
View File

@@ -0,0 +1,28 @@
SHELL = bash
NPM ?= $(shell command -v npm)
ifeq ($(NPM),)
$(error "Node not installed (https://nodejs.org/en/)")
endif
YARN ?= $(shell command -v yarn)
ifeq ($(YARN),)
# If yarn isn't already installed, it is built locally
YARN = ./node_modules/.bin/yarn
endif
.PHONY: all build clean
all: build
clean:
@rm -rf built/ 2>/dev/null || true
really-clean: clean
@rm -rf node_modules 2>/dev/null || true
build: built/index.built.js
built/index.built.js:
$(NPM) install
$(YARN) run build --progress

View File

@@ -0,0 +1,342 @@
// NOTE: Info on widgets http://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Low%20Level.html
var disposable = require('@phosphor/disposable');
var coreutils = require('@jupyterlab/coreutils');
// The KernelFutureHandler allows comms to register their callbacks to be
// called when messages are received in response to a request sent to the
// kernel.
var KernelFutureHandler = require('@jupyterlab/services/kernel/future').KernelFutureHandler;
// The CommHandler object handles comm interaction to/from the kernel. It takes
// a target_name, usually jupyter.widget, and a comm_id. It takes care of
// sending comm messages to the kernel and calls the callback methods of a Comm
// when a comm_msg is received from the kernel.
//
// A Comm object is essentially a wrapper around a CommHandler that updates the
// CommHandler callbacks and registers callbacks on the futures created when a
// Comm sends a message on the shell channel.
var CommHandler = require('@jupyterlab/services/kernel/comm').CommHandler;
// A CommManager takes care of registering new comm targets and creating new
// comms and holding a list of all the live comms.
// It looks like I just ned to implement the IKernel interface and pass the
// object that implements it to CommManager, this way I can create new comms
// with CommManager.new_comm when handling comm_open messages. In the IKernel
// interface, I'll just redirect all the message sending functions to Emacs.
// It looks like widgets send messages through the callbacks of a
// KernelFutureHandler so I will have to redirect all received messages that
// originated from a request generated by skewer.postJSON back to the
// JavaScript environment. Emacs then acts as an intermediary, capturing kernel
// messages and re-packaging them to send to the Javascript environment.
//
// It looks like whenever the kernel receives a message it accesse the correct
// future object using this.futures.get and calls handleMsg function of the
// future.
//
// The flow of message with respect to Comm objects is that Comm object send
// shell messages, then widgets register callbacks on the future.
var EmacsJupyter = function(options, port) {
var _this = this;
this.username = options.username || '';
// This is the Jupyter session id
this.clientId = options.clientId;
this.isDisposed = false;
// A mapping from comm_id's to promises that resolve to their open Comm
// objects.
this.commPromises = new Map();
// The targetRegistry is a dictionary mapping target names to target
// functions that are called whenever a new Comm is requested to be open by
// the kernel. The target function gets called with the initial comm_open
// message data and a comm handler for the new Comm.
this.targetRegistry = {};
// A mapping of msg_id's for messages sent to the kernel and their
// KernelFutureHandler objects.
this.futures = new Map();
// The WidgetManager that connects comms to their corresponding widget
// models, construct widget views, load widget modules, and get the current
// widget state.
this.widgetManager = null;
this.widgetState = null;
// The CommManager that registers the target names and their target
// functions handles opening and closing comms for a particular
// target name.
this.commManager = null;
this.messagePromise = new Promise(function (resolve) { resolve(); });
window.addEventListener("unload", function(event) {
// TODO: Send widget state
});
// Localhost
this.wsPort = port;
this.ws = new WebSocket("ws://127.0.0.1:" + port);
this.ws.onopen = function () {
// Ensure that Emacs knows which websocket connection corresponds to
// each kernel client
_this.ws.send(JSON.stringify({
header: {
msg_type: "connect",
session: _this.clientId
}
}));
};
this.ws.onmessage = function(event) {
if(_this.isDisposed) {
return;
}
var msg = JSON.parse(event.data);
_this.messagePromise =
_this.messagePromise.then(function () {
if(msg.buffers && msg.buffers.length > 0) {
for(var i = 0; i < msg.buffers.length; i++) {
var bin = atob(msg.buffers[i]);
var len = bin.length;
var buf = new Uint8Array(len);
for(var j = 0; j < len; j++) {
buf[j] = bin.charCodeAt(j);
}
msg.buffers[i] = buf.buffer;
}
}
_this.handleMessage(msg);
});
};
};
exports.EmacsJupyter = EmacsJupyter;
EmacsJupyter.prototype.dispose = function () {
if (this.isDisposed) {
return;
}
this.isDisposed = true;
this.commPromises.forEach(function (promise, key) {
promise.then(function (comm) {
comm.dispose();
});
});
};
EmacsJupyter.prototype.registerCommTarget = function(targetName, callback) {
var _this = this;
this.targetRegistry[targetName] = callback;
return new disposable.DisposableDelegate(function () {
if (!_this.isDisposed) {
delete _this.targetRegistry[targetName];
}
});
};
EmacsJupyter.prototype.connectToComm = function (targetName, commId) {
var _this = this;
var id = commId || coreutils.uuid();
if (this.commPromises.has(id)) {
return this.commPromises.get(id);
}
var promise = Promise.resolve(void 0).then(function () {
return new CommHandler(targetName, id, _this, function () {
_this._unregisterComm(id);
});
});
this.commPromises.set(id, promise);
return promise;
};
EmacsJupyter.prototype.handleCommOpen = function (msg) {
var _this = this;
var content = msg.content;
if (this.isDisposed) {
return;
}
var promise = this.loadObject(content.target_name,
content.target_module,
this.targetRegistry)
.then(function (target) {
var comm = new CommHandler(content.target_name,
content.comm_id,
_this, function () {
_this._unregisterComm(content.comm_id);
});
var response;
try {
response = target(comm, msg);
}
catch (e) {
comm.close();
console.error('Exception opening new comm');
throw (e);
}
return Promise.resolve(response).then(function () {
if (_this.isDisposed) {
return null;
}
return comm;
});
});
this.commPromises.set(content.comm_id, promise);
};
EmacsJupyter.prototype.handleCommClose = function (msg) {
var _this = this;
var content = msg.content;
var promise = this.commPromises.get(content.comm_id);
if (!promise) {
console.error('Comm not found for comm id ' + content.comm_id);
return;
}
promise.then(function (comm) {
if (!comm) {
return;
}
_this._unregisterComm(comm.commId);
try {
var onClose = comm.onClose;
if (onClose) {
onClose(msg);
}
comm.dispose();
}
catch (e) {
console.error('Exception closing comm: ', e, e.stack, msg);
}
});
};
EmacsJupyter.prototype.handleCommMsg = function (msg) {
var promise = this.commPromises.get(msg.content.comm_id);
if (!promise) {
// We do have a registered comm for this comm id, ignore.
return;
}
promise.then(function (comm) {
if (!comm) {
return;
}
try {
var onMsg = comm.onMsg;
if (onMsg) {
onMsg(msg);
}
}
catch (e) {
console.error('Exception handling comm msg: ', e, e.stack, msg);
}
});
};
EmacsJupyter.prototype.loadObject = function(name, moduleName, registry) {
return new Promise(function (resolve, reject) {
// Try loading the view module using require.js
if (moduleName) {
if (typeof window.require === 'undefined') {
throw new Error('requirejs not found');
}
window.require([moduleName], function (mod) {
if (mod[name] === void 0) {
var msg = "Object '" + name + "' not found in module '" + moduleName + "'";
reject(new Error(msg));
}
else {
resolve(mod[name]);
}
}, reject);
}
else {
if (registry && registry[name]) {
resolve(registry[name]);
}
else {
reject(new Error("Object '" + name + "' not found in registry"));
}
}
});
}
EmacsJupyter.prototype._unregisterComm = function (commId) {
this.commPromises.delete(commId);
};
EmacsJupyter.prototype.sendShellMessage = function(msg, expectReply, disposeOnDone) {
var _this = this;
if (expectReply === void 0) { expectReply = false; }
if (disposeOnDone === void 0) { disposeOnDone = true; }
var future = new KernelFutureHandler(function () {
var msgId = msg.header.msg_id;
_this.futures.delete(msgId);
}, msg, expectReply, disposeOnDone, this);
this.ws.send(JSON.stringify(msg));
this.futures.set(msg.header.msg_id, future);
return future;
};
EmacsJupyter.prototype.requestCommInfo = function(targetName) {
var msg = {
channel: 'shell',
msg_type: 'comm_info_request',
// A message ID will be added by Emacs anyway
header: {msg_id: ''},
content: {target_name: targetName}
};
var future = this.sendShellMessage(msg, true);
return new Promise(function (resolve) {
future.onReply = resolve;
});
};
EmacsJupyter.prototype.handleMessage = function(msg) {
var _this = this;
var parentHeader = msg.parent_header;
var future = parentHeader && this.futures && this.futures.get(parentHeader.msg_id);
if (future) {
return new Promise(function (resolve, reject) {
try {
future.handleMsg(msg);
resolve(msg);
} catch(err) {
reject(err);
}
});
} else {
return new Promise(function (resolve, reject) {
switch(msg.msg_type) {
// Special messages not really a Jupyter message
case 'display_model':
_this.widgetManager.get_model(msg.content.model_id).then(function (model) {
_this.widgetManager.display_model(undefined, model);
});
break;
case 'clear_display':
var widget = _this.widgetManager.area;
while(widget.firstChild) {
widget.removeChild(widget.firstChild);
}
break;
// Regular Jupyter messages
case 'comm_open':
_this.handleCommOpen(msg);
// Periodically get the state of the widgetManager, this gets
// sent to the browser when its unloaded.
// _this.widgetManager.get_state({}).then(function (state) {
// _this.widgetState = state;
// });
break;
case 'comm_close':
_this.handleCommClose(msg);
break;
case 'comm_msg':
_this.handleCommMsg(msg);
break;
case 'status':
// Comes from the comm info messages
break;
default:
reject(new Error('Unhandled message', msg));
};
resolve(msg);
});
}
}

12
lisp/jupyter/js/index.js Normal file
View File

@@ -0,0 +1,12 @@
window.CommManager = require('@jupyter-widgets/base').shims.services.CommManager;
window.WidgetManager = require('./manager').WidgetManager;
window.EmacsJupyter = require('./emacs-jupyter').EmacsJupyter;
require('font-awesome/css/font-awesome.min.css');
require('@jupyter-widgets/controls/css/widgets.built.css');
document.addEventListener("DOMContentLoaded", function(event) {
var widget = document.createElement("div");
widget.setAttribute("id", "widget");
document.body.appendChild(widget);
});

View File

@@ -0,0 +1,82 @@
var base = require('@jupyter-widgets/base');
var output = require('@jupyter-widgets/output');
var controls = require('@jupyter-widgets/controls');
var PhosphorWidget = require('@phosphor/widgets').Widget;
var defineWidgetModules = function () {
if(window.define) {
window.define('@jupyter-widgets/output', [], function () { return output; });
window.define('@jupyter-widgets/base', [], function () { return base; });
window.define('@jupyter-widgets/controls', [], function () { return controls; });
} else {
setTimeout(defineWidgetModules, 50);
}
};
// requirejs loading is async so it may not be available on this event
window.addEventListener("DOMContentLoaded", function () {
defineWidgetModules();
});
var WidgetManager = exports.WidgetManager = function(kernel, area) {
base.ManagerBase.call(this);
this.kernel = kernel;
this.area = area;
};
WidgetManager.prototype = Object.create(base.ManagerBase.prototype);
WidgetManager.prototype.loadClass = function(className, moduleName, moduleVersion) {
return new Promise(function(resolve, reject) {
if (moduleName === '@jupyter-widgets/controls') {
resolve(controls);
} else if (moduleName === '@jupyter-widgets/base') {
resolve(base);
} else if (moduleName === '@jupyter-widgets/output')
resolve(output);
else {
var fallback = function(err) {
var failedId = err.requireModules && err.requireModules[0];
if (failedId) {
console.log('Falling back to unpkg.com for ' + moduleName + '@' + moduleVersion);
window.require(['https://unpkg.com/' + moduleName + '@' + moduleVersion + '/dist/index.js'], resolve, reject);
} else {
throw err;
}
};
window.require([moduleName + '.js'], resolve, fallback);
}
}).then(function(module) {
if (module[className]) {
return module[className];
} else {
return Promise.reject('Class ' + className + ' not found in module ' + moduleName + '@' + moduleVersion);
}
});
}
WidgetManager.prototype.display_view = function(msg, view, options) {
var _this = this;
return Promise.resolve(view).then(function(view) {
PhosphorWidget.attach(view.pWidget, _this.area);
view.on('remove', function() {
console.log('View removed', view);
});
view.trigger('displayed');
return view;
});
};
WidgetManager.prototype._get_comm_info = function() {
return this.kernel.requestCommInfo(this.comm_target_name).then(function(reply) {
return reply.content.comms;
});
};
WidgetManager.prototype._create_comm = function(targetName, commId, data, metadata) {
// Construct a comm that already exists
var comm = this.kernel.connectToComm(targetName, commId);
if(data || metadata) {
comm.open(data, metadata);
}
return Promise.resolve(new base.shims.services.Comm(comm));
}

View File

@@ -0,0 +1,33 @@
{
"private": true,
"name": "emacs-jupyter",
"version": "0.3.0",
"description": "Integrate emacs-jupyter with widgets in a browser.",
"main": "index.js",
"scripts": {
"clean": "rm -rf built",
"build": "webpack",
"test": "npm run test:default",
"test:default": "echo \"No test specified\""
},
"author": "Nathaniel Nicandro",
"license": "GPL-2.0-or-later",
"dependencies": {
"@jupyter-widgets/base": "^1.2.2",
"@jupyter-widgets/controls": "^1.2.1",
"@jupyter-widgets/output": "^1.0.10",
"codemirror": "^5.9.0",
"font-awesome": "^4.7.0",
"npm": "^6.4.1",
"yarn": "^1.12.3"
},
"devDependencies": {
"css-loader": "^0.28.4",
"file-loader": "^0.11.2",
"json-loader": "^0.5.4",
"raw-loader": "^0.5.1",
"style-loader": "^0.18.1",
"url-loader": "^1.1.2",
"webpack": "^3.5.5"
}
}

View File

@@ -0,0 +1,29 @@
var path = require('path');
module.exports = {
entry: "./index.js",
output: {
filename: 'index.built.js',
path: path.resolve(__dirname, 'built'),
publicPath: 'built/'
},
resolve: {
alias: {
'@jupyterlab/services/kernel/future': path.resolve(__dirname, 'node_modules/@jupyterlab/services/lib/kernel/future'),
'@jupyterlab/services/kernel/comm': path.resolve(__dirname, 'node_modules/@jupyterlab/services/lib/kernel/comm')
}
},
module: {
rules: [
{ test: /\.css$/, loader: "style-loader!css-loader" },
// jquery-ui loads some images
{ test: /\.(jpg|png|gif)$/, use: 'file-loader' },
// required to load font-awesome
{ test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=application/font-woff' },
{ test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=application/font-woff' },
{ test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=application/octet-stream' },
{ test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, use: 'file-loader' },
{ test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=image/svg+xml' }
]
},
}

63
lisp/jupyter/jupyter-R.el Normal file
View File

@@ -0,0 +1,63 @@
;;; jupyter-R.el --- Jupyter support for R -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Jack Kamm <jackkamm@gmail.com>
;; Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; 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:
;; Support methods for integration with R.
;;; Code:
(require 'jupyter-repl)
(require 'jupyter-org-client)
(require 'jupyter-mime)
(defvar ess-font-lock-keywords)
(cl-defmethod jupyter-repl-initialize-fontification (&context (jupyter-lang R))
(when (featurep 'ess)
(setq-local ess-font-lock-keywords 'ess-R-font-lock-keywords))
(cl-call-next-method))
(cl-defmethod jupyter-org-result ((_mime (eql :text/html)) content params
&context (jupyter-lang R))
"If html DATA is an iframe, save it to a separate file and open in browser.
Otherwise, parse it as normal."
(if (plist-get (plist-get content :metadata) :isolated)
(let* ((data (plist-get content :data))
(file (or (alist-get :file params)
(jupyter-org-image-file-name data ".html"))))
(with-temp-file file
(insert data))
(browse-url-of-file file)
(jupyter-org-file-link file))
(cl-call-next-method)))
(cl-defmethod jupyter-insert ((_mime (eql :text/html)) data
&context (jupyter-lang R)
&optional metadata)
(if (plist-get metadata :isolated)
(jupyter-browse-url-in-temp-file data)
(cl-call-next-method)))
(provide 'jupyter-R)
;;; jupyter-R.el ends here

View File

@@ -0,0 +1,793 @@
;;; jupyter-base.el --- Core definitions for Jupyter -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 06 Jan 2018
;; 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:
;; This file holds the core requires, variables, and type definitions necessary
;; for jupyter.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'cl-lib)
(require 'eieio)
(require 'eieio-base)
(require 'json)
(declare-function tramp-dissect-file-name "tramp" (name &optional nodefault))
(declare-function tramp-file-name-user "tramp")
(declare-function tramp-file-name-host "tramp")
(declare-function jupyter-message-content "jupyter-messages" (msg))
(declare-function jupyter-new-uuid "jupyter-messages")
(cl-deftype json-plist () '(satisfies json-plist-p))
;;; Custom variables
(defcustom jupyter-pop-up-frame nil
"Whether or not buffers should be displayed in a new frame by default.
Note, this variable is only considered when evaluating code
interactively with functions like `jupyter-eval-line-or-region'.
If equal to nil, frames will never be popped up. When equal to t,
pop-up frames instead of windows.
`jupyter-pop-up-frame' can also be a list of message type
keywords for messages which will cause frames to be used. For any
message type not in the list, windows will be used instead.
Currently only `execute_result', `error', and `stream'
messages consider this variable."
:group 'jupyter
:type '(choice (const :tag "Pop up frames" t)
(const :tag "Pop up windows" nil)
;; TODO: These are the only ones where `jupyter-pop-up-frame'
;; is checked at the moment.
(set (const "execute_result")
(const "error")
(const "stream"))))
(defcustom jupyter-use-zmq (and (locate-library "zmq") t)
"Whether or not ZMQ can be used to communicate with kernels.
If ZMQ is not available for use, kernels can only be launched
from a backing notebook server."
:group 'jupyter
:type 'boolean)
(defconst jupyter-root (file-name-directory load-file-name)
"Root directory containing emacs-jupyter.")
(defconst jupyter-protocol-version "5.3"
"The jupyter protocol version that is implemented.")
(defconst jupyter-message-types
(list "execute_result"
"execute_request"
"execute_reply"
"inspect_request"
"inspect_reply"
"complete_request"
"complete_reply"
"history_request"
"history_reply"
"is_complete_request"
"is_complete_reply"
"comm_info_request"
"comm_info_reply"
"comm_open"
"comm_msg"
"comm_close"
"kernel_info_request"
"kernel_info_reply"
"shutdown_request"
"shutdown_reply"
"interrupt_request"
"interrupt_reply"
"stream"
"display_data"
"update_display_data"
"execute_input"
"error"
"status"
"clear_output"
"input_reply"
"input_request")
"A list of valid Jupyter message types.")
(defconst jupyter-mime-types '(:application/vnd.jupyter.widget-view+json
:text/html :text/markdown
:image/svg+xml :image/jpeg :image/png
:text/latex :text/plain)
"MIME types handled by Jupyter.")
(defconst jupyter-nongraphic-mime-types '(:application/vnd.jupyter.widget-view+json
:text/html :text/markdown
:text/plain)
"MIME types that can be used in terminal Emacs.")
(defvar jupyter--debug nil
"When non-nil, some parts of Jupyter will emit debug statements.
If the symbol \='message, messages received by a kernel will only
be handled by clients when the function
`jupyter--debug-replay-requests' is called manually. This allows
for stepping through the code with Edebug.")
(defvar jupyter--debug-request-queue nil)
(defun jupyter-debug (format-string &rest args)
"Display a message when `jupyter--debug' is non-nil.
FORMAT-STRING and ARGS have the same meaning as in `message'."
(when jupyter--debug
(apply #'message (concat "Jupyter: " format-string) args)))
(defvar jupyter-default-timeout 2.5
"The default timeout in seconds for `jupyter-wait-until'.")
(defvar jupyter-long-timeout 10
"A longer timeout than `jupyter-default-timeout' used for some operations.
A longer timeout is needed, for example, when retrieving the
`jupyter-kernel-info' to allow for the kernel to startup.")
(defconst jupyter-version "1.0"
"Current version of Jupyter.")
;;; Macros
(defmacro jupyter-with-timeout (spec &rest wait-forms)
"Periodically evaluate WAIT-FORMS until timeout.
Or until WAIT-FORMS evaluates to a non-nil value.
Wait until timeout SECONDS, periodically evaluating WAIT-FORMS
until it returns non-nil. If WAIT-FORMS returns non-nil, stop
waiting and return its value. Otherwise if timeout SECONDS
elapses, evaluate TIMEOUT-FORMS and return its value.
If PROGRESS is non-nil and evaluates to a string, a progress
reporter will be used with PROGRESS as the message while waiting.
SPEC takes the form (PROGRESS SECONDS TIMEOUT-FORMS...).
\(fn (PROGRESS SECONDS TIMEOUT-FORMS...) WAIT-FORMS...)"
(declare (indent 1) (debug ((form form body) body)))
(let ((res (make-symbol "res"))
(prog (make-symbol "prog"))
(prog-msg (make-symbol "prog-msg"))
(timeout (make-symbol "timeout"))
(wait-time (make-symbol "wait-time")))
`(let* ((,res nil)
(,prog-msg ,(pop spec))
(,timeout ,(pop spec))
(,wait-time (/ ,timeout 10.0))
(,prog (and (stringp ,prog-msg)
(make-progress-reporter ,prog-msg))))
(with-timeout (,timeout ,@spec)
(while (not (setq ,res (progn ,@wait-forms)))
(accept-process-output nil ,wait-time)
(when ,prog (progress-reporter-update ,prog))))
(prog1 ,res
(when ,prog (progress-reporter-done ,prog))))))
(defmacro jupyter-with-insertion-bounds (beg end bodyform &rest afterforms)
"Bind BEG and END to `point-marker's, evaluate BODYFORM then AFTERFORMS.
The END marker will advance if BODYFORM inserts text in the
current buffer. Thus after BODYFORM is evaluated, AFTERFORMS will
have access to the bounds of the text inserted by BODYFORM in the
variables BEG and END. The result of evaluating BODYFORM is
returned."
(declare (indent 3) (debug (symbolp symbolp form body)))
`(let ((,beg (point-marker))
(,end (point-marker)))
(set-marker-insertion-type ,end t)
(unwind-protect
(prog1 ,bodyform ,@afterforms)
(set-marker ,beg nil)
(set-marker ,end nil))))
(defun jupyter-map-mime-bundle (mime-types content fun)
"For each mime-type in MIME-TYPES, call FUN with its data in CONTENT.
If the result of evaluating FUN on the data of a mime-type is
non-nil, return it. Otherwise, call FUN for the next mime-type.
Return nil if FUN was evaluated on all mime-types without a
non-nil result. FUN is only called on mime-types that have data
in CONTENT.
CONTENT is a mime bundle, a property list containing a :data key
and, optionally, a :metadata key that are themselves property
lists with mime-type keywords as keys.
A call to FUN looks like this
\(funcall fun MIME-TYPE \='(:data D :metadata M))
where D will be the data associated with MIME-TYPE in CONTENT and
M is any associated metadata."
(declare (indent 2))
(cl-destructuring-bind (&key data metadata &allow-other-keys)
content
(catch 'mime-type
(mapc
(lambda (mime-type)
(let ((d (plist-get data mime-type))
(m (plist-get metadata mime-type)))
(if d
(let ((r (funcall fun mime-type `(:data ,d :metadata ,m))))
(if r (throw 'mime-type r))))))
mime-types)
nil)))
(defun jupyter-mime-value (content mime)
"Extract a value from a mime bundle.
CONTENT has the same meaning as in `jupyter-map-mime-bundle'.
Return the value of MIME in CONTENT. If MIME is not in CONTENT,
return nil."
(jupyter-map-mime-bundle (list mime)
content
(lambda (_mime content)
(plist-get content :data))))
;;;; Display buffers
(defvar-local jupyter-display-buffer-marker nil
"The marker to store the last output position of an output buffer.
See `jupyter-with-display-buffer'.")
(defvar-local jupyter-display-buffer-request-id nil
"The last `jupyter-request' message ID that generated output.")
(defun jupyter-get-buffer-create (name)
"Return a buffer with some special properties.
- The buffer's name is based on NAME, specifically it will be
\"*jupyter-NAME*\"
- Its `major-mode' will be `special-mode'."
(let* ((bname (format "*jupyter-%s*" name))
(buffer (get-buffer bname)))
(unless buffer
(setq buffer (get-buffer-create bname))
(with-current-buffer buffer
;; For buffers such as the jupyter REPL, showing trailing whitespaces
;; may be a nuisance (as evidenced by the Python banner).
(setq-local show-trailing-whitespace nil)
(unless (eq major-mode 'special-mode)
(special-mode))))
buffer))
(defun jupyter--reset-display-buffer-p (arg)
"Return non-nil if the current output buffer should be reset.
If ARG is a `jupyter-request', reset the buffer if ARG's
`jupyter-request-id' is no equal to the
`jupyter-buffer-last-request-id'. If ARG is not a
`jupyter-request-id', return ARG."
(if (jupyter-request-p arg)
;; Reset the output buffer is the last request ID does not
;; match the current request's ID.
(let ((id (jupyter-request-id arg)))
(and (not (equal id jupyter-display-buffer-request-id))
(setq jupyter-display-buffer-request-id id)
t))
;; Otherwise reset the output buffer if RESET evaluates to a
;; non-nil value
arg))
(defmacro jupyter-with-display-buffer (name reset &rest body)
"In a buffer with a name derived from NAME current, evaluate BODY.
The buffer's name is obtained by a call to
`jupyter-get-buffer-create'.
A display buffer is similar to a *Help* buffer, but maintains its
previous output on subsequent invocations that use the same NAME
and BODY is wrapped using `jupyter-with-control-code-handling' so
that any insertions into the buffer that contain ANSI escape
codes are properly handled.
Note, before BODY is evaluated, `point' is moved to the end of
the most recent output.
Also note, the `jupyter-current-client' variable in the buffer
that BODY is evaluated in is let bound to whatever value it has
before making that buffer current.
RESET is a form or symbol that determines if the buffer should be
erased before evaluating BODY. If RESET is nil, no erasing of the
buffer is ever performed. If RESET evaluates to a
`jupyter-request' object, reset the buffer if the previous
request that generated output in the buffer is not the same
request. Otherwise if RESET evaluates to any non-nil value, reset
the output buffer."
(declare (indent 2) (debug (stringp [&or atom form] body)))
(let ((buffer (make-symbol "buffer"))
(client (make-symbol "client")))
`(let ((,client jupyter-current-client)
(,buffer (jupyter-get-buffer-create ,name)))
(setq other-window-scroll-buffer ,buffer)
(with-current-buffer ,buffer
(unless jupyter-display-buffer-marker
(setq jupyter-display-buffer-marker (point-max-marker))
(set-marker-insertion-type jupyter-display-buffer-marker t))
(let ((inhibit-read-only t)
(jupyter-current-client ,client))
(when (jupyter--reset-display-buffer-p ,reset)
(erase-buffer)
(set-marker jupyter-display-buffer-marker (point))
(setq ansi-color-context-region nil))
(goto-char jupyter-display-buffer-marker)
(jupyter-with-control-code-handling ,@body))))))
(defun jupyter-display-current-buffer-reuse-window (&optional msg-type alist &rest actions)
"Convenience function to call `display-buffer' on the `current-buffer'.
If a window showing the current buffer is already available,
re-use it.
If ALIST is non-nil it is used as the ACTION alist of
`display-buffer'.
If MSG-TYPE is specified, it should be one of the keywords in
`jupyter-message-types' and is used in setting `pop-up-frames'
and `pop-up-windows'. See `jupyter-pop-up-frame'.
The rest of the arguments are display ACTIONS tried after
attempting to re-use a window and before attempting to pop-up a
new window or frame."
(let* ((jupyter-pop-up-frame (jupyter-pop-up-frame-p msg-type))
(pop-up-frames (and jupyter-pop-up-frame 'graphic-only))
(pop-up-windows (not jupyter-pop-up-frame))
(display-buffer-base-action
(cons
(append '(display-buffer-reuse-window)
(delq nil actions))
alist)))
(display-buffer (current-buffer))))
(defun jupyter-pop-up-frame-p (msg-type)
"Return non-nil if a frame should be popped up for MSG-TYPE."
(or (eq jupyter-pop-up-frame t)
(member msg-type jupyter-pop-up-frame)))
(defun jupyter-display-current-buffer-guess-where (msg-type)
"Display the current buffer in a window or frame depending on MSG-TYPE.
Call `jupyter-display-current-buffer-reuse-window' passing
MSG-TYPE as argument. If MSG-TYPE should be displayed in a window
and the current buffer is not already being displayed, display
the buffer below the selected window."
(jupyter-display-current-buffer-reuse-window
msg-type nil (unless (jupyter-pop-up-frame-p msg-type)
#'display-buffer-below-selected)))
;;; Some useful classes
(defclass jupyter-instance-tracker ()
((tracking-symbol :type symbol))
:documentation "Similar to `eieio-instance-tracker', but keeping weak references.
To access all the objects in TRACKING-SYMBOL, use
`jupyter-all-objects'."
:abstract t)
(cl-defmethod initialize-instance ((obj jupyter-instance-tracker) &optional _slots)
(cl-call-next-method)
(let ((sym (oref obj tracking-symbol)))
(unless (hash-table-p (symbol-value sym))
(put sym 'jupyter-instance-tracker t)
(set sym (make-hash-table :weakness 'key)))
(puthash obj t (symbol-value sym))))
(defun jupyter-all-objects (sym)
"Return all tracked objects in tracking SYM.
SYM is a symbol used for tracking objects that inherit from the
class corresponding to the symbol `jupyter-instance-tracker'."
(let ((table (symbol-value sym)))
(when (hash-table-p table)
(cl-assert (get sym 'jupyter-instance-tracker) t)
(hash-table-keys table))))
(defclass jupyter-finalized-object ()
((finalizers :type list :initform nil))
:documentation "A list of finalizers."
:documentation "A base class for cleaning up resources.
Adds the method `jupyter-add-finalizer' which maintains a list of
finalizer functions to be called when the object is garbage
collected.")
(cl-defmethod jupyter-add-finalizer ((obj jupyter-finalized-object) finalizer)
"Cleanup resources automatically.
FINALIZER if a function to be added to a list of finalizers that
will be called when OBJ is garbage collected."
(cl-check-type finalizer function)
(push (make-finalizer finalizer) (oref obj finalizers)))
;;; Session object definition
(cl-defstruct (jupyter-session
(:constructor nil)
(:constructor
jupyter-session
(&key
(conn-info nil)
(id (jupyter-new-uuid))
(key nil))))
"A `jupyter-session' holds the information needed to
authenticate messages. A `jupyter-session' contains the following
fields:
- CONN-INFO :: The connection info. property list of the kernel
this session is used to sign messages for.
- ID :: A string of bytes that uniquely identifies this session.
- KEY :: The key used when signing messages. If KEY is nil,
message signing is not performed."
(conn-info nil :read-only t)
(id nil :read-only t)
(key nil :read-only t))
(cl-defmethod jupyter-session-endpoints ((session jupyter-session))
"Return a property list containing the endpoints from SESSION."
(cl-destructuring-bind
(&key shell_port iopub_port stdin_port hb_port control_port
ip transport
&allow-other-keys)
(jupyter-session-conn-info session)
(cl-assert (and transport ip))
(let ((addr (lambda (port) (format "%s://%s:%d" transport ip port))))
(cl-loop
for (channel . port) in `((:hb . ,hb_port)
(:stdin . ,stdin_port)
(:shell . ,shell_port)
(:iopub . ,iopub_port)
(:control . ,control_port))
do (cl-assert port) and
collect channel and collect (funcall addr port)))))
;;; Request object definition
(cl-defstruct jupyter-request
"Represents a request made to a kernel.
Requests sent by a client always return something that can be
interpreted as a `jupyter-request'. It holds the state of a
request as the kernel and client communicate messages between
each other. A client has a request table to keep track of all
requests that are not considered idle. The most recent idle
request is also kept track of.
Each request contains: a message ID, a request type, a request
message, a time sent, a last message received by the client that
sent it, and a list of message types that tell the client to not
call the handler methods of those types."
(id (jupyter-new-uuid) :read-only t)
(type nil :read-only t)
(content nil :read-only t)
(client nil :read-only nil)
(time (current-time) :read-only t)
(idle-p nil)
(last-message nil)
(messages nil)
(message-publisher nil)
(inhibited-handlers nil))
(defun jupyter-channel-from-request-type (type)
"Return the name of the channel that a request with TYPE is sent on."
(pcase type
((or "input_reply" "input_request") "stdin")
("interrupt_request" "control")
(_ "shell")))
;;; Connecting to a kernel's channels
(eval-when-compile (require 'tramp))
(defun jupyter-available-local-ports (n)
"Return a list of N ports available on the localhost."
(let (servers)
(unwind-protect
(cl-loop
repeat n
do (push (make-network-process
:name "jupyter-available-local-ports"
:server t
:host "127.0.0.1"
:service t)
servers)
finally return (mapcar (lambda (p) (cadr (process-contact p))) servers))
(mapc #'delete-process servers))))
(defun jupyter-make-ssh-tunnel (lport rport server remoteip)
(or remoteip (setq remoteip "127.0.0.1"))
(start-process
"jupyter-ssh-tunnel" nil
"ssh"
;; Run in background
"-f"
;; Wait until the tunnel is open
"-o ExitOnForwardFailure=yes"
;; Local forward
"-L" (format "127.0.0.1:%d:%s:%d" lport remoteip rport)
server
;; Close the tunnel if no other connections are made within 60
;; seconds
"sleep 60"))
(defun jupyter-read-connection (conn-file)
"Return the connection information in CONN-FILE.
Return a property list representation of the JSON in CONN-FILE, a
Jupyter connection file."
(let ((conn-info (jupyter-read-plist conn-file)))
;; Also validate the signature scheme here.
(cl-destructuring-bind (&key key signature_scheme &allow-other-keys)
conn-info
(when (and (> (length key) 0)
(not (functionp
(intern (concat "jupyter-" signature_scheme)))))
(error "Unsupported signature scheme: %s" signature_scheme)))
conn-info))
(defun jupyter-tunnel-connection (conn-file &optional server)
"Forward local ports to the remote ports in CONN-FILE.
CONN-FILE is the path to a Jupyter connection file, SERVER is the
host that the kernel connection in CONN-FILE is located. Return a
copy of the connection plist in CONN-FILE, but with the ports
replaced by the local ports used for the forwarding.
If CONN-FILE is a `tramp' file name, the SERVER argument will be
ignored and the host will be extracted from the information
contained in the file name.
Note only SSH tunnels are currently supported."
(catch 'no-tunnels
(let ((conn-info (jupyter-read-connection conn-file)))
(when (and (file-remote-p conn-file)
(functionp 'tramp-dissect-file-name))
(pcase-let (((cl-struct tramp-file-name method user host)
(tramp-dissect-file-name conn-file)))
(pcase method
;; TODO: Document this in the README along with the fact that
;; connection files can use /ssh: TRAMP files.
("docker"
;; Assume docker is using the -p argument to publish its exposed
;; ports to the localhost. The ports used in the container should
;; be the same ports accessible on the local host. For example, if
;; the shell port is on 1234 in the container, the published port
;; flag should be "-p 1234:1234".
(throw 'no-tunnels conn-info))
(_
(setq server (if user (concat user "@" host)
host))))))
(let* ((keys '(:hb_port :shell_port :control_port
:stdin_port :iopub_port))
(lports (jupyter-available-local-ports (length keys))))
(cl-loop
with remoteip = (plist-get conn-info :ip)
for (key maybe-rport) on conn-info by #'cddr
collect key and if (memq key keys)
collect (let ((lport (pop lports)))
(prog1 lport
(jupyter-make-ssh-tunnel lport maybe-rport server remoteip)))
else collect maybe-rport)))))
(defun jupyter-connection-file-to-session (conn-file)
"Return a `jupyter-session' based on CONN-FILE.
CONN-FILE is a Jupyter connection file. If CONN-FILE is a remote
file, open local SSH tunnels to the remote ports listed in
CONN-FILE. The returned session object will have the remote
ports remapped to the local ports."
(let ((conn-info (if (file-remote-p conn-file)
(jupyter-tunnel-connection conn-file)
(jupyter-read-connection conn-file))))
(jupyter-session
:conn-info conn-info
:key (plist-get conn-info :key))))
;;; Kernel I/O
(defvar jupyter-io-cache (make-hash-table :weakness 'key))
(cl-defgeneric jupyter-io (thing)
"Return the I/O object of THING.")
(cl-defmethod jupyter-io :around (thing)
"Cache the I/O object of THING in `jupyter-io-cache'."
(or (gethash thing jupyter-io-cache)
(puthash thing (cl-call-next-method) jupyter-io-cache)))
;;; Helper functions
(defun jupyter-canonicalize-language-string (str)
"Return STR with \" \" converted to \"-\".
The `file-name-nondirectory' of STR will be converted and
returned if it looks like a file path."
;; The call to `file-name-nondirectory' is here to be more robust when
;; running on systems like Guix or Nix. Some builders on those kinds of
;; systems will indiscriminately replace "python" with something like
;; "/gnu/store/.../bin/python" when building the kernelspecs.
(replace-regexp-in-string " " "-" (file-name-nondirectory str)))
(defvar server-buffer)
(defvar jupyter-current-client)
(defvar jupyter-server-mode-client-timer nil
"Timer used to unset `jupyter-current-client' from `server-buffer'.")
;; FIXME: This works if we only consider a single send request that will also
;; finish within TIMEOUT which is probably 99% of the cases. It doesn't work
;; for multiple requests that have been sent using different clients where one
;; sets the client in `server-buffer' and, before a file is opened by the
;; underlying kernel, another sets the client in `server-buffer'.
(defun jupyter-server-mode-set-client (client &optional timeout)
"Set CLIENT as the `jupyter-current-client' in the `server-buffer'.
Kill `jupyter-current-client's local value in `server-buffer'
after TIMEOUT seconds, defaulting to `jupyter-long-timeout'.
If a function causes a buffer to be displayed through
emacsclient, e.g. when a function calls an external command that
invokes the EDITOR, we don't know when the buffer will be
displayed. All we know is that the buffer that will be current
before display will be the `server-buffer'. So we temporarily set
`jupyter-current-client' in `server-buffer' so that the client
gets a chance to be propagated to the displayed buffer, see
`jupyter-repl-persistent-mode'.
For this to work properly you should have something like the
following in your Emacs configuration
(server-mode 1)
(setenv \"EDITOR\" \"emacsclient\")
before starting any Jupyter kernels. The kernel also has to know
that it should use EDITOR to open files."
(when (bound-and-true-p server-mode)
;; After switching to a server buffer, keep the client alive in `server-buffer'
;; to account for multiple files being opened by the server.
(unless (and (boundp 'server-switch-hook)
(memq #'jupyter-server-mode--unset-client-soon
server-switch-hook))
(add-hook 'server-switch-hook #'jupyter-server-mode--unset-client-soon))
(with-current-buffer (get-buffer-create server-buffer)
(setq jupyter-current-client client)
(jupyter-server-mode--unset-client-soon timeout))))
(defun jupyter-server-mode-unset-client ()
"Set `jupyter-current-client' to nil in `server-buffer'."
(when (and (bound-and-true-p server-mode)
(get-buffer server-buffer))
(with-current-buffer server-buffer
(setq jupyter-current-client nil))))
(defun jupyter-server-mode--unset-client-soon (&optional timeout)
(when (timerp jupyter-server-mode-client-timer)
(cancel-timer jupyter-server-mode-client-timer))
(setq jupyter-server-mode-client-timer
(run-at-time (or timeout jupyter-long-timeout)
nil #'jupyter-server-mode-unset-client)))
(defun jupyter-read-plist (file)
"Read a JSON encoded FILE as a property list."
(let ((json-object-type 'plist))
(json-read-file file)))
(defun jupyter-read-plist-from-string (string)
"Read a property list from a JSON encoded STRING."
(let ((json-object-type 'plist))
(json-read-from-string string)))
(defun jupyter-normalize-data (plist &optional metadata)
"Return a property list (:data DATA :metadata META) from PLIST.
DATA is a property list of mimetype data extracted from PLIST.
If PLIST is a message plist, DATA will be the value of the :data
key in the `jupyter-message-content'. Otherwise, DATA is either
the :data key of PLIST or PLIST itself.
A similar extraction process is performed for the :metadata key
of PLIST which will be the META argument in the return value. If
no :metadata key can be found, then META will be METADATA."
(list :data (or
;; Allow for passing message plists
(plist-get (jupyter-message-content plist) :data)
;; Allow for passing (jupyter-message-content msg)
(plist-get plist :data)
;; Otherwise assume the plist contains mimetypes
plist)
:metadata (or (plist-get (jupyter-message-content plist) :metadata)
(plist-get plist :metadata)
metadata)))
(defun jupyter-line-count-greater-p (str n)
"Return non-nil if STR has more than N lines."
(string-match-p
(format "^\\(?:[^\n]*\n\\)\\{%d,\\}" (1+ n))
str))
(defun jupyter-format-time-low-res (time)
"Return a description string describing TIME.
If TIME is nil return \"Never\", otherwise return strings like
\"1 day ago\", \"an hour ago\", \"in 10 minutes\", ...
depending on the relative value of TIME from the `current-time'.
TIME is assumed to have the same form as the return value of
`current-time'."
(if (null time) "Never"
(let* ((seconds (- (float-time time)
(float-time (current-time))))
(past (< seconds 0))
(seconds (abs seconds))
(minutes (floor (/ seconds 60.0)))
(hours (floor (/ seconds 3600.0)))
(days (floor (/ seconds 86400.0))))
(cond
((< seconds 60)
(if (or past
;; Account for discrepancies between time resolution
(< seconds 0.1))
"a few seconds ago"
"in a few seconds"))
((not (zerop days))
(format "%s%d day%s%s"
(if past "" "in ")
days
(if (= days 1) "" "s")
(if past " ago" "")))
((not (zerop hours))
(if (= hours 1)
(if past "an hour ago"
"in one hour")
(format "%s%d hours%s"
(if past "" "in ")
hours
(if hours " ago" ""))))
((not (zerop minutes))
(if (= minutes 1)
(if past "a minute ago"
"in one minute")
(format "%s%d minutes%s"
(if past "" "in ")
minutes
(if past " ago" ""))))))))
;;; Simple weak references
;; Thanks to Chris Wellon https://nullprogram.com/blog/2014/01/27/
(defun jupyter-weak-ref (object)
"Return a weak reference for OBJECT."
(let ((ref (make-hash-table :weakness 'value :size 1)))
(prog1 ref
(puthash t object ref))))
(defsubst jupyter-weak-ref-resolve (ref)
"Resolve a weak REF.
Return nil if the underlying object has been garbage collected,
otherwise return the underlying object."
(gethash t ref))
;;; Errors
(defun jupyter-error-if-not-client-class-p (class &optional check-class)
"Signal a wrong-type-argument error if CLASS is not a client class.
If CHECK-CLASS is provided check CLASS against it. CHECK-CLASS
defaults to `jupyter-kernel-client'."
(or check-class (setq check-class 'jupyter-kernel-client))
(cl-assert (class-p check-class))
(unless (child-of-class-p class check-class)
(signal 'wrong-type-argument
(list (list 'subclass check-class) class))))
(provide 'jupyter-base)
;;; jupyter-base.el ends here

View File

@@ -0,0 +1,45 @@
;;; jupyter-c++.el --- Jupyter support for C++ -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 12 April 2019
;; 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:
;; Support methods for integration with C++.
;;; Code:
(require 'jupyter-repl)
(cl-defmethod jupyter-repl-initialize-fontification (&context (jupyter-lang c++))
"Copy buffer local variables used for fontification to the REPL buffer."
(cl-loop
with c-vars = (jupyter-with-repl-lang-buffer
(cl-loop
for var-val in (buffer-local-variables)
if (string-prefix-p "c-" (symbol-name (car var-val)))
collect var-val))
for (var . val) in c-vars
do (set (make-local-variable var) val))
(cl-call-next-method))
(provide 'jupyter-c++)
;;; jupyter-c++.el ends here

View File

@@ -0,0 +1,187 @@
;;; jupyter-channel-ioloop.el --- Abstract class to communicate with a jupyter-channel in a subprocess -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 27 Jun 2019
;; 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:
;; Define a `jupyter-ioloop' that can be sent events to start, stop, or send a
;; message on a set of `jupyter-channel' objects. For example to start a
;; `jupyter-channel' in the subprocess environment you would do something like
;;
;; (jupyter-send ioloop 'start-channel TYPE ENDPOINT)
;;
;; where TYPE and ENDPOINT have the same meaning as in `jupyter-channel'.
;;
;; Note by default, no channels are available in the subprocess environment.
;; You initialize channels by setting the `jupyter-channel-ioloop-channels'
;; variable in the subprocess environment, e.g. using
;; `jupyter-ioloop-add-setup', before starting the `jupyter-ioloop'.
;;
;; When you call `jupyter-ioloop-start' a `jupyter-session' object needs to
;; passed as the second argument with whatever object you would like to receive
;; events as the third. The `jupyter-session-id' will be used as the value of
;; the :identity key in the call to `jupyter-start' when starting a
;; channel.
;;
;; Each event sent to the subprocess will send back a corresponding
;; confirmation event, the three events that can be sent and their
;; corresponding confirmation events are:
;;
;; (start-channel TYPE ENDPOINT) -> (start-channel TYPE)
;; (stop-channel TYPE) -> (stop-channel TYPE)
;; (send TYPE MSG-TYPE MSG MSG-ID) -> (sent MSG-ID)
;;
;; For the send event, the MSG-TYPE, MSG, and MSG-ID have the same meaning as
;; the TYPE, MSG, and MSG-ID arguments of the `jupyter-send' method of a
;; `jupyter-channel'.
;;
;; Ex.
;;
;; (let ((ioloop (jupyter-channel-ioloop))
;; (session (jupyter-session :id ...)))
;; (jupyter-start-ioloop ioloop session ...)
;; ...
;; (jupyter-send ioloop 'start-channel ...)
;; ...)
;;; Code:
(require 'jupyter-ioloop)
(defvar jupyter-channel-ioloop-session nil
"The `jupyter-session' used when initializing Jupyter channels.")
(defvar jupyter-channel-ioloop-channels nil
"A list of synchronous channels in an ioloop controlling Jupyter channels.")
(jupyter-ioloop-add-arg-type jupyter-channel
(lambda (arg)
`(or (object-assoc ,arg :type jupyter-channel-ioloop-channels)
(error "Channel not alive (%s)" ,arg))))
(defclass jupyter-channel-ioloop (jupyter-ioloop)
()
:abstract t)
(cl-defmethod initialize-instance ((ioloop jupyter-channel-ioloop) &optional _slots)
(cl-call-next-method)
(jupyter-ioloop-add-setup ioloop
(require 'jupyter-channel-ioloop))
(jupyter-channel-ioloop-add-start-channel-event ioloop)
(jupyter-channel-ioloop-add-stop-channel-event ioloop)
(jupyter-channel-ioloop-add-send-event ioloop)
(jupyter-ioloop-add-teardown ioloop
(mapc #'jupyter-stop jupyter-channel-ioloop-channels)))
(defun jupyter-channel-ioloop-set-session (ioloop session)
"In the IOLOOP, set SESSION as the `jupyter-channel-ioloop-session'.
Add a form to IOLOOP's setup that sets the variable
`jupyter-channel-ioloop-session' to a `jupyter-session' based on
SESSION's id and key. Remove any top level form in the setup that
sets `jupyter-channel-ioloop-session' via `setq' before doing so."
(cl-callf (lambda (setup)
(cons `(setq jupyter-channel-ioloop-session
(jupyter-session
:id ,(jupyter-session-id session)
:key ,(jupyter-session-key session)))
(cl-remove-if
(lambda (f) (and (eq (car f) 'setq)
(eq (cadr f) 'jupyter-channel-ioloop-session)))
setup)))
(oref ioloop setup)))
;;; Channel events
(defun jupyter-channel-ioloop-add-start-channel-event (ioloop)
"Add a start-channel event handler to IOLOOP.
The event fires when the IOLOOP receives a list with the form
(start-channel CHANNEL-TYPE ENDPOINT)
and shall stop any existing channel with CHANNEL-TYPE and start a
new channel with CHANNEL-TYPE connected to ENDPOINT. The
underlying socket IDENTITY is derived from
`jupyter-channel-ioloop-session' in the IOLOOP environment. The
channel will be added to the variable
`jupyter-channel-ioloop-channels' in the IOLOOP environment.
Note, before sending this event to IOLOOP, the corresponding
channel needs to be available in the
`jupyer-channel-ioloop-channels' variable. You can initialize
this variable in the setup form of IOLOOP.
A list with the form
(start-channel CHANNEL-TYPE)
is returned to the parent process."
(jupyter-ioloop-add-event
ioloop start-channel ((channel jupyter-channel) endpoint)
;; Stop the channel if it is already alive
(when (jupyter-alive-p channel)
(jupyter-stop channel))
;; Start the channel
(oset channel endpoint endpoint)
(let ((identity (jupyter-session-id jupyter-channel-ioloop-session)))
(jupyter-start channel :identity identity))
(list 'start-channel (oref channel type))))
(defun jupyter-channel-ioloop-add-stop-channel-event (ioloop)
"Add a stop-channel event handler to IOLOOP.
The event fires when the IOLOOP receives a list with the form
(stop-channel CHANNEL-TYPE)
If a channel with CHANNEL-TYPE exists and is alive, it is stopped.
A list with the form
(stop-channel CHANNEL-TYPE)
is returned to the parent process."
(jupyter-ioloop-add-event ioloop stop-channel (type)
(let ((channel (object-assoc type :type jupyter-channel-ioloop-channels)))
(when (and channel (jupyter-alive-p channel))
(jupyter-stop channel))
(list 'stop-channel type))))
(defun jupyter-channel-ioloop-add-send-event (ioloop)
"Add a send event handler to IOLOOP.
The event fires when the IOLOOP receives a list of the form
(send CHANNEL-TYPE MSG-TYPE MSG MSG-ID)
and calls (jupyter-send CHANNEL MSG-TYPE MSG MSG-ID) using the
channel corresponding to CHANNEL-TYPE in the IOLOOP environment.
A list of the form
(sent CHANNEL-TYPE MSG-ID)
is returned to the parent process."
(jupyter-ioloop-add-event
ioloop send ((channel jupyter-channel) msg-type msg msg-id)
(list 'sent (oref channel type)
(jupyter-send channel msg-type msg msg-id))))
(provide 'jupyter-channel-ioloop)
;;; jupyter-channel-ioloop.el ends here

View File

@@ -0,0 +1,73 @@
;;; jupyter-channel.el --- Jupyter channel interface -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 27 Jun 2019
;; 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:
;; Defines the `jupyter-channel' interface.
;;; Code:
(require 'eieio)
(defclass jupyter-channel ()
((type
:type keyword
:initarg :type
:documentation "The type of this channel.")
(session
:type jupyter-session
:initarg :session
:documentation "The session object used to sign and send/receive messages.")
(endpoint
:type string
:initarg :endpoint
:documentation "The endpoint this channel is connected to.
Typical endpoints look like \"tcp://127.0.0.1:5555\"."))
:abstract t)
(cl-defmethod jupyter-start ((_channel jupyter-channel) &key _identity)
"Start a Jupyter CHANNEL using IDENTITY as the routing ID.
If CHANNEL is already alive, do nothing."
(cl-call-next-method))
(cl-defmethod jupyter-stop ((_channel jupyter-channel))
"Stop a Jupyter CHANNEL.
If CHANNEL is already stopped, do nothing."
(cl-call-next-method))
(cl-defmethod jupyter-alive-p ((_channel jupyter-channel))
"Return non-nil if a CHANNEL is alive."
(cl-call-next-method))
(cl-defmethod jupyter-send (_channel _type _message &optional _msg-id)
"On CHANNEL send MESSAGE which has message TYPE and optionally a MSG-ID."
(cl-call-next-method))
(cl-defmethod jupyter-recv (_channel &optional _dont-wait)
"Receive a message on CHANNEL.
If DONT-WAIT is non-nil, return nil immediately if there is no
message available to receive."
(cl-call-next-method))
(provide 'jupyter-channel)
;;; jupyter-channel.el ends here

File diff suppressed because it is too large Load Diff

192
lisp/jupyter/jupyter-env.el Normal file
View File

@@ -0,0 +1,192 @@
;;; jupyter-env.el --- Query the jupyter shell command for information -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 27 Jun 2019
;; 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:
;; Custom variables and functions related to calling the jupyter shell command
;; and its sub-commands for information.
;;; Code:
(require 'jupyter-base)
(eval-when-compile (require 'subr-x))
(defvar jupyter-runtime-directory nil
"The Jupyter runtime directory.
When a new kernel is started through `jupyter-start-kernel', this
directory is where kernel connection files are written to.
This variable should not be used. To obtain the runtime directory
call the function `jupyter-runtime-directory'.")
(defcustom jupyter-executable "jupyter"
"The `jupyter` command executable."
:type 'string
:group 'jupyter)
(defun jupyter-command (&rest args)
"Run a Jupyter shell command synchronously, return its output.
The shell command run is
jupyter ARGS...
If the command fails or the jupyter shell command doesn't exist,
return nil."
(let ((stderr-file (make-temp-file "jupyter"))
(stdout (get-buffer-create " *jupyter-command-stdout*")))
(unwind-protect
(let* ((status (apply #'process-file
jupyter-executable
nil
(list stdout stderr-file)
nil
args))
(buffer (find-file-noselect stderr-file)))
(unwind-protect
(with-current-buffer buffer
(unless (eq (point-min) (point-max))
(message "jupyter-command: Content written to stderr stream")
(while (not (eq (point) (point-max)))
(message " %s" (buffer-substring (line-beginning-position)
(line-end-position)))
(forward-line))))
(kill-buffer buffer))
(when (zerop status)
(with-current-buffer stdout
(string-trim-right (buffer-string)))))
(delete-file stderr-file)
(kill-buffer stdout))))
(defun jupyter-runtime-directory ()
"Return the runtime directory used by Jupyter.
Create the directory if necessary. If `default-directory' is a
remote directory, return the runtime directory on that remote.
As a side effect, the variable `jupyter-runtime-directory' is set
to the local runtime directory if it is nil."
(unless jupyter-runtime-directory
(setq jupyter-runtime-directory
(let ((default-directory (expand-file-name "~" user-emacs-directory)))
(file-name-as-directory (jupyter-command "--runtime-dir")))))
(let ((dir (if (file-remote-p default-directory)
(jupyter-command "--runtime-dir")
jupyter-runtime-directory)))
(unless dir
(error "Can't obtain runtime directory from jupyter shell command"))
(setq dir (concat (file-remote-p default-directory) dir))
(make-directory dir 'parents)
(file-name-as-directory dir)))
(defun jupyter-locate-python ()
"Return the path to the python executable in use by Jupyter.
If the `default-directory' is a remote directory, search on that
remote. Raise an error if the executable could not be found.
The paths examined are the data paths of \"jupyter --paths\" in
the order specified.
This function always returns the `file-local-name' of the path."
(let* ((remote (file-remote-p default-directory))
(paths (mapcar (lambda (x) (concat remote x))
(or (plist-get
(jupyter-read-plist-from-string
(jupyter-command "--paths" "--json"))
:data)
(error "Can't get search paths"))))
(path nil))
(cl-loop
with programs = '("bin/python3" "bin/python"
;; Need to also check Windows since paths can be
;; pointing to local or remote files.
"python3.exe" "python.exe")
with pred = (lambda (dir)
(cl-loop
for program in programs
for spath = (expand-file-name program dir)
thereis (setq path (and (file-exists-p spath) spath))))
for path in paths
thereis (locate-dominating-file path pred)
finally (error "No `python' found in search paths"))
(file-local-name path)))
(defun jupyter-write-connection-file (session)
"Write a connection file based on SESSION to `jupyter-runtime-directory'.
Return the path to the connection file."
(cl-check-type session jupyter-session)
(let* ((temporary-file-directory (jupyter-runtime-directory))
(json-encoding-pretty-print t)
(file (make-temp-file "emacs-kernel-" nil ".json")))
(prog1 file
(with-temp-file file
(insert (json-encode-plist
(jupyter-session-conn-info session)))))))
(defun jupyter-session-with-random-ports ()
"Return a `jupyter-session' with random channel ports.
The session can be used to write a connection file, see
`jupyter-write-connection-file'."
;; The actual work of making the connection file is left up to the
;; `jupyter kernel` shell command. This is done to support
;; launching remote kernels via TRAMP. The Jupyter suite of shell
;; commands probably exist on the remote system, so we rely on them
;; to figure out a set of open ports on the remote.
(with-temp-buffer
;; NOTE: On Windows, apparently the "jupyter kernel" command uses something
;; like an exec shell command to start the process which launches the kernel,
;; but exec like commands on Windows start a new process instead of replacing
;; the current one which results in the process we start here exiting after
;; the new process is launched. We call python directly to avoid this.
(let ((process (start-file-process
"jupyter-session-with-random-ports" (current-buffer)
(jupyter-locate-python) "-c"
"from jupyter_client.kernelapp import main; main()")))
(set-process-query-on-exit-flag process nil)
(jupyter-with-timeout
(nil jupyter-long-timeout
(error "`jupyter kernel` failed to show connection file path"))
(and (process-live-p process)
(goto-char (point-min))
(re-search-forward "Connection file: \\(.+\\)\n" nil t)))
(let* ((conn-file (concat
(save-match-data
(file-remote-p default-directory))
(match-string 1)))
(conn-info (jupyter-read-connection conn-file)))
;; Tell the `jupyter kernel` process to shutdown itself and
;; the launched kernel.
(interrupt-process process)
;; Wait until the connection file is cleaned up before
;; forgetting about the process completely.
(jupyter-with-timeout
(nil jupyter-default-timeout
(delete-file conn-file))
(file-exists-p conn-file))
(delete-process process)
(let ((new-key (jupyter-new-uuid)))
(plist-put conn-info :key new-key)
(jupyter-session
:conn-info conn-info
:key new-key))))))
(provide 'jupyter-env)
;;; jupyter-env.el ends here

View File

@@ -0,0 +1,502 @@
;;; jupyter-ioloop.el --- Jupyter channel subprocess -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 03 Nov 2018
;; 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:
;; An ioloop encapsulates a subprocess that communicates with its parent
;; process in a pre-defined way. The parent process sends events (lists with a
;; head element tagging the type of event and the rest of the elements being
;; the arguments), via a call to the `jupyter-send' method of a
;; `jupyter-ioloop'. The ioloop subprocess then handles the event in its
;; environment. You add an event that can be handled in the ioloop environment
;; by calling `jupyter-ioloop-add-event' before calling `jupyter-ioloop-start'.
;;
;; When one of the events added through `jupyter-ioloop-add-event'
;; returns something other than nil, it is sent back to the parent
;; process and the handler function passed to `jupyter-ioloop-start'
;; is called.
;;
;; An example that will echo back what was sent to the ioloop as a message in
;; the parent process:
;;
;; (let ((ioloop (jupyter-ioloop))
;; (jupyter-ioloop-add-event ioloop echo (data)
;; "Return DATA back to the parent process."
;; (list 'echo data))
;; (jupyter-ioloop-start ioloop (lambda (event) (message "%s" (cadr event))))
;; (jupyter-send ioloop 'echo "Message")
;; (jupyter-ioloop-stop ioloop))
;;; Code:
(require 'jupyter-base)
(require 'zmq)
(eval-when-compile (require 'subr-x))
(defvar jupyter-ioloop-poller nil
"The polling object being used to poll for events in an ioloop.")
(defvar jupyter-ioloop-stdin nil
"A file descriptor or ZMQ socket used to receive events in an ioloop.")
(defvar jupyter-ioloop-nsockets 1
"The number of sockets being polled by `jupyter-ioloop-poller'.")
(defvar jupyter-ioloop-pre-hook nil
"A hook called at the start of every polling loop.
The hook is called with no arguments.")
(defvar jupyter-ioloop-post-hook nil
"A hook called at the end of every polling loop.
The hook is called with a single argument, the list of polling
events that occurred for this iteration or nil. The polling
events have the same value as the return value of
`zmq-poller-wait-all'.")
(defvar jupyter-ioloop-timers nil)
(defvar jupyter-ioloop-timeout 200
"Maximum time (in ms) to wait for polling events on `jupyter-ioloop-poller'.")
(defvar jupyter-ioloop--argument-types nil
"Argument types added via `jupyter-ioloop-add-arg-type'.")
(defun jupyter-ioloop-environment-p ()
"Return non-nil if this Emacs instance is an IOLoop subprocess."
(and noninteractive jupyter-ioloop-stdin jupyter-ioloop-poller))
(defclass jupyter-ioloop (jupyter-finalized-object)
((process :type (or null process) :initform nil)
(callbacks :type list :initform nil)
(events :type list :initform nil)
(setup :type list :initform nil)
(teardown :type list :initform nil))
:documentation "An interface for sending asynchronous messages via a subprocess.
An ioloop starts an Emacs subprocess setup to send events back
and forth between the parent Emacs process and the ioloop
asynchronously. The ioloop subprocess is essentially a polling
loop that polls its stdin and any sockets that may have been
created in the ioloop environment and performs pre-defined
actions when stdin sends an event. The structure of the
subprocess is the following
\(progn
(let ((jupyter-ioloop-poller (zmq-poller)))
<jupyter-ioloop-setup>
<send start event to parent>
(condition-case nil
(while t
(run-hook 'jupyter-ioloop-pre-hook)
<poll for stdin/socket events>
(run-hook 'jupyter-ioloop-post-hook))
(quit
<jupyter-ioloop-teardown>
<send quit event to parent>))))
<jupyter-ioloop-setup> is replaced by the form in the setup slot
of an ioloop and can be conveniently added to using
`jupyter-ioloop-add-setup'.
<jupyter-ioloop-teardown> is replaced with the teardown slot and
can be added to using `jupyter-ioloop-add-teardown'.
<poll for stdin/socket events> is replaced by code that will
listen for stdin/socket events using `jupyter-ioloop-poller'.
You add events to be handled by the subprocess using
`jupyter-ioloop-add-event', the return value of any event added
is what is sent to the parent Emacs process and what will
eventually be the sole argument to the handler function passed to
`jupyter-ioloop-start'. To suppress the subprocess from sending
anything back to the parent, ensure nil is returned by the form
created by `jupyter-ioloop-add-event'.
See `jupyter-channel-ioloop' for an example of its usage.")
(cl-defmethod initialize-instance ((ioloop jupyter-ioloop) &optional _slots)
(cl-call-next-method)
(jupyter-add-finalizer ioloop
(lambda ()
(with-slots (process) ioloop
(when (process-live-p process)
(delete-process process))))))
(defun jupyter-ioloop-wait-until (ioloop event cb &optional timeout progress-msg)
"Wait until EVENT occurs on IOLOOP.
If EVENT occurs, call CB and return its value if non-nil. CB is
called with a single argument, an event list whose first element
is EVENT. If CB returns nil, continue waiting until EVENT occurs
again or until TIMEOUT seconds elapses, TIMEOUT defaults to
`jupyter-default-timeout'. If TIMEOUT is reached, return nil.
If PROGRESS-MSG is non-nil, a progress reporter will be displayed
while waiting using PROGRESS-MSG as the message."
(declare (indent 2))
(cl-check-type ioloop jupyter-ioloop)
(jupyter-with-timeout
(progress-msg (or timeout jupyter-default-timeout))
(let ((e (jupyter-ioloop-last-event ioloop)))
(when (eq (car-safe e) event) (funcall cb e)))))
(defun jupyter-ioloop-last-event (ioloop)
"Return the last event received on IOLOOP."
(cl-check-type ioloop jupyter-ioloop)
(and (oref ioloop process)
(process-get (oref ioloop process) :last-event)))
(defmacro jupyter-ioloop-add-setup (ioloop &rest body)
"Set IOLOOP's `jupyter-ioloop-setup' slot to BODY.
BODY is the code that will be evaluated before the IOLOOP sends a
start event to the parent process."
(declare (indent 1))
`(setf (oref ,ioloop setup)
(append (oref ,ioloop setup)
(quote ,body))))
(defmacro jupyter-ioloop-add-teardown (ioloop &rest body)
"Set IOLOOP's `jupyter-ioloop-teardown' slot to BODY.
BODY is the code that will be evaluated just before the IOLOOP
sends a quit event to the parent process."
(declare (indent 1))
`(setf (oref ,ioloop teardown)
(append (oref ,ioloop teardown)
(quote ,body))))
(defmacro jupyter-ioloop-add-arg-type (tag fun)
"Add a new argument type for arguments in `jupyter-ioloop-add-event'.
If an argument has the form (arg TAG), where TAG is a symbol, in
the ARGS argument of `jupyter-ioloop-add-event', replace it with
the result of evaluating the form returned by FUN on arg in the
IOLOOP environment.
For example suppose we define an argument type, jupyter-channel:
(jupyter-ioloop-add-arg-type jupyter-channel
(lambda (arg)
`(or (object-assoc ,arg :type jupyter-channel-ioloop-channels)
(error \"Channel not alive (%s)\" ,arg))))
and define an event like
(jupyter-ioloop-add-event ioloop stop-channel ((channel jupyter-channel))
(jupyter-stop channel))
Finally after adding other events and starting the ioloop we send
an event like
(jupyter-send ioloop \='stop-channel :shell)
Then before the stop-channel event defined by
`jupyter-ioloop-add-event' is called in the IOLOOP environment,
the value for the channel argument passed by the `jupyter-send'
call is replaced by the form returned by the function specified
in the `jupyter-ioloop-add-arg-type' call."
(declare (indent 1))
`(progn
(setf (alist-get ',tag jupyter-ioloop--argument-types nil 'remove) nil)
;; NOTE: FUN is quoted to ensure lexical closures aren't created
(push (cons ',tag ,(list '\` fun)) jupyter-ioloop--argument-types)))
(defun jupyter-ioloop--replace-args (args)
"Convert special arguments in ARGS.
Map over ARGS, converting its elements into
,arg or ,(app (lambda (x) BODY) arg)
for use in a `pcase' form. The latter form occurs when one of
ARGS is of the form (arg TAG) where TAG is one of the keys in
`jupyter-ioloop--argument-types'. BODY will be replaced with the
result of calling the function associated with TAG in
`jupyter-ioloop--argument-types'.
Return the list of converted arguments."
(mapcar (lambda (arg)
(pcase arg
(`(,val ,tag)
(let ((form (alist-get tag jupyter-ioloop--argument-types)))
(list '\, (list 'app `(lambda (x) ,(funcall form 'x)) val))))
(_ (list '\, arg))))
args))
(defmacro jupyter-ioloop-add-event (ioloop event args &optional doc &rest body)
"For IOLOOP, add an EVENT handler.
ARGS is a list of arguments that are bound when EVENT occurs. DOC
is an optional documentation string describing what BODY, the
expression which will be evaluated when EVENT occurs, does. If
BODY evaluates to any non-nil value, it will be sent to the
parent Emacs process. A nil value for BODY means don't send
anything.
Some arguments are treated specially:
If one of ARGS is a list (<sym> tag) where <sym> is any symbol,
then the parent process that sends EVENT to IOLOOP is expected to
send a value that will be bound to <sym> and be handled by an
argument handler associated with tag before BODY is evaluated in
the IOLOOP process, see `jupyter-ioloop-add-arg-type'."
(declare (indent 3) (doc-string 4) (debug t))
(unless (stringp doc)
(when doc
(setq body (cons doc body))))
`(setf (oref ,ioloop events)
(cons (list (quote ,event) (quote ,args) (quote ,body))
(cl-remove-if (lambda (x) (eq (car x) (quote ,event)))
(oref ,ioloop events)))))
(defun jupyter-ioloop--event-dispatcher (ioloop exp)
"For IOLOOP return a form suitable for matching against EXP.
That is, return an expression which will cause an event to be
fired if EXP matches any event types handled by IOLOOP.
TODO: Explain these
By default this adds the events quit, callback, and timer."
(let ((user-events
(cl-loop
for (event args body) in (oref ioloop events)
for cond = (list '\` (cl-list*
event (jupyter-ioloop--replace-args args)))
if (memq event '(quit callback timer))
do (error "Event can't be one of quit, callback, or, timer")
;; cond = `(event ,arg1 ,arg2 ...)
else collect `(,cond ,@body))))
`(let* ((cmd ,exp)
(res (pcase cmd
,@user-events
;; Default events
(`(timer ,id ,period ,cb)
;; Ensure we don't send anything back to the parent process
(prog1 nil
(let ((timer (run-at-time 0.0 period (byte-compile cb))))
(puthash id timer jupyter-ioloop-timers))))
(`(callback ,cb)
;; Ensure we don't send anything back to the parent process
(prog1 nil
(setq jupyter-ioloop-timeout 0)
(add-hook 'jupyter-ioloop-pre-hook (byte-compile cb) 'append)))
('(quit) (signal 'quit nil))
(_ (error "Unhandled command %s" cmd)))))
;; Can only send lists at the moment
(when (and res (listp res)) (zmq-prin1 res)))))
(cl-defgeneric jupyter-ioloop-add-callback ()
(declare (indent 1)))
(cl-defmethod jupyter-ioloop-add-callback ((ioloop jupyter-ioloop) cb)
"In IOLOOP, add CB to be run in the IOLOOP environment.
CB is run at the start of every polling loop. Callbacks are
called in the order they are added.
WARNING: A function added as a callback should be quoted to avoid
sending closures to the IOLOOP. An example:
(jupyter-ioloop-add-callback ioloop
`(lambda () (zmq-prin1 \='foo \"bar\")))"
(cl-assert (functionp cb))
(cl-callf append (oref ioloop callbacks) (list cb))
(when (process-live-p (oref ioloop process))
(jupyter-send ioloop 'callback (macroexpand-all cb))))
(defun jupyter-ioloop-poller-add (socket events)
"Add SOCKET to be polled using the `jupyter-ioloop-poller'.
EVENTS are the polling events that should be listened for on
SOCKET. If `jupyter-ioloop-poller' is not a `zmq-poller' object
do nothing."
(when (zmq-poller-p jupyter-ioloop-poller)
(zmq-poller-add jupyter-ioloop-poller socket events)
(cl-incf jupyter-ioloop-nsockets)))
(defun jupyter-ioloop-poller-remove (socket)
"Remove SOCKET from the `jupyter-ioloop-poller'.
If `jupyter-ioloop-poller' is not a `zmq-poller' object do
nothing."
(when (zmq-poller-p jupyter-ioloop-poller)
(zmq-poller-remove jupyter-ioloop-poller socket)
(cl-decf jupyter-ioloop-nsockets)))
(defun jupyter-ioloop--body (ioloop on-stdin)
`(let (events)
(condition-case nil
(progn
,@(oref ioloop setup)
;; Initialize any callbacks that were added before the ioloop was
;; started
(setq jupyter-ioloop-pre-hook
(mapcar (lambda (f)
(when (symbolp f)
(setq f (symbol-function f)))
(unless (byte-code-function-p f)
(byte-compile f)))
(append jupyter-ioloop-pre-hook
(quote ,(mapcar #'macroexpand-all
(oref ioloop callbacks))))))
;; Notify the parent process we are ready to do something
(zmq-prin1 '(start))
(let ((on-stdin (byte-compile (lambda () ,on-stdin))))
(while t
(run-hooks 'jupyter-ioloop-pre-hook)
(setq events
(condition-case nil
(zmq-poller-wait-all
jupyter-ioloop-poller
jupyter-ioloop-nsockets
jupyter-ioloop-timeout)
((zmq-EAGAIN zmq-EINTR zmq-ETIMEDOUT) nil)))
(let ((stdin-event (zmq-assoc jupyter-ioloop-stdin events)))
(when stdin-event
(setq events (delq stdin-event events))
(funcall on-stdin)))
(run-hook-with-args 'jupyter-ioloop-post-hook events))))
(quit
,@(oref ioloop teardown)
(zmq-prin1 '(quit))))))
(defun jupyter-ioloop--function (ioloop port)
"Return the function that does the work of IOLOOP.
The returned function is suitable to send to a ZMQ subprocess for
evaluation using `zmq-start-process'.
If PORT is non-nil the returned function will create a ZMQ PULL
socket to receive events from the parent process on the PORT of
the local host, otherwise events are expected to be received on
STDIN. This is useful on Windows systems which don't allow
polling the STDIN file handle."
`(lambda (ctx)
(push ,(file-name-directory (locate-library "jupyter-base")) load-path)
(require 'jupyter-ioloop)
(setq jupyter-ioloop-poller (zmq-poller))
(setq jupyter-ioloop-stdin
,(if port
`(let ((sock (zmq-socket ctx zmq-PAIR)))
(prog1 sock
(zmq-connect sock (format "tcp://127.0.0.1:%s" ,port))))
0))
(zmq-poller-add jupyter-ioloop-poller jupyter-ioloop-stdin zmq-POLLIN)
,(jupyter-ioloop--body
ioloop (jupyter-ioloop--event-dispatcher
ioloop (if port '(read (zmq-recv jupyter-ioloop-stdin))
'(zmq-subprocess-read))))))
(defun jupyter-ioloop-alive-p (ioloop)
"Return non-nil if IOLOOP is ready to receive/send events."
(cl-check-type ioloop jupyter-ioloop)
(with-slots (process) ioloop
(and (process-live-p process) (process-get process :start))))
(defun jupyter-ioloop--make-filter (ioloop handler)
(lambda (event)
(let ((process (oref ioloop process)))
(process-put process :last-event event)
(cond
((eq (car-safe event) 'start)
(process-put process :start t))
((eq (car-safe event) 'quit)
(process-put process :quit t))
(t
(funcall handler event))))))
(cl-defmethod jupyter-ioloop-start ((ioloop jupyter-ioloop)
handler
&key buffer)
"Start an IOLOOP.
HANDLER is a function of one argument and will be passed an event
received by the subprocess that IOLOOP represents, an event is
just a list.
If IOLOOP was previously running, it is stopped first.
If BUFFER is non-nil it should be a buffer that will be used as
the IOLOOP subprocess buffer, see `zmq-start-process'."
(jupyter-ioloop-stop ioloop)
(let (stdin port)
;; NOTE: A socket is used to read input from the parent process to avoid
;; the stdin buffering done when using `read-from-minibuffer' in the
;; subprocess. When `noninteractive', `read-from-minibuffer' uses
;; `getc_unlocked' internally and `getc_unlocked' reads from the stdin FILE
;; object as opposed to reading directly from STDIN_FILENO. The problem is
;; that FILE objects are buffered streams which means that every message
;; the parent process sends does not necessarily correspond to a POLLIN
;; event on STDIN_FILENO in the subprocess. Since we only call
;; `read-from-minibuffer' when there is a POLLIN event on STDIN_FILENO
;; there is the potential that a message is waiting to be handled in the
;; buffer used by stdin which will only get handled if we send more
;; messages to the subprocess thereby creating more POLLIN events.
(when (or t (memq system-type '(windows-nt ms-dos cygwin)))
(setq stdin (zmq-socket (zmq-current-context) zmq-PAIR))
(setq port (zmq-bind-to-random-port stdin "tcp://127.0.0.1")))
(let ((process (zmq-start-process
(jupyter-ioloop--function ioloop (when stdin port))
:filter (jupyter-ioloop--make-filter ioloop handler)
:buffer buffer)))
(oset ioloop process process)
(when stdin
(process-put process :stdin stdin))
(jupyter-ioloop-wait-until ioloop 'start #'identity))))
(cl-defmethod jupyter-ioloop-stop ((ioloop jupyter-ioloop))
"Stop IOLOOP.
Send a quit event to IOLOOP, wait until it actually quits before
returning."
(with-slots (process) ioloop
(when (process-live-p process)
(jupyter-send ioloop 'quit)
(unless (jupyter-ioloop-wait-until ioloop 'quit #'identity)
(delete-process process))
(when-let* ((stdin (process-get process :stdin))
(socket-p (zmq-socket-p stdin)))
(zmq-unbind stdin (zmq-get-option stdin zmq-LAST-ENDPOINT))))))
(defvar jupyter-ioloop--send-buffer nil)
(defun jupyter-ioloop--dump-message (plist)
(with-current-buffer
(if (buffer-live-p jupyter-ioloop--send-buffer)
jupyter-ioloop--send-buffer
(setq jupyter-ioloop--send-buffer
(get-buffer-create " *jupyter-ioloop-send*")))
(erase-buffer)
(let (print-level print-length)
(prin1 plist (current-buffer)))
(buffer-string)))
(cl-defmethod jupyter-send ((ioloop jupyter-ioloop) &rest args)
"Using IOLOOP, send ARGS to its process.
All arguments passed to this function are sent as a list to the
process unchanged. This means that all arguments should be
serializable."
(with-slots (process) ioloop
(cl-assert (process-live-p process))
(let ((stdin (process-get process :stdin)))
(if stdin
(let ((msg (jupyter-ioloop--dump-message args)) sent)
(while (not sent)
(condition-case nil
(progn
(zmq-send stdin (encode-coding-string msg 'utf-8) zmq-DONTWAIT)
(setq sent t))
(zmq-EAGAIN (accept-process-output nil 0)))))
(zmq-subprocess-send process args)))))
(provide 'jupyter-ioloop)
;;; jupyter-ioloop.el ends here

View File

@@ -0,0 +1,54 @@
;;; jupyter-javascript.el --- Jupyter support for Javascript -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 23 Oct 2018
;; 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:
;; Support methods for integration with Javascript.
;;; Code:
(require 'jupyter-repl)
(declare-function js2-parse "ext:js2-mode")
(declare-function js2-mode-apply-deferred-properties "ext:js2-mode")
(cl-defmethod jupyter-repl-after-init (&context (jupyter-lang javascript)
(jupyter-repl-mode js2-mode))
"If `js2-mode' is used for Javascript kernels, enable syntax highlighting.
`js2-mode' does not use `font-lock-defaults', but their own
custom method."
(add-hook 'after-change-functions
(lambda (_beg _end len)
;; Insertions only
(when (= len 0)
(unless (jupyter-repl-cell-finalized-p)
(let ((cbeg (jupyter-repl-cell-code-beginning-position))
(cend (jupyter-repl-cell-code-end-position)))
(save-restriction
(narrow-to-region cbeg cend)
(js2-parse)
(js2-mode-apply-deferred-properties))))))
t t))
(provide 'jupyter-javascript)
;;; jupyter-javascript.el ends here

View File

@@ -0,0 +1,245 @@
;;; jupyter-julia.el --- Jupyter support for Julia -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 23 Oct 2018
;; 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:
;; Support methods for integration with Julia.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'jupyter-repl)
(declare-function julia-latexsub-or-indent "ext:julia-mode" (arg))
(cl-defmethod jupyter-indent-line (&context (major-mode julia-mode))
"Call `julia-latexsub-or-indent'."
(call-interactively #'julia-latexsub-or-indent))
(cl-defmethod jupyter-load-file-code (file &context (jupyter-lang julia))
(format "include(\"%s\");" file))
;;; Completion
(cl-defmethod jupyter-completion-prefix (&context (jupyter-lang julia))
(cond
;; Completing argument lists
((and (char-before)
(eq (char-syntax (char-before)) ?\()
(or (not (char-after))
(looking-at-p "\\_>")
(not (memq (char-syntax (char-after)) '(?w ?_)))))
(buffer-substring-no-properties
(jupyter-completion-symbol-beginning (1- (point)))
(point)))
(t
(let ((prefix (cl-call-next-method "\\\\\\|\\.\\|::?" 2)))
(prog1 prefix
(when (consp prefix)
(let ((beg (- (point) (length (car prefix)))))
(cond
;; Include the \ in the prefix so it gets replaced if a canidate is
;; selected.
((eq (char-before beg) ?\\)
(setcar prefix (concat "\\" (car prefix))))
;; Also include : to complete symbols when used as dictionary keys
((and (eq (char-before beg) ?:)
(not (eq (char-before (1- beg)) ?:))
;; Except for when it is part of range expressions like 1:len
(not (memq (char-syntax (char-before (1- beg))) '(?w ?_))))
(setcar prefix (concat ":" (car prefix))))))))))))
(cl-defmethod jupyter-completion-post-completion (candidate
&context (jupyter-lang julia))
"Insert the unicode representation of a LaTeX completion."
(if (eq (aref candidate 0) ?\\)
(when (get-text-property 0 'annot candidate)
(search-backward candidate)
(delete-region (point) (match-end 0))
;; Alternatively use `julia-latexsub-or-indent', but I have found
;; problems with that.
(insert (string-trim (get-text-property 0 'annot candidate))))
(cl-call-next-method)))
;;; `markdown-mode'
(cl-defmethod jupyter-markdown-follow-link (link-text url _ref-label _title-text _bang
&context (jupyter-lang julia))
"Send a help query to the Julia REPL for LINK-TEXT if URL is \"@ref\".
If URL is \"@ref <section>\" then open a browser to the Julia
manual for <section>. Otherwise follow the link normally."
(if (string-prefix-p "@ref" url)
(if (string= url "@ref")
;; Links have the form `fun`
(let ((fun (substring link-text 1 -1)))
(if (not (eq major-mode 'jupyter-repl-mode))
(jupyter-inspect fun (1- (length fun)))
(goto-char (point-max))
(jupyter-repl-replace-cell-code (concat "?" fun))
(jupyter-repl-ret)))
(let* ((ref (split-string url))
(section (cadr ref)))
(browse-url
(format "https://docs.julialang.org/en/latest/manual/%s/" section))))
(cl-call-next-method)))
;;; `jupyter-repl-after-change'
(defvar ansi-color-names-vector)
(defun jupyter-julia-add-prompt (prompt color)
"Display PROMPT at the beginning of the cell using COLOR as the foreground.
Make the character after `point' invisible."
(add-text-properties (point) (1+ (point)) '(invisible t rear-nonsticky t))
(let ((ov (make-overlay (point) (1+ (point)) nil t))
(md (propertize prompt
'fontified t
'font-lock-face `((:foreground ,color)))))
(overlay-put ov 'after-string (propertize " " 'display md))
(overlay-put ov 'evaporate t)))
(defun jupyter-julia-pkg-prompt ()
"Return the Pkg prompt.
If the Pkg prompt can't be retrieved from the kernel, return
nil."
(let ((prompt-code "import Pkg; Pkg.REPLMode.promptf()"))
(jupyter-run-with-client jupyter-current-client
(jupyter-mlet* ((msg
(jupyter-reply
(jupyter-execute-request
:code ""
:silent t
:user-expressions (list :prompt prompt-code)))))
(cl-destructuring-bind (&key prompt &allow-other-keys)
(jupyter-message-get msg :user_expressions)
(cl-destructuring-bind (&key status data &allow-other-keys)
prompt
(jupyter-return
(when (equal status "ok")
(plist-get data :text/plain)))))))))
(cl-defmethod jupyter-repl-after-change ((_type (eql insert)) beg _end
&context (jupyter-lang julia))
"Change the REPL prompt when a REPL mode is entered."
(when (= beg (jupyter-repl-cell-code-beginning-position))
(save-excursion
(goto-char beg)
(when (and (bound-and-true-p blink-paren-function)
(eq (char-syntax (char-after)) ?\)))
;; Spoof `last-command-event' so that a "No matching paren" message
;; doesn't happen.
(setq last-command-event ?\[))
(cl-case (char-after)
(?\]
(when-let* ((pkg-prompt (jupyter-julia-pkg-prompt)))
(jupyter-julia-add-prompt
(substring pkg-prompt 1 (1- (length pkg-prompt)))
(aref ansi-color-names-vector 5)))) ; magenta
(?\;
(jupyter-julia-add-prompt
"shell> " (aref ansi-color-names-vector 1))) ; red
(?\?
(jupyter-julia-add-prompt
"help?> " (aref ansi-color-names-vector 3)))))) ; yellow
(cl-call-next-method))
(cl-defmethod jupyter-repl-after-change ((_type (eql delete)) beg _len
&context (jupyter-lang julia))
"Reset the prompt if needed."
(when (= beg (jupyter-repl-cell-code-beginning-position))
(jupyter-repl-cell-reset-prompt)))
;;; REPL font lock
(defun jupyter-julia--propertize-repl-mode-char (beg end)
(jupyter-repl-map-cells beg end
(lambda ()
;; Handle Julia package prompt so `syntax-ppss' works properly.
(when (and (eq (char-syntax (char-after (point-min))) ?\))
(= (point-min)
(save-restriction
(widen)
;; Looks at the position before the narrowed cell-code
;; which is why the widen is needed here.
(jupyter-repl-cell-code-beginning-position))))
(put-text-property
(point-min) (1+ (point-min)) 'syntax-table '(1 . ?.))))
#'ignore))
;;; `jupyter-repl-after-init'
(defun jupyter-julia--setup-hooks (client)
(jupyter-run-with-client client
(jupyter-sent
(jupyter-execute-request
:handlers nil
:store-history nil
:silent t
;; This is mainly for supporting the :dir header argument in
;; `org-mode' source blocks.
:code "\
if !isdefined(Main, :__JUPY_saved_dir)
Core.eval(Main, :(__JUPY_saved_dir = Ref(\"\")))
let popdir = () -> begin
if !isempty(Main.__JUPY_saved_dir[])
cd(Main.__JUPY_saved_dir[])
Main.__JUPY_saved_dir[] = \"\"
end
end
IJulia.push_posterror_hook(popdir)
IJulia.push_postexecute_hook(popdir)
end
end"))))
(cl-defmethod jupyter-repl-after-init (&context (jupyter-lang julia))
(if syntax-propertize-function
(add-function
:after (local 'syntax-propertize-function)
#'jupyter-julia--propertize-repl-mode-char)
(setq-local syntax-propertize-function #'jupyter-julia--propertize-repl-mode-char))
(jupyter-julia--setup-hooks jupyter-current-client)
;; Setup hooks after restart as well
(jupyter-add-hook jupyter-current-client 'jupyter-iopub-message-hook
(lambda (client msg)
(when (jupyter-message-status-starting-p msg)
(jupyter-julia--setup-hooks client)))))
;;; `jupyter-org'
(cl-defmethod jupyter-org-error-location (&context (jupyter-lang julia))
(when (and (re-search-forward "^Stacktrace:" nil t)
(re-search-forward "top-level scope" nil t)
(re-search-forward "In\\[[0-9]+\\]:\\([0-9]+\\)" nil t))
(string-to-number (match-string 1))))
(cl-defmethod org-babel-jupyter-transform-code (code changelist &context (jupyter-lang julia))
(when (plist-get changelist :dir)
(setq code
;; Stay on one line so that tracebacks will report the right line
;; numbers
(format "Main.__JUPY_saved_dir[] = pwd(); cd(\"%s\"); %s"
(plist-get changelist :dir) code)))
code)
(provide 'jupyter-julia)
;;; jupyter-julia.el ends here

View File

@@ -0,0 +1,416 @@
;;; jupyter-kernel-process.el --- Jupyter kernels as Emacs processes -*- lexical-binding: t -*-
;; Copyright (C) 2020-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 25 Apr 2020
;; 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:
;; Jupyter kernels as Emacs processes.
;;; Code:
(require 'jupyter-kernel)
(require 'jupyter-monads)
(defgroup jupyter-kernel-process nil
"Jupyter kernels as Emacs processes"
:group 'jupyter)
(declare-function jupyter-ioloop-start "jupyter-ioloop")
(declare-function jupyter-ioloop-stop "jupyter-ioloop")
(declare-function jupyter-send "jupyter-ioloop")
(declare-function jupyter-ioloop-alive-p "jupyter-ioloop")
(declare-function jupyter-channel-ioloop-set-session "jupyter-channel-ioloop")
(declare-function ansi-color-apply "ansi-color")
(declare-function jupyter-hb-pause "jupyter-zmq-channel")
(defvar jupyter--kernel-processes '()
"The list of kernel processes launched.
Elements look like (PROCESS CONN-FILE) where PROCESS is a kernel
process and CONN-FILE the associated connection file.
Cleaning up the list removes elements whose PROCESS is no longer
live. When removing an element, CONN-FILE will be deleted and
PROCESS's buffer killed.
The list is periodically cleaned up when a new process is
launched.
Also, just before Emacs exits any connection files that still
exist are deleted.")
;;; Kernel definition
(cl-defstruct (jupyter-kernel-process
(:include jupyter-kernel))
connect-p)
(cl-defmethod jupyter-process ((kernel jupyter-kernel-process))
"Return the process of KERNEL.
Return nil if KERNEL does not have an associated process."
(car (cl-find-if (lambda (x) (and (processp (car x))
(eq (process-get (car x) :kernel) kernel)))
jupyter--kernel-processes)))
(cl-defmethod jupyter-alive-p ((kernel jupyter-kernel-process))
(let ((process (jupyter-process kernel)))
(and (process-live-p process)
(cl-call-next-method))))
(defun jupyter-kernel-process (&rest args)
"Return a `jupyter-kernel-process' initialized with ARGS."
(apply #'make-jupyter-kernel-process args))
(cl-defmethod jupyter-kernel :extra "process" (&rest args)
"Return a kernel as an Emacs process.
If ARGS contains a :spec key with a value being a
`jupyter-kernelspec', a `jupyter-kernel-process' initialized from
it will be returned. The value can also be a string, in which
case it is considered the name of a kernelspec to use.
If ARGS contains a :conn-info key, a `jupyter-kernel-process'
with a session initialized from its value, either the name of a
connection file to read or a connection property list itself (see
`jupyter-read-connection'), will be returned. The remaining ARGS
will be used to initialize the returned kernel.
Call the next method if ARGS does not contain a :spec or
:conn-info key."
(if (plist-get args :server) (cl-call-next-method)
(let ((spec (plist-get args :spec))
(conn-info (plist-get args :conn-info)))
(cond
((and spec (not conn-info))
(when (stringp spec)
(plist-put args :spec
(or (jupyter-guess-kernelspec spec)
(error "No kernelspec matching name (%s)" spec))))
(cl-check-type (plist-get args :spec) jupyter-kernelspec)
(apply #'jupyter-kernel-process args))
(conn-info
(apply #'jupyter-kernel-process
:session (if (stringp conn-info)
(jupyter-connection-file-to-session conn-info)
conn-info)
(cl-loop
for (k v) on args by #'cddr
unless (eq k :conn-info) collect k and collect v)))
(t
(cl-call-next-method))))))
;;; Client connection
(cl-defmethod jupyter-zmq-io ((kernel jupyter-kernel-process))
(unless (jupyter-kernel-process-connect-p kernel)
(jupyter-launch kernel))
(let ((channels '(:shell :iopub :stdin :control))
session ch-group hb kernel-io ioloop shutdown)
(cl-macrolet ((continue-after
(cond on-timeout)
`(jupyter-with-timeout
(nil jupyter-default-timeout ,on-timeout)
,cond)))
(cl-labels ((set-session
()
(or (setq session (jupyter-kernel-session kernel))
(error "A session is needed to connect to a kernel's I/O")))
(set-ch-group
()
(let ((endpoints (jupyter-session-endpoints (set-session))))
(setq ch-group
(cl-loop
for ch in channels
collect ch
collect (list :endpoint (plist-get endpoints ch)
:alive-p nil)))))
(ch-put
(ch prop value)
(plist-put (plist-get ch-group ch) prop value))
(ch-get
(ch prop)
(plist-get (plist-get ch-group ch) prop))
(ch-alive-p
(ch)
(and ioloop (jupyter-ioloop-alive-p ioloop)
(ch-get ch :alive-p)))
(ch-start
(ch)
(unless (ch-alive-p ch)
(jupyter-send ioloop 'start-channel ch
(ch-get ch :endpoint))
(continue-after
(ch-alive-p ch)
(error "Channel failed to start: %s" ch))))
(ch-stop
(ch)
(when (ch-alive-p ch)
(jupyter-send ioloop 'stop-channel ch)
(continue-after
(not (ch-alive-p ch))
(error "Channel failed to stop: %s" ch))))
(start
()
(unless ioloop
(require 'jupyter-zmq-channel-ioloop)
(setq ioloop (make-instance 'jupyter-zmq-channel-ioloop))
(jupyter-channel-ioloop-set-session ioloop session))
(unless (jupyter-ioloop-alive-p ioloop)
(jupyter-ioloop-start
ioloop
(lambda (event)
(pcase (car event)
((and 'start-channel (let ch (cadr event)))
(ch-put ch :alive-p t))
((and 'stop-channel (let ch (cadr event)))
(ch-put ch :alive-p nil))
;; TODO: Get rid of this
('sent nil)
(_
(jupyter-run-with-io kernel-io
(jupyter-publish event))))))
(condition-case err
(cl-loop
for ch in channels
do (ch-start ch))
(error
(jupyter-ioloop-stop ioloop)
(signal (car err) (cdr err)))))
ioloop)
(stop
()
(when hb
(jupyter-hb-pause hb)
(setq hb nil))
(when ioloop
(when (jupyter-ioloop-alive-p ioloop)
(jupyter-ioloop-stop ioloop))
(setq ioloop nil))))
(set-ch-group)
(setq kernel-io
;; TODO: (jupyter-publisher :name "Session I/O" :fn ...)
;;
;; so that on error in a subscriber, the name can be
;; displayed to know where to look. This requires a
;; `jupyter-publisher' struct type.
(jupyter-publisher
(lambda (content)
(if shutdown
(error "Kernel I/O no longer available: %s"
(cl-prin1-to-string session))
(pcase (car content)
;; ('message channel idents . msg)
('message
(pop content)
;; Set the channel key of the message property list
(plist-put
(cddr content) :channel
(substring (symbol-name (car content)) 1))
(jupyter-content (cddr content)))
('send
;; Set the channel argument to a keyword so its
;; recognized by the ioloop
(setq content
(cons (car content)
(cons (intern (concat ":" (cadr content)))
(cddr content))))
(apply #'jupyter-send (start) content))
('hb
(unless hb
(setq hb
(let ((endpoints (set-session)))
(make-instance
'jupyter-hb-channel
:session session
:endpoint (plist-get endpoints :hb)))))
(jupyter-run-with-io (cadr content)
(jupyter-publish hb)))
(_ (error "Unhandled I/O: %s" content)))))))
(list kernel-io
(jupyter-subscriber
(lambda (action)
(pcase action
('interrupt
(jupyter-interrupt kernel))
('shutdown
(jupyter-shutdown kernel)
(stop)
(setq shutdown t))
('restart
(setq shutdown nil)
(jupyter-restart kernel)
(stop)
(set-ch-group)
(start))
(`(action ,fn)
(funcall fn kernel))))))))))
(cl-defmethod jupyter-io ((kernel jupyter-kernel-process))
"Return an I/O connection to KERNEL's session."
(jupyter-zmq-io kernel))
;;; Kernel management
(defun jupyter--gc-kernel-processes ()
(setq jupyter--kernel-processes
(cl-loop for (p conn-file) in jupyter--kernel-processes
if (process-live-p p) collect (list p conn-file)
else do (delete-process p)
(when (file-exists-p conn-file)
(delete-file conn-file))
and when (buffer-live-p (process-buffer p))
do (kill-buffer (process-buffer p)))))
(defun jupyter-delete-connection-files ()
"Delete all connection files created by Emacs."
;; Ensure Emacs can be killed on error
(ignore-errors
(cl-loop for (_ conn-file) in jupyter--kernel-processes
do (when (file-exists-p conn-file)
(delete-file conn-file)))))
(add-hook 'kill-emacs-hook #'jupyter-delete-connection-files)
(defun jupyter--start-kernel-process (name kernelspec conn-file)
(let* ((process-name (format "jupyter-kernel-%s" name))
(buffer-name (format " *jupyter-kernel[%s]*" name))
(process-environment
(append (jupyter-process-environment kernelspec)
process-environment))
(args (jupyter-kernel-argv kernelspec conn-file))
(atime (nth 4 (file-attributes conn-file)))
(process (apply #'start-file-process process-name
(generate-new-buffer buffer-name)
(car args) (cdr args))))
(set-process-query-on-exit-flag process jupyter--debug)
;; Wait until the connection file has been read before returning.
;; This is to give the kernel a chance to setup before sending it
;; messages.
;;
;; TODO: Replace with a check of the heartbeat channel.
(jupyter-with-timeout
((format "Starting %s kernel process..." name)
jupyter-long-timeout
(unless (process-live-p process)
(error "Kernel process exited:\n%s"
(with-current-buffer (process-buffer process)
(ansi-color-apply (buffer-string))))))
;; Windows systems may not have good time resolution when retrieving
;; the last access time of a file so we don't bother with checking that
;; the kernel has read the connection file and leave it to the
;; downstream initialization to ensure that we can communicate with a
;; kernel.
(or (memq system-type '(ms-dos windows-nt cygwin))
(let ((attribs (file-attributes conn-file)))
;; `file-attributes' can potentially return nil, in this case
;; just assume it has read the connection file so that we can
;; know for sure it is not connected if it fails to respond to
;; any messages we send it.
(or (null attribs)
(not (equal atime (nth 4 attribs)))))))
(jupyter--gc-kernel-processes)
(push (list process conn-file) jupyter--kernel-processes)
process))
(cl-defmethod jupyter-launch :before ((kernel jupyter-kernel-process))
"Ensure KERNEL has a non-nil SESSION slot.
A `jupyter-session' with random port numbers for the channels and
a newly generated message signing key will be set as the value of
KERNEL's SESSION slot if it is nil."
(pcase-let (((cl-struct jupyter-kernel-process session) kernel))
(unless session
(setf (jupyter-kernel-session kernel) (jupyter-session-with-random-ports))
;; This is here for stability when running the tests. Sometimes
;; the kernel ports are not set up fast enough due to the hack
;; done in `jupyter-session-with-random-ports'. The effect
;; seems to be messages that are sent but never received by the
;; kernel.
(sit-for 0.2))))
(cl-defmethod jupyter-launch ((kernel jupyter-kernel-process))
"Start KERNEL's process.
Do nothing if KERNEL's process is already live.
The process arguments are constructed from KERNEL's SPEC. The
connection file passed as argument to the process is first
written to file, its contents are generated from KERNEL's SESSION
slot.
See also https://jupyter-client.readthedocs.io/en/stable/kernels.html#kernel-specs"
(let ((process (jupyter-process kernel)))
(unless (process-live-p process)
(pcase-let (((cl-struct jupyter-kernel-process spec session) kernel))
(let ((conn-file (jupyter-write-connection-file session)))
(setq process (jupyter--start-kernel-process
(jupyter-kernel-name kernel) spec
conn-file))
;; Make local tunnels to the remote ports when connecting to
;; remote kernels. Update the session object to reflect
;; these changes.
(when (file-remote-p conn-file)
(setf (jupyter-kernel-session kernel)
(let ((conn-info (jupyter-tunnel-connection conn-file)))
(jupyter-session
:conn-info conn-info
:key (plist-get conn-info :key)))))))
(setf (process-get process :kernel) kernel)
(setf (process-sentinel process)
(lambda (process _)
(pcase (process-status process)
('signal
(let ((kernel (process-get process :kernel)))
(when kernel
(warn "Kernel died unexpectedly")
(jupyter-shutdown kernel)))))))))
(cl-call-next-method))
(cl-defmethod jupyter-shutdown ((kernel jupyter-kernel-process))
"Shutdown KERNEL by killing its process unconditionally."
(let ((process (jupyter-process kernel)))
(when process
(setf (process-get process :kernel) nil)
(delete-process process))
(cl-call-next-method)))
(cl-defmethod jupyter-restart ((_kernel jupyter-kernel-process))
(cl-call-next-method))
(cl-defmethod jupyter-interrupt ((kernel jupyter-kernel-process))
"Interrupt KERNEL's process.
The process can be interrupted when the interrupt mode of
KERNEL's spec. is \"signal\" or not specified.
See also https://jupyter-client.readthedocs.io/en/stable/kernels.html#kernel-specs"
(pcase-let* ((process (jupyter-process kernel))
((cl-struct jupyter-kernel-process spec) kernel)
((cl-struct jupyter-kernelspec plist) spec)
(imode (plist-get plist :interrupt_mode)))
(cond
((or (null imode) (string= imode "signal"))
(when (process-live-p process)
(interrupt-process process t)))
((string= imode "message")
(error "Send an interrupt_request using a client"))
(t (cl-call-next-method)))))
(provide 'jupyter-kernel-process)
;;; jupyter-kernel-process.el ends here

View File

@@ -0,0 +1,130 @@
;;; jupyter-kernel.el --- Kernels -*- lexical-binding: t -*-
;; Copyright (C) 2020-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 21 Apr 2020
;; 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:
;; Working with Jupyter kernels. This file contains the functions
;; used to control the lifetime of a kernel and how clients can
;; connect to launched kernels.
;;; Code:
(require 'jupyter-base)
(require 'jupyter-monads)
(require 'jupyter-kernelspec)
(defgroup jupyter-kernel nil
"Kernels"
:group 'jupyter)
;;; Kernel definition
(cl-defstruct jupyter-kernel
"A Jupyter kernel."
(spec (make-jupyter-kernelspec)
:type jupyter-kernelspec
:documentation "The kernelspec of this kernel.")
;; FIXME: Remove this slot, used by `jupyter-widget-client'.
(session nil :type jupyter-session))
(cl-defmethod jupyter-alive-p ((kernel jupyter-kernel))
"Return non-nil if KERNEL has been launched."
(and (jupyter-kernel-session kernel) t))
(cl-defmethod cl-print-object ((kernel jupyter-kernel) stream)
(princ (format "#<jupyter-kernel %s%s>"
(jupyter-kernelspec-name (jupyter-kernel-spec kernel))
(if (jupyter-alive-p kernel)
(concat " " (truncate-string-to-width
(jupyter-session-id (jupyter-kernel-session kernel))
9 nil nil ""))
""))
stream))
(cl-defgeneric jupyter-kernel (&rest args)
"Return a kernel constructed from ARGS.
This method can be extended with extra primary methods for the
purposes of handling different forms of ARGS."
(let ((server (plist-get args :server))
(conn-info (plist-get args :conn-info))
(spec (plist-get args :spec)))
(cond
(server
(require 'jupyter-server-kernel)
(apply #'jupyter-kernel args))
((or conn-info spec)
(require 'jupyter-kernel-process)
(apply #'jupyter-kernel args))
(t (cl-call-next-method)))))
;;; Kernel management
(defun jupyter-kernel-name (kernel)
(jupyter-kernelspec-name
(jupyter-kernel-spec kernel)))
(cl-defmethod jupyter-launch ((kernel jupyter-kernel))
"Launch KERNEL."
(cl-assert (jupyter-alive-p kernel)))
(cl-defmethod jupyter-launch :before ((kernel jupyter-kernel))
"Notify that the kernel launched."
(message "Launching %s kernel..." (jupyter-kernel-name kernel)))
(cl-defmethod jupyter-launch :after ((kernel jupyter-kernel))
"Notify that the kernel launched."
(message "Launching %s kernel...done" (jupyter-kernel-name kernel)))
(cl-defmethod jupyter-shutdown ((kernel jupyter-kernel))
"Shutdown KERNEL.
Once a kernel has been shutdown it has no more connected clients
and the process it represents no longer exists.
The default implementation of this method disconnects all
connected clients of KERNEL and sets KERNEL's session slot to
nil."
(setf (jupyter-kernel-session kernel) nil))
(cl-defmethod jupyter-shutdown :before ((kernel jupyter-kernel))
"Notify that the kernel will be shutdown."
(message "%s kernel shutdown..." (jupyter-kernel-name kernel)))
(cl-defmethod jupyter-shutdown :after ((kernel jupyter-kernel))
"Notify that the kernel launched."
(message "%s kernel shutdown...done" (jupyter-kernel-name kernel)))
(cl-defmethod jupyter-restart ((kernel jupyter-kernel))
"Restart KERNEL.
The default implementation shuts down and then re-launches
KERNEL."
(jupyter-shutdown kernel)
(jupyter-launch kernel))
(cl-defmethod jupyter-interrupt ((_kernel jupyter-kernel))
"Interrupt KERNEL."
(ignore))
(provide 'jupyter-kernel)
;;; jupyter-kernel.el ends here

View File

@@ -0,0 +1,273 @@
;;; jupyter-kernelspec.el --- Jupyter kernelspecs -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 17 Jan 2018
;; 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:
;; Functions to work with kernelspecs found by the shell command
;;
;; jupyter kernelspec list
;;; Code:
(require 'json)
(require 'jupyter-env)
(defgroup jupyter-kernelspec nil
"Jupyter kernelspecs"
:group 'jupyter)
(declare-function jupyter-read-plist "jupyter-base" (file))
(declare-function jupyter-read-plist-from-string "jupyter-base" (file))
(cl-defstruct jupyter-kernelspec
(name "python"
:type string
:documentation "The name of the kernelspec."
:read-only t)
(plist nil
:type list
:documentation "The kernelspec as a property list."
:read-only t)
(resource-directory nil
:type (or null string)
:documentation "The resource directory."
:read-only t))
(defvar jupyter--kernelspecs (make-hash-table :test #'equal :size 5)
"A hash table mapping hosts to the kernelspecs available on them.
The top level hash-table maps hosts to nested hash-tables keyed
on virtual environment path or nil for a system-wide Jupyter
install: hosts[hash-table] -> venv[hash-table] -> kernelspecs.")
(defun jupyter-kernelspecs-ensure-cache (host)
"Return, creating if necessary, the hash-table for HOST."
(let ((cache (gethash host jupyter--kernelspecs)))
(if cache cache
(puthash host (make-hash-table :test #'equal :size 5)
jupyter--kernelspecs))))
(defun jupyter-kernelspecs-cache-put (host kernelspecs)
"Cache KERNELSPECS available on HOST.
This takes into account any currently active virtual
environment."
(let ((venv (getenv "VIRTUAL_ENV")))
(let ((cache (jupyter-kernelspecs-ensure-cache host)))
(puthash venv kernelspecs cache))))
(defun jupyter-kernelspecs-cache-get (host)
"Retrieve cached KERNELSPECS available on HOST.
This takes into account any currently active virtual
environment."
(let ((venv (getenv "VIRTUAL_ENV")))
(let ((cache (jupyter-kernelspecs-ensure-cache host)))
(gethash venv cache))))
(defun jupyter-available-kernelspecs (&optional refresh)
"Return the available kernelspecs.
Return a list of `jupyter-kernelspec's available on the host
associated with the `default-directory'. If `default-directory'
is a remote file name, return the list of available kernelspecs
on the remote system. The kernelspecs on the local system are
returned otherwise (taking into account any currently active
virtual environment).
On any system, the list is formed by parsing the output of the
shell command
jupyter kernelspec list --json
By default the available kernelspecs are cached. To force an
update of the cached kernelspecs, give a non-nil value to
REFRESH."
(let* ((host (or (file-remote-p default-directory) "local"))
(kernelspecs
(or (and (not refresh) (jupyter-kernelspecs-cache-get host))
(let ((specs
(plist-get
(let ((json (or (jupyter-command "kernelspec" "list"
"--json" "--log-level" "ERROR")
(error "\
Can't obtain kernelspecs from jupyter shell command"))))
(condition-case nil
(jupyter-read-plist-from-string json)
(error
(error "\
Jupyter kernelspecs couldn't be parsed from
jupyter kernelspec list --json
To investiagate further, run that command in a shell and examine
why it isn't returning valid JSON."))))
:kernelspecs)))
(jupyter-kernelspecs-cache-put
host
(sort
(cl-loop
for (kname spec) on specs by #'cddr
for name = (substring (symbol-name kname) 1)
for dir = (plist-get spec :resource_dir)
collect (make-jupyter-kernelspec
:name name
:resource-directory (concat
(unless (string= host "local") host)
dir)
:plist (plist-get spec :spec)))
(lambda (x y)
(string< (jupyter-kernelspec-name x)
(jupyter-kernelspec-name y)))))))))
kernelspecs))
(cl-defgeneric jupyter-kernelspecs (host &optional refresh)
"Return a list of kernelspecs on HOST.
If REFRESH is non-nil, then refresh the list of cached
kernelspecs first. Otherwise a cached version of the kernelspecs
may be returned.")
(cl-defmethod jupyter-kernelspecs ((host string) &optional refresh)
(let ((default-directory host))
(jupyter-available-kernelspecs refresh)))
(cl-defmethod jupyter-do-refresh-kernelspecs ()
(jupyter-kernelspecs default-directory 'refresh))
;;;###autoload
(defun jupyter-refresh-kernelspecs ()
"Refresh the list of available kernelspecs.
Execute this command if the kernelspecs seen by Emacs is out of
sync with those specified on your system or notebook server."
(interactive)
(message "Refreshing kernelspecs...")
(jupyter-do-refresh-kernelspecs)
(message "Refreshing kernelspecs...done"))
(defun jupyter-get-kernelspec (name &optional specs refresh)
"Get the kernelspec for a kernel named NAME.
If no kernelspec is found, return nil. Otherwise return the
kernelspec for the kernel named NAME.
If SPECS is provided, it is a list of kernelspecs that will be
searched. Otherwise the kernelspecs associated with the
`default-directory' are used.
Optional argument REFRESH has the same meaning as in
`jupyter-kernelspecs'."
(cl-loop
for kernelspec in (or specs (jupyter-kernelspecs default-directory refresh))
thereis (when (string= (jupyter-kernelspec-name kernelspec) name)
kernelspec)))
(defun jupyter-find-kernelspecs (re &optional specs refresh)
"Find all specs of kernels that have names matching RE.
RE is a regular expression use to match the name of a kernel.
Return a list of `jupyter-kernelspec' objects.
If SPECS is non-nil search SPECS, otherwise search the
kernelspecs associated with the `default-directory'.
Optional argument REFRESH has the same meaning as in
`jupyter-kernelspecs'."
(cl-remove-if-not
(lambda (kernelspec)
(string-match-p re (jupyter-kernelspec-name kernelspec)))
(or specs (jupyter-kernelspecs default-directory refresh))))
(defun jupyter-guess-kernelspec (name &optional specs refresh)
"Return the first kernelspec starting with NAME.
Raise an error if no kernelspec could be found.
SPECS and REFRESH have the same meaning as in
`jupyter-find-kernelspecs'."
(or (car (jupyter-find-kernelspecs (format "^%s" name) specs refresh))
(error "No valid kernelspec for kernel name (%s)" name)))
(defun jupyter-completing-read-kernelspec (&optional specs refresh)
"Use `completing-read' to select a kernel and return its kernelspec.
SPECS is a list of kernelspecs that will be used for completion,
if it is nil the kernelspecs associated with the
`default-directory' will be used.
Optional argument REFRESH has the same meaning as in
`jupyter-kernelspecs'."
(let* ((specs (or specs (jupyter-kernelspecs default-directory refresh)))
(display-names (if (null specs) (error "No kernelspecs available")
(mapcar (lambda (k)
(plist-get
(jupyter-kernelspec-plist k)
:display_name))
specs)))
(name (completing-read "kernel: " display-names nil t)))
(when (equal name "")
(error "No kernelspec selected"))
(nth (- (length display-names)
(length (member name display-names)))
specs)))
(defun jupyter-expand-environment-variables (var)
"Return VAR with all environment variables expanded.
VAR is a string, if VAR contains a sequence of characters like
${ENV_VAR}, substitute it with the value of ENV_VAR in
`process-environment'."
(let ((expanded "")
(start 0))
(while (string-match "\\${\\([^}]+\\)}" var start)
(cl-callf concat expanded
(substring var start (match-beginning 0))
(getenv (match-string 1 var)))
(setq start (match-end 0)))
(cl-callf concat expanded (substring var start))))
(defun jupyter-process-environment (kernelspec)
"Return a list of environment variables contained in KERNELSPEC.
The list of environment variables have the same form as the
entries in `process-environment'.
The environment variables returned are constructed from those in
the :env key of KERNELSPEC's property list."
(cl-loop
with env = (plist-get (jupyter-kernelspec-plist kernelspec) :env)
for (k v) on env by #'cddr
collect (format "%s=%s" (cl-subseq (symbol-name k) 1)
(jupyter-expand-environment-variables v))))
(defun jupyter-kernel-argv (kernelspec conn-file)
"Return a list of process arguments contained in KERNELSPEC.
The process arguments are the ones that should be passed to
kernel processes launched using KERNELSPEC.
CONN-FILE is the file name of a connection file, containing the
IP address and ports (among other things), a
launched kernel should connect to."
(cl-loop
with argv = (plist-get (jupyter-kernelspec-plist kernelspec) :argv)
for arg in (append argv nil)
if (equal arg "{connection_file}")
collect (file-local-name conn-file)
else if (equal arg "{resource_dir}")
collect (file-local-name
(jupyter-kernelspec-resource-directory
kernelspec))
else collect arg))
(provide 'jupyter-kernelspec)
;;; jupyter-kernelspec.el ends here

View File

@@ -0,0 +1,678 @@
;;; jupyter-messages.el --- Jupyter messages -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 08 Jan 2018
;; 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:
;; Routines to sign, encode, decode, send, and receive Jupyter messages.
;; Messages are represented as property lists, the contents of a message should
;; never be accessed directly since decoding of a message's contents is done on
;; demand. You access the message contents through `jupyter-message-content',
;; `jupyter-message-header', `jupyter-message-metadata', etc.
;;
;; There are convenience macros: `jupyter-with-message-content' and
;; `jupyter-with-message-data'.
;;
;; There are many convenience functions: `jupyter-message-data',
;; `jupyter-message-get', `jupyter-message-type',
;; `jupyter-message-status-idle-p', etc.
;;
;; See the "Convenience functions and macros" section.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'jupyter-base)
(require 'hmac-def)
(require 'parse-time)
(require 'json)
(declare-function jupyter-request "jupyter-monads" (type &rest content))
(declare-function jupyter-verify-inhibited-handlers "jupyter-client")
(defgroup jupyter-messages nil
"Jupyter messages"
:group 'jupyter)
(defconst jupyter-message-delimiter "<IDS|MSG>"
"The message delimiter required in the jupyter messaging protocol.")
(defconst jupyter--false :json-false
"The symbol used to disambiguate nil from boolean false.")
(defconst jupyter--empty-dict (make-hash-table :size 1)
"An empty hash table to disambiguate nil during encoding.
Message parts that are nil, but should be encoded into an empty
dictionary are set to this value so that they are encoded as
dictionaries.")
;;; UUID
(defun jupyter-new-uuid ()
"Return a version 4 UUID."
(format "%04x%04x-%04x-%04x-%04x-%06x%06x"
(cl-random 65536)
(cl-random 65536)
(cl-random 65536)
;; https://tools.ietf.org/html/rfc4122
(let ((r (cl-random 65536)))
(if (= (byteorder) ?l)
;; ?l = little-endian
(logior (logand r 4095) 16384)
;; big-endian
(logior (logand r 65295) 64)))
(let ((r (cl-random 65536)))
(if (= (byteorder) ?l)
(logior (logand r 49151) 32768)
(logior (logand r 65471) 128)))
(cl-random 16777216)
(cl-random 16777216)))
;;; Signing messages
(defun jupyter-sha256 (object)
"Return the SHA256 hash of OBJECT."
(secure-hash 'sha256 object nil nil t))
(define-hmac-function jupyter-hmac-sha256 jupyter-sha256 64 32)
(cl-defun jupyter-sign-message (session parts &optional (signer #'jupyter-hmac-sha256))
"Use SESSION to sign message PARTS.
Return the signature of PARTS. PARTS should be in the order of a
valid Jupyter message, see `jupyter-decode-message'. SIGNER is
the message signing function and should take two arguments, the
text to sign and the key used for signing. The default value
signs messages using `jupyter-hmac-sha256'."
(if (> (length (jupyter-session-key session)) 0)
(cl-loop
;; NOTE: Encoding to a unibyte representation due to an "Attempt to
;; change byte length of a string" error.
with key = (encode-coding-string
(jupyter-session-key session) 'utf-8 t)
with parts = (encode-coding-string
(cl-loop for part in parts concat part)
'utf-8 t)
for byte across (funcall signer parts key)
concat (format "%02x" byte))
""))
(defun jupyter--split-identities (parts)
"Extract the identities from a list of message PARTS.
Return a cons cell (IDENTS . REST-PARTS)."
(or (cl-loop
for (part . rest-parts) on parts by #'cdr
if (equal part jupyter-message-delimiter)
return (cons idents rest-parts)
else collect part into idents)
(error "Message delimiter not in message list")))
(defun jupyter--message-header (session msg-type msg-id)
"Return a message header.
The `:session' key of the header will have its value set to
SESSION's ID, and its `:msg_type' will be set to MSG-TYPE. MSG-ID
will be set to the value of the `:msg_id' key. The other fields
of the returned plist are `:version', `:username', and `:date'.
They are all set to appropriate default values."
(list
:msg_id msg-id
:msg_type msg-type
:version jupyter-protocol-version
:username user-login-name
:session (jupyter-session-id session)
:date (format-time-string "%FT%T.%6N%z" (current-time))))
;;; Encode/decoding messages
(defun jupyter--encode (part)
"Encode PART into a JSON string.
Take into account `jupyter-message-type' keywords by replacing
them with their appropriate message type strings according to the
Jupyter messaging spec. After encoding into a JSON
representation, return the UTF-8 encoded string.
If PART is a string, return the UTF-8 encoded string without
encoding into JSON first.
If PART is a list whose first element is the symbol,
`message-part', then return the second element of the list if it
is non-nil. If it is nil, then set the list's second element to
the result of calling `jupyter--encode' on the third element and
return the result."
(let ((original (if (fboundp 'json--print)
#'json--print
#'json-encode)))
(cl-letf (((symbol-function original)
(apply-partially #'jupyter--json-encode
(symbol-function original))))
(encode-coding-string
(cond
((stringp part) part)
(t (json-encode part)))
'utf-8 t))))
(defun jupyter--json-encode (original object)
(let (msg-type)
(cond
((eq (car-safe object) 'message-part)
(cl-destructuring-bind (_ encoded-rep decoded-rep) object
(or encoded-rep (setf (nth 1 object)
(jupyter--json-encode original decoded-rep)))))
((and (keywordp object)
(setf msg-type (plist-get jupyter-message-types object)))
(json-encode msg-type))
((and (listp object)
(= (length object) 4)
(cl-every #'integerp object))
(jupyter-encode-time object))
(t (funcall original object)))))
(defun jupyter--decode (part)
"Decode a message PART.
If PART is a list whose first element is the symbol,
`message-part', then return the third element of the list if it
is non-nil. If it is nil, then set the list's third element to
the result of calling `jupyter--decode' on the second element and
return the result.
Otherwise, if PART is a string decode it using UTF-8 encoding and
read it as a JSON string. If it is not valid JSON, return the
decoded string."
(if (eq (car-safe part) 'message-part)
(cl-destructuring-bind (_ encoded-rep decoded-rep) part
(or decoded-rep (setf (nth 2 part) (jupyter--decode encoded-rep))))
(let* ((json-object-type 'plist)
(str (decode-coding-string part 'utf-8)))
(condition-case nil
(json-read-from-string str)
;; If it can't be read as JSON, assume its just a regular
;; string
(json-unknown-keyword str)))))
(defun jupyter-decode-time (str)
"Decode an ISO 8601 time STR into a time object.
The returned object has the same form as the object returned by
`current-time'."
(unless (string-match-p "T[^.,Z+-]+" str)
(setq str (concat str "T00:00:00")))
(save-match-data
(string-match "T[^.,Z+-]+\\([.,]\\([0-9]+\\)\\)" str)
(let ((fraction (match-string 2 str)))
(when fraction
(setq str (replace-match "" nil nil str 1)))
(nconc (parse-iso8601-time-string str)
(if fraction
(let* ((plen (- 6 (length fraction)))
(pad (and (> plen 0) (expt 10 plen))))
(list (if pad (* pad (string-to-number fraction))
(string-to-number (substring fraction 0 6)))
0))
(list 0 0))))))
(defun jupyter-encode-time (time)
"Encode TIME into an ISO 8601 time string."
(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
type
&key idents
content
(msg-id (jupyter-new-uuid))
parent-header
metadata
buffers
(signer #'jupyter-hmac-sha256))
(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))
(and (stringp idents) (setq idents (list idents)))
(let ((parts (mapcar #'jupyter--encode
(list (jupyter--message-header session type msg-id)
parent-header
metadata
content))))
(nconc (cl-list* msg-id idents)
(cl-list* jupyter-message-delimiter
(jupyter-sign-message session parts signer)
parts)
buffers)))
(cl-defun jupyter-decode-message (session parts &key (signer #'jupyter-hmac-sha256))
"Use SESSION to decode message PARTS.
PARTS should be a list of message parts in the order of a valid
Jupyter message, i.e. a list of the form
(signature header parent-header metadata content buffers...)
If SESSION supports signing messages, then the signature
resulting from the signing of (cdr PARTS) using SESSION should be
equal to SIGNATURE. An error is thrown if it is not.
If SIGNER is non-nil it should be a function used to sign the
message. Otherwise the default signing function is used, see
`jupyter-sign-message'.
The returned plist has elements of the form
(message-part JSON PLIST)
for the keys `:header', `:parent-header', `:metadata', and
`:content'. JSON is the JSON encoded string of the message part.
For `:header' and `:parent-header', PLIST will be the decoded
message PLIST for the part. The other message parts are decoded
into property lists on demand, i.e. after a call to
`jupyter-message-metadata' or `jupyter-message-content' PLIST
will be decoded message part.
The binary buffers are left unchanged and will be the value of
the `:buffers' key in the returned plist. Also, the message ID
and type are available in the top level of the plist as `:msg_id'
and `:msg_type'."
(when (< (length parts) 5)
(error "Malformed message. Minimum length of parts is 5"))
(when (jupyter-session-key session)
(let ((signature (car parts)))
(when (= (length signature) 0)
(error "Unsigned message"))
;; TODO: digest_history
;; https://github.com/jupyter/jupyter_client/blob/7a0278af7c1652ac32356d6f00ae29d24d78e61c/jupyter_client/session.py#L915
(unless (string= (jupyter-sign-message session (cdr parts) signer) signature)
(error "Invalid signature (%s) for parts %S" signature (cdr parts)))))
(cl-destructuring-bind
(header parent-header metadata content &rest buffers)
(cdr parts)
(let ((dheader (jupyter--decode header)))
(list
:header (list 'message-part header dheader)
:msg_id (plist-get dheader :msg_id)
:msg_type (plist-get dheader :msg_type)
;; Also decode the parent header here since it is used quite often in
;; the parent Emacs process
:parent_header (list 'message-part parent-header
(jupyter--decode parent-header))
:metadata (list 'message-part metadata nil)
:content (list 'message-part content nil)
:buffers buffers))))
(defvar jupyter-inhibit-handlers)
(defmacro jupyter-with-client-handlers (handlers &rest body)
"Evaluate BODY with `jupyter-inhibit-handlers' bound according to HANDLERS.
HANDLERS has the inverted meaning of `jupyter-inhibit-handlers'."
(declare (indent 1) (debug (form body)))
(let ((h (make-symbol "handlers")))
`(let* ((,h ,handlers)
(jupyter-inhibit-handlers
(pcase ,h
('t nil)
('nil t)
(`(not . ,els) els)
(_ (cons 'not ,h)))))
(jupyter-verify-inhibited-handlers)
,@body)))
;;; Control messages
(cl-defun jupyter-interrupt-request (&key (handlers t))
(jupyter-with-client-handlers handlers
(jupyter-request "interrupt_request")))
;;; stdin messages
(cl-defun jupyter-input-reply (&key value (handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type value string)
(jupyter-request "input_reply"
:value value)))
;;; shell messages
(cl-defun jupyter-kernel-info-request (&key (handlers t))
(jupyter-with-client-handlers handlers
(jupyter-request "kernel_info_request")))
(cl-defun jupyter-execute-request (&key code
(silent nil)
(store-history t)
(user-expressions nil)
(allow-stdin t)
(stop-on-error nil)
(handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type code string)
(cl-check-type user-expressions json-plist)
(jupyter-request "execute_request"
:code code :silent (if silent t jupyter--false)
:store_history (if store-history t jupyter--false)
:user_expressions (or user-expressions jupyter--empty-dict)
:allow_stdin (if allow-stdin t jupyter--false)
:stop_on_error (if stop-on-error t jupyter--false))))
(cl-defun jupyter-inspect-request (&key code (pos 0) (detail 0)
(handlers t))
(jupyter-with-client-handlers handlers
(setq detail (or detail 0))
(unless (member detail '(0 1))
(error "Detail can only be 0 or 1 (%s)" detail))
(when (markerp pos)
(setq pos (marker-position pos)))
(cl-check-type code string)
(cl-check-type pos integer)
(jupyter-request "inspect_request"
:code code :cursor_pos pos :detail_level detail)))
(cl-defun jupyter-complete-request (&key code (pos 0) (handlers t))
(jupyter-with-client-handlers handlers
(when (markerp pos)
(setq pos (marker-position pos)))
(cl-check-type code string)
(cl-check-type pos integer)
(jupyter-request "complete_request"
:code code :cursor_pos pos)))
(cl-defun jupyter-history-request (&key
output
raw
(hist-access-type "tail")
session
start
stop
(n 10)
pattern
unique
(handlers t))
(jupyter-with-client-handlers handlers
(unless (member hist-access-type '("range" "tail" "search"))
(error "History access type can only be one of (range, tail, search)"))
(apply #'jupyter-request "history_request"
(append
(list :output (if output t jupyter--false) :raw (if raw t jupyter--false)
:hist_access_type hist-access-type)
(cond
((equal hist-access-type "range")
(cl-check-type session integer)
(cl-check-type start integer)
(cl-check-type stop integer)
(list :session session :start start :stop stop))
((equal hist-access-type "tail")
(cl-check-type n integer)
(list :n n))
((equal hist-access-type "search")
(cl-check-type pattern string)
(cl-check-type n integer)
(list :pattern pattern :unique (if unique t jupyter--false) :n n)))))))
(cl-defun jupyter-is-complete-request (&key code (handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type code string)
(jupyter-request "is_complete_request"
:code code)))
(cl-defun jupyter-comm-info-request (&key (target-name "")
(handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type target-name string)
(jupyter-request "comm_info_request"
:target_name target-name)))
(cl-defun jupyter-comm-open (&key id target-name data
(handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type id string)
(cl-check-type target-name string)
(cl-check-type data json-plist)
(jupyter-request "comm_open"
:comm_id id :target_name target-name :data data)))
(cl-defun jupyter-comm-msg (&key id data (handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type id string)
(cl-check-type data json-plist)
(jupyter-request "comm_msg"
:comm_id id :data data)))
(cl-defun jupyter-comm-close (&key id data (handlers t))
(jupyter-with-client-handlers handlers
(cl-check-type id string)
(cl-check-type data json-plist)
(jupyter-request "comm_close"
:comm_id id :data data)))
(cl-defun jupyter-shutdown-request (&key restart (handlers t))
(jupyter-with-client-handlers handlers
(jupyter-request "shutdown_request"
:restart (if restart t jupyter--false))))
;;; Convenience functions and macros
(defmacro jupyter-with-message-content (msg keys &rest body)
"For MSG, bind the corresponding KEYS of its contents then evaluate BODY.
KEYS is a list of key names found in the
`jupyter-message-content' of MSG. The values are bound to their
key names while evaluating BODY.
So to bind the :status key of MSG you would do
(jupyter-with-message-content msg (status)
BODY)"
(declare (indent 2) (debug (form listp body)))
(if keys
`(cl-destructuring-bind (&key ,@keys &allow-other-keys)
(jupyter-message-content ,msg)
,@body)
`(progn ,@body)))
(defmacro jupyter-with-message-data (msg varlist &rest body)
"For MSG, bind the mimetypes in VARLIST and evaluate BODY.
VARLIST has a similar form to the VARLIST of a `let' binding
except the `cadr' of each binding is a mimetype that will be
passed to `jupyter-message-data'.
So to bind the :text/plain mimetype of MSG to a variable, res,
you would do
(jupyter-with-message-data msg ((res text/plain))
BODY)"
(declare (indent 2) (debug (form (&rest (symbolp symbolp)) body)))
(let* ((m (make-symbol "msg"))
(vars
(mapcar (lambda (el)
(list (car el)
`(jupyter-message-data
,m ',(if (keywordp (cadr el)) (cadr el)
(intern (concat ":" (symbol-name (cadr el))))))))
varlist)))
(if vars `(let* ((,m ,msg) ,@vars)
,@body)
`(progn ,@body))))
(defmacro jupyter-message-lambda (keys &rest body)
"Return a function binding KEYS to fields of a message then evaluating BODY.
The returned function takes a single argument which is expected
to be a Jupyter message property list.
The elements of KEYS can either be a symbol, KEY, or a two
element list (VAL MIMETYPE). In the former case, KEY will be
bound to the corresponding value of KEY in the
`jupyter-message-content' of the message argument. In the latter
case, VAL will be bound to the value of the MIMETYPE found in the
`jupyter-message-data' of the message."
(declare (indent defun) (debug ((&rest [&or symbolp (symbolp symbolp)]) body)))
(let ((msg (cl-gensym "msg"))
content-keys
data-keys)
(while (car keys)
(let ((key (pop keys)))
(push key (if (listp key) data-keys content-keys))))
`(lambda (,msg)
,(cond
((and data-keys content-keys)
`(jupyter-with-message-content ,msg ,content-keys
(jupyter-with-message-data ,msg ,data-keys
,@body)))
(data-keys
`(jupyter-with-message-data ,msg ,data-keys
,@body))
(content-keys
`(jupyter-with-message-content ,msg ,content-keys
,@body))
(t
`(progn ,@body))))))
(defmacro jupyter--decode-message-part (key msg)
"Return a form to decode the value of KEY in MSG.
If the value of KEY is a list whose first element is the symbol
`message-part', then if the the third element of the list is nil
set it to the result of calling `jupyter--decode' on the second
element. If the third element is non-nil, return it. Otherwise
return the value of KEY in MSG."
`(let ((part (plist-get ,msg ,key)))
(if (and (consp part) (eq (car part) 'message-part))
(or (nth 2 part) (jupyter--decode part))
part)))
(defun jupyter-message-header (msg)
"Get the header of MSG."
(jupyter--decode-message-part :header msg))
(defun jupyter-message-parent-header (msg)
"Get the parent header of MSG."
(jupyter--decode-message-part :parent_header msg))
(defun jupyter-message-metadata (msg)
"Get the metadata key of MSG."
(jupyter--decode-message-part :metadata msg))
(defun jupyter-message-content (msg)
"Get the MSG contents."
(jupyter--decode-message-part :content msg))
(defsubst jupyter-message-id (msg)
"Get the ID of MSG."
(or (plist-get msg :msg_id)
(plist-get (jupyter-message-header msg) :msg_id)))
(defsubst jupyter-message-parent-id (msg)
"Get the parent ID of MSG."
(jupyter-message-id (jupyter-message-parent-header msg)))
(defsubst jupyter-message-type (msg)
"Get the type of MSG."
(or (plist-get msg :msg_type)
(plist-get (jupyter-message-header msg) :msg_type)))
(defsubst jupyter-message-session (msg)
"Get the session ID of MSG."
(plist-get (jupyter-message-header msg) :session))
(defsubst jupyter-message-parent-type (msg)
"Get the type of MSG's parent message."
(jupyter-message-type (jupyter-message-parent-header msg)))
(defun jupyter-message-time (msg)
"Get the MSG time.
The returned time has the same form as returned by
`current-time'."
(let* ((header (jupyter-message-header msg))
(date (plist-member header :data)))
(when (stringp (car date))
(setcar date (jupyter-decode-time (car date))))
(car date)))
(defsubst jupyter-message-get (msg key)
"Get the value in MSG's `jupyter-message-content' that corresponds to KEY."
(plist-get (jupyter-message-content msg) key))
(defsubst jupyter-message-data (msg mimetype)
"Get the message data for a specific mimetype.
MSG should be a message with a `:data' field in its contents.
MIMETYPE is should be a standard media mimetype
keyword (`:text/plain', `:image/png', ...). If the messages data
has a key corresponding to MIMETYPE, return the value. Otherwise
return nil."
(plist-get (jupyter-message-get msg :data) mimetype))
(defsubst jupyter-message-status-idle-p (msg)
"Determine if MSG is a status: idle message."
(and (string= (jupyter-message-type msg) "status")
(string= (jupyter-message-get msg :execution_state) "idle")))
(defun jupyter-message-status-starting-p (msg)
"Determine if MSG is a status: starting message."
(and (string= (jupyter-message-type msg) "status")
(string= (jupyter-message-get msg :execution_state) "starting")))
(provide 'jupyter-messages)
;;; jupyter-messages.el ends here

View File

@@ -0,0 +1,671 @@
;;; jupyter-mime.el --- Insert mime types -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 09 Nov 2018
;; 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:
;; Routines for working with MIME types.
;; Also adds the following methods which may be extended:
;;
;; - jupyter-markdown-follow-link
;; - jupyter-insert
;;
;; For working with display IDs, currently rudimentary
;;
;; - jupyter-current-display
;; - jupyter-beginning-of-display
;; - jupyter-end-of-display
;; - jupyter-next-display-with-id
;; - jupyter-delete-current-display
;; - jupyter-update-display
;;; Code:
(require 'jupyter-base)
(require 'shr)
(require 'ansi-color)
(declare-function jupyter-message-content "jupyter-messages" (msg))
(declare-function org-format-latex "org" (prefix &optional beg end dir overlays msg forbuffer processing-type))
(declare-function markdown-link-at-pos "ext:markdown-mode" (pos))
(declare-function markdown-follow-link-at-point "ext:markdown-mode")
;;; User variables
(defcustom jupyter-image-max-width 0
"Maximum width of images in REPL.
Wider images are resized. Special value 0 means no limit."
:type 'integer
:group 'jupyter-repl)
;;; Implementation
(defvar-local jupyter-display-ids nil
"A hash table of display IDs.
Display IDs are implemented by setting the text property,
`jupyter-display', to the display ID requested by a
`:display-data' message. When a display is updated from an
`:update-display-data' message, the display ID from the initial
`:display-data' message is retrieved from this table and used to
find the display in the REPL buffer. See
`jupyter-update-display'.")
;;; Macros
;; Taken from `eshell-handle-control-codes'
(defun jupyter-handle-control-codes (beg end)
"Handle any control sequences between BEG and END."
(save-excursion
(goto-char beg)
(while (< (point) end)
(let ((char (char-after)))
(cond
((eq char ?\r)
(if (< (1+ (point)) end)
(if (memq (char-after (1+ (point)))
'(?\n ?\r))
(delete-char 1)
(let ((end (1+ (point))))
(beginning-of-line)
(delete-region (point) end)))
(add-text-properties (point) (1+ (point))
'(invisible t))
(forward-char)))
((eq char ?\a)
(delete-char 1)
(beep))
((eq char ?\C-h)
(delete-region (1- (point)) (1+ (point))))
(t
(forward-char)))))))
(defmacro jupyter-with-control-code-handling (&rest body)
"Handle control codes in any produced output generated by evaluating BODY.
After BODY is evaluated, call `jupyter-handle-control-codes'
on the region inserted by BODY."
(let ((beg (make-symbol "beg"))
(end (make-symbol "end")))
`(jupyter-with-insertion-bounds
,beg ,end (progn ,@body)
;; Handle continuation from previous messages
(when (eq (char-before ,beg) ?\r)
(move-marker ,beg (1- ,beg)))
(jupyter-handle-control-codes ,beg ,end))))
;;; Fontificiation routines
(defun jupyter-fontify-buffer-name (mode)
"Return the buffer name for fontifying MODE."
(format " *jupyter-fontify[%s]*" mode))
(defun jupyter-fontify-buffer (mode)
"Return the buffer used to fontify text for MODE.
Retrieve the buffer for MODE from `jupyter-fontify-buffers'.
If no buffer for MODE exists, create a new one."
(let ((buf (get-buffer-create (jupyter-fontify-buffer-name mode))))
(with-current-buffer buf
(unless (eq major-mode mode)
(delay-mode-hooks (funcall mode))))
buf))
(defun jupyter-fixup-font-lock-properties (beg end &optional object)
"Fixup the text properties in the `current-buffer' between BEG END.
If OBJECT is non-nil, fixup the text properties of OBJECT. Fixing
the text properties involves substituting any `face' property
with `font-lock-face'."
(let ((next beg) val)
(while (/= beg end)
(setq val (get-text-property beg 'face object)
next (next-single-property-change beg 'face object end))
(remove-text-properties beg next '(face) object)
(put-text-property beg next 'font-lock-face (or val 'default) object)
(setq beg next))))
(defun jupyter-add-font-lock-properties (start end &optional object use-face)
"Add font lock text properties between START and END in the `current-buffer'.
START, END, and OBJECT have the same meaning as in
`add-text-properties'. The properties added are the ones that
mark the text between START and END as fontified according to
font lock. Any text between START and END that does not have a
font-lock-face property will have the default face filled in for
the property and the face text property is swapped for
font-lock-face.
If USE-FACE is non-nil, do not replace the face text property
with font-lock-face."
(unless use-face
(jupyter-fixup-font-lock-properties start end object))
(add-text-properties start end '(fontified t font-lock-fontified t) object))
(defun jupyter-fontify-according-to-mode (mode str &optional use-face)
"Fontify a string according to MODE.
Return the fontified string. In addition to fontifying STR, if
MODE has a non-default `fill-forward-paragraph-function', STR
will be filled using `fill-region'.
If USE-FACE is non-nil, do not replace the face text property
with font-lock-face in the returned string."
(with-current-buffer (jupyter-fontify-buffer mode)
(erase-buffer)
(insert str)
(font-lock-ensure)
(jupyter-add-font-lock-properties (point-min) (point-max) nil use-face)
(when (not (memq fill-forward-paragraph-function
'(forward-paragraph)))
(fill-region (point-min) (point-max) t 'nosqueeze))
(buffer-string)))
(defun jupyter-fontify-region-according-to-mode (mode beg end)
"Fontify a region according to MODE.
Fontify the region between BEG and END in the current buffer
according to MODE. This works by creating a new indirect buffer,
enabling MODE in the new buffer, ensuring the region is font
locked, adding required text properties, and finally re-enabling
the `major-mode' that was current before the call to this
function."
(let ((restore-mode major-mode))
(with-current-buffer
(make-indirect-buffer
(current-buffer) (generate-new-buffer-name
(jupyter-fontify-buffer-name mode)))
(unwind-protect
(save-restriction
(narrow-to-region beg end)
(delay-mode-hooks (funcall mode))
(font-lock-ensure)
(jupyter-fixup-font-lock-properties beg end))
(kill-buffer)))
(funcall restore-mode)))
;;; Special handling of ANSI sequences
(defun jupyter-ansi-color-apply-on-region (begin end &optional face-prop)
"`ansi-color-apply-on-region' with Jupyter specific modifications.
In particular, does not delete escape sequences between BEGIN and
END from the buffer. Instead, an invisible text property with a
value of t is added to render the escape sequences invisible.
Also, the `ansi-color-apply-face-function' is hard-coded to a
custom function that prepends to the face property of the text
and also sets the FACE-PROP to the prepended face, if FACE-PROP
is nil it defaults to `font-lock-face'.
For convenience, a jupyter-invisible property is also added with
a value of t. This is mainly for modes like `org-mode' which
strip invisible properties during fontification. In such cases,
the jupyter-invisible property can act as an alias to the
invisible property by adding it to `char-property-alias-alist'."
(cl-letf (((symbol-function #'delete-region)
(lambda (beg end)
(add-text-properties beg end '(invisible t jupyter-invisible t))))
(ansi-color-apply-face-function
(lambda (beg end face)
(when face
(setq face (list face))
(font-lock-prepend-text-property beg end 'face face)
(put-text-property beg end (or face-prop 'font-lock-face) face)))))
(ansi-color-apply-on-region begin end)))
;;; `jupyter-insert' method
(cl-defgeneric jupyter-insert (_mime _data &optional _metadata)
"Insert MIME data in the current buffer.
Additions to this method should insert DATA assuming it has a
mime type of MIME. If METADATA is non-nil, it will be a property
list containing extra properties for inserting DATA such as
:width and :height for image mime types.
If MIME is considered handled, but does not insert anything in
the current buffer, return a non-nil value to indicate that MIME
has been handled."
(ignore))
(cl-defmethod jupyter-insert ((plist cons) &optional metadata)
"Insert the content contained in PLIST.
PLIST should be a property list that contains the key :data and
optionally the key :metadata. The value of :data shall be another
property list that contains MIME types as keys and their
representations as values. Alternatively, PLIST can be a full
message property list or be a property list that itself contains
mimetypes.
For each MIME type in `jupyter-mime-types' call
(jupyter-insert MIME (plist-get data MIME) (plist-get metadata MIME))
until one of the invocations inserts text into the current
buffer (tested by comparisons with `buffer-modified-tick') or
returns a non-nil value. When either of these cases occur, return
MIME.
Note on non-graphic displays, `jupyter-nongraphic-mime-types' is
used instead of `jupyter-mime-types'.
When no valid mimetype is present, a warning is shown and nil is
returned."
(cl-assert plist json-plist)
(let ((content (jupyter-normalize-data plist metadata)))
(cond
((let ((tick (buffer-modified-tick)))
(jupyter-map-mime-bundle (if (display-graphic-p) jupyter-mime-types
jupyter-nongraphic-mime-types)
content
(lambda (mime content)
(and (or (jupyter-insert
mime (plist-get content :data)
(plist-get content :metadata))
(/= tick (buffer-modified-tick)))
mime)))))
(t
(prog1 nil
(let ((warning
(format "No valid mimetype found: %s"
(cl-loop for (k _v) on (plist-get content :data)
by #'cddr collect k))))
(display-warning 'jupyter warning)))))))
;;; HTML
(defun jupyter--shr-put-image (spec alt &optional flags)
"Identical to `shr-put-image', but ensure :ascent is 50.
SPEC, ALT and FLAGS have the same meaning as in `shr-put-image'.
The :ascent of an image is set to 50 so that the image center
aligns on the current line."
(let ((image (shr-put-image spec alt flags)))
(prog1 image
(when image
;; Ensure we use an ascent of 50 so that the image center aligns with
;; the output prompt of a REPL buffer.
(setf (image-property image :ascent) 50)
(force-window-update)))))
(defun jupyter-browse-url-in-temp-file (data)
"Insert DATA into a temp file and call `browse-url-of-file' on it."
(let* ((secs (time-to-seconds))
;; Allow showing the same DATA, but only after a 10s period. This is
;; so that the same data doesn't get displayed multiple times very
;; quickly. See #121.
(secs (- secs (cl-rem secs 10)))
(hash (sha1 (concat data (format-time-string "%H%M%S" secs))))
(file (expand-file-name
(concat "emacs-jupyter-" hash ".html")
temporary-file-directory)))
(unless (file-exists-p file)
(with-temp-file file (insert data))
(browse-url-of-file file)
;; Give the external browser time to open the tmp file before deleting it
;; based on mm-display-external
(run-at-time
60 nil
(lambda ()
(ignore-errors (delete-file file)))))))
(defun jupyter--delete-script-tags (beg end)
(save-excursion
(save-restriction
(narrow-to-region beg end)
(goto-char beg)
(while (re-search-forward "<script[^>]*>" nil t)
(delete-region
(match-beginning 0)
(if (re-search-forward "</script>" nil t)
(point)
(point-max)))))))
(defun jupyter-insert-html (html)
"Parse and insert the HTML string using `shr'."
(jupyter-with-insertion-bounds
beg end (insert html)
;; TODO: We can't really do much about javascript so
;; delete those regions instead of trying to parse
;; them. Maybe just re-direct to a browser like with
;; widgets?
;; NOTE: Parsing takes a very long time when the text
;; is > ~500000 characters.
(jupyter--delete-script-tags beg end)
(let ((shr-put-image-function #'jupyter--shr-put-image)
;; Avoid issues with proportional fonts. Sometimes not all of the
;; text is rendered using proportional fonts. See #52.
(shr-use-fonts nil))
(if (save-excursion
(goto-char beg)
(looking-at "<\\?xml"))
;; Be strict about syntax when the html returned explicitly asks to
;; be parsed as xml. `libxml-parse-html-region' converts camel cased
;; tags/attributes such as viewBox to viewbox in the dom since html
;; is case insensitive. See #4.
(cl-letf (((symbol-function #'libxml-parse-html-region)
#'libxml-parse-xml-region))
(shr-render-region beg end))
(shr-render-region beg end)))
(jupyter-add-font-lock-properties beg end)))
;;; Markdown
(defvar markdown-hide-markup)
(defvar markdown-enable-math)
(defvar markdown-hide-urls)
(defvar markdown-fontify-code-blocks-natively)
(defvar markdown-mode-mouse-map)
(defvar jupyter-markdown-mouse-map
(let ((map (make-sparse-keymap)))
(define-key map [return] 'jupyter-markdown-follow-link-at-point)
(define-key map [follow-link] 'mouse-face)
(define-key map [mouse-2] 'jupyter-markdown-follow-link-at-point)
map)
"Keymap when `point' is over a markdown link in the REPL buffer.")
(cl-defgeneric jupyter-markdown-follow-link (_link-text _url _ref-label _title-text _bang)
"Follow the markdown link at `point'."
(markdown-follow-link-at-point))
(defun jupyter-markdown-follow-link-at-point ()
"Handle markdown links specially."
(interactive)
(let ((link (markdown-link-at-pos (point))))
(when (car link)
(apply #'jupyter-markdown-follow-link (cddr link)))))
(defun jupyter-insert-markdown (text)
"Insert TEXT, fontifying it using `markdown-mode' first."
(let ((beg (point)))
(insert
(let ((markdown-hide-markup t)
(markdown-hide-urls t)
(markdown-enable-math t)
(markdown-fontify-code-blocks-natively t))
(jupyter-fontify-according-to-mode 'markdown-mode text)))
;; Update keymaps
(let ((end (point)) next)
(setq beg (next-single-property-change beg 'keymap nil end))
(while (/= beg end)
(setq next (next-single-property-change beg 'keymap nil end))
(when (eq (get-text-property beg 'keymap) markdown-mode-mouse-map)
(put-text-property beg next 'keymap jupyter-markdown-mouse-map))
(setq beg next)))))
;;; LaTeX
(defvar org-format-latex-options)
(defvar org-preview-latex-image-directory)
(defvar org-babel-jupyter-resource-directory)
(defvar org-preview-latex-default-process)
(defun jupyter-insert-latex (tex)
"Generate and insert a LaTeX image based on TEX.
Note that this uses `org-format-latex' to generate the LaTeX
image."
;; FIXME: Getting a weird error when killing the temp buffers created by
;; `org-format-latex'. When generating the image, it seems that the temp
;; buffers created have the same major mode and local variables as the REPL
;; buffer which causes the query function to ask to kill the kernel client
;; when the temp buffers are killed!
(let ((kill-buffer-query-functions nil)
;; This is added to in `org-babel-jupyter-initiate-session-by-key'
(kill-buffer-hook nil)
(org-format-latex-options
`(:foreground
default
:background default :scale 2.0
:matchers ,(plist-get org-format-latex-options :matchers))))
(jupyter-with-insertion-bounds
beg end (insert tex)
;; FIXME: Best way to cleanup these files? Just delete them by reading
;; the image data and using that for the image instead?
(org-format-latex
"ltximg" beg end org-babel-jupyter-resource-directory
'overlays nil 'forbuffer
;; Use the default method for creating image files
org-preview-latex-default-process)
;; Avoid deleting the image overlays due to text property changes
(dolist (o (overlays-in beg end))
(when (eq (overlay-get o 'org-overlay-type)
'org-latex-overlay)
(overlay-put o 'modification-hooks nil)))
(overlay-recenter end)
(goto-char end))))
;;; Images
(defun jupyter-insert-image (data type &optional metadata)
"Insert image DATA as TYPE in the current buffer.
TYPE has the same meaning as in `create-image'. METADATA is a
plist containing :width and :height keys that will be used as the
width and height of the image."
(cl-destructuring-bind (&key width height needs_background &allow-other-keys)
metadata
(let ((img (create-image
data type 'data :width width :height height
:max-width (when (> jupyter-image-max-width 0)
jupyter-image-max-width)
:mask (when needs_background
'(heuristic t)))))
(insert-image img))))
;;; Plain text
(defun jupyter-insert-ansi-coded-text (text)
"Insert TEXT, converting ANSI color codes to font lock faces."
(jupyter-with-insertion-bounds
beg end (insert (ansi-color-apply text))
(jupyter-fixup-font-lock-properties beg end)))
;;; `jupyter-insert' method additions
(cl-defmethod jupyter-insert ((_mime (eql :text/html)) data
&optional _metadata)
(if (not (functionp 'libxml-parse-html-region))
(cl-call-next-method)
(jupyter-insert-html data)
(insert "\n")))
(cl-defmethod jupyter-insert ((_mime (eql :text/markdown)) data
&context ((require 'markdown-mode nil t)
(eql markdown-mode))
&optional _metadata)
(jupyter-insert-markdown data))
(cl-defmethod jupyter-insert ((_mime (eql :text/latex)) data
&context ((require 'org nil t)
(eql org))
&optional _metadata)
(jupyter-insert-latex data)
(insert "\n"))
(cl-defmethod jupyter-insert ((_mime (eql :image/svg+xml)) data
&context ((and (image-type-available-p 'svg) t)
(eql t))
&optional metadata)
(jupyter-insert-image data 'svg metadata)
(insert "\n"))
(cl-defmethod jupyter-insert ((_mime (eql :image/jpeg)) data
&context ((and (image-type-available-p 'jpeg) t)
(eql t))
&optional metadata)
(jupyter-insert-image (base64-decode-string data) 'jpeg metadata)
(insert "\n"))
(cl-defmethod jupyter-insert ((_mime (eql :image/png)) data
&context ((and (image-type-available-p 'png) t)
(eql t))
&optional metadata)
(jupyter-insert-image (base64-decode-string data) 'png metadata)
(insert "\n"))
(cl-defmethod jupyter-insert ((_mime (eql :text/plain)) data
&optional _metadata)
;; Reset the context so that it doesn't leak into DATA if DATA has
;; no ANSI sequences.
(setq ansi-color-context nil)
(jupyter-insert-ansi-coded-text data)
(insert "\n"))
;;; Insert with display IDs
(cl-defmethod jupyter-insert :before ((_display-id string) &rest _ignore)
"Initialize `juptyer-display-ids'"
;; FIXME: Set the local display ID hash table for the current buffer, or
;; should display IDs be global? Then we would have to associate marker
;; positions as well in this table.
(unless jupyter-display-ids
(setq jupyter-display-ids (make-hash-table
:test #'equal
:weakness 'value))))
(cl-defmethod jupyter-insert ((display-id string) data &optional metadata)
"Associate DISPLAY-ID with DATA when inserting DATA.
DATA and METADATA have the same meaning as in
`jupyter-insert'.
The default implementation adds a jupyter-display text property
to any inserted text and a jupyter-display-begin property to the
first character.
Currently there is no support for associating a DISPLAY-ID if
DATA is displayed as a widget."
(jupyter-with-insertion-bounds
beg end (jupyter-insert data metadata)
;; Don't add display IDs to widgets since those are currently implemented
;; using an external browser and not in the current buffer.
(when (and (not (memq :application/vnd.jupyter.widget-view+json data))
(< beg end))
(let ((id (gethash display-id jupyter-display-ids)))
(unless id
(setq id (puthash display-id display-id jupyter-display-ids)))
(put-text-property beg end 'jupyter-display id)
(put-text-property beg (1+ beg) 'jupyter-display-begin t)))))
(cl-defgeneric jupyter-current-display ()
"Return the display ID for the display at `point'.
The default implementation returns the jupyter-display text
property at `point'."
(get-text-property (point) 'jupyter-display))
(cl-defgeneric jupyter-beginning-of-display ()
"Go to the beginning of the current Jupyter display.
The default implementation moves `point' to the position of the
character with a jupyter-display-begin property. If `point' is
already at a character with such a property, then `point' is
returned."
(if (get-text-property (point) 'jupyter-display-begin) (point)
(goto-char
(previous-single-property-change
(point) 'jupyter-display-begin nil (point-min)))))
(cl-defgeneric jupyter-end-of-display ()
"Go to the end of the current Jupyter display."
(goto-char
(min (next-single-property-change
(point) 'jupyter-display nil (point-max))
(next-single-property-change
(min (1+ (point)) (point-max))
'jupyter-display-begin nil (point-max)))))
(cl-defgeneric jupyter-next-display-with-id (id)
"Go to the start of the next display matching ID.
Return non-nil if successful. If no display with ID is found,
return nil without moving `point'.
The default implementation searches the current buffer for text
with a jupyter-display text property matching ID."
(or (and (bobp) (eq id (get-text-property (point) 'jupyter-display)))
(let ((pos (next-single-property-change (point) 'jupyter-display-begin)))
(while (and pos (not (eq (get-text-property pos 'jupyter-display) id)))
(setq pos (next-single-property-change pos 'jupyter-display-begin)))
(and pos (goto-char pos)))))
(cl-defgeneric jupyter-delete-current-display ()
"Delete the current Jupyter display.
The default implementation checks if `point' has a non-nil
jupyter-display text property, if so, it deletes the surrounding
region around `point' containing that same jupyter-display
property."
(when (jupyter-current-display)
(delete-region
(save-excursion (jupyter-beginning-of-display) (point))
(save-excursion (jupyter-end-of-display) (point)))))
(cl-defmethod jupyter-update-display ((display-id string) data &optional metadata)
"Update the display with DISPLAY-ID using DATA.
DATA and METADATA have the same meaning as in a `:display-data'
message."
(let ((id (and jupyter-display-ids
(gethash display-id jupyter-display-ids))))
(unless id
(error "Display ID not found (%s)" display-id))
(save-excursion
(goto-char (point-min))
(let (bounds)
(while (jupyter-next-display-with-id id)
(jupyter-delete-current-display)
(jupyter-with-insertion-bounds
beg end (if bounds (insert-buffer-substring
(current-buffer) (car bounds) (cdr bounds))
(jupyter-insert id data metadata))
(unless bounds
(setq bounds (cons (copy-marker beg) (copy-marker end))))
(pulse-momentary-highlight-region beg end 'secondary-selection)))
(when bounds
(set-marker (car bounds) nil)
(set-marker (cdr bounds) nil)))
(when (= (point) (point-min))
(error "No display matching id (%s)" id)))))
;;; Pandoc
(defun jupyter-pandoc-convert (from to from-string &optional callback)
"Use pandoc to convert a string in FROM format to TO format.
Starts a process and converts FROM-STRING, assumed to be in FROM
format, to a string in TO format and returns the converted
string.
If CALLBACK is specified, return the process object. When the
process exits, call CALLBACK with zero arguments and with the
buffer containing the converted string current."
(cl-assert (executable-find "pandoc"))
(let* ((process-connection-type nil)
(proc (start-process
"jupyter-pandoc"
(generate-new-buffer " *jupyter-pandoc*")
"pandoc" "-f" from "-t" to "--")))
(set-process-sentinel
proc (lambda (proc _)
(when (memq (process-status proc) '(exit signal))
(with-current-buffer (process-buffer proc)
(funcall callback)
(kill-buffer (process-buffer proc))))))
(process-send-string proc from-string)
(process-send-eof proc)
(if callback proc
(let ((to-string ""))
(setq callback (lambda () (setq to-string (buffer-string))))
(while (zerop (length to-string))
(accept-process-output nil 1))
to-string))))
(provide 'jupyter-mime)
;;; jupyter-mime.el ends here

View File

@@ -0,0 +1,494 @@
;;; jupyter-monads.el --- Monadic Jupyter -*- lexical-binding: t -*-
;; Copyright (C) 2020-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 11 May 2020
;; 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:
;; TODO: Generalize `jupyter-with-io' and `jupyter-do' for any monad,
;; not just the I/O one.
;;
;; TODO: Allow pcase patterns in mlet*
;;
;; (jupyter-mlet* ((value (jupyter-server-kernel-io kernel)))
;; (pcase-let ((`(,kernel-sub ,event-pub) value))
;; ...))
;;
;; into
;;
;; (jupyter-mlet* ((`(,kernel-sub ,event-pub)
;; (jupyter-server-kernel-io kernel)))
;; ...)
;;; Code:
(require 'jupyter-base)
(require 'thunk)
(declare-function jupyter-handle-message "jupyter-client")
(declare-function jupyter-kernel-io "jupyter-client")
(declare-function jupyter-generate-request "jupyter-client")
(declare-function jupyter-wait-until-idle "jupyter-client" (req &optional timeout progress-msg))
(defgroup jupyter-monads nil
"Monadic Jupyter"
:group 'jupyter)
(defconst jupyter--return-nil (lambda (state) (cons nil state)))
(defun jupyter-return (value)
"Return a monadic value wrapping VALUE."
(declare (indent 0)
(compiler-macro
(lambda (exp)
(cond
((null value)
'jupyter--return-nil)
((if (atom value)
(not (symbolp value))
(eq (car value) 'quote))
`(lambda (state) (cons ,value state)))
(t exp)))))
(lambda (state) (cons value state)))
(defun jupyter-get-state ()
"Return a monadic valid whose unwrapped value is the current state."
(lambda (state) (cons state state)))
(defun jupyter-put-state (value)
"Return a monadic value that sets the current state to VALUE.
The unwrapped value is nil."
(lambda (_state) (cons nil value)))
(defun jupyter-bind (mvalue mfn)
"Bind MVALUE to MFN."
(declare (indent 1))
(lambda (state)
(pcase-let* ((`(,value . ,state) (funcall mvalue state)))
(funcall (funcall mfn value) state))))
(defmacro jupyter-mlet* (varlist &rest body)
"Bind the monadic values in VARLIST, evaluate BODY.
Return the result of evaluating BODY. The result of evaluating
BODY should be another monadic value."
(declare (indent 1) (debug ((&rest (symbolp form)) body)))
(if (null varlist)
(if (zerop (length body)) 'jupyter--return-nil
`(progn ,@body))
(pcase-let ((`(,name ,mvalue) (car varlist)))
`(jupyter-bind ,mvalue
(lambda (,name)
(jupyter-mlet* ,(cdr varlist)
,@body))))))
(defmacro jupyter-do (&rest actions)
"Return a monadic value that performs all actions in ACTIONS.
The actions are evaluated in the order given. The result of the
returned action is the result of the last action in ACTIONS."
(declare (indent 0) (debug (body)))
(if (zerop (length actions)) 'jupyter--return-nil
(let ((result (make-symbol "result")))
`(jupyter-mlet*
,(cl-loop
for action being the elements of actions using (index i)
for sym = (if (= i (1- (length actions))) result '_)
collect `(,sym ,action))
(jupyter-return ,result)))))
(defun jupyter-run-with-state (state mvalue)
"Pass STATE as the state to MVALUE, return the resulting value."
(declare (indent 1))
;; Discard the final state
(car (funcall mvalue state)))
(defmacro jupyter-run-with-io (io &rest body)
"Return the result of evaluating the I/O value BODY evaluates to.
All I/O operations are done in the context of IO."
(declare (indent 1) (debug (form body)))
`(jupyter-run-with-state ,io (progn ,@body)))
(defmacro jupyter-run-with-client (client &rest body)
"Return the result of evaluating the monadic value BODY evaluates to.
The initial state given to the monadic value is CLIENT."
(declare (indent 1) (debug (form body)))
`(jupyter-run-with-state ,client (progn ,@body)))
(defmacro jupyter-with-io (io &rest body)
"Return an I/O action evaluating BODY in IO's context.
The result of the returned action is the result of the I/O action
BODY evaluates to."
(declare (indent 1) (debug (form body)))
`(lambda (_)
(jupyter-run-with-io ,io ,@body)))
;;; Publisher/subscriber
(define-error 'jupyter-subscribed-subscriber
"A subscriber cannot be subscribed to.")
(defun jupyter-subscriber (sub-fn)
"Return a subscriber evaluating SUB-FN on published content.
SUB-FN should return the result of evaluating
`jupyter-unsubscribe' if the subscriber's subscription should be
canceled.
Ex. Unsubscribe after consuming one message
(jupyter-subscriber
(lambda (value)
(message \"The published content: %s\" value)
(jupyter-unsubscribe)))
Used like this, where sub is the above subscriber:
(jupyter-run-with-io (jupyter-publisher)
(jupyter-subscribe sub)
(jupyter-publish (list \='topic \"today's news\")))"
(declare (indent 0))
(lambda (sub-content)
(pcase sub-content
(`(content ,content) (funcall sub-fn content))
(`(subscribe ,_) (signal 'jupyter-subscribed-subscriber nil))
(_ (error "Unhandled subscriber content: %s" sub-content)))))
(defun jupyter-content (value)
"Arrange for VALUE to be sent to subscribers of a publisher."
(list 'content value))
(defsubst jupyter-unsubscribe ()
"Arrange for the current subscription to be canceled.
A subscriber (or publisher with a subscription) can return the
result of this function to cancel its subscription with the
publisher providing content."
(list 'unsubscribe))
(define-error 'jupyter-publisher-subscribers-had-errors
"Publisher's subscribers had errors")
(defun jupyter-pseudo-bind-content (pub-fn content subs)
"Apply PUB-FN on submitted CONTENT to produce published content.
Call each subscriber in SUBS on the published content. Remove
those subscribers that cancel their subscription.
When a subscriber signals an error it is noted and the remaining
subscribers are processed. After processing all subscribers, a
`jupyter-publisher-errors' error is raised with the data being
the list of errors raised when calling subscribers. Note, when a
subscriber errors, it remains in the list of subscribers."
(pcase (funcall pub-fn content)
((and `(content ,_) sub-content)
;; NOTE: The first element of SUBS is ignored here so that the
;; pointer to the subscriber list remains the same for each
;; publisher, even when subscribers are being destructively
;; removed.
(let ((errors nil))
(while (cadr subs)
(condition-case err
;; Publish subscriber content to subscribers
(pcase (funcall (cadr subs) sub-content)
;; Destructively remove the subscriber when it returns an
;; unsubscribe value.
('(unsubscribe) (setcdr subs (cddr subs)))
(_ (pop subs)))
(error
;; Skip over any subscribers that raised an error.
(pop subs)
(push err errors))))
;; Inform about the errors.
(when errors
(signal 'jupyter-publisher-subscribers-had-errors errors)))
nil)
;; Cancel a publisher's subscription to another publisher.
('(unsubscribe) '(unsubscribe))
(_ nil)))
(defun jupyter-publisher (&optional pub-fn)
"Return a publisher function.
A publisher function is a closure, function with a local scope,
that maintains a list of subscribers and distributes the content
that PUB-FN returns to each of them.
PUB-FN is a function that optionally returns content to
publish (by returning the result of `jupyter-content' on a
value). It's called when a value is submitted for publishing
using `jupyter-publish', like this:
(let ((pub (jupyter-publisher
(lambda (submitted-value)
(message \"Publishing %s to subscribers\" submitted-value)
(jupyter-content submitted-value)))))
(jupyter-run-with-io pub
(jupyter-publish (list 1 2 3))))
The default for PUB-FN is `jupyter-content'. See
`jupyter-subscribe' for an example on how to subscribe to a
publisher.
If no content is returned by PUB-FN, no content is sent to
subscribers.
A publisher can also be a subscriber of another publisher. In
this case, if PUB-FN returns the result of `jupyter-unsubscribe'
its subscription is canceled.
Ex. Publish the value 1 regardless of what is given to PUB-FN.
(jupyter-publisher
(lambda (_)
(jupyter-content 1)))
Ex. Publish \='app if \='app is given to a publisher, nothing is sent
to subscribers otherwise. In this case, a publisher is a
filter of the value given to it for publishing.
(jupyter-publisher
(lambda (value)
(if (eq value \='app)
(jupyter-content value))))"
(declare (indent 0))
(let ((subs (list 'subscribers))
(pub-fn (or pub-fn #'jupyter-content)))
;; A publisher value is either a value representing a subscriber
;; or a value representing content to send to subscribers.
(lambda (pub-value)
(pcase (car-safe pub-value)
('content (jupyter-pseudo-bind-content pub-fn (cadr pub-value) subs))
('subscribe (cl-pushnew (cadr pub-value) (cdr subs)))
(_ (error "Unhandled publisher content: %s" pub-value))))))
(defun jupyter-subscribe (sub)
"Return an I/O action that subscribes SUB to published content.
If a subscriber (or a publisher with a subscription to another
publisher) returns the result of `jupyter-unsubscribe', its
subscription is canceled.
Ex. Subscribe to a publisher and unsubscribe after receiving two
messages.
(let* ((msgs \='())
(pub (jupyter-publisher))
(sub (jupyter-subscriber
(lambda (n)
(if (> n 2) (jupyter-unsubscribe)
(push n msgs))))))
(jupyter-run-with-io pub
(jupyter-subscribe sub))
(cl-loop
for x in \='(1 2 3)
do (jupyter-run-with-io pub
(jupyter-publish x)))
(reverse msgs)) ; => \='(1 2)"
(declare (indent 0))
(lambda (io)
(funcall io (list 'subscribe sub))
(cons nil io)))
(defun jupyter-publish (value)
"Return an I/O action that submits VALUE to publish as content."
(declare (indent 0))
(lambda (io)
(funcall io (jupyter-content value))
(cons nil io)))
;;; Working with requests
(define-error 'jupyter-timeout-before-idle "Timeout before idle")
(defun jupyter-sent (dreq)
(jupyter-mlet* ((client (jupyter-get-state))
(req dreq))
(let ((type (jupyter-request-type req)))
(jupyter-run-with-io (jupyter-kernel-io client)
(jupyter-do
(jupyter-subscribe (jupyter-request-message-publisher req))
(jupyter-publish
(list 'send
(jupyter-channel-from-request-type type)
type
(jupyter-request-content req)
(jupyter-request-id req))))))
(jupyter-return req)))
(defun jupyter-idle (dreq &optional timeout)
"Wait until DREQ has become idle, return DREQ.
Signal a `jupyter-timeout-before-idle' error if TIMEOUT seconds
elapses and the request has not become idle yet."
(jupyter-mlet* ((req (jupyter-sent dreq)))
(or (jupyter-wait-until-idle req timeout)
(signal 'jupyter-timeout-before-idle (list req)))
(jupyter-return req)))
(defun jupyter-messages (dreq &optional timeout)
"Return all the messages of REQ.
TIMEOUT has the same meaning as in `jupyter-idle'."
(jupyter-mlet* ((req (jupyter-idle dreq timeout)))
(jupyter-return (jupyter-request-messages req))))
(defun jupyter-find-message (msg-type msgs)
"Return a message whose type is MSG-TYPE in MSGS."
(cl-find-if
(lambda (msg)
(let ((type (jupyter-message-type msg)))
(string= type msg-type)))
msgs))
(defun jupyter-reply (dreq &optional timeout)
"Return the reply message of REQ.
TIMEOUT has the same meaning as in `jupyter-idle'."
(jupyter-mlet* ((msgs (jupyter-messages dreq timeout)))
(jupyter-return
(cl-find-if
(lambda (msg)
(let ((type (jupyter-message-type msg)))
(string-suffix-p "_reply" type)))
msgs))))
(defun jupyter-result (dreq &optional timeout)
"Return the result message of REQ.
TIMEOUT has the same meaning as in `jupyter-idle'."
(jupyter-mlet* ((msgs (jupyter-messages dreq timeout)))
(jupyter-return
(cl-find-if
(lambda (msg)
(let ((type (jupyter-message-type msg)))
(string-suffix-p "_result" type)))
msgs))))
(defun jupyter-message-subscribed (dreq cbs)
"Return an IO action that subscribes CBS to a request's message publisher.
IO-REQ is an IO action that evaluates to a sent request. CBS is
an alist mapping message types to callback functions like
`((\"execute_reply\" ,(lambda (msg) ...))
...)
The returned IO action returns the sent request after subscribing
the callbacks."
(jupyter-mlet* ((req dreq))
(jupyter-run-with-io
(jupyter-request-message-publisher req)
(jupyter-subscribe
(jupyter-subscriber
(lambda (msg)
(when-let*
((msg-type (jupyter-message-type msg))
(fn (car (alist-get msg-type cbs nil nil #'string=))))
(funcall fn msg))))))
(jupyter-return req)))
;; When replaying messages, the request message publisher is already
;; unsubscribed from any upstream publishers.
(defun jupyter--debug-replay-requests ()
(setq jupyter--debug-request-queue (nreverse jupyter--debug-request-queue))
(while jupyter--debug-request-queue
(pcase-let ((`(,client ,req) (pop jupyter--debug-request-queue)))
(cl-loop
for msg in (jupyter-request-messages req)
do (condition-case nil
(jupyter-handle-message
client (plist-get msg :channel)
(cl-list* :parent-request req msg))
(error (setq jupyter--debug-request-queue
(nreverse jupyter--debug-request-queue))))))))
;;; Request
(defun jupyter-message-publisher (req)
(let ((id (jupyter-request-id req)))
(jupyter-publisher
(lambda (msg)
(pcase (jupyter-message-type msg)
;; Send what doesn't appear to be a message as is.
((pred null) (jupyter-content msg))
;; A status message after a request goes idle means there is
;; a new request and there will, theoretically, be no more
;; messages for the idle one.
;;
;; FIXME: Is that true? Figure out the difference between a
;; status: busy and a status: idle message.
((and type (guard (jupyter-request-idle-p req))
(guard (string= type "status")))
(jupyter-unsubscribe))
;; TODO: `jupyter-message-parent-id' -> `jupyter-parent-id'
;; and the like.
((guard (string= id (jupyter-message-parent-id msg)))
(setf (jupyter-request-last-message req) msg)
(cl-callf nconc (jupyter-request-messages req) (list msg))
(when (or (jupyter-message-status-idle-p msg)
;; Jupyter protocol 5.1, IPython
;; implementation 7.5.0 doesn't give
;; status: busy or status: idle messages
;; on kernel-info-requests. Whereas
;; IPython implementation 6.5.0 does.
;; Seen on Appveyor tests.
;;
;; TODO: May be related
;; jupyter/notebook#3705 as the problem
;; does happen after a kernel restart
;; when testing.
(string= (jupyter-message-type msg) "kernel_info_reply")
;; No idle message is received after a
;; shutdown reply so consider REQ as
;; having received an idle message in
;; this case.
(string= (jupyter-message-type msg) "shutdown_reply"))
(setf (jupyter-request-idle-p req) t))
(jupyter-content
(cl-list* :parent-request req msg))))))))
(defvar jupyter-inhibit-handlers)
(defun jupyter-request (type &rest content)
"Return an IO action that sends a `jupyter-request'.
TYPE is the message type of the message that CONTENT, a property
list, represents."
(declare (indent 1))
(let ((ih jupyter-inhibit-handlers))
(lambda (client)
(let* ((req (jupyter-generate-request
client
:type type
:content content
:client client
;; Anything sent to stdin is a reply not a request
;; so consider the "request" completed.
:idle-p (string= "stdin"
(jupyter-channel-from-request-type type))
:inhibited-handlers ih))
(pub (jupyter-message-publisher req)))
(setf (jupyter-request-message-publisher req) pub)
(if (eq jupyter--debug 'message)
(push (list client req) jupyter--debug-request-queue)
(when (string= (jupyter-request-type req)
"execute_request")
(jupyter-server-mode-set-client client))
(jupyter-run-with-io pub
(jupyter-subscribe
(jupyter-subscriber
(lambda (msg)
;; Only handle what looks to be a Jupyter message.
(when (jupyter-message-type msg)
(let ((channel (plist-get msg :channel)))
(jupyter-handle-message client channel msg))))))))
(cons req client)))))
(provide 'jupyter-monads)
;;; jupyter-monads.el ends here

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,686 @@
;;; jupyter-org-extensions.el --- Jupyter Org Extensions -*- lexical-binding: t; -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Carlos Garcia C. <carlos@binarycharly.com>
;; Created: 01 March 2019
;; 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:
;; Functions that extend the functionality of Org mode to interact with
;; jupyter source blocks.
;;; Code:
(require 'jupyter-kernelspec)
(require 'jupyter-org-client)
(eval-when-compile (require 'subr-x))
(declare-function org-babel-jupyter-initiate-session "ob-jupyter" (&optional session params))
(declare-function org-babel-jupyter-session-initiated-p "ob-jupyter" (&optional session params))
(declare-function org-babel-jupyter-src-block-session "ob-jupyter" ())
(declare-function org-babel-jupyter-language-p "ob-jupyter" (lang))
(declare-function org-in-src-block-p "org" (&optional inside))
(declare-function org-narrow-to-subtree "org" ())
(declare-function org-previous-line-empty-p "org" ())
(declare-function org-fold-show-context "org-fold" (&optional key))
(declare-function org-next-line-empty-p "org" ())
(declare-function org-element-context "org-element" (&optional element))
(declare-function org-element-type "org-element" (element))
(declare-function org-element-property "org-element" (property element))
(declare-function org-element-interpret-data "org-element" (data))
(declare-function org-element-at-point "org-element" ())
(declare-function org-element-put-property "org-element" (element property value))
(declare-function outline-show-entry "outline" ())
(declare-function avy-jump "ext:avy")
(declare-function ivy-read "ext:ivy")
(defcustom jupyter-org-jump-to-block-context-lines 3
"Number of lines to show when showing the context of a block.
The function `jupyter-org-jump-to-block' uses these many lines from the
beginning of a source block in a list."
:group 'ob-jupyter
:type 'integer)
(defun jupyter-org-closest-jupyter-language (&optional query)
"Return the language of the closest Jupyter source block.
If QUERY is non-nil, ask for a language to use instead. Asking
for which language to use is also done if no Jupyter source
blocks could be found in the buffer.
Distance is line based, not character based. Also, `point' is
assumed to not be inside a source block."
(org-save-outline-visibility nil
(or (save-excursion
(and (null query)
(cl-loop
with start = (line-number-at-pos)
with previous = (ignore-errors
(save-excursion
(org-babel-previous-src-block)
(point)))
with next = (ignore-errors
(save-excursion
(org-babel-next-src-block)
(point)))
with maybe-return-lang =
(lambda ()
(let ((info (org-babel-get-src-block-info 'light)))
(when (org-babel-jupyter-language-p (nth 0 info))
(cl-return (nth 0 info)))))
while (or previous next) do
(cond
((or
;; Maybe return the previous Jupyter source block's language
;; if it is closer to the start point than the next source
;; block
(and previous next (< (- start (line-number-at-pos previous))
(- (line-number-at-pos next) start)))
;; or when there is no next source block
(and (null next) previous))
(goto-char previous)
(funcall maybe-return-lang)
(setq previous (ignore-errors
(org-babel-previous-src-block)
(point))))
(next
(goto-char next)
(funcall maybe-return-lang)
(setq next (ignore-errors
(org-babel-next-src-block)
(point))))))))
;; If all else fails, query for the language to use
(let* ((kernelspec (jupyter-completing-read-kernelspec))
(lang (plist-get
(jupyter-kernelspec-plist kernelspec)
:language)))
(if (org-babel-jupyter-language-p lang) lang
(format "jupyter-%s" lang))))))
(defun jupyter-org-between-block-end-and-result-p ()
"If `point' is between a src-block and its result, return the result end.
`point' is considered between a src-block and its result when the
result begins where the src-block ends, i.e. when only whitespace
separates the two."
;; Move after a src block's results first if `point' is between a src
;; block and it's results. Don't do this if the results are not directly
;; after a src block, e.g. for named results that appear somewhere else.
(save-excursion
(let ((start (point)))
(when-let* ((src (and (org-save-outline-visibility nil
(ignore-errors (org-babel-previous-src-block)))
(org-element-context)))
(end (org-element-property :end src))
(result-pos (org-babel-where-is-src-block-result)))
(goto-char end)
(skip-chars-backward " \n\t\r")
(when (and (= result-pos end)
(< (point) start result-pos))
(goto-char result-pos)
(org-element-property :end (org-element-context)))))))
;;;###autoload
(defun jupyter-org-insert-src-block (&optional below query)
"Insert a src-block above `point'.
With prefix arg BELOW, insert it below `point'.
If `point' is in a src-block use the language of the src-block and
copy the header to the new block.
If QUERY is non-nil and `point' is not in a src-block, ask for
the language to use for the new block. Otherwise try to select a
language based on the src-block's near `point'."
(interactive (list current-prefix-arg nil))
(if (org-in-src-block-p)
(let* ((src (org-element-context))
(start (org-element-property :begin src))
(end (org-element-property :end src))
(lang (org-element-property :language src))
(switches (org-element-property :switches src))
(parameters (org-element-property :parameters src)))
(if below
(let ((location (progn
(goto-char start)
(org-babel-where-is-src-block-result))))
(if (not location)
(goto-char end)
(goto-char location)
(goto-char (org-element-property :end (org-element-context))))
(unless (org-previous-line-empty-p)
(insert "\n"))
(insert
(org-element-interpret-data
(org-element-put-property
(jupyter-org-src-block lang parameters "\n" switches)
:post-blank 1)))
(forward-line -3))
;; after current block
(goto-char (org-element-property :begin src))
(unless (org-previous-line-empty-p)
(insert "\n"))
(insert
(org-element-interpret-data
(org-element-put-property
(jupyter-org-src-block lang parameters "\n" switches)
:post-blank 1)))
(forward-line -3)))
;; not in a src block, insert a new block, query for jupyter kernel
(beginning-of-line)
(let* ((lang (jupyter-org-closest-jupyter-language query))
(src-block (jupyter-org-src-block lang nil "\n")))
(when-let* ((pos (jupyter-org-between-block-end-and-result-p)))
(goto-char pos)
(skip-chars-backward " \n\t\r"))
(unless (looking-at-p "^[\t ]*$")
;; Move past the current element first
(let ((elem (org-element-at-point)) parent)
(while (and (setq parent (org-element-property :parent elem))
(not (memq (org-element-type parent)
'(inlinetask))))
(setq elem parent))
(when elem
(goto-char (org-element-property
(if below :end :begin) elem))))
(cond
(below
(skip-chars-backward " \n\t\r")
(insert "\n"))
(t
(insert "\n")
(forward-line -1))))
(unless (or (bobp) (org-previous-line-empty-p))
(insert "\n"))
(insert (string-trim-right (org-element-interpret-data src-block)))
(unless (org-next-line-empty-p)
(insert "\n"))
(skip-chars-backward "\n")
(forward-line -1))))
;;;###autoload
(defun jupyter-org-split-src-block (&optional below)
"Split the current src block with point in upper block.
With a prefix BELOW move point to lower block."
(interactive "P")
(unless (org-in-src-block-p)
(error "Not in a source block"))
(beginning-of-line)
(org-babel-demarcate-block)
(if below
(progn
(org-babel-next-src-block)
(forward-line)
(open-line 1))
(forward-line -2)
(end-of-line)))
;;;###autoload
(defun jupyter-org-execute-and-next-block (&optional new)
"Execute his block and jump or add a new one.
If a new block is created, use the same language, switches and parameters.
With prefix arg NEW, always insert new cell."
(interactive "P")
(unless (org-in-src-block-p)
(error "Not in a source block"))
(let ((next-src-block
(save-excursion (ignore-errors (org-babel-next-src-block)))))
;; instert a new block before executing the current block; otherwise, the new
;; ... block gets added to the results of the next block (due to how
;; ... jupyter works)
(when (or new (not next-src-block))
(save-excursion
(jupyter-org-insert-src-block t)))
(org-babel-execute-src-block)
(org-babel-next-src-block)))
;;;###autoload
(defun jupyter-org-execute-to-point (any)
"Execute Jupyter source blocks that start before point.
Only execute Jupyter source blocks that have the same session.
Non-Jupyter source blocks are evaluated conditionally.
The session is selected in the following way:
* If `point' is at a Jupyter source block, use its session.
* If `point' is not at a Jupyter source block, examine the
source blocks before `point' and ask the user to select a
session if multiple exist. If there is only one session, use
it without asking.
* Finally, if a session could not be found, then no Jupyter
source blocks exist before `point'. In this case, no session
is selected and all the source blocks before `point' will be
evaluated, e.g. when all source blocks before `point' are
shell source blocks.
NOTE: If a session could be selected, only Jupyter source blocks
that have the same session are evaluated *without* evaluating any
other source blocks. You can also evaluate ANY source block that
doesn't have a Jupyter session by providing a prefix argument.
This is useful, e.g. to evaluate shell source blocks along with
Jupyter source blocks."
(interactive "P")
;; Use a marker here to account for buffer changes during evaluation of
;; source blocks.
(let* ((p (point-marker))
(session
(or (org-babel-jupyter-src-block-session)
(let (this-session sessions)
(catch 'done
(org-babel-map-src-blocks nil
(when (> (point) p)
(throw 'done t))
(when (and (setq this-session
(org-babel-jupyter-src-block-session))
(not (member this-session sessions)))
(push this-session sessions))))
(setq sessions (nreverse sessions))
(if (> (length sessions) 1)
(completing-read "Select session: " sessions)
(car sessions))))))
;; Move P after insertion at P
(set-marker-insertion-type p t)
(catch 'done
(org-babel-map-src-blocks nil
(when (> (point) p)
(throw 'done t))
;; If there is no SESSION that can be found, just evaluate any source
;; block.
;;
;; If a Jupyter based SESSION could be found, only source blocks that
;; have a Jupyter session matching SESSION are evaluated. When a source
;; block doesn't have a Jupyter session, it is only evaluated when ANY
;; is non-nil.
(when (or (null session)
(let ((this-session (org-babel-jupyter-src-block-session)))
(if (null this-session) any
(equal session this-session))))
(org-babel-execute-src-block))))
(goto-char p)
(set-marker p nil)))
;;;###autoload
(defun jupyter-org-execute-subtree (any)
"Execute Jupyter source blocks that start before point in the current subtree.
This function narrows the buffer to the current subtree and calls
`jupyter-org-execute-to-point'. See that function for the meaning
of the ANY argument."
(interactive "P")
(save-restriction
(org-narrow-to-subtree)
(jupyter-org-execute-to-point any)))
;;;###autoload
(defun jupyter-org-next-busy-src-block (arg &optional backward)
"Jump to the next busy source block.
With a prefix argument ARG, jump forward ARG many blocks.
When BACKWARD is non-nil, jump to the previous block."
(interactive "p")
(org-save-outline-visibility nil
(cl-loop
with count = (abs (or arg 1))
with origin = (point)
while (ignore-errors
(if backward (org-babel-previous-src-block)
(org-babel-next-src-block)))
thereis (when (jupyter-org-request-at-point)
(zerop (cl-decf count)))
finally (goto-char origin)
(user-error "No %s busy code blocks" (if backward "previous" "further"))))
(save-match-data (org-fold-show-context)))
;;;###autoload
(defun jupyter-org-previous-busy-src-block (arg)
"Jump to the previous busy source block.
With a prefix argument ARG, jump backward ARG many source blocks."
(interactive "p")
(jupyter-org-next-busy-src-block arg 'backward))
;;;###autoload
(defun jupyter-org-inspect-src-block ()
"Inspect the symbol under point when in a source block."
(interactive)
(unless (jupyter-org-with-src-block-client
(jupyter-inspect-at-point)
t)
(error "Not in a source block")))
;;;###autoload
(defun jupyter-org-restart-kernel-execute-block ()
"Restart the kernel of the source block where point is and execute it."
(interactive)
(jupyter-org-with-src-block-client
(jupyter-repl-restart-kernel))
(org-babel-execute-src-block-maybe))
;;;###autoload
(defun jupyter-org-restart-and-execute-to-point (&optional any)
"Kill the kernel and run all Jupyter src-blocks to point.
With a prefix argument, run ANY source block that doesn't have a
Jupyter session as well.
See `jupyter-org-execute-to-point' for more information on which
source blocks are evaluated."
(interactive "P")
(jupyter-org-with-src-block-client
(jupyter-repl-restart-kernel))
(jupyter-org-execute-to-point any))
;;;###autoload
(defun jupyter-org-restart-kernel-execute-buffer ()
"Restart kernel and execute buffer."
(interactive)
(jupyter-org-with-src-block-client
(jupyter-repl-restart-kernel))
(org-babel-execute-buffer))
;;;###autoload
(defun jupyter-org-jump-to-block (&optional context)
"Jump to a source block in the buffer using `ivy'.
If narrowing is in effect, jump to a block in the narrowed region.
Use a numeric prefix CONTEXT to specify how many lines of context to showin the
process of selecting a source block.
Defaults to `jupyter-org-jump-to-block-context-lines'."
(interactive
(list (if current-prefix-arg
(prefix-numeric-value current-prefix-arg)
jupyter-org-jump-to-block-context-lines)))
(unless (require 'ivy nil t)
(error "Package `ivy' not installed"))
(let ((blocks '()))
(when (or (null context) (< context 1))
(setq context jupyter-org-jump-to-block-context-lines))
;; consider the #+SRC_BLOCK line of the block, thereby making CONTEXT
;; ... equivalent to actual lines after the block header
(setq context (1+ context))
(save-excursion
(goto-char (point-min))
(while (re-search-forward org-babel-src-block-regexp nil t)
(push (list (format "line %s:\n%s"
(line-number-at-pos (match-beginning 0))
(save-excursion
(goto-char (match-beginning 0))
(let ((s (point)))
(forward-line context)
(buffer-substring s (point)))))
(line-number-at-pos (match-beginning 0)))
blocks)))
(ivy-read "block: " (reverse blocks)
:action (lambda (candidate)
(goto-char (point-min))
(forward-line (1- (nth 1 candidate)))
(ignore-errors (outline-show-entry))
(recenter)))))
;;;###autoload
(defun jupyter-org-jump-to-visible-block ()
"Jump to a visible src block with avy."
(interactive)
(unless (require 'avy nil t)
(error "Package `avy' not installed"))
;; Jumping through these hoops to avoid depending on `avy'
(defalias 'jupyter-org-jump-to-visible-block
(byte-compile
`(lambda ()
(interactive)
(avy-with #'jupyter-org-jump-to-block
(avy-jump "#\\+begin_src"
:beg (point-min)
:end (point-max)))))
(documentation 'jupyter-org-jump-to-visible-block))
;; Now call the new definition
(jupyter-org-jump-to-visible-block))
;;;###autoload
(defun jupyter-org-edit-header ()
"Edit the src-block header in the minibuffer."
(interactive)
(let ((src-info (org-babel-get-src-block-info 'light)))
(unless src-info
(error "Not in a source block"))
(let* ((header-start (nth 5 src-info))
(header-end (save-excursion (goto-char header-start)
(line-end-position))))
(let ((header (read-string "Header: "
(buffer-substring header-start header-end))))
(save-excursion
(delete-region header-start header-end)
(goto-char header-start)
(insert header))))))
(defun jupyter-org-src-block-bounds ()
"Return the region containing the current source block.
If the source block has results, include the results in the
returned region. The region is returned as (BEGIN . END)"
(unless (org-in-src-block-p)
(error "Not in a source block"))
(let* ((src (org-element-context))
(results-start (org-babel-where-is-src-block-result))
(results-end
(when results-start
(save-excursion
(goto-char results-start)
(goto-char (org-babel-result-end))
;; if results are empty, take its empy line
(when (looking-at-p org-babel-result-regexp)
(forward-line 1))
(point)))))
`(,(org-element-property :begin src) .
,(or results-end (jupyter-org-element-end-before-blanks src)))))
;;;###autoload
(defun jupyter-org-kill-block-and-results ()
"Kill the block and its results."
(interactive)
(let ((region (jupyter-org-src-block-bounds)))
(kill-region (car region) (cdr region))))
;;;###autoload
(defun jupyter-org-copy-block-and-results ()
"Copy the src block at the current point and its results."
(interactive)
(let ((region (jupyter-org-src-block-bounds)))
(kill-new (buffer-substring (car region) (cdr region)))))
;;;###autoload
(defun jupyter-org-clone-block (&optional below)
"Clone the block above the current block.
If BELOW is non-nil, add the cloned block below."
(interactive "P")
(let* ((src (org-element-context))
(code (org-element-property :value src)))
(unless (org-in-src-block-p)
(error "Not in a source block"))
(jupyter-org-insert-src-block below)
(delete-char 1)
(insert code)
;; move to the end of the last line of the cloned block
(forward-line -1)
(end-of-line)))
;;;###autoload
(defun jupyter-org-merge-blocks ()
"Merge the current block with the next block."
(interactive)
(unless (org-in-src-block-p)
(error "Not in a source block"))
(let ((current-src-block (org-element-context)))
(org-babel-remove-result)
(org-babel-next-src-block)
(let* ((next-src-block (prog1 (org-element-context)
(org-babel-remove-result)))
(next-src-block-beg (set-marker
(make-marker)
(org-element-property :begin next-src-block)))
(next-src-block-end (set-marker
(make-marker)
(jupyter-org-element-end-before-blanks next-src-block))))
(goto-char (jupyter-org-element-end-before-blanks current-src-block))
(forward-line -1)
(insert
(delete-and-extract-region
(save-excursion
(goto-char (jupyter-org-element-begin-after-affiliated next-src-block))
(forward-line 1)
(point))
(save-excursion
(goto-char next-src-block-end)
(forward-line -1)
(point))))
;; delete a leftover space
(save-excursion
(goto-char next-src-block-end)
(when (looking-at-p "[[:space:]]*$")
(set-marker next-src-block-end (+ (line-end-position) 1))))
(delete-region next-src-block-beg next-src-block-end)
(set-marker next-src-block-beg nil)
(set-marker next-src-block-end nil)))
;; move to the end of the last line
(forward-line -1)
(end-of-line))
;;;###autoload
(defun jupyter-org-move-src-block (&optional below)
"Move source block before of after another.
If BELOW is non-nil, move the block down, otherwise move it up."
(interactive)
(unless (org-in-src-block-p)
(error "Not in a source block"))
;; throw error if there's no previous or next source block
(when (ignore-errors
(save-excursion
(if below
(org-babel-next-src-block)
(org-babel-previous-src-block))))
(let* ((region (jupyter-org-src-block-bounds))
(block (delete-and-extract-region (car region) (cdr region))))
;; if there is an empty line remaining, take that line as part of the
;; ... block
(when (and (looking-at-p "[[:space:]]*$") (/= (point) (point-max)))
(delete-region (line-beginning-position) (+ (line-end-position) 1))
(setq block (concat block "\n")))
(if below
;; if below, move past the next source block or its result
(let ((next-src-block-head (org-babel-where-is-src-block-head)))
(if next-src-block-head
(goto-char next-src-block-head)
(org-babel-next-src-block))
(let ((next-src-block (org-element-context))
(next-results-start (org-babel-where-is-src-block-result)))
(if (not next-results-start)
(goto-char (org-element-property :end next-src-block))
(goto-char next-results-start)
(goto-char (org-babel-result-end))
(when (and (looking-at-p org-babel-result-regexp)
(/= (point) (point-max)))
;; the results are empty, take the next empty line
(forward-line 1))
(when (looking-at-p "[[:space:]]*$")
(forward-line 1)))))
;; else, move to the begining of the previous block
(org-babel-previous-src-block))
;; keep cursor where the insertion takes place
(save-excursion (insert block)))))
;;;###autoload
(defun jupyter-org-clear-all-results ()
"Clear all results in the buffer."
(interactive)
(org-save-outline-visibility nil
(save-excursion
(goto-char (point-min))
(while (org-babel-next-src-block)
(org-babel-remove-result)))))
;;;###autoload
(defun jupyter-org-interrupt-kernel ()
"Interrupt the kernel."
(interactive)
(unless (org-in-src-block-p)
(error "Not in a source block"))
(jupyter-org-with-src-block-client
(jupyter-repl-interrupt-kernel)))
(defun jupyter-org-hydra/body ()
"Hack to bind a hydra only if the hydra package exists."
(interactive)
(unless (require 'hydra nil t)
(error "Package `hydra' not installed"))
;; unbinding this function and define the hydra
(fmakunbound 'jupyter-org-hydra/body)
(eval `(defhydra jupyter-org-hydra (:color blue :hint nil)
"
Execute Navigate Edit Misc
-------------------------------------------------------------------------------------------
_<return>_: current _p_: previous _C-p_: move up _/_: inspect
_C-<return>_: current to next _P_: previous busy _C-n_: move down _l_: clear result
_M-<return>_: to point _n_: next _x_: kill _L_: clear all
_C-M-<return>_: subtree to point _N_: next busy _c_: copy _i_: interrupt
_S-<return>_: Restart/block _g_: visible _o_: clone _C-s_: scratch buffer
_S-C-<return>_: Restart/to point _G_: any _m_: merge
_S-M-<return>_: Restart/buffer _<tab>_: (un)fold _s_: split
_r_: Goto repl ^ ^ _+_: insert above
^ ^ ^ ^ _=_: insert below
^ ^ ^ ^ _h_: header"
("<return>" org-ctrl-c-ctrl-c :color red)
("C-<return>" jupyter-org-execute-and-next-block :color red)
("M-<return>" jupyter-org-execute-to-point)
("C-M-<return>" jupyter-org-execute-subtree)
("S-<return>" jupyter-org-restart-kernel-execute-block)
("S-C-<return>" jupyter-org-restart-and-execute-to-point)
("S-M-<return>" jupyter-org-restart-kernel-execute-buffer)
("r" org-babel-switch-to-session)
("p" org-babel-previous-src-block :color red)
("P" jupyter-org-previous-busy-src-block :color red)
("n" org-babel-next-src-block :color red)
("N" jupyter-org-next-busy-src-block :color red)
("g" jupyter-org-jump-to-visible-block)
("G" jupyter-org-jump-to-block)
("<tab>" org-cycle :color red)
("C-p" jupyter-org-move-src-block :color red)
("C-n" (jupyter-org-move-src-block t) :color red)
("x" jupyter-org-kill-block-and-results)
("c" jupyter-org-copy-block-and-results)
("o" (jupyter-org-clone-block t))
("m" jupyter-org-merge-blocks)
("s" jupyter-org-split-src-block)
("+" (jupyter-org-insert-src-block nil current-prefix-arg))
("=" (jupyter-org-insert-src-block t current-prefix-arg))
("l" org-babel-remove-result)
("L" jupyter-org-clear-all-results)
("h" jupyter-org-edit-header)
("/" jupyter-org-inspect-src-block)
("i" jupyter-org-interrupt-kernel)
("C-s" org-babel-jupyter-scratch-buffer)))
(call-interactively #'jupyter-org-hydra/body))
(define-key jupyter-org-interaction-mode-map (kbd "C-c h") #'jupyter-org-hydra/body)
(provide 'jupyter-org-extensions)
;;; jupyter-org-extensions.el ends here

View File

@@ -0,0 +1,17 @@
(define-package "jupyter" "20240418.1642" "Jupyter"
'((emacs "26")
(cl-lib "0.5")
(org "9.1.6")
(zmq "0.10.10")
(simple-httpd "1.5.0")
(websocket "1.9"))
:commit "f1394d303be76a1fa44d4135b4f3ceab9387a16b" :authors
'(("Nathaniel Nicandro" . "nathanielnicandro@gmail.com"))
:maintainers
'(("Nathaniel Nicandro" . "nathanielnicandro@gmail.com"))
:maintainer
'("Nathaniel Nicandro" . "nathanielnicandro@gmail.com")
:url "https://github.com/emacs-jupyter/jupyter")
;; Local Variables:
;; no-byte-compile: t
;; End:

View File

@@ -0,0 +1,110 @@
;;; jupyter-python.el --- Jupyter support for python -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 23 Oct 2018
;; 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:
;; Support methods for integration with Python.
;;; Code:
(require 'jupyter-repl)
(require 'jupyter-org-client)
(declare-function org-babel-python-table-or-string "ob-python")
(cl-defmethod jupyter-handle-error :after ((client jupyter-repl-client) req msg
&context (jupyter-lang python)
(major-mode jupyter-repl-mode))
"Add spacing between the first occurance of ENAME and \"Traceback\".
Do this only when the traceback of REQ was inserted into the REPL
buffer."
(unless (equal (jupyter-message-parent-type msg) "comm_msg")
(jupyter-with-repl-buffer client
(jupyter-with-message-content msg (ename)
(save-excursion
(jupyter-repl-goto-cell req)
(goto-char (jupyter-repl-cell-code-end-position))
(when (and (search-forward ename nil t)
(looking-at "Traceback"))
(let ((len (- fill-column
jupyter-repl-prompt-margin-width
(- (point) (line-beginning-position))
(- (line-end-position) (point)))))
(insert-and-inherit
(propertize (make-string (if (> len 4) len 4) ? )
'read-only t)))))))))
(cl-defmethod jupyter-insert :around ((msg cons)
&context (jupyter-lang python)
&rest _ignore)
"Fontify docstrings after inserting inspect messages."
(let ((mime (cl-call-next-method)))
(prog1 mime
(cond
((and (eq mime :text/plain)
(string= (jupyter-message-type msg) "inspect_reply"))
(save-excursion
(goto-char (point-min))
(when (re-search-forward "^Docstring:" nil t)
(jupyter-fontify-region-according-to-mode
#'rst-mode (1+ (point))
(or (and (re-search-forward "^\\(File\\|Type\\):" nil t)
(line-beginning-position))
(point-max))))))
(t nil)))))
(cl-defmethod jupyter-load-file-code (file &context (jupyter-lang python))
(concat "%run " file))
;;; `jupyter-org'
(cl-defmethod jupyter-org-result ((_mime (eql :text/plain)) _content params
&context (jupyter-lang python))
(let ((result (cl-call-next-method)))
(cond
((and (stringp result)
(not (member "scalar" (alist-get :result-params params))))
(org-babel-python-table-or-string result))
(t result))))
(cl-defmethod jupyter-org-error-location (&context (jupyter-lang python))
(and (or (save-excursion (re-search-forward "^----> \\([0-9]+\\)" nil t))
(re-search-forward "^[\t ]*File.+line \\([0-9]+\\)$" nil t))
(string-to-number (match-string 1))))
(cl-defmethod org-babel-jupyter-transform-code (code changelist &context (jupyter-lang python))
(when (plist-get changelist :dir)
(setq code
(format "\
import os
__JUPY_saved_dir = os.getcwd()
os.chdir(\"%s\")
try:
get_ipython().run_cell(r\"\"\"%s\"\"\")
finally:
os.chdir(__JUPY_saved_dir)"
(plist-get changelist :dir) code)))
code)
(provide 'jupyter-python)
;;; jupyter-python.el ends here

2179
lisp/jupyter/jupyter-repl.el Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,375 @@
;;; jupyter-server-kernel.el --- Working with kernels behind a Jupyter server -*- lexical-binding: t -*-
;; Copyright (C) 2020-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 23 Apr 2020
;; 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:
;; Holds the definitions of `jupyter-server', what communicates to the
;; Jupyter server using the REST API, and `jupyter-kernel-server' a
;; representation of a kernel on a server.
;;; Code:
(require 'jupyter-kernel)
(require 'jupyter-rest-api)
(require 'jupyter-monads)
(require 'websocket)
(declare-function jupyter-encode-raw-message "jupyter-messages")
(declare-function jupyter-tramp-server-from-file-name "jupyter-tramp")
(declare-function jupyter-tramp-file-name-p "jupyter-tramp")
(declare-function jupyter-server-kernel-id-from-name "jupyter-server")
(defgroup jupyter-server-kernel nil
"Kernel behind a Jupyter server"
:group 'jupyter)
;;; `jupyter-server'
(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)
(defvar jupyter--servers nil)
;; TODO: We should really rename `jupyter-server' to something like
;; `jupyter-server-client' since it isn't a representation of a server, but a
;; communication channel with one.
(defclass jupyter-server (jupyter-rest-client eieio-instance-tracker)
((tracking-symbol :initform 'jupyter--servers)
(kernelspecs
:type json-plist
:initform nil
:documentation "Kernelspecs for the kernels available behind
this gateway. Access them through `jupyter-kernelspecs'.")))
(cl-defmethod make-instance ((_class (subclass jupyter-server)) &rest slots)
(cl-assert (plist-get slots :url))
(or (cl-loop
with url = (plist-get slots :url)
for server in jupyter--servers
if (equal url (oref server url)) return server)
(cl-call-next-method)))
(defun jupyter-servers ()
"Return a list of all `jupyter-server's."
(jupyter-gc-servers)
jupyter--servers)
(defun jupyter-gc-servers ()
"Delete `jupyter-server' instances that are no longer accessible."
(dolist (server jupyter--servers)
(unless (jupyter-api-server-exists-p server)
(jupyter-api-delete-cookies (oref server url))
(delete-instance server))))
(cl-defmethod jupyter-api-request :around ((server jupyter-server) _method &rest _plist)
(condition-case nil
(cl-call-next-method)
(jupyter-api-unauthenticated
(if (memq jupyter-api-authentication-method '(ask token password))
(oset server auth jupyter-api-authentication-method)
(error "Unauthenticated request, can't attempt re-authentication \
with default `jupyter-api-authentication-method'"))
(prog1 (cl-call-next-method)
(jupyter-reauthenticate-websockets server)))))
(cl-defmethod jupyter-kernelspecs ((client jupyter-rest-client) &optional _refresh)
(or (jupyter-api-get-kernelspec client)
(error "Can't retrieve kernelspecs from server @ %s"
(oref client url))))
(cl-defmethod jupyter-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."
(when (or refresh (null (oref server kernelspecs)))
(let ((specs (cl-call-next-method)))
(plist-put specs :kernelspecs
(cl-loop
for (_ spec) on (plist-get specs :kernelspecs) by #'cddr
for name = (plist-get spec :name)
collect (make-jupyter-kernelspec
:name name
:plist (plist-get spec :spec))))
(oset server kernelspecs specs)))
(plist-get (oref server kernelspecs) :kernelspecs))
(cl-defmethod jupyter-kernelspecs :extra "server" ((host string) &optional refresh)
(if (jupyter-tramp-file-name-p host)
(jupyter-kernelspecs (jupyter-tramp-server-from-file-name host) refresh)
(cl-call-next-method)))
(cl-defmethod jupyter-server-has-kernelspec-p ((server jupyter-server) name)
"Return non-nil if SERVER can launch kernels with kernelspec NAME."
(jupyter-guess-kernelspec name (jupyter-kernelspecs server)))
;;; Kernel definition
(cl-defstruct (jupyter-server-kernel
(:include jupyter-kernel))
(server jupyter-current-server
:read-only t
:documentation "The kernel server.")
;; TODO: Make this read only by only allowing creating
;; representations of kernels that have already been launched and
;; have a connection to the kernel.
(id nil
:type (or null string)
:documentation "The kernel ID."))
(cl-defmethod jupyter-alive-p ((kernel jupyter-server-kernel))
(pcase-let (((cl-struct jupyter-server-kernel server id) kernel))
(and id server
;; TODO: Cache this call
(condition-case err
(jupyter-api-get-kernel server id)
(file-error nil) ; Non-existent server
(jupyter-api-http-error
(unless (= (nth 1 err) 404) ; Not Found
(signal (car err) (cdr err)))))
(cl-call-next-method))))
(defun jupyter-server-kernel (&rest args)
"Return a `jupyter-server-kernel' initialized with ARGS."
(apply #'make-jupyter-server-kernel args))
(cl-defmethod jupyter-kernel :extra "server" (&rest args)
"Return a representation of a kernel on a Jupyter server.
If ARGS has a :server key, return a `jupyter-server-kernel'
initialized using ARGS. If ARGS also has a :spec key, whose
value is the name of a kernelspec, the returned kernel's spec
slot will be the corresponding `jupyter-kernelspec'.
Call the next method if ARGS does not contain :server."
(let ((server (plist-get args :server)))
(if (not server) (cl-call-next-method)
(cl-assert (object-of-class-p server 'jupyter-server))
(let ((spec (plist-get args :spec)))
(when (stringp spec)
(plist-put args :spec
;; TODO: (jupyter-server-kernelspec server "python3")
;; which returns an I/O action and then arrange
;; for that action to be bound by mlet* and set
;; as the spec value. Or better yet, have
;; `jupyter-kernel' return a delayed kernel with
;; the server connection already open and
;; kernelspecs already retrieved.
(or (jupyter-guess-kernelspec
spec (jupyter-kernelspecs server))
;; TODO: Return the error to the I/O context.
(error "No kernelspec matching %s @ %s" spec
(oref server url))))))
(apply #'jupyter-server-kernel args))))
;;; Websocket IO
(defvar jupyter--reauth-subscribers (make-hash-table :weakness 'key :test 'eq))
(defun jupyter-reauthenticate-websockets (server)
"Re-authenticate WebSocket connections of SERVER."
(when-let* ((pub (gethash server jupyter--reauth-subscribers)))
(jupyter-run-with-io pub
(jupyter-publish 'reauthenticate))))
(cl-defmethod jupyter-websocket-io ((kernel jupyter-server-kernel))
"Return a list representing an IO connection to KERNEL.
The list is composed of two elements (IO-PUB ACTION-SUB), IO-PUB
is a publisher used to send/receive messages to/from KERNEL and
ACTION-SUB is a subscriber of kernel actions to perform on
KERNEL.
To send a message to KERNEL, publish a list of the form
(list \='send CHANNEL MSG-TYPE CONTENT MSG-ID)
to IO-PUB, e.g.
(jupyter-run-with-io IO-PUB
(jupyter-publish (list \='send CHANNEL MSG-TYPE CONTENT MSG-ID)))
To receive messages from KERNEL, subscribe to IO-PUB e.g.
(jupyter-run-with-io IO-PUB
(jupyter-subscribe
(jupyter-subscriber
(lambda (msg)
...))))
The value \='interrupt or \='shutdown can be published to ACTION-SUB
to interrupt or shutdown KERNEL. The value (list \='action FN)
where FN is a single argument function can also be published, in
this case FN will be evaluated on KERNEL."
(jupyter-launch kernel)
(pcase-let* (((cl-struct jupyter-server-kernel server id) kernel))
(letrec ((status-pub (jupyter-publisher))
(reauth-pub (or (gethash server jupyter--reauth-subscribers)
(setf (gethash server jupyter--reauth-subscribers)
(jupyter-publisher))))
(shutdown nil)
(kernel-io
(jupyter-publisher
(lambda (event)
(pcase event
(`(message . ,rest) (jupyter-content rest))
(`(send ,channel ,msg-type ,content ,msg-id)
(when shutdown
(error "Attempting to send message to shutdown kernel"))
(let ((send
(lambda ()
(websocket-send-text
ws (let* ((cd (websocket-client-data ws))
(session (plist-get cd :session)))
(jupyter-encode-raw-message session msg-type
:channel channel
:msg-id msg-id
:content content))))))
(condition-case nil
(funcall send)
(websocket-closed
(setq ws (funcall make-websocket))
(funcall send)))))
('start
(when shutdown
(error "Can't start I/O connection to shutdown kernel"))
(unless (websocket-openp ws)
(setq ws (funcall make-websocket))))
('stop (websocket-close ws))))))
(ws-failed-to-open t)
(make-websocket
(lambda ()
(jupyter-api-kernel-websocket
server id
:custom-header-alist (jupyter-api-auth-headers server)
:on-open
(lambda (_ws)
(setq ws-failed-to-open nil))
:on-close
(lambda (_ws)
(if ws-failed-to-open
;; TODO: Retry?
(error "Kernel connection could not be established")
(setq ws-failed-to-open t)))
;; TODO: on-error publishes to status-pub
:on-message
(lambda (_ws frame)
(pcase (websocket-frame-opcode frame)
((or 'text 'binary)
(let ((msg (jupyter-read-plist-from-string
(websocket-frame-payload frame))))
(jupyter-run-with-io kernel-io
(jupyter-publish (cons 'message msg)))))
(_
(jupyter-run-with-io status-pub
(jupyter-publish
(list 'error (websocket-frame-opcode frame))))))))))
(ws (prog1 (funcall make-websocket)
(jupyter-run-with-io reauth-pub
(jupyter-subscribe
(jupyter-subscriber
(lambda (_reauth)
(if shutdown (jupyter-unsubscribe)
(jupyter-run-with-io kernel-io
(jupyter-do
(jupyter-publish 'stop)
(jupyter-publish 'start)))))))))))
(list kernel-io
(jupyter-subscriber
(lambda (action)
(pcase action
('interrupt
(jupyter-interrupt kernel))
('shutdown
(jupyter-shutdown kernel)
(setq shutdown t)
(when (websocket-openp ws)
(websocket-close ws)))
('restart
(jupyter-restart kernel))
(`(action ,fn)
(funcall fn kernel)))))))))
(cl-defmethod jupyter-io ((kernel jupyter-server-kernel))
(jupyter-websocket-io kernel))
;;; Kernel management
;; The KERNEL argument is optional here so that `jupyter-launch'
;; does not require more than one argument just to handle this case.
(cl-defmethod jupyter-launch ((server jupyter-server) &optional kernel)
(cl-check-type kernel string)
(let* ((spec (jupyter-guess-kernelspec
kernel (jupyter-kernelspecs server)))
(plist (jupyter-api-start-kernel
server (jupyter-kernelspec-name spec))))
(jupyter-kernel :server server :id (plist-get plist :id) :spec spec)))
;; FIXME: Don't allow creating kernels without them being launched.
(cl-defmethod jupyter-launch ((kernel jupyter-server-kernel))
"Launch KERNEL based on its kernelspec.
When KERNEL does not have an ID yet, launch KERNEL on SERVER
using its SPEC."
(pcase-let (((cl-struct jupyter-server-kernel server id spec session) kernel))
(unless session
(and id (setq id (or (jupyter-server-kernel-id-from-name server id) id)))
(if id
;; When KERNEL already has an ID before it has a session,
;; assume we are connecting to an already launched kernel. In
;; this case, make sure the KERNEL's SPEC is the same as the
;; one being connected to.
;;
;; Note, this also has the side effect of raising an error
;; when the ID does not match one on the server.
(unless spec
(let ((model (jupyter-api-get-kernel server id)))
(setf (jupyter-kernel-spec kernel)
(jupyter-guess-kernelspec
(plist-get model :name)
(jupyter-kernelspecs server)))))
(let ((plist (jupyter-api-start-kernel
server (jupyter-kernelspec-name spec))))
(setf (jupyter-server-kernel-id kernel) (plist-get plist :id))
(sit-for 1)))
;; TODO: Replace with the real session object
(setf (jupyter-kernel-session kernel) (jupyter-session))))
(cl-call-next-method))
(cl-defmethod jupyter-shutdown ((kernel jupyter-server-kernel))
(pcase-let (((cl-struct jupyter-server-kernel server id session) kernel))
(cl-call-next-method)
(when session
(jupyter-api-shutdown-kernel server id))))
(cl-defmethod jupyter-restart ((kernel jupyter-server-kernel))
(pcase-let (((cl-struct jupyter-server-kernel server id session) kernel))
(when session
(jupyter-api-restart-kernel server id))))
(cl-defmethod jupyter-interrupt ((kernel jupyter-server-kernel))
(pcase-let (((cl-struct jupyter-server-kernel server id) kernel))
(jupyter-api-interrupt-kernel server id)))
(provide 'jupyter-server-kernel)
;;; jupyter-server-kernel.el ends here

View File

@@ -0,0 +1,574 @@
;;; jupyter-server.el --- Support for the Jupyter kernel servers -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 02 Apr 2019
;; 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-kernelspecs'.
;;
;; 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.
;;; Code:
(eval-when-compile (require 'subr-x))
(require 'jupyter-repl)
(require 'jupyter-server-kernel)
(declare-function jupyter-tramp-file-name-p "jupyter-tramp" (filename))
(declare-function jupyter-tramp-server-from-file-name "jupyter-tramp" (filename))
(declare-function jupyter-tramp-file-name-from-url "jupyter-tramp" (url))
(defgroup jupyter-server nil
"Support for the Jupyter kernel gateway"
:group 'jupyter)
;;; Assigning names to kernel IDs
(defvar jupyter-server-kernel-names nil
"An alist mapping URLs to alists mapping kernel IDs to human friendly names.
For example
\((\"http://localhost:8888\"
(\"72d92ded-1275-440a-852f-90f655197305\" . \"thermo\"))\)
You can persist this alist across Emacs sessions using `desktop',
`savehist', or any other session persistence package. For
example, when using `savehist' you can add the following to your
init file to persist the server names across Emacs sessions.
\(savehist-mode\)
\(add-to-list \='savehist-additional-variables \='jupyter-server-kernel-names\).")
(defun jupyter-server-cull-kernel-names (&optional server)
"Ensure all names in `jupyter-server-kernel-names' map to existing kernels.
If SERVER is non-nil only check the kernels on SERVER, otherwise
check all kernels on all existing servers."
(let ((servers (if server (list server)
(jupyter-gc-servers)
(jupyter-servers))))
(unless server
;; Only remove non-existing servers when culling all kernels on all
;; servers.
(let ((urls (mapcar (lambda (x) (oref x url)) servers)))
(cl-callf2 cl-remove-if-not (lambda (x) (member (car x) urls))
jupyter-server-kernel-names)))
(dolist (server servers)
(when-let* ((names (assoc (oref server url) jupyter-server-kernel-names)))
(setf (alist-get (oref server url)
jupyter-server-kernel-names nil nil #'equal)
(cl-loop
for kernel across (jupyter-api-get-kernel server)
for name = (assoc (plist-get kernel :id) names)
when name collect name))))))
(defun jupyter-server-kernel-name (server id)
"Return the associated name of the kernel with ID on SERVER.
If there is no name associated, return nil. See
`jupyter-server-kernel-names'."
(cl-check-type server jupyter-server)
(let ((kernel-names (assoc (oref server url) jupyter-server-kernel-names)))
(cdr (assoc id kernel-names))))
(defun jupyter-server-kernel-id-from-name (server name)
"Return the ID of the kernel that has NAME on SERVER.
If NAME does not have a kernel associated, return nil. See
`jupyter-server-kernel-names'."
(cl-check-type server jupyter-server)
(jupyter-server-cull-kernel-names server)
(let ((kernel-names (assoc (oref server url) jupyter-server-kernel-names)))
(car (rassoc name kernel-names))))
(defun jupyter-server-name-kernel (server id name)
"NAME the kernel with ID on SERVER.
See `jupyter-server-kernel-names'."
(cl-check-type server jupyter-server)
(setf (alist-get id
(alist-get (oref server url)
jupyter-server-kernel-names
nil nil #'equal)
nil nil #'equal)
name))
(defun jupyter-server-name-client-kernel (client name)
"For the kernel connected to CLIENT associate NAME.
CLIENT must be communicating with a `jupyter-server-kernel', the
CLIENT must be communicating with a `jupyter-server-kernel', see
`jupyter-server-kernel-names'."
(cl-check-type client jupyter-kernel-client)
(jupyter-kernel-action client
(lambda (kernel)
(pcase-let (((cl-struct jupyter-server-kernel server id) kernel))
(jupyter-server-name-kernel server id name)))))
;;; Launching notebook processes
(defvar jupyter-notebook-procs nil)
(defvar jupyter-default-notebook-port 8888)
(defun jupyter-port-available-p (port)
"Return non-nil if PORT is available."
(let ((proc
(condition-case nil
(make-network-process
:name "jupyter-port-available-p"
:server t
:host "127.0.0.1"
:service port)
(file-error nil))))
(when proc
(prog1 t
(delete-process proc)))))
(defun jupyter-launch-notebook (&optional port authentication)
"Launch a Jupyter notebook on PORT with AUTHENTICATION.
If PORT is nil, launch the notebook on the
`jupyter-default-notebook-port' if available. Launch the
notebook on a random port otherwise. Return the actual port
used.
If AUTHENTICATION is t, use the default, token, authentication of
a Jupyter notebook. If AUTHENTICATION is a string, it is
interpreted as the password to the notebook. Any other value of
AUTHENTICATION means the notebook is not authenticated."
(let ((port (if port
(if (jupyter-port-available-p port)
port
(error "Port %s not available" port))
(if (jupyter-port-available-p jupyter-default-notebook-port)
jupyter-default-notebook-port
(car (jupyter-available-local-ports 1))))))
(prog1 port
(let ((buffer (generate-new-buffer "*jupyter-notebook*"))
(args (append
(list "notebook" "--no-browser" "--debug"
(format "--NotebookApp.port=%s" port))
(cond
((eq authentication t)
(list))
((stringp authentication)
(list
"--NotebookApp.token=''"
(format "--NotebookApp.password='%s'"
authentication)))
(t
(list
"--NotebookApp.token=''"
"--NotebookApp.password=''"))))))
(setq jupyter-notebook-procs
(cl-loop for (port . proc) in jupyter-notebook-procs
if (process-live-p proc) collect (cons port proc)))
(push
(cons port
(apply #'start-file-process
"jupyter-notebook" buffer "jupyter" args))
jupyter-notebook-procs)
(with-current-buffer buffer
(jupyter-with-timeout ((format "Launching notebook process on port %s..." port) 5)
(save-excursion
(goto-char (point-min))
(re-search-forward "Jupyter Notebook.+running at:$" nil t))))))))
(defun jupyter-notebook-process (server)
"Return a process object for the notebook associated with SERVER.
Return nil if the associated notebook process was not launched by
Emacs."
(let ((url (url-generic-parse-url (oref server url))))
(cdr (assoc (url-port url) jupyter-notebook-procs))))
;;; 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))))
(define-error 'jupyter-server-non-existent
"The server doesn't exist")
(defun jupyter-current-server (&optional ask)
"Return an existing `jupyter-server' or ASK for a new one.
If ASK is non-nil, always ask for a URL and return the
`jupyter-server' object corresponding to it. If no Jupyter server
at URL exists, `signal' a `jupyter-server-non-existent' error
with error data being URL.
If the buffer local value of `jupyter-current-server' is non-nil,
return its value. If `jupyter-current-server' is nil and the
`jupyter-current-client' is communicating with a kernel behind a
kernel server, return the `jupyter-server' managing the
connection.
If `jupyter-current-client' is nil or not communicating with a
kernel behind a server and if `default-directory' is a Jupyter
remote file name, return the `jupyter-server' object
corresponding to that connection.
If all of the above fails, either return the most recently used
`jupyter-server' object if there is one or ask for one based off
a URL."
(interactive "P")
(let ((read-url-make-server
(lambda ()
;; From the list of available server
;; (if (> (length jupyter--servers) 1)
;; (let ((server (cdr (completing-read
;; "Jupyter Server: "
;; (mapcar (lambda (x) (cons (oref x url) x))
;; jupyter--servers)))))
;; )
(jupyter-gc-servers)
(let* ((url (read-string "Server URL: " "http://localhost:8888"))
(ws-url (read-string "Websocket URL: "
(let ((u (url-generic-parse-url url)))
(setf (url-type u) "ws")
(url-recreate-url u)))))
(let ((server (jupyter-server :url url :ws-url ws-url)))
(if (jupyter-api-server-exists-p server) server
(delete-instance server)
(signal 'jupyter-server-non-existent (list url))))))))
(let ((server
(if ask (funcall read-url-make-server)
(cond
(jupyter-current-server)
;; Server of the current kernel client
((and jupyter-current-client
(jupyter-kernel-action
jupyter-current-client
(lambda (kernel)
(and (jupyter-server-kernel-p kernel)
(jupyter-server-kernel-server kernel))))))
;; Server of the current TRAMP remote context
((and (file-remote-p default-directory)
(jupyter-tramp-file-name-p default-directory)
(jupyter-tramp-server-from-file-name default-directory)))
;; Most recently accessed
(t
(or (car jupyter--servers)
(funcall read-url-make-server)))))))
(prog1 server
(setq jupyter--servers
(cons server (delq server jupyter--servers)))))))
;;; 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-kernelspecs server))
(spec (jupyter-completing-read-kernelspec specs)))
(jupyter-api-start-kernel server (jupyter-kernelspec-name 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.
(defun jupyter-server-repl (kernel &optional repl-name associate-buffer client-class display)
(or client-class (setq client-class 'jupyter-repl-client))
(jupyter-error-if-not-client-class-p client-class 'jupyter-repl-client)
(jupyter-bootstrap-repl
(jupyter-client kernel client-class)
repl-name associate-buffer display))
;;;###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
(jupyter-completing-read-kernelspec
(jupyter-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)))
(jupyter-server-repl
(jupyter-kernel :server server :spec kernel-name)
repl-name associate-buffer client-class 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
(completing-read
"Kernel ID: "
(mapcar (lambda (kernel)
(cl-destructuring-bind (&key id &allow-other-keys)
kernel
(or (jupyter-server-kernel-name server id) id)))
(jupyter-api-get-kernel 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)))
(jupyter-server-repl
(jupyter-kernel
:server server
:id (or (jupyter-server-kernel-id-from-name server kernel-id)
kernel-id))
repl-name associate-buffer client-class 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)))))
(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? ")))
(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-server-repl
(jupyter-kernel
:server jupyter-current-server
:id 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))
(defun jupyter-server-kernel-list-name-kernel ()
"Name the kernel under `point'."
(interactive)
(when-let* ((id (tabulated-list-get-id))
(name (read-string
(let ((cname (jupyter-server-kernel-name
jupyter-current-server id)))
(if cname (format "Rename %s to: " cname)
(format "Name kernel [%s]: " id))))))
(when (zerop (length name))
(jupyter-server-kernel-list-name-kernel))
(jupyter-server-name-kernel jupyter-current-server id name)
(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 "C-<return>") #'jupyter-server-kernel-list-launch-kernel)
(define-key map (kbd "<return>") #'jupyter-server-kernel-list-new-repl)
(define-key map "R" #'jupyter-server-kernel-list-name-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")))
;; So that `dired-jump' will visit the directory of the kernel server.
(setq default-directory
(jupyter-tramp-file-name-from-url
(oref jupyter-current-server url))))
(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 (propertize
(or (jupyter-server-kernel-name jupyter-current-server id)
(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)
name))
'face 'font-lock-constant-face))
(activity (propertize (jupyter-format-time-low-res time)
'face 'font-lock-doc-face
'jupyter-time time))
(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.
When called interactively, ask to select a SERVER when given a
prefix argument otherwise the `jupyter-current-server' will be
used."
(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
tabulated-list-sort-key (cons "Activity" t))
(jupyter-server-kernel-list-mode)
;; So that `dired-jump' will visit the directory of the kernel server.
(setq default-directory
(jupyter-tramp-file-name-from-url (oref server url))))
(jupyter-display-current-buffer-reuse-window))))
(provide 'jupyter-server)
;;; jupyter-server.el ends here

View File

@@ -0,0 +1,881 @@
;;; jupyter-tramp.el --- TRAMP interface to the Jupyter REST API -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 25 May 2019
;; 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:
;; Integrate the Jupyter REST API contents endpoint with Emacs' file handling
;; facilities for remote files. Adds two new remote file methods, /jpy: and
;; /jpys:, the former being HTTP connections and the latter being HTTPS
;; connections.
;;
;; If you run a local notebook server on port 8888 then reading and writing
;; files to the server is as easy as
;;
;; (write-region "xxxx" nil "/jpy:localhost:happy.txt")
;;
;; or
;;
;; (find-file "/jpy:localhost:serious.py")
;;
;; To open a `dired' listing to the base directory of the notebook server
;;
;; (dired "/jpy:localhost:/")
;;
;; You can change the default port by changing the `tramp-default-port' entry
;; of the jpy or jpys method in `tramp-methods' or you can specify a port
;; inline using something like /jpy:localhost#8888:/.
;;
;; You can also set an entry in `tramp-default-host-alist' like
;;
;; (add-to-list 'tramp-default-host-alist (list "jpy" nil "HOST"))
;;
;; Then specifying filenames like /jpy::/foo is equivalent to /jpy:HOST:
;;
;; TODO: Same messages for implemented file operations that TRAMP and Emacs
;; give.
;;
;; TODO: How can checkpoints be used with: `auto-save-mode',
;; `diff-latest-backup-file', ...
;;; Code:
(eval-when-compile
(require 'subr-x)
(require 'tramp-compat))
(require 'jupyter-rest-api)
(require 'jupyter-server)
(require 'tramp)
(require 'tramp-cache)
(defgroup jupyter-tramp nil
"TRAMP integration with the Jupyter Contents REST API"
:group 'jupyter)
(declare-function jupyter-decode-time "jupyter-messages" (str))
(defmacro jupyter-tramp-with-api-connection (file &rest body)
"Set `jupyter-current-server' based on FILE, evaluate BODY.
FILE must be a remote file name recognized as corresponding to a
file on a server that can be communicated with using the Jupyter
notebook REST API.
Note, BODY is wrapped with a call to
`with-parsed-tramp-file-name' so that the variables method, user,
host, localname, ..., are all bound to values parsed from FILE."
(declare (indent 1) (debug ([&or stringp symbolp] body)))
`(with-parsed-tramp-file-name ,file nil
;; FIXME: There is a dilemma here, a `jupyter-server' is a more particular
;; object than what we need. There is really no reason to have it here, we
;; just need a `jupyter-rest-client'. Is there a reason this needs to be
;; here?
(let ((jupyter-current-server
(jupyter-tramp-server-from-file-name ,file)))
,@body)))
;;; File name handler setup
;; Actual functions implemented by `jupyter-tramp' all the others are either
;; ignored or handled by the TRAMP handlers.
;;
;; jupyter-tramp-copy-file
;; jupyter-tramp-delete-directory
;; jupyter-tramp-delete-file
;; jupyter-tramp-expand-file-name
;; jupyter-tramp-file-attributes
;; jupyter-tramp-file-directory-p
;; jupyter-tramp-file-exists-p
;; jupyter-tramp-file-local-copy
;; jupyter-tramp-file-name-all-completions
;; jupyter-tramp-file-remote-p
;; jupyter-tramp-file-symlink-p
;; jupyter-tramp-file-writable-p
;; jupyter-tramp-make-directory-internal
;; jupyter-tramp-rename-file
;; jupyter-tramp-write-region
;;;###autoload
(defconst jupyter-tramp-file-name-handler-alist
'((access-file . tramp-handle-access-file)
(add-name-to-file . tramp-handle-add-name-to-file)
;; `byte-compiler-base-file-name' performed by default handler.
;; `copy-directory' performed by default handler.
(copy-file . jupyter-tramp-copy-file)
(delete-directory . jupyter-tramp-delete-directory)
(delete-file . jupyter-tramp-delete-file)
;; TODO: Use the `checkpoint' file? I think we can only create a checkpoint
;; or restore a file from a checkpoint so maybe we can do something with
;; auto-save and checkpoints?
;; `diff-latest-backup-file' performed by default handler.
(directory-file-name . tramp-handle-directory-file-name)
(directory-files . tramp-handle-directory-files)
(directory-files-and-attributes . tramp-handle-directory-files-and-attributes)
(dired-compress-file . ignore)
(dired-uncache . tramp-handle-dired-uncache)
(expand-file-name . jupyter-tramp-expand-file-name)
(file-accessible-directory-p . tramp-handle-file-accessible-directory-p)
(file-acl . ignore)
(file-attributes . jupyter-tramp-file-attributes)
(file-directory-p . jupyter-tramp-file-directory-p)
(file-equal-p . tramp-handle-file-equal-p)
(file-executable-p . tramp-handle-file-exists-p)
(file-exists-p . jupyter-tramp-file-exists-p)
(file-in-directory-p . tramp-handle-file-in-directory-p)
(file-local-copy . jupyter-tramp-file-local-copy)
(file-modes . tramp-handle-file-modes)
(file-name-all-completions . jupyter-tramp-file-name-all-completions)
(file-name-as-directory . tramp-handle-file-name-as-directory)
(file-name-case-insensitive-p . tramp-handle-file-name-case-insensitive-p)
(file-name-completion . tramp-handle-file-name-completion)
(file-name-directory . tramp-handle-file-name-directory)
(file-name-nondirectory . tramp-handle-file-name-nondirectory)
;; `file-name-sans-versions' performed by default handler.
(file-newer-than-file-p . tramp-handle-file-newer-than-file-p)
(file-notify-add-watch . tramp-handle-file-notify-add-watch)
(file-notify-rm-watch . tramp-handle-file-notify-rm-watch)
(file-notify-valid-p . tramp-handle-file-notify-valid-p)
(file-ownership-preserved-p . ignore)
(file-readable-p . tramp-handle-file-exists-p)
(file-regular-p . tramp-handle-file-regular-p)
;; NOTE: We can't use `tramp-handle-file-remote-p' since it expects a
;; process to check for the connected argument whereas we are using an HTTP
;; connection which may or may not be as long lived as something like an
;; SSH connection as the liveness depends on the Keep-Alive header of an
;; HTTP request.
(file-remote-p . jupyter-tramp-file-remote-p)
(file-selinux-context . tramp-handle-file-selinux-context)
(file-symlink-p . jupyter-tramp-file-symlink-p)
(file-system-info . ignore)
(file-truename . tramp-handle-file-truename)
(file-writable-p . jupyter-tramp-file-writable-p)
;; TODO: Can we do something here with checkpoints on the remote?
(find-backup-file-name . ignore)
;; `find-file-noselect' performed by default handler.
;; `get-file-buffer' performed by default handler.
(insert-directory . tramp-handle-insert-directory)
;; Uses `file-local-copy' to get the contents so be sure thats implemented
(insert-file-contents . tramp-handle-insert-file-contents)
(load . tramp-handle-load)
(make-auto-save-file-name . tramp-handle-make-auto-save-file-name)
(make-directory . jupyter-tramp-make-directory)
(make-directory-internal . ignore)
(make-nearby-temp-file . tramp-handle-make-nearby-temp-file)
(make-symbolic-link . tramp-handle-make-symbolic-link)
;; `process-file' performed by default handler.
(rename-file . jupyter-tramp-rename-file)
(set-file-acl . ignore)
(set-file-modes . ignore)
(set-file-selinux-context . ignore)
(set-file-times . ignore)
(set-visited-file-modtime . tramp-handle-set-visited-file-modtime)
;; `shell-command' performed by default handler.
;; `start-file-process' performed by default handler.
(substitute-in-file-name . tramp-handle-substitute-in-file-name)
(temporary-file-directory . tramp-handle-temporary-file-directory)
;; Important that we have this so that `call-process' and friends don't try
;; to set a Jupyter notebook directory as a directory in which a process
;; should run.
(unhandled-file-name-directory . ignore)
(vc-registered . ignore)
(verify-visited-file-modtime . tramp-handle-verify-visited-file-modtime)
(write-region . jupyter-tramp-write-region))
"Alist of handler functions for Tramp Jupyter method.
Operations not mentioned here will be handled by the default Emacs primitives.")
;;;###autoload
(defconst jupyter-tramp-methods '("jpy" "jpys")
"Methods to connect Jupyter kernel servers.")
;;;###autoload
(with-eval-after-load 'tramp
(mapc (lambda (method)
(add-to-list
'tramp-methods
(list method
(list 'tramp-default-port 8888)
(list 'tramp-tmpdir "/tmp"))))
jupyter-tramp-methods)
(tramp-register-foreign-file-name-handler
'jupyter-tramp-file-name-p 'jupyter-tramp-file-name-handler)
(add-to-list 'tramp-default-host-alist
'("\\`jpys?\\'" nil "localhost")))
;;;###autoload
(defsubst jupyter-tramp-file-name-method-p (method)
"Return METHOD if it corresponds to a Jupyter filename method or nil."
(and (string-match-p "\\`jpys?\\'" method) method))
;; Port of `tramp-ensure-dissected-file-name' in Emacs 29
;;;###autoload
(defun jupyter-tramp-ensure-dissected-file-name (vec-or-filename)
(cond
((tramp-file-name-p vec-or-filename) vec-or-filename)
((tramp-tramp-file-p vec-or-filename)
(tramp-dissect-file-name vec-or-filename))))
;; NOTE: Needs to be a `defsubst' to avoid recursive loading.
;;;###autoload
(defsubst jupyter-tramp-file-name-p (vec-or-filename)
"If FILENAME is a Jupyter filename, return its method otherwise nil."
(when-let* ((vec (jupyter-tramp-ensure-dissected-file-name vec-or-filename)))
(jupyter-tramp-file-name-method-p (tramp-file-name-method vec))))
;;;###autoload
(defun jupyter-tramp-file-name-handler (operation &rest args)
(let ((handler (assq operation jupyter-tramp-file-name-handler-alist)))
(if (not handler)
(tramp-run-real-handler operation args)
(apply (cdr handler) args))))
;;;; Converting file names to authenticated `jupyter-rest-client' instances
(defvar tramp-current-method)
(defvar tramp-current-user)
(defvar tramp-current-domain)
(defvar tramp-current-host)
(defvar tramp-current-port)
(defun jupyter-tramp-read-passwd (filename &optional prompt)
"Read a password based off of FILENAME's TRAMP filename components.
Use PROMPT to prompt the user for the password if needed, PROMPT
defaults to \"Password:\"."
(unless (jupyter-tramp-file-name-p filename)
(error "Not a Jupyter filename"))
(with-parsed-tramp-file-name filename nil
(let ((tramp-current-method method)
(tramp-current-user (or user user-login-name))
(tramp-current-domain nil)
(tramp-current-host host)
(tramp-current-port port))
(tramp-read-passwd nil (or prompt "Password: ")))))
;;;###autoload
(defun jupyter-tramp-file-name-from-url (url)
"Return a Jupyter TRAMP filename for the root directory of a kernel server.
The filename is based off of URL's host and port if any."
(let ((url (if (url-p url) url
(url-generic-parse-url url))))
(format "/jpy%s:%s%s:/"
(if (equal (url-type url) "https") "s" "")
(url-host url)
(let ((port (url-port-if-non-default url)))
(if port (format "#%d" port) "")))))
;;;###autoload
(defun jupyter-tramp-url-from-file-name (filename)
"Return a URL string based off the method, host, and port of FILENAME."
(with-parsed-tramp-file-name filename nil
(unless port (setq port (when (functionp 'tramp-file-name-port-or-default)
;; This function was introduced in Emacs 26.1
(tramp-file-name-port-or-default v))))
(format "%s://%s%s" (if (equal method "jpys") "https" "http")
host (if port (format ":%s" port) ""))))
;;;###autoload
(defun jupyter-tramp-server-from-file-name (filename)
"Return a `jupyter-server' instance based off of FILENAME's remote components.
If the connection has not been authenticated by the server,
attempt to authenticate the connection. Raise an error if that
fails."
(unless (jupyter-tramp-file-name-p filename)
(error "Not a Jupyter filename"))
(with-parsed-tramp-file-name filename nil
(let* ((url (jupyter-tramp-url-from-file-name filename))
(client (jupyter-server :url url)))
(prog1 client
(unless (jupyter-api-server-accessible-p client)
(cond
((y-or-n-p (format "Login to %s using a token? " url))
(jupyter-api-authenticate client 'token))
(t
;; This is here so that reading a password using
;; `tramp-read-passwd' via `jupyter-tramp-read-passwd' will check
;; auth sources.
(tramp-set-connection-property v "first-password-request" t)
(jupyter-api-authenticate client
'password
(let ((remote (file-remote-p filename)))
(lambda ()
(jupyter-tramp-read-passwd
filename (format "Password [%s]: " remote))))))))))))
;;; Getting information about file models
(defalias 'jupyter-tramp-flush-file-properties
(if (functionp 'tramp-flush-file-properties)
;; New in Emacs 27
'tramp-flush-file-properties
'tramp-flush-file-property))
(defun jupyter-tramp--get-directory-or-file-model (file localname path no-content)
(cond
(no-content
(jupyter-tramp-get-file-model (file-name-directory file)))
(t
(condition-case err
;; Unset `signal-hook-function' so that TRAMP in Emacs >= 27 does not
;; mess with the signal data until we have a chance to look at it.
(let (signal-hook-function)
(jupyter-api-get-file-model jupyter-current-server localname))
(jupyter-api-http-error
(cl-destructuring-bind (_ code msg) err
(if (and (eq code 404)
(string-match-p
"\\(?:No such \\)?file or directory\\(?:does not exist\\)?"
msg))
(list :path path :name nil
;; If a file doesn't exist we need to check if the
;; containing directory is writable to determine if
;; FILE is.
:writable (plist-get
(jupyter-tramp-get-file-model
(file-name-directory
(directory-file-name file))
'no-content)
:writable))
(signal (car err) (cdr err)))))
(error (signal (car err) (cdr err)))))))
(defun jupyter-tramp--get-file-model (file localname no-content)
(let* ((path (jupyter-api-content-path localname))
(model (jupyter-tramp--get-directory-or-file-model
file localname path no-content)))
(or (jupyter-api-find-model path model)
;; We reach here when MODEL is a directory that does
;; not contain PATH. PATH is writable if the
;; directory is.
(list :path path :name nil
:writable (plist-get model :writable)))))
(defun jupyter-tramp-get-file-model (file &optional no-content)
"Return a model of FILE or raise an error.
For non-existent files the model
(:path PATH :name nil :writable WRITABLE)
is returned, where PATH is a local path name to FILE on the
server, i.e. excludes the remote part of FILE. WRITABLE will be t
if FILE can be created on the server or nil if PATH is outside
the base directory the server was started in.
When NO-CONTENT is non-nil, return a model for file that excludes
:content if an actual request needs to be made. The :content key
may or may not be present in this case. If NO-CONTENT is nil,
guarantee that we request FILE's content as well.
See `jupyter-tramp-get-file-model' for details on what a file model is."
(setq file (expand-file-name file))
(jupyter-tramp-with-api-connection file
(let ((value (or (tramp-get-file-property v localname "model" nil)
(when no-content
(tramp-get-file-property v localname "nc-model" nil)))))
(unless value
(setq value (jupyter-tramp--get-file-model file localname no-content))
(tramp-set-file-property
v localname (if no-content "nc-model" "model") value))
value)))
(defun jupyter-tramp-flush-file-and-directory-properties (filename)
(with-parsed-tramp-file-name filename nil
(jupyter-tramp-flush-file-properties v localname)
(jupyter-tramp-flush-file-properties v (file-name-directory localname))))
;;; Predicates
(defun jupyter-tramp--barf-if-not-file (file)
(unless (file-exists-p file)
(error "No such file or directory: %s" file)))
(defun jupyter-tramp--barf-if-not-regular-file (file)
(jupyter-tramp--barf-if-not-file file)
(unless (file-regular-p file)
(error "Not a file: %s" file)))
(defun jupyter-tramp--barf-if-not-directory (directory)
(jupyter-tramp--barf-if-not-file directory)
(unless (file-directory-p directory)
(error "Not a directory: %s" (expand-file-name directory))))
(defun jupyter-tramp-file-writable-p (filename)
(jupyter-tramp-with-api-connection filename
(plist-get (jupyter-tramp-get-file-model filename 'no-content) :writable)))
;; Actually this may not be true, but there is no way to tell if a file is a
;; symlink or not
(defun jupyter-tramp-file-symlink-p (_filename)
nil)
(defun jupyter-tramp-file-directory-p (filename)
(jupyter-tramp-with-api-connection filename
(equal (plist-get (jupyter-tramp-get-file-model filename 'no-content) :type)
"directory")))
(defvar url-http-open-connections)
(defun jupyter-tramp-connected-p (vec-or-filename)
"Return non-nil if connected to a Jupyter based remote host."
(let* ((vec (tramp-ensure-dissected-file-name vec-or-filename))
(port (tramp-file-name-port-or-default vec))
(key (cons (tramp-file-name-host vec)
(if (numberp port) port
(string-to-number port)))))
(catch 'connected
(dolist (conn (gethash key url-http-open-connections))
(when (memq (process-status conn) '(run open connect))
(throw 'connected t))))))
(defun jupyter-tramp-file-remote-p (file &optional identification connected)
(when (file-name-absolute-p file)
(with-parsed-tramp-file-name file nil
(when (or (null connected)
(jupyter-tramp-connected-p v))
(cl-case identification
(method method)
(host host)
(user user)
(localname localname)
(t (tramp-make-tramp-file-name v "")))))))
;; Adapted from `tramp-handle-file-exists-p'
(defun jupyter-tramp-file-exists-p (filename)
;; `file-exists-p' is used as predicate in file name completion.
;; We don't want to run it when `non-essential' is t, or there is
;; no connection process yet.
(when (or (jupyter-tramp-connected-p filename)
(not non-essential))
(with-parsed-tramp-file-name (expand-file-name filename) nil
(with-tramp-file-property v localname "file-exists-p"
(not (null (file-attributes filename)))))))
;;; File name manipulation
(defun jupyter-tramp-expand-file-name (name &optional directory)
;; From `tramp-sh-handle-expand-file-name'
(setq directory (or directory default-directory "/"))
(unless (file-name-absolute-p name)
(setq name (concat (file-name-as-directory directory) name)))
(if (tramp-tramp-file-p name)
(let ((v (tramp-dissect-file-name name)))
(if (jupyter-tramp-file-name-method-p (tramp-file-name-method v))
(tramp-make-tramp-file-name
v
(tramp-drop-volume-letter
(tramp-run-real-handler
'expand-file-name (list (tramp-file-name-localname v) "/"))))
(let ((tramp-foreign-file-name-handler-alist
(remove (cons 'jupyter-tramp-file-name-p
'jupyter-tramp-file-name-handler)
tramp-foreign-file-name-handler-alist)))
(expand-file-name name))))
(tramp-run-real-handler 'expand-file-name (list name directory))))
;;; File operations
;; Adapted from `tramp-smb-handle-rename-file'
(defun jupyter-tramp-rename-file (filename newname &optional ok-if-already-exists)
(setq filename (expand-file-name filename)
newname (expand-file-name newname))
(when (and (not ok-if-already-exists)
(file-exists-p newname))
(tramp-error
(tramp-dissect-file-name
(if (tramp-tramp-file-p filename) filename newname))
'file-already-exists newname))
(with-tramp-progress-reporter
(tramp-dissect-file-name
(if (tramp-tramp-file-p filename) filename newname))
0 (format "Renaming %s to %s" filename newname)
(if (and (not (file-exists-p newname))
(tramp-equal-remote filename newname))
;; We can rename directly.
(jupyter-tramp-with-api-connection filename
;; We must also flush the cache of the directory, because
;; `file-attributes' reads the values from there.
(jupyter-tramp-flush-file-and-directory-properties filename)
(jupyter-tramp-flush-file-and-directory-properties newname)
(jupyter-api-rename-file jupyter-current-server
filename newname))
;; We must rename via copy.
(copy-file filename newname ok-if-already-exists)
(if (file-directory-p filename)
(delete-directory filename 'recursive)
(delete-file filename)))))
;; NOTE: Deleting to trash is configured on the server.
(defun jupyter-tramp-delete-directory (directory &optional recursive _trash)
(jupyter-tramp--barf-if-not-directory directory)
(jupyter-tramp-with-api-connection directory
(jupyter-tramp-flush-file-properties v localname)
(let ((files (cl-remove-if
(lambda (x) (member x '("." "..")))
(directory-files directory nil nil t))))
(unless (or recursive (not files))
(error "Directory %s not empty" directory))
(let ((deleted
;; Try to delete the directory, if we get an error because its not
;; empty, manually delete all files below and then try again.
(condition-case err
(prog1 t
;; Unset `signal-hook-function' so that TRAMP in Emacs >= 27
;; does not mess with the signal data until we have a chance
;; to look at it.
(let (signal-hook-function)
(jupyter-api-delete-file
jupyter-current-server
directory)))
(jupyter-api-http-error
(unless (and (= (nth 1 err) 400)
(string-match-p "not empty" (caddr err)))
(signal (car err) (cdr err))))
(error (signal (car err) (cdr err))))))
(unless deleted
;; Recursive delete, we need to do this manually since we can get a 400
;; error on Windows when deleting to trash and also in general when not
;; deleting to trash if the directory isn't empty, see
;; jupyter/notebook/notebook/services/contents/filemanager.py
(while files
(let ((file (expand-file-name (pop files) directory)))
(if (file-directory-p file)
(delete-directory file recursive)
(delete-file file))))
(jupyter-api-delete-file jupyter-current-server directory))))
;; Need to uncache both the file and its directory
(jupyter-tramp-flush-file-and-directory-properties directory)))
(defun jupyter-tramp-delete-file (filename &optional _trash)
(jupyter-tramp--barf-if-not-regular-file filename)
(jupyter-tramp-with-api-connection filename
(jupyter-api-delete-file jupyter-current-server filename)
;; Need to uncache both the file and its directory
(jupyter-tramp-flush-file-and-directory-properties filename)))
;; Adapted from `tramp-smb-handle-copy-file'
(defun jupyter-tramp-copy-file (filename newname &optional ok-if-already-exists
keep-date _preserve-uid-gid _preserve-permissions)
(setq filename (expand-file-name filename)
newname (expand-file-name newname))
(with-tramp-progress-reporter
(tramp-dissect-file-name
(if (tramp-tramp-file-p filename) filename newname))
0 (format "Copying %s to %s" filename newname)
(if (file-directory-p filename)
(copy-directory filename newname keep-date 'parents 'copy-contents)
(cond
((tramp-equal-remote filename newname)
(jupyter-tramp-with-api-connection newname
(when (and (not ok-if-already-exists)
(file-exists-p newname))
(tramp-error v 'file-already-exists newname))
(jupyter-api-copy-file jupyter-current-server filename newname)))
(t
(let ((tmpfile (file-local-copy filename)))
(if tmpfile
;; Remote filename.
(condition-case err
(rename-file tmpfile newname ok-if-already-exists)
((error quit)
(delete-file tmpfile)
(signal (car err) (cdr err))))
;; Remote newname.
(when (and (file-directory-p newname)
(directory-name-p newname))
(setq newname
(expand-file-name (file-name-nondirectory filename) newname)))
(with-parsed-tramp-file-name newname nil
(when (and (not ok-if-already-exists)
(file-exists-p newname))
(tramp-error v 'file-already-exists newname))
(with-temp-file newname
(insert-file-contents-literally filename)))))))
(when (tramp-tramp-file-p newname)
;; We must also flush the cache of the directory, because
;; `file-attributes' reads the values from there.
(jupyter-tramp-flush-file-and-directory-properties newname)))))
;; Ported from `trapm-skeleton-make-directory' in Emacs 29
(defun jupyter-tramp-make-directory (dir &optional parents)
(jupyter-tramp-with-api-connection dir
(let* ((dir (directory-file-name (expand-file-name dir)))
(par (file-name-directory dir)))
(when (and (null parents) (file-exists-p dir))
(tramp-error v 'file-already-exists dir))
;; Make missing directory parts.
(when parents
(unless (file-directory-p par)
(make-directory par parents)))
;; Just do it.
(if (file-exists-p dir) t
(jupyter-tramp-flush-file-and-directory-properties dir)
(jupyter-api-make-directory jupyter-current-server dir)
nil))))
;;; File name completion
(defun jupyter-tramp-file-name-all-completions (filename directory)
(when (jupyter-tramp-file-name-p directory)
(all-completions
filename (mapcar #'car (jupyter-tramp-directory-file-models directory))
(lambda (f)
(let ((ext (file-name-extension f t)))
(and (or (null ext) (not (member ext completion-ignored-extensions)))
(or (null completion-regexp-list)
(not (cl-loop for re in completion-regexp-list
thereis (not (string-match-p re f)))))))))))
;;; Insert file contents
;; XXX: WIP
(defun jupyter-tramp--recover-this-file (orig)
"If the `current-buffer' is Jupyter file, revert back to a checkpoint.
If no checkpoints exist, revert back to the file that exists on
the server. For any other file, call ORIG, which is the function
`recover-this-file'"
(interactive)
(let ((file (buffer-file-name)))
(if (not (jupyter-tramp-file-name-p file)) (funcall orig)
(jupyter-tramp-with-api-connection file
(let ((checkpoint (jupyter-api-get-latest-checkpoint
jupyter-current-server
file)))
(when checkpoint
(jupyter-api-restore-checkpoint
jupyter-current-server
file checkpoint))
(let ((tmpfile (file-local-copy file)))
(unwind-protect
(save-restriction
(widen)
(insert-file-contents tmpfile nil nil nil 'replace)
;; TODO: What else needs to be done here
(set-buffer-modified-p nil))
(delete-file tmpfile))))))))
;; TODO: Something that doesn't use advise
;; (advice-add 'recover-this-file :around 'jupyter-tramp--recover-this-file)
;; TODO: What to do about reading and writing large files? Also the out of
;; band functions of TRAMP.
;;
;; Adapted from `tramp-sh-handle-write-region'
(defun jupyter-tramp-write-region (start end filename &optional append visit lockname mustbenew)
(setq filename (expand-file-name filename))
(when (and mustbenew (file-exists-p filename)
(or (eq mustbenew 'excl)
(not
(y-or-n-p
(format "File %s exists; overwrite anyway? " filename)))))
(signal 'file-already-exists (list filename)))
(jupyter-tramp-with-api-connection filename
;; Ensure we don't use stale model contents
(jupyter-tramp-flush-file-and-directory-properties filename)
(if (and append (file-exists-p filename))
(let* ((tmpfile (file-local-copy filename))
(model (jupyter-tramp-get-file-model filename))
(binary (jupyter-api-binary-content-p model))
(coding-system-for-read (if binary 'no-conversion 'utf-8))
(coding-system-for-write (if binary 'no-conversion 'utf-8)))
(condition-case err
(tramp-run-real-handler
'write-region
(list start end tmpfile append 'no-message lockname mustbenew))
(error
(delete-file tmpfile)
(signal (car err) (cdr err))))
(unwind-protect
(with-temp-buffer
(insert-file-contents-literally tmpfile)
(decode-coding-region (point-min) (point-max) 'utf-8-auto)
(jupyter-api-write-file-content
jupyter-current-server
filename (buffer-string) binary))
(delete-file tmpfile)))
(let ((source (if (stringp start) start
(if (null start) (buffer-string)
(buffer-substring-no-properties start end))))
(binary (coding-system-equal
(or coding-system-for-write
(if enable-multibyte-characters 'utf-8
'binary))
'binary)))
(jupyter-api-write-file-content
jupyter-current-server
filename source binary)
;; Adapted from `tramp-sh-handle-write-region'
(when (or (eq visit t) (stringp visit))
(let ((file-attr (file-attributes filename)))
(when (stringp visit)
(setq buffer-file-name visit))
(set-buffer-modified-p nil)
(set-visited-file-modtime
;; We must pass modtime explicitly, because FILENAME can
;; be different from (buffer-file-name), f.e. if
;; `file-precious-flag' is set.
(or (file-attribute-modification-time file-attr)
(current-time)))))
(when (and (null noninteractive)
(or (eq visit t) (null visit) (stringp visit)))
(tramp-message v 0 "Wrote %s" filename))))
;; Another flush after writing for consistency
;; TODO: Figure out more exactly where these should go
(jupyter-tramp-flush-file-and-directory-properties filename)))
;; TODO: Set `jupyter-current-server' in every buffer that visits a file, this
;; way `jupyter-current-server' will always use the right server for file
;; operations if there happen to be more than one server.
;;
;; NOTE: Not currently used since `file-local-copy' is used as a way to get
;; files from the server and then `write-region' is used to write them back.
(defun jupyter-tramp-insert-file-contents (filename &optional visit beg end replace)
(setq filename (expand-file-name filename))
(let ((do-visit
(lambda ()
(setq buffer-file-name filename)
(set-buffer-modified-p nil))))
(condition-case err
(jupyter-tramp--barf-if-not-file filename)
(error
(and visit (funcall do-visit))
(signal (car err) (cdr err))))
(jupyter-tramp-with-api-connection filename
;; Ensure we grab a fresh model since the cached version may be out of
;; sync with the server.
(jupyter-tramp-flush-file-properties v localname)
(let ((model (jupyter-tramp-get-file-model filename)))
(when (and visit (jupyter-api-binary-content-p model))
(set-buffer-multibyte nil))
(let ((pos (point)))
(jupyter-api-insert-model-content model replace beg end)
(and visit (funcall do-visit))
(list filename (- (point) pos)))))))
(defun jupyter-tramp-file-local-copy (filename)
(jupyter-tramp-with-api-connection filename
(unless (file-exists-p filename)
(tramp-error
v 'file-missing
"Cannot make local copy of non-existing file `%s'" filename))
;; Ensure we grab a fresh model since the cached version may be out of
;; sync with the server.
(jupyter-tramp-flush-file-properties v localname)
(let ((model (jupyter-tramp-get-file-model filename)))
(when (jupyter-api-notebook-p model)
(error "Notebooks not supported yet"))
(let ((coding-system-for-write
(if (jupyter-api-binary-content-p model)
'no-conversion
'utf-8)))
(tramp-run-real-handler
'make-temp-file
(list "jupyter-tramp." nil (file-name-extension filename t)
(with-current-buffer (jupyter-api-content-buffer model)
(buffer-string))))))))
;;; File/directory attributes
(defun jupyter-tramp-file-attributes-from-model (model &optional id-format)
;; :name is nil if the corresponding file of MODEL doesn't exist, see
;; `jupyter-tramp-get-file-model'.
(when (plist-get model :name)
(let* ((dirp (equal (plist-get model :type) "directory"))
(last-modified (plist-get model :last_modified))
(created (plist-get model :created))
(mtime (or (and last-modified (jupyter-decode-time last-modified))
(current-time)))
(ctime (or (and created (jupyter-decode-time created))
(current-time)))
;; Sometimes the model doesn't contain a size
(size (or (plist-get model :size) 64))
;; FIXME: What to use for these two?
(ugid (if (eq id-format 'string) "jupyter" 100))
(mbits (format "%sr%s%s-------"
(if dirp "d" "-")
(if (plist-get model :writable) "w" "")
(if dirp "x" ""))))
(list dirp 1 user-login-name ugid
mtime mtime ctime size mbits nil -1 -1))))
(defun jupyter-tramp-file-attributes (filename &optional id-format)
(jupyter-tramp-file-attributes-from-model
(jupyter-tramp-with-api-connection filename
(jupyter-tramp-get-file-model filename 'no-content))
id-format))
(defun jupyter-tramp-directory-file-models (directory &optional full match)
"Return the files contained in DIRECTORY as Jupyter file models.
The returned files have the form (PATH . MODEL) where PATH is
relative to DIRECTORY unless FULL is non-nil. In that case PATH
is an absolute file name. PATH will have an ending / character if
MODEL corresponds to a directory.
If MATCH is non-nil, it should be a regular expression. Only
return files that match it.
If DIRECTORY does not correspond to a directory on the server,
return nil."
(when (file-directory-p directory)
(jupyter-tramp-with-api-connection directory
(let ((dir-model (jupyter-tramp-get-file-model directory)))
(cl-loop
for model across (plist-get dir-model :content)
for dirp = (equal (plist-get model :type) "directory")
for name = (concat (plist-get model :name) (and dirp "/"))
for path = (if full (expand-file-name name directory) name)
if match when (string-match-p match name)
collect (cons path model) into files end
else collect (cons path model) into files
finally return
(let ((pdir-model (jupyter-tramp-get-file-model
(file-name-directory
(directory-file-name directory)))))
(dolist (d (list (cons "../" pdir-model)
(cons "./" dir-model)))
(when (or (null match)
(string-match-p match (car d)))
(when full
(setcar d (expand-file-name (car d) directory)))
(push d files)))
files))))))
(defun jupyter-tramp-directory-files-and-attributes
(directory &optional full match nosort id-format)
(jupyter-tramp--barf-if-not-directory directory)
(let ((files
(cl-loop
for (file . model)
in (jupyter-tramp-directory-file-models directory full match)
for attrs = (jupyter-tramp-file-attributes-from-model model id-format)
collect (cons file attrs))))
(if nosort files
(sort files (lambda (a b) (string-lessp (car a) (car b)))))))
(provide 'jupyter-tramp)
;;; jupyter-tramp.el ends here

View File

@@ -0,0 +1,287 @@
;;; jupyter-widget-client.el --- Widget support -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 21 May 2018
;; 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:
;; Use an external browser to interact with Jupyter widgets.
;;
;; A `jupyter-kernel-client' does not come with any widget support by default,
;; the purpose of the `jupyter-widget-client' class is to provide such support.
;; This is done by opening an external browser and serving it the necessary
;; resources to display widgets using the `simple-httpd' package. Emacs then
;; acts as an intermediary for the widget comm messages sent between the
;; browser and the kernel, communicating with the kernel through `zmq' and with
;; the browser through `websocket'.
;;
;; To add widget support to a client, subclass `jupyter-widget-client'.
;;; Code:
(require 'simple-httpd)
(require 'websocket)
(require 'jupyter-client)
(defvar jupyter-widgets-initialized nil
"A client local variable that is non-nil if a browser for widgets is opened.")
(defvar jupyter-widgets-server nil
"The `websocket-server' redirecting kernel messages.")
(defvar jupyter-widgets-port 8090
"The port that `jupyter-widgets-server' listens on.")
(defvar jupyter-widgets-supported-targets '("jupyter.widget")
"A list of the supported widget target names.")
(defvar jupyter-widgets-url-format
"http://127.0.0.1:%d/jupyter/widgets?username=%s&clientId=%s&port=%d"
"Format of the URL that will be visited to display widgets.")
(defclass jupyter-widget-client (jupyter-kernel-client)
((widget-sock
:type (or null websocket)
:initform nil
:documentation "The `websocket' connected to the browser
displaying the widgets for this client.")
(widget-state
:type string
:initform "null"
:documentation "The JSON encode string representing the
widget state. When a browser displaying the widgets of the client
is closed, the state of the widgets is sent back to Emacs so that
the state can be recovred when a new browser is opened.")
(widget-messages
:type list
:initform nil
:documentation "A list of pending messages to send to the
widget socket."))
:abstract t)
;;; Websocket handlers
(defsubst jupyter-widgets--send-deferred (client)
(cl-loop for msg in (nreverse (oref client widget-messages))
do (websocket-send-text (oref client widget-sock) msg))
(oset client widget-messages nil))
(defun jupyter-widgets-on-message (ws frame)
"When websocket, WS, receives a message FRAME, handle it.
Send the contents of the message FRAME to the kernel and register
callbacks."
(cl-assert (eq (websocket-frame-opcode frame) 'text))
(let* ((msg (jupyter-read-plist-from-string
(websocket-frame-payload frame)))
(client (jupyter-find-client-for-session
(jupyter-message-session msg))))
(cl-assert client)
(unless (equal ws (oref client widget-sock))
;; TODO: Handle multiple clients and sending widget state to new clients
(oset client widget-sock ws))
(pcase (jupyter-message-type msg)
("connect"
(jupyter-widgets--send-deferred client))
(_
;; Any other message the browser sends is meant for the kernel so do the
;; redirection and setup the callbacks
(let* ((msg-type (jupyter-message-type msg))
(content (jupyter-message-content msg)))
(jupyter-run-with-client client
(jupyter-sent
(jupyter-message-subscribed
(let ((jupyter-inhibit-handlers
(if (member msg-type '("comm_info_request"))
'("comm_msg" "status" "comm_info_reply")
'("comm_msg"))))
(apply #'jupyter-request msg-type content))
(let ((fn (apply-partially #'jupyter-widgets-send-message client)))
`(("comm_open" ,fn)
("comm_close" ,fn)
("comm_info_reply" ,fn)
("comm_msg" ,fn)
("status" ,fn)))))))))))
(defun jupyter-widgets-on-close (ws)
"Uninitialize the client whose widget-sock is WS."
(cl-loop
for client in jupyter--clients
when (and (object-of-class-p client 'jupyter-widget-client)
(equal ws (oref client widget-sock)))
do (oset client widget-sock nil)
(jupyter-set client 'jupyter-widgets-initialized nil)))
;;; Working with comm messages
(defun jupyter-widgets-normalize-comm-msg (msg)
"Ensure that a comm MSG's fields are not ambiguous before encoding.
For example, for fields that are supposed to be arrays, ensure
that they will be encoded as such. In addition, add fields
required by the JupyterLab widget manager."
(prog1 msg
(when (member (jupyter-message-type msg)
'("comm_open" "comm_close" "comm_msg"))
(let ((buffers (plist-member msg :buffers)))
(if (null buffers) (plist-put msg :buffers [])
(when (eq (cadr buffers) nil)
(setcar (cdr buffers) [])))
(unless (equal (cadr buffers) [])
(setq buffers (cadr buffers))
(while (car buffers)
(setcar buffers
(base64-encode-string
(encode-coding-string (car buffers) 'utf-8-auto t) t))
(setq buffers (cdr buffers))))
;; Needed by WidgetManager
(unless (jupyter-message-metadata msg)
(plist-put msg :metadata '(:version "2.0")))))))
(cl-defmethod jupyter-widgets-send-message ((client jupyter-widget-client) msg)
"Send a MSG to CLIENT's `widget-sock' `websocket'."
(setq msg (jupyter-widgets-normalize-comm-msg msg))
(let ((msg-type (jupyter-message-type msg)))
(plist-put msg :channel
(cond
((member msg-type '("status" "comm_msg"
"comm_close" "comm_open"))
:iopub)
((member msg-type '("comm_info_reply"))
:shell)))
(push (jupyter--encode msg) (oref client widget-messages))
(when (websocket-openp (oref client widget-sock))
(jupyter-widgets--send-deferred client))))
;;; Displaying widgets in the browser
;; NOTE: The "display_model" and "clear_display" messages below are not true
;; Jupyter messages, but are only used for communication between the browser
;; and Emacs.
(cl-defmethod jupyter-widgets-display-model ((client jupyter-widget-client) model-id)
"Display the model with MODEL-ID for the kernel CLIENT is connected to."
;; (jupyter-widgets-clear-display client)
(jupyter-widgets-send-message
client (list :msg_type "display_model"
:content (list :model_id model-id))))
(cl-defmethod jupyter-widgets-clear-display ((client jupyter-widget-client))
"Clear the models being displayed for CLIENT."
(jupyter-widgets-send-message client (list :msg_type "clear_display")))
;;; `jupyter-kernel-client' methods
(defun jupyter-widgets-start-websocket-server ()
"Start the `jupyter-widgets-server' if necessary."
(unless (process-live-p jupyter-widgets-server)
(setq jupyter-widgets-server
(websocket-server
jupyter-widgets-port
:host 'local
:on-message #'jupyter-widgets-on-message
:on-close #'jupyter-widgets-on-close))))
(defun jupyter-widgets--initialize-client (client)
(unless (jupyter-get client 'jupyter-widgets-initialized)
(jupyter-set client 'jupyter-widgets-initialized t)
(unless (get-process "httpd")
(httpd-start))
(browse-url
(format jupyter-widgets-url-format
httpd-port
user-login-name
(jupyter-session-id (oref client session))
jupyter-widgets-port))))
(cl-defmethod jupyter-handle-comm-open ((client jupyter-widget-client) _req msg)
(jupyter-with-message-content msg (target_name)
(when (member target_name jupyter-widgets-supported-targets)
(jupyter-widgets-start-websocket-server)
(jupyter-widgets--initialize-client client)
(jupyter-widgets-send-message client msg)))
(cl-call-next-method))
(cl-defmethod jupyter-handle-comm-close ((client jupyter-widget-client) _req msg)
(jupyter-widgets-send-message client msg)
(cl-call-next-method))
(cl-defmethod jupyter-handle-comm-msg ((client jupyter-widget-client) _req msg)
(jupyter-widgets-send-message client msg)
(cl-call-next-method))
;;; `httpd' interface
(defun httpd/jupyter (proc path _query &rest _args)
"Serve the javascript required for Jupyter widget support.
PROC is the httpd process and PATH is the requested resource
path. Currently no resources are accessible at any PATH other
than the root, which will serve the necessary Javascript to
load."
(let ((split-path (split-string (substring path 1) "/")))
(if (= (length split-path) 1)
(with-httpd-buffer proc "text/javascript; charset=UTF-8"
(insert-file-contents
(expand-file-name "js/built/index.built.js" jupyter-root)))
(error "Not found"))))
(defun httpd/jupyter/widgets/built (proc path _query &rest _args)
"Serve the resources required by the widgets in the browser.
PROC is the httpd process and PATH is the requested resource
path. Currently this will only serve a file from the js/built
directory if it has one of the extensions woff, woff2, ttf, svg,
or eot. These are used by Jupyter."
(let* ((split-path (split-string (substring path 1) "/"))
(file (car (last split-path)))
(mime (pcase (file-name-extension file)
((or "woff" "woff2")
"application/font-woff")
("ttf"
"application/octet-stream")
("svg"
"image/svg+xml")
("eot"
"application/vnd.ms-fontobject"))))
(unless mime
(error "Unsupported file type"))
(setq file (expand-file-name (concat "js/built/" file) jupyter-root))
;; TODO: Fix this, when loading the files through httpd, font awesome
;; doesnt work
(when (file-exists-p file)
(error "File nonexistent (%s)" (file-name-nondirectory file)))
(with-temp-buffer
(insert-file-contents file)
(httpd-send-header proc mime 200
:Access-Control-Allow-Origin "*"))))
;; TODO: Since the path when we instantiate widgets is jupyter/widgets, all
;; files that are trying to be loaded locally in the javascript will be
;; referenced to this path. If we encounter a javascript file requesting to be
;; loaded we can automatically search the jupyter --paths for notebook
;; extension modules matching it.
(defun httpd/jupyter/widgets (proc &rest _args)
"Serve the HTML page to display widgets.
PROC is the httpd process."
(with-temp-buffer
(insert-file-contents (expand-file-name "widget.html" jupyter-root))
(httpd-send-header
proc "text/html; charset=UTF-8" 200
:Access-Control-Allow-Origin "*")))
(provide 'jupyter-widget-client)
;;; jupyter-widget-client.el ends here

View File

@@ -0,0 +1,82 @@
;;; jupyter-zmq-channel-ioloop.el --- IOLoop functions for Jupyter channels -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 08 Nov 2018
;; 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:
;; A `jupyter-channel-ioloop' using `jupyter-zmq-channel' to send and receive
;; messages. Whenever a message is received on a channel an event that looks
;; like the following will be sent back to the parent process
;;
;; (message CHANNEL-TYPE IDENTS . MSG)
;;
;; where CHANNEL-TYPE is the channel on which the message was received (one of
;; `jupyter-socket-types'), IDENTS are ZMQ identities, typically ignored, and
;; MSG is the message plist.
;;; Code:
(require 'jupyter-base)
(require 'jupyter-channel-ioloop)
(require 'jupyter-zmq-channel)
(defclass jupyter-zmq-channel-ioloop (jupyter-channel-ioloop)
()
:documentation "A `jupyter-ioloop' configured for Jupyter channels.")
(cl-defmethod initialize-instance ((ioloop jupyter-zmq-channel-ioloop) &optional _slots)
(cl-call-next-method)
(jupyter-ioloop-add-setup ioloop
(require 'jupyter-zmq-channel-ioloop)
(push 'jupyter-zmq-channel-ioloop--recv-messages jupyter-ioloop-post-hook)
(cl-loop
for channel in '(:shell :stdin :iopub :control)
unless (object-assoc channel :type jupyter-channel-ioloop-channels)
do (push (jupyter-zmq-channel
:session jupyter-channel-ioloop-session
:type channel)
jupyter-channel-ioloop-channels))))
(defun jupyter-zmq-channel-ioloop--recv-messages (events)
"Print the received messages described in EVENTS.
EVENTS is a list of socket events as returned by
`zmq-poller-wait-all'. If any of the sockets in EVENTS matches
one of the sockets in `jupyter-channel-ioloop-channels', receive a
message on the channel and print a list with the form
(message CHANNEL-TYPE . MSG...)
to stdout. CHANNEL-TYPE is the channel on which MSG was
received, either :shell, :stdin, :iopub, or :control. MSG is a
list as returned by `jupyter-recv'."
(let (messages)
(dolist (channel jupyter-channel-ioloop-channels)
(with-slots (type socket) channel
(when (zmq-assoc socket events)
(push (cons type (jupyter-recv channel)) messages))))
(when messages
;; Send messages
(mapc (lambda (msg) (prin1 (cons 'message msg))) (nreverse messages))
(zmq-flush 'stdout))))
(provide 'jupyter-zmq-channel-ioloop)
;;; jupyter-zmq-channel-ioloop.el ends here

View File

@@ -0,0 +1,252 @@
;;; jupyter-zmq-channel.el --- A Jupyter channel implementation using ZMQ sockets -*- lexical-binding: t -*-
;; Copyright (C) 2019-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 27 Jun 2019
;; 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:
;; Implements synchronous channel types using ZMQ sockets. Each channel is
;; essentially a wrapper around a `zmq-socket' constrained to a socket type by
;; the type of the channel and with an associated `zmq-IDENTITY' obtained from
;; the `jupyter-session' that must be associated with the channel. A heartbeat
;; channel is distinct from the other channels in that it is implemented using
;; a timer which periodically pings the kernel depending on how its configured.
;; In order for communication to occur on the other channels, one of
;; `jupyter-send' or `jupyter-recv' must be called after starting the channel
;; with `jupyter-start'.
;;; Code:
(require 'jupyter-messages)
(require 'zmq)
(require 'jupyter-channel)
(eval-when-compile (require 'subr-x))
(declare-function jupyter-ioloop-poller-remove "jupyter-ioloop")
(declare-function jupyter-ioloop-poller-add "jupyter-ioloop")
(defconst jupyter-socket-types
(list :hb zmq-REQ
:shell zmq-DEALER
:iopub zmq-SUB
:stdin zmq-DEALER
:control zmq-DEALER)
"The socket types for the various channels used by `jupyter'.")
(cl-deftype zmq-socket () '(satisfies zmq-socket-p))
(defclass jupyter-zmq-channel (jupyter-channel)
((socket
:type (or null zmq-socket)
:initform nil
:documentation "The socket used for communicating with the kernel.")))
(defun jupyter-connect-endpoint (type endpoint &optional identity)
"Create socket with TYPE and connect to ENDPOINT.
If IDENTITY is non-nil, it will be set as the ROUTING-ID of the
socket. Return the created socket."
(let ((sock (zmq-socket (zmq-current-context) type)))
(prog1 sock
(zmq-socket-set sock zmq-LINGER 1000)
(when identity
(zmq-socket-set sock zmq-ROUTING-ID identity))
(zmq-connect sock endpoint))))
(defun jupyter-connect-channel (ctype endpoint &optional identity)
"Create a socket based on a Jupyter channel type.
CTYPE is one of the symbols `:hb', `:stdin', `:shell',
`:control', or `:iopub' and represents the type of channel to
connect to ENDPOINT. If IDENTITY is non-nil, it will be set as
the ROUTING-ID of the socket. Return the created socket."
(let ((sock-type (plist-get jupyter-socket-types ctype)))
(unless sock-type
(error "Invalid channel type (%s)" ctype))
(jupyter-connect-endpoint sock-type endpoint identity)))
(cl-defmethod jupyter-start ((channel jupyter-zmq-channel)
&key (identity (jupyter-session-id
(oref channel session))))
(unless (jupyter-alive-p channel)
(let ((socket (jupyter-connect-channel
(oref channel type) (oref channel endpoint) identity)))
(oset channel socket socket)
(cl-case (oref channel type)
(:iopub
(zmq-socket-set socket zmq-SUBSCRIBE ""))))
(when (and (functionp 'jupyter-ioloop-environment-p)
(jupyter-ioloop-environment-p))
(jupyter-ioloop-poller-add (oref channel socket) zmq-POLLIN))))
(cl-defmethod jupyter-stop ((channel jupyter-zmq-channel))
(when (jupyter-alive-p channel)
(when (and (functionp 'jupyter-ioloop-environment-p)
(jupyter-ioloop-environment-p))
(jupyter-ioloop-poller-remove (oref channel socket)))
(with-slots (socket) channel
(zmq-disconnect socket (zmq-socket-get socket zmq-LAST-ENDPOINT)))
(oset channel socket nil)))
(cl-defmethod jupyter-alive-p ((channel jupyter-zmq-channel))
(not (null (oref channel socket))))
(cl-defmethod jupyter-send ((channel jupyter-zmq-channel) type message &optional msg-id)
"Send a message on a ZMQ based Jupyter channel.
CHANNEL is the channel to send MESSAGE on. TYPE is a Jupyter
message type, like :kernel-info-request. Return the message ID
of the sent message."
(cl-destructuring-bind (id . msg)
(jupyter-encode-message (oref channel session) type
:msg-id msg-id
:content message)
(prog1 id
(zmq-send-multipart (oref channel socket) msg))))
(cl-defmethod jupyter-recv ((channel jupyter-zmq-channel) &optional dont-wait)
"Receive a message on CHANNEL.
Return a cons cell (IDENTS . MSG) where IDENTS are the ZMQ
message identities, as a list, and MSG is the received message.
If DONT-WAIT is non-nil, return immediately without waiting for a
message if one isn't already available."
(condition-case nil
(let ((session (oref channel session))
(msg (zmq-recv-multipart (oref channel socket)
(and dont-wait zmq-DONTWAIT))))
(when msg
(cl-destructuring-bind (idents . parts)
(jupyter--split-identities msg)
(cons idents (jupyter-decode-message session parts)))))
(zmq-EAGAIN nil)))
;;; Heartbeat channel
(defvar jupyter-hb-max-failures 3
"Number of heartbeat failures until the kernel is considered unreachable.
A ping is sent to the kernel on a heartbeat channel and waits
until `time-to-dead' seconds to see if the kernel sent a ping
back. If the kernel doesn't send a ping back after
`jupyter-hb-max-failures', the callback associated with the
heartbeat channel is called. See `jupyter-hb-on-kernel-dead'.")
(defclass jupyter-hb-channel (jupyter-zmq-channel)
((type
:type keyword
:initform :hb
:documentation "The type of this channel is `:hb'.")
(time-to-dead
:type number
:initform 10
:documentation "The time in seconds to wait for a response
from the kernel until the connection is assumed to be dead. Note
that this slot only takes effect when starting the channel.")
(dead-cb
:type function
:initform #'ignore
:documentation "A callback function that takes 0 arguments
and is called when the kernel has not responded for
\(* `jupyter-hb-max-failures' `time-to-dead'\) seconds.")
(beating
:type (or boolean symbol)
:initform t
:documentation "A flag variable indicating that the heartbeat
channel is communicating with the kernel.")
(paused
:type boolean
:initform t
:documentation "A flag variable indicating that the heartbeat
channel is paused and not communicating with the kernel. To
pause the heartbeat channel use `jupyter-hb-pause', to unpause
use `jupyter-hb-unpause'."))
:documentation "A base class for heartbeat channels.")
(cl-defmethod jupyter-alive-p ((channel jupyter-hb-channel))
"Return non-nil if CHANNEL is alive."
(zmq-socket-p (oref channel socket)))
(defun jupyter-hb--pingable-p (channel)
(and (not (oref channel paused))
(jupyter-alive-p channel)))
(cl-defmethod jupyter-hb-beating-p ((channel jupyter-hb-channel))
"Return non-nil if CHANNEL is reachable."
(and (jupyter-hb--pingable-p channel)
(oref channel beating)))
(cl-defmethod jupyter-hb-pause ((channel jupyter-hb-channel))
"Pause checking for heartbeat events on CHANNEL."
(oset channel paused t))
(cl-defmethod jupyter-hb-unpause ((channel jupyter-hb-channel))
"Un-pause checking for heatbeat events on CHANNEL."
(when (oref channel paused)
(if (jupyter-alive-p channel)
;; Consume a pending message from the kernel if there is one. We send a
;; ping and then schedule a timer which fires TIME-TO-DEAD seconds
;; later to receive the ping back from the kernel and start the process
;; all over again. If the channel is paused before TIME-TO-DEAD
;; seconds, there may still be a ping from the kernel waiting.
(ignore-errors (zmq-recv (oref channel socket) zmq-DONTWAIT))
(jupyter-start channel))
(oset channel paused nil)
(jupyter-hb--send-ping channel)))
(cl-defgeneric jupyter-hb-on-kernel-dead (channel fun)
(declare (indent 1)))
(cl-defmethod jupyter-hb-on-kernel-dead ((channel jupyter-hb-channel) fun)
"When the kernel connected to CHANNEL dies, call FUN.
A kernel is considered dead when CHANNEL does not receive a
response after \(* `jupyter-hb-max-failures' `time-to-dead'\)
seconds has elapsed without the kernel sending a ping back."
(oset channel dead-cb fun))
(defun jupyter-hb--send-ping (channel &optional failed-count)
(when (jupyter-hb--pingable-p channel)
(condition-case nil
(progn
(zmq-send (oref channel socket) "ping")
(run-with-timer
(oref channel time-to-dead) nil
(lambda ()
(when-let* ((sock (and (jupyter-hb--pingable-p channel)
(oref channel socket))))
(oset channel beating
(condition-case nil
(and (zmq-recv sock zmq-DONTWAIT) t)
((zmq-EINTR zmq-EAGAIN) nil)))
(if (oref channel beating)
(jupyter-hb--send-ping channel)
;; Reset the socket
(jupyter-stop channel)
(jupyter-start channel)
(or failed-count (setq failed-count 0))
(if (< failed-count jupyter-hb-max-failures)
(jupyter-hb--send-ping channel (1+ failed-count))
(oset channel paused t)
(when (functionp (oref channel dead-cb))
(funcall (oref channel dead-cb)))))))))
;; FIXME: Should be a part of `jupyter-hb--pingable-p'
(zmq-ENOTSOCK
(jupyter-hb-pause channel)
(oset channel socket nil)))))
(provide 'jupyter-zmq-channel)
;;; jupyter-zmq-channel.el ends here

44
lisp/jupyter/jupyter.el Normal file
View File

@@ -0,0 +1,44 @@
;;; jupyter.el --- Jupyter -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 11 Jan 2018
;; Version: 1.0
;; Package-Requires: ((emacs "26") (cl-lib "0.5") (org "9.1.6") (zmq "0.10.10") (simple-httpd "1.5.0") (websocket "1.9"))
;; URL: https://github.com/emacs-jupyter/jupyter
;; 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:
;; An interface for communicating with Jupyter kernels.
;;; Code:
(defgroup jupyter nil
"Jupyter"
:group 'processes)
(require 'jupyter-base)
(require 'jupyter-client)
(require 'jupyter-kernelspec)
(require 'jupyter-server)
(require 'jupyter-repl)
(provide 'jupyter)
;;; jupyter.el ends here

836
lisp/jupyter/ob-jupyter.el Normal file
View File

@@ -0,0 +1,836 @@
;;; ob-jupyter.el --- Jupyter integration with org-mode -*- lexical-binding: t -*-
;; Copyright (C) 2018-2024 Nathaniel Nicandro
;; Author: Nathaniel Nicandro <nathanielnicandro@gmail.com>
;; Created: 21 Jan 2018
;; 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:
;; Interact with a Jupyter kernel via `org-mode' src-block's.
;;; Code:
(defgroup ob-jupyter nil
"Jupyter integration with org-mode"
:group 'org-babel)
(require 'jupyter-env)
(require 'jupyter-kernelspec)
(require 'jupyter-org-client)
(require 'jupyter-org-extensions)
(eval-when-compile
(require 'jupyter-repl) ; For `jupyter-with-repl-buffer'
(require 'subr-x))
(declare-function org-in-src-block-p "org" (&optional inside))
(declare-function org-element-at-point "org-element")
(declare-function org-element-property "org-element" (property element))
(declare-function org-element-context "org-element" (&optional element))
(declare-function org-babel-execute-src-block "ob-core" (&optional arg info params executor-type))
(declare-function org-babel-variable-assignments:python "ob-python" (params))
(declare-function org-babel-expand-body:generic "ob-core" (body params &optional var-lines))
(declare-function org-export-derived-backend-p "ox" (backend &rest backends))
(declare-function jupyter-run-server-repl "jupyter-server")
(declare-function jupyter-connect-server-repl "jupyter-server")
(declare-function jupyter-kernelspecs "jupyter-server")
(declare-function jupyter-server-kernel-id-from-name "jupyter-server")
(declare-function jupyter-server-name-client-kernel "jupyter-server")
(declare-function jupyter-api-get-kernel "jupyter-rest-api")
(declare-function jupyter-tramp-url-from-file-name "jupyter-tramp")
(declare-function jupyter-tramp-server-from-file-name "jupyter-tramp")
(declare-function jupyter-tramp-file-name-p "jupyter-tramp")
(defcustom org-babel-jupyter-language-aliases '(("python3" "python"))
"An alist mapping kernel language names to another name.
If a kernel has a language name matching the CAR of an element of
this list, the associated name will be used for the names of the
source blocks instead.
So if this variable has an entry like \\='(\"python3\" \"python\")
then instead of jupyter-python3 source blocks, you can use
jupyter-python source blocks for the associated kernel."
:type '(alist :key-type string :value-type string))
(defvaralias 'org-babel-jupyter-resource-directory
'jupyter-org-resource-directory)
(defvar org-babel-jupyter-session-clients (make-hash-table :test #'equal)
"A hash table mapping session names to Jupyter clients.
`org-babel-jupyter-src-block-session' returns a key into this
table for the source block at `point'.")
(defvar org-babel-header-args:jupyter '((kernel . :any)
(async . ((yes no))))
"Available header arguments for Jupyter src-blocks.")
(defvar org-babel-default-header-args:jupyter '((:kernel . "python")
(:async . "no"))
"Default header arguments for Jupyter src-blocks.")
;;; Helper functions
(defun org-babel-jupyter--src-block-kernel-language ()
(when (org-in-src-block-p)
(let ((info (org-babel-get-src-block-info)))
(save-match-data
(string-match "^jupyter-\\(.+\\)$" (car info))
(match-string 1 (car info))))))
(defun org-babel-jupyter-language-p (lang)
"Return non-nil if LANG src-blocks are executed using Jupyter."
(or (string-prefix-p "jupyter-" lang)
;; Check if the language has been overridden, see
;; `org-babel-jupyter-override-src-block'
(advice-member-p
'ob-jupyter (intern (concat "org-babel-execute:" lang)))))
(defun org-babel-jupyter-session-key (params)
"Return a string that is the concatenation of the :session and :kernel PARAMS.
PARAMS is the arguments alist as returned by
`org-babel-get-src-block-info'. The returned string can then be
used to identify unique Jupyter Org babel sessions."
;; Take into account a Lisp expression as a session name.
(let ((session (org-babel-read (alist-get :session params)))
(kernel (alist-get :kernel params)))
(unless (and session kernel
(not (equal session "none")))
(error "Need a valid session and a kernel to form a key"))
(concat session "-" kernel)))
(defun org-babel-jupyter-src-block-session ()
"Return the session key for the current Jupyter source block.
Return nil if the current source block is not a Jupyter block or
if there is no source block at point."
(let ((info (or (and (org-in-src-block-p)
(org-babel-get-src-block-info 'light))
(org-babel-lob-get-info))))
(when info
(cl-destructuring-bind (lang _ params . rest) info
(when (org-babel-jupyter-language-p lang)
(org-babel-jupyter-session-key params))))))
;;; `ob' integration
(defun org-babel-variable-assignments:jupyter (params &optional lang)
"Assign variables in PARAMS according to the Jupyter kernel language.
LANG is the kernel language of the source block. If LANG is nil,
get the kernel language from the current source block.
The variables are assigned by looking for the function
`org-babel-variable-assignments:LANG'. If this function does not
exist or if LANG cannot be determined, assign variables using
`org-babel-variable-assignments:python'."
(or lang (setq lang (org-babel-jupyter--src-block-kernel-language)))
(let ((fun (when lang
(intern (format "org-babel-variable-assignments:%s" lang)))))
(if (functionp fun) (funcall fun params)
(require 'ob-python)
(org-babel-variable-assignments:python params))))
(cl-defgeneric org-babel-jupyter-transform-code (code _changelist)
"Transform CODE according to CHANGELIST, return the transformed CODE.
CHANGELIST is a property list containing the requested changes. The default
implementation returns CODE unchanged.
This is useful for kernel languages to extend using the
jupyter-lang method specializer, e.g. to return new code to change
directories before evaluating CODE.
See `org-babel-expand-body:jupyter' for possible changes that can
be in CHANGELIST."
code)
(defun org-babel-expand-body:jupyter (body params &optional var-lines lang)
"Expand BODY according to PARAMS.
BODY is the code to expand, PARAMS should be the header arguments
of the src block with BODY as its code, and VAR-LINES should be
the list of strings containing the variables to evaluate before
executing body. LANG is the kernel language of the source block.
This function is similar to
`org-babel-variable-assignments:jupyter' in that it attempts to
find the kernel language of the source block if LANG is not
provided.
BODY is expanded by calling the function
`org-babel-expand-body:LANG'. If this function doesn't exist or
if LANG cannot be determined, fall back to
`org-babel-expand-body:generic'.
If PARAMS has a :dir parameter, the expanded code is passed to
`org-babel-jupyter-transform-code' with a changelist that
includes the :dir parameter with the directory being an absolute
path."
(or lang (setq lang (org-babel-jupyter--src-block-kernel-language)))
(let* ((expander (when lang
(intern (format "org-babel-expand-body:%s" lang))))
(expanded (if (functionp expander)
(funcall expander body params)
(org-babel-expand-body:generic body params var-lines)))
(changelist nil))
(when-let* ((dir (alist-get :dir params)))
(setq changelist (plist-put changelist :dir (expand-file-name dir))))
(if changelist (org-babel-jupyter-transform-code expanded changelist)
expanded)))
(defun org-babel-edit-prep:jupyter (info)
"Prepare the edit buffer according to INFO.
Enable `jupyter-repl-interaction-mode' in the edit buffer
associated with the session found in INFO.
If the session is a Jupyter TRAMP file name, the
`default-directory' of the edit buffer is set to the root
directory the notebook serves.
If `jupyter-org-auto-connect' is nil, this function does nothing
if the session has not been initiated yet."
(let* ((params (nth 2 info))
(session (alist-get :session params))
(client-buffer
(when (or jupyter-org-auto-connect
(org-babel-jupyter-session-initiated-p params))
(org-babel-jupyter-initiate-session session params))))
(when client-buffer
(jupyter-repl-associate-buffer client-buffer)
(when (jupyter-tramp-file-name-p session)
(setq default-directory (concat (file-remote-p session) "/"))))))
(defun org-babel-jupyter--insert-variable-assignments (params)
"Insert variable assignment lines from PARAMS into the `current-buffer'.
Return non-nil if there are variable assignments, otherwise
return nil."
(let ((var-lines (org-babel-variable-assignments:jupyter params)))
(prog1 var-lines
(jupyter-repl-replace-cell-code (mapconcat #'identity var-lines "\n")))))
(defun org-babel-prep-session:jupyter (session params)
"Prepare a Jupyter SESSION according to PARAMS."
(with-current-buffer (org-babel-jupyter-initiate-session session params)
(goto-char (point-max))
(and (org-babel-jupyter--insert-variable-assignments params)
(jupyter-repl-execute-cell jupyter-current-client))
(current-buffer)))
(defun org-babel-load-session:jupyter (session body params)
"In a Jupyter SESSION, load BODY according to PARAMS."
(save-window-excursion
(with-current-buffer (org-babel-jupyter-initiate-session session params)
(goto-char (point-max))
(when (org-babel-jupyter--insert-variable-assignments params)
(insert "\n"))
(insert (org-babel-expand-body:jupyter (org-babel-chomp body) params))
(current-buffer))))
(defvar org-babel-jupyter-resolving-reference-p nil
"Non-nil if a reference is being resolved.")
(defun org-babel-jupyter--indicate-resolve (&rest args)
"Set `org-babel-jupyter-resolving-referece-p', apply ARGS."
(let ((org-babel-jupyter-resolving-reference-p t))
(apply args)))
(advice-add #'org-babel-ref-resolve :around #'org-babel-jupyter--indicate-resolve)
;;;; Initializing session clients
(cl-defstruct (org-babel-jupyter-session
(:constructor org-babel-jupyter-session))
name)
(cl-defstruct (org-babel-jupyter-remote-session
(:include org-babel-jupyter-session)
(:constructor org-babel-jupyter-remote-session))
connect-repl-p)
(cl-defmethod org-babel-jupyter-parse-session ((session string))
"Return a parsed representation of SESSION."
(org-babel-jupyter-session :name session))
(cl-defmethod org-babel-jupyter-initiate-client ((_session org-babel-jupyter-session) kernel)
"Launch SESSION's KERNEL, return a `jupyter-org-client' connected to it.
SESSION is the :session header argument of a source block and
KERNEL is the name of the kernel to launch."
(jupyter-run-repl kernel nil nil 'jupyter-org-client))
(cl-defmethod org-babel-jupyter-initiate-client :around (session _kernel)
"Rename the returned client's REPL buffer to include SESSION's name.
Also set `jupyter-include-other-output' to nil for the session so
that output produced by other clients do not get handled by the
client."
(let ((client (cl-call-next-method)))
(prog1 client
(jupyter-set client 'jupyter-include-other-output nil)
;; Append the name of SESSION to the initiated client REPL's
;; `buffer-name'.
(jupyter-with-repl-buffer client
(let ((name (buffer-name)))
(when (string-match "^\\*\\(.+\\)\\*" name)
(rename-buffer
(concat "*" (match-string 1 name) "-"
(org-babel-jupyter-session-name session)
"*")
'unique)))))))
(cl-defmethod org-babel-jupyter-parse-session :extra "remote" ((session string))
"If SESSION is a remote file name, return a `org-babel-jupyter-remote-session'.
A `org-babel-jupyter-remote-session' is also returned if SESSION
ends in \".json\", regardless of SESSION being a remote file
name, with `org-babel-jupyter-remote-session-connect-repl-p' set
to nil. The CONNECT-REPL-P slot indicates that a connection file
is read to connect to the session, as opposed to launching a
kernel."
(if jupyter-use-zmq
(let ((json-p (string-suffix-p ".json" session)))
(if (or json-p (file-remote-p session))
(org-babel-jupyter-remote-session
:name session
:connect-repl-p json-p)
(cl-call-next-method)))
(when (file-remote-p session)
(error "ZMQ is required for remote sessions (%s)" session))
(cl-call-next-method)))
(cl-defmethod org-babel-jupyter-initiate-client :before ((session org-babel-jupyter-remote-session) _kernel)
"Raise an error if SESSION's name is a remote file name without a local name.
The local name is used as a unique identifier of a remote
session."
(unless (not (zerop (length (file-local-name
(org-babel-jupyter-session-name session)))))
(error "No remote session name")))
(cl-defmethod org-babel-jupyter-initiate-client ((session org-babel-jupyter-remote-session) kernel)
"Initiate a client connected to a remote kernel process."
(pcase-let (((cl-struct org-babel-jupyter-remote-session name connect-repl-p) session))
(if connect-repl-p
(jupyter-connect-repl name nil nil 'jupyter-org-client)
(let ((default-directory (file-remote-p name)))
(org-babel-jupyter-aliases-from-kernelspecs)
(jupyter-run-repl kernel nil nil 'jupyter-org-client)))))
(require 'jupyter-server)
(require 'jupyter-tramp)
(cl-defstruct (org-babel-jupyter-server-session
(:include org-babel-jupyter-remote-session)
(:constructor org-babel-jupyter-server-session)))
(cl-defmethod org-babel-jupyter-parse-session :extra "server" ((session string))
"If SESSION is a Jupyter TRAMP file name return a
`org-babel-jupyter-server-session'."
(if (jupyter-tramp-file-name-p session)
(org-babel-jupyter-server-session :name session)
(cl-call-next-method)))
(cl-defmethod org-babel-jupyter-initiate-client ((session org-babel-jupyter-server-session) kernel)
(let* ((rsession (org-babel-jupyter-session-name session))
(server (with-parsed-tramp-file-name rsession nil
(when (member host '("127.0.0.1" "localhost"))
(setq port (tramp-file-name-port-or-default v))
(when (jupyter-port-available-p port)
(if (y-or-n-p (format "Notebook not started on port %s. Launch one? "
port))
;; TODO: Specify authentication? But then
;; how would you get the token for the
;; login that happens in
;; `jupyter-tramp-server-from-file-name'.
(jupyter-launch-notebook port)
(user-error "Launch a notebook on port %s first." port))))
(jupyter-tramp-server-from-file-name rsession))))
(unless (jupyter-server-has-kernelspec-p server kernel)
(error "No kernelspec matching \"%s\" exists at %s"
kernel (oref server url)))
;; Language aliases may not exist for the kernels that are accessible on
;; the server so ensure they do.
(org-babel-jupyter-aliases-from-kernelspecs
nil (jupyter-kernelspecs server))
(let ((sname (file-local-name rsession)))
(if-let ((id (jupyter-server-kernel-id-from-name server sname)))
;; Connecting to an existing kernel
(cl-destructuring-bind (&key name id &allow-other-keys)
(or (ignore-errors (jupyter-api-get-kernel server id))
(error "Kernel ID, %s, no longer references a kernel at %s"
id (oref server url)))
(unless (string-match-p kernel name)
(error "\":kernel %s\" doesn't match \"%s\"" kernel name))
(jupyter-connect-server-repl server id nil nil 'jupyter-org-client))
;; Start a new kernel
(let ((client (jupyter-run-server-repl
server kernel nil nil 'jupyter-org-client)))
(prog1 client
;; TODO: If a kernel gets renamed in the future it doesn't affect
;; any source block :session associations because the hash of the
;; session name used here is already stored in the
;; `org-babel-jupyter-session-clients' variable. Should that
;; variable be updated on a kernel rename?
;;
;; TODO: Would we always want to do this?
(jupyter-server-name-client-kernel client sname)))))))
(defun org-babel-jupyter-session-initiated-p (params)
"Return non-nil if the session corresponding to PARAMS is initiated."
(let ((key (org-babel-jupyter-session-key params)))
(gethash key org-babel-jupyter-session-clients)))
(defun org-babel-jupyter-initiate-session-by-key (session params)
"Return the Jupyter REPL buffer for SESSION.
If SESSION does not have a client already, one is created based
on SESSION and PARAMS. If SESSION ends with \".json\" then
SESSION is interpreted as a kernel connection file and a new
kernel connected to SESSION is created.
Otherwise a kernel is started based on the `:kernel' parameter in
PARAMS which should be either a valid kernel name or a prefix of
one, in which case the first kernel that matches the prefix will
be used.
If SESSION is a remote file name, like /ssh:ec2:jl, then the
kernel starts on the remote host /ssh:ec2: with a session name of
jl. The remote host must have jupyter installed since the
\"jupyter kernel\" command will be used to start the kernel on
the host."
(let* ((key (org-babel-jupyter-session-key params))
(client (gethash key org-babel-jupyter-session-clients)))
(unless client
(setq client (org-babel-jupyter-initiate-client
(org-babel-jupyter-parse-session session)
(alist-get :kernel params)))
(puthash key client org-babel-jupyter-session-clients)
(jupyter-with-repl-buffer client
(let ((forget-client (lambda () (remhash key org-babel-jupyter-session-clients))))
(add-hook 'kill-buffer-hook forget-client nil t))))
(oref client buffer)))
(defun org-babel-jupyter-initiate-session (&optional session params)
"Initialize a Jupyter SESSION according to PARAMS."
(if (equal session "none") (error "Need a session to run")
(when session
;; Take into account a Lisp expression as a session name.
(setq session (org-babel-read session)))
(org-babel-jupyter-initiate-session-by-key session params)))
;;;; Helper functions
;;;###autoload
(defun org-babel-jupyter-scratch-buffer ()
"Display a scratch buffer connected to the current block's session."
(interactive)
(let (buffer)
(org-babel-do-in-edit-buffer
(setq buffer (save-window-excursion
(jupyter-repl-scratch-buffer))))
(if buffer (pop-to-buffer buffer)
(user-error "No source block at point"))))
(cl-defmethod jupyter-do-refresh-kernelspecs (&context (major-mode org-mode))
(or (jupyter-org-when-in-src-block
(let* ((info (org-babel-get-src-block-info 'light))
(params (nth 2 info))
(session (org-babel-read (alist-get :session params))))
(when (file-remote-p session)
(jupyter-kernelspecs session 'refresh))))
(cl-call-next-method)))
;;;; `org-babel-execute:jupyter'
(defvar org-link-bracket-re)
(defun org-babel-jupyter-cleanup-file-links ()
"Delete the files of image links for the current source block result.
Do this only if the file exists in
`org-babel-jupyter-resource-directory'."
(when-let*
((pos (org-babel-where-is-src-block-result))
(link-re (format "^[ \t]*%s[ \t]*$" org-link-bracket-re))
(resource-dir (expand-file-name org-babel-jupyter-resource-directory)))
(save-excursion
(goto-char pos)
(forward-line)
(let ((bound (org-babel-result-end)))
;; This assumes that `jupyter-org-client' only emits bracketed links as
;; images
(while (re-search-forward link-re bound t)
(when-let*
((path (org-element-property :path (org-element-context)))
(dir (when (file-name-directory path)
(expand-file-name (file-name-directory path)))))
(when (and (equal dir resource-dir)
(file-exists-p path))
(delete-file path))))))))
;; TODO: What is a better way to handle discrepancies between how `org-mode'
;; views header arguments and how `emacs-jupyter' views them? Should the
;; strategy be to always try to emulate the `org-mode' behavior?
(defun org-babel-jupyter--remove-file-param (params)
"Destructively remove the file result parameter from PARAMS.
These parameters are handled internally."
(let* ((result-params (assq :result-params params))
(fresult (member "file" result-params))
(fparam (assq :file params)))
(setcar fresult "")
(delq fparam params)))
(defconst org-babel-jupyter-async-inline-results-pending-indicator "???"
"A string to disambiguate pending inline results from empty results.")
(defun org-babel-jupyter--execute (code async-p)
(jupyter-run-with-client jupyter-current-client
(let ((dreq (jupyter-execute-request :code code)))
(jupyter-mlet* ((req (jupyter-org-maybe-queued dreq)))
(jupyter-return
`(,req
,(cond
(async-p
(when (bound-and-true-p org-export-current-backend)
(jupyter-add-idle-sync-hook
'org-babel-after-execute-hook req 'append))
(if (jupyter-org-request-inline-block-p req)
org-babel-jupyter-async-inline-results-pending-indicator
;; This returns the message ID of REQ as an indicator
;; for the pending results.
(jupyter-org-pending-async-results req)))
(t
(jupyter-idle-sync req)
(if (jupyter-org-request-inline-block-p req)
;; When evaluating a source block synchronously, only the
;; :execute-result will be in `jupyter-org-request-results' since
;; stream results and any displayed data will be placed in a separate
;; buffer.
(let ((el (jupyter-org-result
req (car (jupyter-org-request-results req)))))
(if (stringp el) el
(org-element-property :value el)))
;; This returns an Org formatted string of the collected
;; results.
(jupyter-org-sync-results req))))))))))
(defvar org-babel-jupyter-current-src-block-params nil
"The block parameters of the most recently executed Jupyter source block.")
(defun org-babel-execute:jupyter (body params)
"Execute BODY according to PARAMS.
BODY is the code to execute for the current Jupyter `:session' in
the PARAMS alist."
(when org-babel-current-src-block-location
(save-excursion
(goto-char org-babel-current-src-block-location)
(when (jupyter-org-request-at-point)
(user-error "Source block currently being executed"))))
(let* ((result-params (assq :result-params params))
(async-p (jupyter-org-execute-async-p params)))
(when (member "replace" result-params)
(org-babel-jupyter-cleanup-file-links))
(let* ((org-babel-jupyter-current-src-block-params params)
(session (alist-get :session params))
(buf (org-babel-jupyter-initiate-session session params))
(jupyter-current-client (buffer-local-value 'jupyter-current-client buf))
(lang (jupyter-kernel-language jupyter-current-client))
(vars (org-babel-variable-assignments:jupyter params lang))
(code (progn
(when-let* ((dir (alist-get :dir params)))
;; `default-directory' is already set according
;; to :dir when executing a source block. Set
;; :dir to the absolute path so that
;; `org-babel-expand-body:jupyter' does not try
;; to re-expand the path. See #302.
(setf (alist-get :dir params) default-directory))
(org-babel-expand-body:jupyter body params vars lang))))
(pcase-let ((`(,req ,maybe-result)
(org-babel-jupyter--execute code async-p)))
;; KLUDGE: Remove the file result-parameter so that
;; `org-babel-insert-result' doesn't attempt to handle it while
;; async results are pending. Do the same in the synchronous
;; case, but not if link or graphics are also result-parameters,
;; only in Org >= 9.2, since those in combination with file mean
;; to interpret the result as a file link, a useful meaning that
;; doesn't interfere with Jupyter style result insertion.
;;
;; Do this after sending the request since
;; `jupyter-generate-request' still needs access to the :file
;; parameter.
(when (and (member "file" result-params)
(or async-p
(not (or (member "link" result-params)
(member "graphics" result-params)))))
(org-babel-jupyter--remove-file-param params))
(prog1 maybe-result
;; KLUDGE: Add the "raw" result parameter for non-inline
;; synchronous results because an Org formatted string is
;; already returned in that case and
;; `org-babel-insert-result' should not process it.
(unless (or async-p
(jupyter-org-request-inline-block-p req))
(nconc (alist-get :result-params params) (list "raw"))))))))
;;; Overriding source block languages, language aliases
(defvar org-babel-jupyter--babel-ops
'(execute expand-body prep-session edit-prep
variable-assignments load-session
initiate))
(defvar org-babel-jupyter--babel-vars
'(header-args default-header-args))
(defun org-babel-jupyter--babel-op-symbol (op lang)
(if (eq op 'initiate)
(intern (format "org-babel-%s-initiate-session" lang))
(intern (format (format "org-babel-%s:%s" op lang)))))
(defun org-babel-jupyter--babel-var-symbol (var lang)
(intern (format "org-babel-%s:%s" var lang)))
(defun org-babel-jupyter--babel-map (alias-action
var-action)
"Loop over Org babel function and variable symbols.
ALIAS-ACTION and VAR-ACTION are functions of one argument.
When ALIAS-ACTION is called, the argument will be a symbol that
represents an Org Babel operation that can be defined by a
language extension to Org Babel, e.g. \\='execute.
Similarly VAR-ACTION is called with a symbol representing an Org
Babel variable that can be defined for a language,
e.g. \\='header-args."
(declare (indent 0))
(dolist (op org-babel-jupyter--babel-ops)
(funcall alias-action op))
(dolist (var org-babel-jupyter--babel-vars)
(funcall var-action var)))
(defun org-babel-jupyter-override-src-block (lang)
"Override the built-in `org-babel' functions for LANG.
This overrides functions like `org-babel-execute:LANG' and
`org-babel-LANG-initiate-session' to use the machinery of
jupyter-LANG source blocks.
Also, set `org-babel-header-args:LANG' to the value of
`org-babel-header-args:jupyter-LANG', if the latter exists. If
`org-babel-header-args:LANG' had a value, save it as a symbol
property of `org-babel-header-args:LANG' for restoring it later.
Do the same for `org-babel-default-header-args:LANG'."
(org-babel-jupyter--babel-map
(lambda (op)
;; Only override operations that are not related to a particular
;; language.
(unless (memq op '(variable-assignments expand-body))
(let ((lang-op
(org-babel-jupyter--babel-op-symbol
op lang))
(jupyter-lang-op
(org-babel-jupyter--babel-op-symbol
op (format "jupyter-%s" lang))))
;; If a language doesn't have a function assigned, set one so it can
;; be overridden
(unless (fboundp lang-op)
(fset lang-op #'ignore))
(advice-add lang-op :override jupyter-lang-op
'((name . ob-jupyter))))))
(lambda (var)
(let ((lang-var
(org-babel-jupyter--babel-var-symbol
var lang))
(jupyter-lang-var
(org-babel-jupyter--babel-var-symbol
var (format "jupyter-%s" lang))))
(when (boundp jupyter-lang-var)
(when (boundp lang-var)
(put lang-var 'jupyter-restore-value (symbol-value lang-var)))
(set lang-var (copy-tree (symbol-value jupyter-lang-var))))))))
(defun org-babel-jupyter-restore-src-block (lang)
"Restore the overridden `org-babel' functions for LANG.
This undoes everything that
`org-babel-jupyter-override-src-block' did."
(org-babel-jupyter--babel-map
(lambda (op)
;; Only override operations that are not related to a particular
;; language.
(unless (memq op '(variable-assignments expand-body))
(let ((lang-op
(org-babel-jupyter--babel-op-symbol
op lang))
(jupyter-lang-op
(org-babel-jupyter--babel-op-symbol
op (format "jupyter-%s" lang))))
(advice-remove lang-op jupyter-lang-op)
;; The function didn't have a definition, so
;; ensure that we restore that fact.
(when (eq (symbol-function lang-op) #'ignore)
(fmakunbound lang-op)))))
(lambda (var)
(let ((lang-var
(org-babel-jupyter--babel-var-symbol
var lang)))
(when (boundp lang-var)
(set lang-var (get lang-var 'jupyter-restore-value)))))))
(defun org-babel-jupyter-make-language-alias (kernel lang)
"Similar to `org-babel-make-language-alias' but for Jupyter src-blocks.
KERNEL should be the name of the default kernel to use for kernel
LANG, the language of the kernel.
The Org Babel functions `org-babel-FN:jupyter-LANG', where FN is
one of execute, expand-body, prep-session, edit-prep,
variable-assignments, or load-session, are aliased to
`org-babel-FN:jupyter'. Similarly,
`org-babel-jupyter-LANG-initiate-session' is aliased to
`org-babel-jupyter-initiate-session'.
If not already defined, the variable
`org-babel-default-header-args:jupyter-LANG' is set to the same
value as `org-babel-header-args:jupyter', which see. The
variable `org-babel-default-header-args:jupyter-LANG' is also set
to
\((:async . \"no\")
\(:kernel . KERNEL))
if that variable does not already have a value.
If LANG has an association in `org-babel-tangle-lang-exts',
associate the same value with jupyter-LANG, if needed.
Similarly, associate the same value for LANG in
`org-src-lang-modes'."
(org-babel-jupyter--babel-map
(lambda (op)
(defalias (org-babel-jupyter--babel-op-symbol
op (format "jupyter-%s" lang))
(org-babel-jupyter--babel-op-symbol
op "jupyter")))
(lambda (var)
(let ((jupyter-var
(org-babel-jupyter--babel-var-symbol
var "jupyter"))
(jupyter-lang-var
(org-babel-jupyter--babel-var-symbol
var (format "jupyter-%s" lang))))
(unless (boundp jupyter-lang-var)
(set jupyter-lang-var (copy-tree (symbol-value jupyter-var)))
(cond
((eq var 'default-header-args)
;; Needed since the default kernel is not language
;; specific and it needs to be.
(setf (alist-get :kernel (symbol-value jupyter-lang-var)) kernel)
(put jupyter-lang-var 'variable-documentation
(format
"Default header arguments for Jupyter %s src-blocks"
lang)))
(t
(put jupyter-lang-var 'variable-documentation
(get jupyter-var 'variable-documentation))))))))
(when (assoc lang org-babel-tangle-lang-exts)
(add-to-list 'org-babel-tangle-lang-exts
(cons (concat "jupyter-" lang)
(cdr (assoc lang org-babel-tangle-lang-exts)))))
(add-to-list 'org-src-lang-modes
(cons (concat "jupyter-" lang)
(or (cdr (assoc lang org-src-lang-modes))
(intern (downcase (replace-regexp-in-string
"[0-9]*" "" lang)))))))
(defun org-babel-jupyter-aliases-from-kernelspecs (&optional refresh specs)
"Make language aliases based on the available kernelspecs.
For all kernel SPECS, make a language alias for the kernel
language if one does not already exist. The alias is created with
`org-babel-jupyter-make-language-alias'.
SPECS defaults to those associated with the `default-directory'.
Optional argument REFRESH has the same meaning as in
`jupyter-kernelspecs'.
Note, spaces in the kernel language name are converted into
dashes in the language alias, e.g.
Wolfram Language -> jupyter-Wolfram-Language
For convenience, after creating a language alias for a kernel
language LANG, set the :kernel default header argument if not
present in `org-babel-default-header-args:jupyter-LANG', see
`org-babel-header-args:jupyter'. This allows users to set that
variable in their configurations without having to also set the
:kernel header argument since it is common for only one per
language to exist on someone's system."
(cl-loop
for spec in (or specs
(with-demoted-errors "Error retrieving kernelspecs: %S"
(jupyter-kernelspecs default-directory refresh)))
for kernel = (jupyter-kernelspec-name spec)
for lang = (let ((lang (jupyter-canonicalize-language-string
(plist-get (jupyter-kernelspec-plist spec) :language))))
(or (cadr (assoc lang org-babel-jupyter-language-aliases))
lang))
unless (member lang languages) collect lang into languages and
do (org-babel-jupyter-make-language-alias kernel lang)
;; KLUDGE: The :kernel header argument is always set, even when we aren't
;; the ones who originally set the defaults. This is here for convenience
;; since usually a user does not set :kernel directly.
(let ((var (intern (concat "org-babel-default-header-args:jupyter-" lang))))
(unless (alist-get :kernel (symbol-value var))
(setf (alist-get :kernel (symbol-value var)) kernel)))))
;;; `ox' integration
(defvar org-latex-minted-langs)
(defun org-babel-jupyter-setup-export (backend)
"Ensure that Jupyter src-blocks are integrated with BACKEND.
Currently this makes sure that Jupyter src-block languages are
mapped to their appropriate minted language in
`org-latex-minted-langs' if BACKEND is latex."
(cond
((org-export-derived-backend-p backend 'latex)
(cl-loop
for spec in (jupyter-kernelspecs default-directory)
for lang = (plist-get (jupyter-kernelspec-plist spec) :language)
do (cl-pushnew (list (intern (concat "jupyter-" lang)) lang)
org-latex-minted-langs :test #'equal)))))
(defun org-babel-jupyter-strip-ansi-escapes (_backend)
"Remove ANSI escapes from Jupyter src-block results in the current buffer."
(org-babel-map-src-blocks nil
(when (org-babel-jupyter-language-p lang)
(when-let* ((pos (org-babel-where-is-src-block-result))
(ansi-color-apply-face-function
(lambda (beg end face)
;; Could be useful for export backends
(when face
(put-text-property beg end 'face face)))))
(goto-char pos)
(ansi-color-apply-on-region (point) (org-babel-result-end))))))
;;; Hook into `org'
;; Defer generation of the aliases until Org is enabled in a buffer to
;; avoid generating them at top-level when loading ob-jupyter. Some
;; users, e.g. those who use conda environments, may not have a
;; jupyter command available at load time.
(defun org-babel-jupyter-make-local-aliases ()
(let ((default-directory user-emacs-directory))
(org-babel-jupyter-aliases-from-kernelspecs)))
(add-hook 'org-mode-hook #'org-babel-jupyter-make-local-aliases 10)
(add-hook 'org-export-before-processing-functions #'org-babel-jupyter-setup-export)
(add-hook 'org-export-before-parsing-functions #'org-babel-jupyter-strip-ansi-escapes)
(provide 'ob-jupyter)
;;; ob-jupyter.el ends here

33
lisp/jupyter/widget.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<title>Jupyter Client</title>
<script type="application/javascript" src="/jupyter"></script>
<script type="application/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"></script>
<style type="text/css">
* {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
</style>
<script type="application/javascript">
var kernel;
document.addEventListener("DOMContentLoaded", function(event) {
// TODO: May not be available everywhere
var p = new URLSearchParams(window.location.search);
var kernel = new EmacsJupyter({username: p.get('username'),
clientId: p.get('clientId')},
p.get('port'));
var commManager = new CommManager(kernel);
var widgetManager = new WidgetManager(kernel, document.getElementById("widget"));
commManager.register_target(widgetManager.comm_target_name, function(comm, msg) {
widgetManager.handle_comm_open(comm, msg);
});
kernel.widgetManager = widgetManager;
kernel.commManager = commManager;
window.kernel = kernel;
});
</script>
</head>
<body>
</body>
</html>