change python config, add jupyter and ein
This commit is contained in:
50
lisp/jupyter/Makefile
Normal file
50
lisp/jupyter/Makefile
Normal 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
28
lisp/jupyter/js/Makefile
Normal 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
|
||||
342
lisp/jupyter/js/emacs-jupyter.js
Normal file
342
lisp/jupyter/js/emacs-jupyter.js
Normal 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
12
lisp/jupyter/js/index.js
Normal 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);
|
||||
});
|
||||
82
lisp/jupyter/js/manager.js
Normal file
82
lisp/jupyter/js/manager.js
Normal 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));
|
||||
}
|
||||
33
lisp/jupyter/js/package.json
Normal file
33
lisp/jupyter/js/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
lisp/jupyter/js/webpack.config.js
Normal file
29
lisp/jupyter/js/webpack.config.js
Normal 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
63
lisp/jupyter/jupyter-R.el
Normal 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
|
||||
793
lisp/jupyter/jupyter-base.el
Normal file
793
lisp/jupyter/jupyter-base.el
Normal 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
|
||||
45
lisp/jupyter/jupyter-c++.el
Normal file
45
lisp/jupyter/jupyter-c++.el
Normal 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
|
||||
187
lisp/jupyter/jupyter-channel-ioloop.el
Normal file
187
lisp/jupyter/jupyter-channel-ioloop.el
Normal 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
|
||||
73
lisp/jupyter/jupyter-channel.el
Normal file
73
lisp/jupyter/jupyter-channel.el
Normal 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
|
||||
1812
lisp/jupyter/jupyter-client.el
Normal file
1812
lisp/jupyter/jupyter-client.el
Normal file
File diff suppressed because it is too large
Load Diff
192
lisp/jupyter/jupyter-env.el
Normal file
192
lisp/jupyter/jupyter-env.el
Normal 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
|
||||
502
lisp/jupyter/jupyter-ioloop.el
Normal file
502
lisp/jupyter/jupyter-ioloop.el
Normal 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
|
||||
54
lisp/jupyter/jupyter-javascript.el
Normal file
54
lisp/jupyter/jupyter-javascript.el
Normal 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
|
||||
245
lisp/jupyter/jupyter-julia.el
Normal file
245
lisp/jupyter/jupyter-julia.el
Normal 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
|
||||
416
lisp/jupyter/jupyter-kernel-process.el
Normal file
416
lisp/jupyter/jupyter-kernel-process.el
Normal 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
|
||||
|
||||
|
||||
130
lisp/jupyter/jupyter-kernel.el
Normal file
130
lisp/jupyter/jupyter-kernel.el
Normal 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
|
||||
273
lisp/jupyter/jupyter-kernelspec.el
Normal file
273
lisp/jupyter/jupyter-kernelspec.el
Normal 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
|
||||
678
lisp/jupyter/jupyter-messages.el
Normal file
678
lisp/jupyter/jupyter-messages.el
Normal 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
|
||||
671
lisp/jupyter/jupyter-mime.el
Normal file
671
lisp/jupyter/jupyter-mime.el
Normal 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
|
||||
494
lisp/jupyter/jupyter-monads.el
Normal file
494
lisp/jupyter/jupyter-monads.el
Normal 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
|
||||
1899
lisp/jupyter/jupyter-org-client.el
Normal file
1899
lisp/jupyter/jupyter-org-client.el
Normal file
File diff suppressed because it is too large
Load Diff
686
lisp/jupyter/jupyter-org-extensions.el
Normal file
686
lisp/jupyter/jupyter-org-extensions.el
Normal 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
|
||||
17
lisp/jupyter/jupyter-pkg.el
Normal file
17
lisp/jupyter/jupyter-pkg.el
Normal 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:
|
||||
110
lisp/jupyter/jupyter-python.el
Normal file
110
lisp/jupyter/jupyter-python.el
Normal 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
2179
lisp/jupyter/jupyter-repl.el
Normal file
File diff suppressed because it is too large
Load Diff
1103
lisp/jupyter/jupyter-rest-api.el
Normal file
1103
lisp/jupyter/jupyter-rest-api.el
Normal file
File diff suppressed because it is too large
Load Diff
375
lisp/jupyter/jupyter-server-kernel.el
Normal file
375
lisp/jupyter/jupyter-server-kernel.el
Normal 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
|
||||
574
lisp/jupyter/jupyter-server.el
Normal file
574
lisp/jupyter/jupyter-server.el
Normal 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
|
||||
881
lisp/jupyter/jupyter-tramp.el
Normal file
881
lisp/jupyter/jupyter-tramp.el
Normal 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
|
||||
287
lisp/jupyter/jupyter-widget-client.el
Normal file
287
lisp/jupyter/jupyter-widget-client.el
Normal 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
|
||||
82
lisp/jupyter/jupyter-zmq-channel-ioloop.el
Normal file
82
lisp/jupyter/jupyter-zmq-channel-ioloop.el
Normal 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
|
||||
252
lisp/jupyter/jupyter-zmq-channel.el
Normal file
252
lisp/jupyter/jupyter-zmq-channel.el
Normal 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
44
lisp/jupyter/jupyter.el
Normal 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
836
lisp/jupyter/ob-jupyter.el
Normal 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
33
lisp/jupyter/widget.html
Normal 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>
|
||||
Reference in New Issue
Block a user