├── .ert-runner
├── .github
├── FUNDING.yml
└── workflows
│ └── test.yml
├── jupyter.png
├── .gitignore
├── Eldev
├── js
├── index.js
├── Makefile
├── package.json
├── webpack.config.js
├── manager.js
└── emacs-jupyter.js
├── Makefile
├── widget.html
├── jupyter.el
├── jupyter-c++.el
├── jupyter-javascript.el
├── .travis.yml
├── jupyter-R.el
├── MAINTAINERS.org
├── appveyor.yml
├── jupyter-channel.el
├── jupyter-zmq-channel-ioloop.el
├── jupyter-kernel.el
├── jupyter-python.el
├── jupyter-channel-ioloop.el
├── jupyter-env.el
├── jupyter-julia.el
├── jupyter-zmq-channel.el
├── jupyter-kernelspec.el
├── jupyter-widget-client.el
├── test
├── jupyter-monad-test.el
└── jupyter-tramp-test.el
├── jupyter-server-kernel.el
├── jupyter-kernel-process.el
└── jupyter-monads.el
/.ert-runner:
--------------------------------------------------------------------------------
1 | -L .
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | liberapay: nnic
2 | open_collective: emacs-jupyter
3 |
--------------------------------------------------------------------------------
/jupyter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/emacs-jupyter/jupyter/HEAD/jupyter.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.elc
2 | .projectile
3 | flycheck_*
4 | node_modules/
5 | *lock*
6 | built/
7 | .cask/
8 | .eldev/
9 | Eldev-local
10 |
--------------------------------------------------------------------------------
/Eldev:
--------------------------------------------------------------------------------
1 | ; -*- mode: emacs-lisp; lexical-binding: t -*-
2 |
3 | (eldev-require-version "1.2")
4 | (eldev-use-package-archive 'melpa)
5 | (eldev-add-extra-dependencies 'test 'org)
6 | (setq eldev-standard-excludes `(:or ,eldev-standard-excludes "./js"))
7 |
--------------------------------------------------------------------------------
/js/index.js:
--------------------------------------------------------------------------------
1 | window.CommManager = require('@jupyter-widgets/base').shims.services.CommManager;
2 | window.WidgetManager = require('./manager').WidgetManager;
3 | window.EmacsJupyter = require('./emacs-jupyter').EmacsJupyter;
4 | require('font-awesome/css/font-awesome.min.css');
5 | require('@jupyter-widgets/controls/css/widgets.built.css');
6 |
7 | document.addEventListener("DOMContentLoaded", function(event) {
8 |
9 | var widget = document.createElement("div");
10 | widget.setAttribute("id", "widget");
11 | document.body.appendChild(widget);
12 | });
13 |
--------------------------------------------------------------------------------
/js/Makefile:
--------------------------------------------------------------------------------
1 | SHELL = bash
2 |
3 | NPM ?= $(shell command -v npm)
4 | ifeq ($(NPM),)
5 | $(error "Node not installed (https://nodejs.org/en/)")
6 | endif
7 |
8 | YARN ?= $(shell command -v yarn)
9 | ifeq ($(YARN),)
10 | # If yarn isn't already installed, it is built locally
11 | YARN = ./node_modules/.bin/yarn
12 | endif
13 |
14 | .PHONY: all build clean
15 |
16 | all: build
17 |
18 | clean:
19 | @rm -rf built/ 2>/dev/null || true
20 |
21 | really-clean: clean
22 | @rm -rf node_modules 2>/dev/null || true
23 |
24 | build: built/index.built.js
25 |
26 | built/index.built.js:
27 | $(NPM) install
28 | $(YARN) run build --progress
29 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | EMACS ?= emacs
2 | ELDEV ?= eldev
3 |
4 | FILES = $(wildcard *.el)
5 | ELCFILES = $(FILES:.el=.elc)
6 | TESTFILES = $(foreach file,$(wildcard test/*.el),-l $(file))
7 | TESTSELECTORS =
8 |
9 | ifneq ($(TAGS),)
10 | comma := ,
11 | TESTSELECTORS := $(foreach tag,$(subst $(comma), ,$(TAGS)),"(tag $(tag))")
12 | endif
13 |
14 | ifneq ($(PATTERN),)
15 | TESTSELECTORS := $(TESTSELECTORS) \"$(PATTERN)\"
16 | endif
17 |
18 | # ifneq ($(TESTSELECTORS),)
19 | # TESTSELECTORS := (quote (or $(TESTSELECTORS)))
20 | # endif
21 |
22 | .PHONY: all
23 | all: compile
24 |
25 | .PHONY: eldev
26 | eldev:
27 | ifeq ($(ELDEV),)
28 | $(error "Install eldev (https://github.com/doublep/eldev)")
29 | endif
30 |
31 | .PHONY: test
32 | test:
33 | $(ELDEV) test $(TESTSELECTORS)
34 |
35 | .PHONY: clean
36 | clean:
37 | make -C js clean
38 | @rm $(ELCFILES) 2>/dev/null || true
39 |
40 | .PHONY: clean-eldev
41 | clean-eldev:
42 | @rm -rf .eldev/ 2>/dev/null || true
43 |
44 | .PHONY: widgets
45 | widgets:
46 | make -C js
47 |
48 | .PHONY: compile
49 | compile:
50 | $(ELDEV) compile
51 |
--------------------------------------------------------------------------------
/js/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "emacs-jupyter",
4 | "version": "0.3.0",
5 | "description": "Integrate emacs-jupyter with widgets in a browser.",
6 | "main": "index.js",
7 | "scripts": {
8 | "clean": "rm -rf built",
9 | "build": "webpack",
10 | "test": "npm run test:default",
11 | "test:default": "echo \"No test specified\""
12 | },
13 | "author": "Nathaniel Nicandro",
14 | "license": "GPL-2.0-or-later",
15 | "dependencies": {
16 | "@jupyter-widgets/base": "^1.2.2",
17 | "@jupyter-widgets/controls": "^1.2.1",
18 | "@jupyter-widgets/output": "^1.0.10",
19 | "codemirror": "^5.9.0",
20 | "font-awesome": "^4.7.0",
21 | "npm": "^6.4.1",
22 | "yarn": "^1.12.3"
23 | },
24 | "devDependencies": {
25 | "css-loader": "^0.28.4",
26 | "file-loader": "^0.11.2",
27 | "json-loader": "^0.5.4",
28 | "raw-loader": "^0.5.1",
29 | "style-loader": "^0.18.1",
30 | "url-loader": "^1.1.2",
31 | "webpack": "^3.5.5"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/widget.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Jupyter Client
5 |
6 |
7 |
12 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/js/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 |
3 | module.exports = {
4 | entry: "./index.js",
5 | output: {
6 | filename: 'index.built.js',
7 | path: path.resolve(__dirname, 'built'),
8 | publicPath: 'built/'
9 | },
10 | resolve: {
11 | alias: {
12 | '@jupyterlab/services/kernel/future': path.resolve(__dirname, 'node_modules/@jupyterlab/services/lib/kernel/future'),
13 | '@jupyterlab/services/kernel/comm': path.resolve(__dirname, 'node_modules/@jupyterlab/services/lib/kernel/comm')
14 | }
15 | },
16 | module: {
17 | rules: [
18 | { test: /\.css$/, loader: "style-loader!css-loader" },
19 | // jquery-ui loads some images
20 | { test: /\.(jpg|png|gif)$/, use: 'file-loader' },
21 | // required to load font-awesome
22 | { test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=application/font-woff' },
23 | { test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=application/font-woff' },
24 | { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=application/octet-stream' },
25 | { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, use: 'file-loader' },
26 | { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?mimetype=image/svg+xml' }
27 | ]
28 | },
29 | }
30 |
--------------------------------------------------------------------------------
/jupyter.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter.el --- Jupyter -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 11 Jan 2018
7 | ;; Version: 1.0
8 | ;; Package-Requires: ((emacs "27") (cl-lib "0.5") (org "9.1.6") (zmq "0.10.10") (simple-httpd "1.5.0") (websocket "1.9"))
9 | ;; URL: https://github.com/emacs-jupyter/jupyter
10 |
11 | ;; This program is free software; you can redistribute it and/or
12 | ;; modify it under the terms of the GNU General Public License as
13 | ;; published by the Free Software Foundation; either version 3, or (at
14 | ;; your option) any later version.
15 |
16 | ;; This program is distributed in the hope that it will be useful, but
17 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
18 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
19 | ;; General Public License for more details.
20 |
21 | ;; You should have received a copy of the GNU General Public License
22 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
23 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
24 | ;; Boston, MA 02111-1307, USA.
25 |
26 | ;;; Commentary:
27 |
28 | ;; An interface for communicating with Jupyter kernels.
29 |
30 | ;;; Code:
31 |
32 | (defgroup jupyter nil
33 | "Jupyter"
34 | :group 'processes)
35 |
36 | (require 'jupyter-base)
37 | (require 'jupyter-client)
38 | (require 'jupyter-kernelspec)
39 | (require 'jupyter-server)
40 | (require 'jupyter-repl)
41 |
42 | (provide 'jupyter)
43 |
44 | ;;; jupyter.el ends here
45 |
--------------------------------------------------------------------------------
/jupyter-c++.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-c++.el --- Jupyter support for C++ -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 12 April 2019
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Support methods for integration with C++.
26 |
27 | ;;; Code:
28 |
29 | (require 'jupyter-repl)
30 |
31 | (cl-defmethod jupyter-repl-initialize-fontification (&context (jupyter-lang c++))
32 | "Copy buffer local variables used for fontification to the REPL buffer."
33 | (cl-loop
34 | with c-vars = (jupyter-with-repl-lang-buffer
35 | (cl-loop
36 | for var-val in (buffer-local-variables)
37 | if (string-prefix-p "c-" (symbol-name (car var-val)))
38 | collect var-val))
39 | for (var . val) in c-vars
40 | do (set (make-local-variable var) val))
41 | (cl-call-next-method))
42 |
43 | (provide 'jupyter-c++)
44 |
45 | ;;; jupyter-c++.el ends here
46 |
--------------------------------------------------------------------------------
/jupyter-javascript.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-javascript.el --- Jupyter support for Javascript -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 23 Oct 2018
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Support methods for integration with Javascript.
26 |
27 | ;;; Code:
28 |
29 | (require 'jupyter-repl)
30 |
31 | (declare-function js2-parse "ext:js2-mode")
32 | (declare-function js2-mode-apply-deferred-properties "ext:js2-mode")
33 |
34 | (cl-defmethod jupyter-repl-after-init (&context (jupyter-lang javascript)
35 | (jupyter-repl-mode js2-mode))
36 | "If `js2-mode' is used for Javascript kernels, enable syntax highlighting.
37 | `js2-mode' does not use `font-lock-defaults', but their own
38 | custom method."
39 | (add-hook 'after-change-functions
40 | (lambda (_beg _end len)
41 | ;; Insertions only
42 | (when (= len 0)
43 | (unless (jupyter-repl-cell-finalized-p)
44 | (let ((cbeg (jupyter-repl-cell-code-beginning-position))
45 | (cend (jupyter-repl-cell-code-end-position)))
46 | (save-restriction
47 | (narrow-to-region cbeg cend)
48 | (js2-parse)
49 | (js2-mode-apply-deferred-properties))))))
50 | t t))
51 |
52 | (provide 'jupyter-javascript)
53 |
54 | ;;; jupyter-javascript.el ends here
55 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/travis-ci/travis-ci/issues/9061
2 | sudo: required
3 | dist: xenial
4 | language: nix
5 | matrix:
6 | # Report build failure/success before allowed failures complete
7 | fast_finish: true
8 | allow_failures:
9 | - env: EMACS_CI=emacs-snapshot
10 | env:
11 | - EMACS_CI=emacs-26-1
12 | - EMACS_CI=emacs-26-3
13 | - EMACS_CI=emacs-27-1
14 | - EMACS_CI=emacs-snapshot
15 | install:
16 | # Install Emacs
17 | - bash <(curl https://raw.githubusercontent.com/purcell/nix-emacs-ci/master/travis-install)
18 | # Install cask
19 | - curl -fsSL https://raw.githubusercontent.com/cask/cask/master/go | python
20 | # We go the route of installing Python3.6 like this because the
21 | # python3 apt package on Xenial maxes out at 3.5 and for some reason
22 | # installing setuptools using the python3-pip apt package installs a
23 | # setuptools that uses f-strings, a feature in 3.6.
24 | #
25 | # One option would have been to update to Bionic, but apparently we
26 | # can't since we use language: nix, see
27 | # https://travis-ci.community/t/for-dist-bionic-xenial-vm-is-used-instead-with-language-rust/4487/3
28 | - sudo add-apt-repository ppa:deadsnakes/ppa -y
29 | - sudo apt-get update
30 | - sudo apt-get install -y python3.6
31 | - wget https://bootstrap.pypa.io/get-pip.py
32 | - sudo python3.6 get-pip.py
33 | - sudo python3.6 -m pip install --upgrade setuptools pip
34 | # --ignore-installed six since jupyter tries to upgrade it, but it can't be
35 | # upgraded since it is a distutils package
36 | - sudo python3.6 -m pip install --ignore-installed six jupyter
37 | # Install the kernelspec using the right python. Jupyter installs a default
38 | # python kernelspec that uses "python" for the command in
39 | # /usr/local/share/jupyter. This installs one with an absolute path using our
40 | # python3
41 | - sudo python3.6 -m ipykernel.kernelspec
42 | # Install additional packages needed by the tests
43 | - sudo apt-get install pandoc
44 | before_script:
45 | # Ensure Jupyter runtime dir can be written to
46 | - mkdir -p $(jupyter --runtime-dir)
47 | - jupyter --runtime-dir
48 | - jupyter --paths
49 | - jupyter --version
50 | - jupyter notebook --version
51 | - jupyter kernelspec list
52 | script:
53 | - export PATH=$HOME/.cask/bin:$PATH
54 | - cd $TRAVIS_BUILD_DIR
55 | - make dev
56 | - make compile
57 | - make test
58 |
--------------------------------------------------------------------------------
/jupyter-R.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-R.el --- Jupyter support for R -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Jack Kamm
6 | ;; Nathaniel Nicandro
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Support methods for integration with R.
26 |
27 | ;;; Code:
28 |
29 | (require 'jupyter-repl)
30 | (require 'jupyter-org-client)
31 | (require 'jupyter-mime)
32 |
33 | (defvar ess-font-lock-keywords)
34 |
35 | (cl-defmethod jupyter-repl-initialize-fontification (&context (jupyter-lang R))
36 | (when (featurep 'ess)
37 | (setq-local ess-font-lock-keywords 'ess-R-font-lock-keywords))
38 | (cl-call-next-method))
39 |
40 | (cl-defmethod jupyter-org-result ((_mime (eql :text/html)) content params
41 | &context (jupyter-lang R))
42 | "If html DATA is an iframe, save it to a separate file and open in browser.
43 | Otherwise, parse it as normal."
44 | (if (plist-get (plist-get content :metadata) :isolated)
45 | (let* ((data (plist-get content :data))
46 | (file (or (alist-get :file params)
47 | (jupyter-org-image-file-name data ".html"))))
48 | (with-temp-file file
49 | (insert data))
50 | (browse-url-of-file file)
51 | (jupyter-org-file-link file))
52 | (cl-call-next-method)))
53 |
54 | (cl-defmethod jupyter-insert ((_mime (eql :text/html)) data
55 | &context (jupyter-lang R)
56 | &optional metadata)
57 | (if (plist-get metadata :isolated)
58 | (jupyter-browse-url-in-temp-file data)
59 | (cl-call-next-method)))
60 |
61 | (provide 'jupyter-R)
62 |
63 | ;;; jupyter-R.el ends here
64 |
--------------------------------------------------------------------------------
/MAINTAINERS.org:
--------------------------------------------------------------------------------
1 | * Reviewing changes to add to =CHANGELOG.org=
2 |
3 | Examine the output of =git log= since the last time the Change Log was updated
4 | and narrow it down to notable commits, e.g. new variables, bug fixes, breaking
5 | changes, etc. Write a few sentences describing the change and how it affects
6 | users going forward.
7 |
8 | #+NAME: changelog
9 | #+HEADER: :var PREV_VERSION="v1.0" VERSION="master"
10 | #+BEGIN_SRC shell :results output
11 | git log --pretty=format:"%s" ${PREV_VERSION}...${VERSION}
12 | #+END_SRC
13 |
14 | #+RESULTS: changelog
15 |
16 | * Updating project metadata
17 |
18 | - Increment copyright years, if needed
19 | - Set the Version header
20 | - Set =jupyter-version= in file:jupyter-base.el
21 |
22 | #+CALL: update-project-metadata(version="1.0")
23 |
24 | #+NAME: source-files
25 | #+begin_src shell
26 | git ls-files | grep -E '.el$'
27 | #+end_src
28 |
29 | #+NAME: eval-in-file
30 | #+begin_src elisp
31 | (lambda (file fun)
32 | (let* ((buf (find-buffer-visiting file))
33 | (kill (null buf)))
34 | (unless buf
35 | (setq buf (find-file-noselect file)))
36 | (with-current-buffer buf
37 | (save-excursion
38 | (save-restriction
39 | (widen)
40 | (goto-char (point-min))
41 | (funcall fun)
42 | (save-buffer)
43 | (when kill
44 | (kill-buffer)))))))
45 | #+end_src
46 |
47 | #+NAME: update-project-metadata
48 | #+HEADERS: :var version="0.8.2"
49 | #+begin_src elisp :results silent :var files=source-files eval-in-file=eval-in-file
50 | (let ((lexical-binding t)
51 | (copyright-re "^;; Copyright (C) \\([0-9]+\\)\\(?:-\\([0-9]+\\)\\)?")
52 | (version-re "^;; Version: \\(\\([0-9]+\\)\\.\\([0-9]+\\)\\.\\([0-9]+\\)\\)")
53 | (this-year (format-time-string "%Y")))
54 | (dolist (file files)
55 | (funcall
56 | eval-in-file (car file)
57 | (lambda ()
58 | (when (re-search-forward copyright-re nil t)
59 | (let* ((end-group (if (match-string 2) 2 1))
60 | (new-span (= end-group 1)))
61 | (if (not new-span) (replace-match this-year nil nil nil end-group)
62 | (goto-char (match-end end-group))
63 | (insert "-" this-year))))
64 | (when (re-search-forward version-re nil t)
65 | (replace-match version nil nil nil 1)))))
66 | (funcall eval-in-file "jupyter-base.el"
67 | (lambda ()
68 | (when (re-search-forward "^(defconst jupyter-version \"\\(.+\\)\"")
69 | (replace-match version nil nil nil 1)))))
70 | #+end_src
71 |
72 | * TODO Makefile =release= target
73 |
74 | - Updates project metadata
75 | - Creates tagged (and signed) commit
76 |
--------------------------------------------------------------------------------
/appveyor.yml:
--------------------------------------------------------------------------------
1 | environment:
2 | PATH: C:\msys64\usr\bin;C:\msys64\mingw64\bin;%PATH%
3 | CASK_PYTHON: C:\Python27\python
4 | JUPYTER_PYTHON_VERSION: 3.6
5 | MINICONDA: C:\Miniconda3-x64
6 | EZMQ_VERSION: v0.10.1
7 | EZMQ_TZG: emacs-zmq-x86_64-w64-mingw32.tar.gz
8 | matrix:
9 | fast_finish: true
10 | cache:
11 | - C:\msys64\var\cache\pacman\pkg
12 | - C:\Miniconda3-x64\envs\jupyter
13 | install:
14 | - pacman --needed --noconfirm -Syu
15 | - pacman --needed --noconfirm -Sy
16 | - pacman --needed --noconfirm -S mingw-w64-x86_64-emacs mingw-w64-x86_64-libxml2 mingw-w64-x86_64-libjpeg-turbo
17 | # Install Cask
18 | # NOTE: Using bash here so that cask writes to ~/.cask
19 | - bash -lc "curl -fsSL https://raw.githubusercontent.com/cask/cask/master/go | $CASK_PYTHON"
20 | - cd %APPVEYOR_BUILD_FOLDER%
21 | - ps: |
22 | Add-Content -Path run-cask -Value @'
23 | #!/usr/bin/env bash
24 | # Swap stderr and stdout, seems that they are mixed up.
25 | $CASK_PYTHON ~/.cask/bin/cask $@
26 | '@
27 | - chmod +x run-cask
28 | - set PATH=%APPVEYOR_BUILD_FOLDER%;%PATH%
29 | - set CASK=run-cask
30 | # Install Jupyter
31 | - '"%MINICONDA%\Scripts\activate.bat"'
32 | - conda config --set always_yes yes --set changeps1 no
33 | - if not exist C:\Miniconda3-x64\envs\jupyter conda create --name jupyter python=%JUPYTER_PYTHON_VERSION%
34 | - conda activate jupyter
35 | - conda install jupyter
36 | # Install additional packages needed by tests
37 | - conda install -c conda-forge pandoc
38 | # Ensure Jupyter runtime dir can be written to
39 | - ps: mkdir -p (jupyter --runtime-dir)
40 | - jupyter --version
41 | - jupyter notebook --version
42 | build_script:
43 | - cd %APPVEYOR_BUILD_FOLDER%
44 | - make dev
45 | # Download emacs-zmq dll binaries
46 | - ps: cd (Get-ChildItem -Recurse -Depth 2 -Path .cask -Filter zmq-*).FullName
47 | - appveyor DownloadFile https://github.com/dzop/emacs-zmq/releases/download/%EZMQ_VERSION%/%EZMQ_TZG%
48 | - tar -xzf %EZMQ_TZG%
49 | # NOTE: Set PATH to include the ZMQ directory so that emacs-zmq.dll can find
50 | # libzmq.dll and it needs to be at the front of PATH to ensure we find the
51 | # right one. See
52 | # https://docs.microsoft.com/en-us/windows/desktop/dlls/dynamic-link-library-search-order
53 | # TODO: Make this unnecessary
54 | - ps: $env:PATH = "$(Get-Location);$env:PATH"
55 | - make compile
56 | test_script:
57 | - cd %APPVEYOR_BUILD_FOLDER%
58 | - ps: $env:PATH = "$(bash -lc 'cd $APPVEYOR_BUILD_FOLDER; run-cask exec-path');$env:PATH"
59 | - ps: $env:EMACSLOADPATH = (bash -lc 'cd $APPVEYOR_BUILD_FOLDER; run-cask load-path')
60 | - ert-runner.bat --script -L .
61 |
--------------------------------------------------------------------------------
/jupyter-channel.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-channel.el --- Jupyter channel interface -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 27 Jun 2019
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Defines the `jupyter-channel' interface.
26 |
27 | ;;; Code:
28 |
29 | (require 'eieio)
30 |
31 | (defclass jupyter-channel ()
32 | ((type
33 | :type keyword
34 | :initarg :type
35 | :documentation "The type of this channel.")
36 | (session
37 | :type jupyter-session
38 | :initarg :session
39 | :documentation "The session object used to sign and send/receive messages.")
40 | (endpoint
41 | :type string
42 | :initarg :endpoint
43 | :documentation "The endpoint this channel is connected to.
44 | Typical endpoints look like \"tcp://127.0.0.1:5555\"."))
45 | :abstract t)
46 |
47 | (cl-defmethod jupyter-start ((_channel jupyter-channel) &key _identity)
48 | "Start a Jupyter CHANNEL using IDENTITY as the routing ID.
49 | If CHANNEL is already alive, do nothing."
50 | (cl-call-next-method))
51 |
52 | (cl-defmethod jupyter-stop ((_channel jupyter-channel))
53 | "Stop a Jupyter CHANNEL.
54 | If CHANNEL is already stopped, do nothing."
55 | (cl-call-next-method))
56 |
57 | (cl-defmethod jupyter-alive-p ((_channel jupyter-channel))
58 | "Return non-nil if a CHANNEL is alive."
59 | (cl-call-next-method))
60 |
61 | (cl-defmethod jupyter-send (_channel _type _message &optional _msg-id)
62 | "On CHANNEL send MESSAGE which has message TYPE and optionally a MSG-ID."
63 | (cl-call-next-method))
64 |
65 | (cl-defmethod jupyter-recv (_channel &optional _dont-wait)
66 | "Receive a message on CHANNEL.
67 | If DONT-WAIT is non-nil, return nil immediately if there is no
68 | message available to receive."
69 | (cl-call-next-method))
70 |
71 | (provide 'jupyter-channel)
72 |
73 | ;;; jupyter-channel.el ends here
74 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | on: push
2 | jobs:
3 | test-windows:
4 | strategy:
5 | matrix:
6 | emacs-version: [27.2, 28.2, 29.3, snapshot]
7 | runs-on: windows-latest
8 | defaults:
9 | run:
10 | shell: bash -el {0}
11 | continue-on-error: true
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: jcs090218/setup-emacs-windows@master
15 | with:
16 | version: ${{ matrix.emacs-version }}
17 | - uses: msys2/setup-msys2@v2
18 | with:
19 | msystem: mingw64
20 | path-type: inherit
21 | install: make
22 | - uses: conda-incubator/setup-miniconda@v3
23 | with:
24 | python-version: 3.9
25 | - name: Install requirements
26 | run: |
27 | conda install -c conda-forge pandoc
28 | conda install -c anaconda jupyter
29 | curl -fsSL https://raw.github.com/doublep/eldev/master/webinstall/eldev | sh
30 | - name: Add Eldev path
31 | shell: powershell
32 | run: |
33 | "$env:UserProfile\.local\bin" | Out-File $env:GITHUB_PATH
34 | - name: Check Jupyter version
35 | run: |
36 | mkdir -p $(jupyter --runtime-dir)
37 | jupyter --paths
38 | jupyter --version
39 | jupyter kernelspec list
40 | - name: Byte compilation
41 | shell: msys2 {0}
42 | run: |
43 | make
44 | - name: Run tests
45 | shell: msys2 {0}
46 | run: |
47 | make test
48 | test-unix-like:
49 | strategy:
50 | matrix:
51 | os: [ubuntu-latest, macos-latest]
52 | emacs-version: [27.2, 28.2, 29.3, snapshot]
53 | exclude:
54 | - os: macos-latest
55 | emacs-version: 27.2
56 | defaults:
57 | run:
58 | shell: bash -el {0}
59 | runs-on: ${{ matrix.os }}
60 | continue-on-error: true
61 | steps:
62 | - uses: actions/checkout@v3
63 | - uses: purcell/setup-emacs@master
64 | with:
65 | version: ${{ matrix.emacs-version }}
66 | - uses: conda-incubator/setup-miniconda@v3
67 | with:
68 | python-version: 3.9
69 | - name: Install requirements
70 | run: |
71 | conda install -c conda-forge pandoc
72 | conda install -c anaconda jupyter
73 | curl -fsSL https://raw.github.com/doublep/eldev/master/webinstall/eldev | sh
74 |
75 | # Make eldev available to all future actions
76 | echo "$HOME/.local/bin" >> $GITHUB_PATH
77 | - name: Check Jupyter version
78 | run: |
79 | mkdir -p $(jupyter --runtime-dir)
80 | jupyter --paths
81 | jupyter --version
82 | jupyter kernelspec list
83 | - name: Install packages for compiling Emacs-ZMQ when needed
84 | # FIXME: It may be necessary to compile ZMQ since the runners can have
85 | # a different architecture than what is available in the
86 | # releases of that package. Should consider the architecture in
87 | # the releases of Emacs-ZMQ.
88 | run: brew install automake libtool
89 | if: matrix.os == 'macos-latest'
90 | - name: Byte compilation
91 | run: |
92 | make compile
93 | - name: Run tests
94 | run: |
95 | make test
96 |
--------------------------------------------------------------------------------
/js/manager.js:
--------------------------------------------------------------------------------
1 | var base = require('@jupyter-widgets/base');
2 | var output = require('@jupyter-widgets/output');
3 | var controls = require('@jupyter-widgets/controls');
4 | var PhosphorWidget = require('@phosphor/widgets').Widget;
5 |
6 | var defineWidgetModules = function () {
7 | if(window.define) {
8 | window.define('@jupyter-widgets/output', [], function () { return output; });
9 | window.define('@jupyter-widgets/base', [], function () { return base; });
10 | window.define('@jupyter-widgets/controls', [], function () { return controls; });
11 | } else {
12 | setTimeout(defineWidgetModules, 50);
13 | }
14 | };
15 |
16 | // requirejs loading is async so it may not be available on this event
17 | window.addEventListener("DOMContentLoaded", function () {
18 | defineWidgetModules();
19 | });
20 |
21 | var WidgetManager = exports.WidgetManager = function(kernel, area) {
22 | base.ManagerBase.call(this);
23 | this.kernel = kernel;
24 | this.area = area;
25 | };
26 | WidgetManager.prototype = Object.create(base.ManagerBase.prototype);
27 |
28 | WidgetManager.prototype.loadClass = function(className, moduleName, moduleVersion) {
29 | return new Promise(function(resolve, reject) {
30 | if (moduleName === '@jupyter-widgets/controls') {
31 | resolve(controls);
32 | } else if (moduleName === '@jupyter-widgets/base') {
33 | resolve(base);
34 | } else if (moduleName === '@jupyter-widgets/output')
35 | resolve(output);
36 | else {
37 | var fallback = function(err) {
38 | var failedId = err.requireModules && err.requireModules[0];
39 | if (failedId) {
40 | console.log('Falling back to unpkg.com for ' + moduleName + '@' + moduleVersion);
41 | window.require(['https://unpkg.com/' + moduleName + '@' + moduleVersion + '/dist/index.js'], resolve, reject);
42 | } else {
43 | throw err;
44 | }
45 | };
46 | window.require([moduleName + '.js'], resolve, fallback);
47 | }
48 | }).then(function(module) {
49 | if (module[className]) {
50 | return module[className];
51 | } else {
52 | return Promise.reject('Class ' + className + ' not found in module ' + moduleName + '@' + moduleVersion);
53 | }
54 | });
55 | }
56 |
57 | WidgetManager.prototype.display_view = function(msg, view, options) {
58 | var _this = this;
59 | return Promise.resolve(view).then(function(view) {
60 | PhosphorWidget.attach(view.pWidget, _this.area);
61 | view.on('remove', function() {
62 | console.log('View removed', view);
63 | });
64 | view.trigger('displayed');
65 | return view;
66 | });
67 | };
68 |
69 | WidgetManager.prototype._get_comm_info = function() {
70 | return this.kernel.requestCommInfo(this.comm_target_name).then(function(reply) {
71 | return reply.content.comms;
72 | });
73 | };
74 |
75 | WidgetManager.prototype._create_comm = function(targetName, commId, data, metadata) {
76 | // Construct a comm that already exists
77 | var comm = this.kernel.connectToComm(targetName, commId);
78 | if(data || metadata) {
79 | comm.open(data, metadata);
80 | }
81 | return Promise.resolve(new base.shims.services.Comm(comm));
82 | }
83 |
--------------------------------------------------------------------------------
/jupyter-zmq-channel-ioloop.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-zmq-channel-ioloop.el --- IOLoop functions for Jupyter channels -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 08 Nov 2018
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; A `jupyter-channel-ioloop' using `jupyter-zmq-channel' to send and receive
26 | ;; messages. Whenever a message is received on a channel an event that looks
27 | ;; like the following will be sent back to the parent process
28 | ;;
29 | ;; (message CHANNEL-TYPE IDENTS . MSG)
30 | ;;
31 | ;; where CHANNEL-TYPE is the channel on which the message was received (one of
32 | ;; `jupyter-socket-types'), IDENTS are ZMQ identities, typically ignored, and
33 | ;; MSG is the message plist.
34 |
35 | ;;; Code:
36 |
37 | (require 'jupyter-base)
38 | (require 'jupyter-channel-ioloop)
39 | (require 'jupyter-zmq-channel)
40 |
41 | (defclass jupyter-zmq-channel-ioloop (jupyter-channel-ioloop)
42 | ()
43 | :documentation "A `jupyter-ioloop' configured for Jupyter channels.")
44 |
45 | (cl-defmethod initialize-instance ((ioloop jupyter-zmq-channel-ioloop) &optional _slots)
46 | (cl-call-next-method)
47 | (jupyter-ioloop-add-setup ioloop
48 | (require 'jupyter-zmq-channel-ioloop)
49 | (push 'jupyter-zmq-channel-ioloop--recv-messages jupyter-ioloop-post-hook)
50 | (cl-loop
51 | for channel in '(:shell :stdin :iopub :control)
52 | unless (object-assoc channel :type jupyter-channel-ioloop-channels)
53 | do (push (jupyter-zmq-channel
54 | :session jupyter-channel-ioloop-session
55 | :type channel)
56 | jupyter-channel-ioloop-channels))))
57 |
58 | (defun jupyter-zmq-channel-ioloop--recv-messages (events)
59 | "Print the received messages described in EVENTS.
60 | EVENTS is a list of socket events as returned by
61 | `zmq-poller-wait-all'. If any of the sockets in EVENTS matches
62 | one of the sockets in `jupyter-channel-ioloop-channels', receive a
63 | message on the channel and print a list with the form
64 |
65 | (message CHANNEL-TYPE . MSG...)
66 |
67 | to stdout. CHANNEL-TYPE is the channel on which MSG was
68 | received, either :shell, :stdin, :iopub, or :control. MSG is a
69 | list as returned by `jupyter-recv'."
70 | (let (messages)
71 | (dolist (channel jupyter-channel-ioloop-channels)
72 | (with-slots (type socket) channel
73 | (when (zmq-assoc socket events)
74 | (push (cons type (jupyter-recv channel)) messages))))
75 | (when messages
76 | ;; Send messages
77 | (mapc (lambda (msg) (prin1 (cons 'message msg))) (nreverse messages))
78 | (zmq-flush 'stdout))))
79 |
80 | (provide 'jupyter-zmq-channel-ioloop)
81 |
82 | ;;; jupyter-zmq-channel-ioloop.el ends here
83 |
--------------------------------------------------------------------------------
/jupyter-kernel.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-kernel.el --- Kernels -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2020-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 21 Apr 2020
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Working with Jupyter kernels. This file contains the functions
26 | ;; used to control the lifetime of a kernel and how clients can
27 | ;; connect to launched kernels.
28 |
29 | ;;; Code:
30 |
31 | (require 'jupyter-base)
32 | (require 'jupyter-monads)
33 | (require 'jupyter-kernelspec)
34 |
35 | (defgroup jupyter-kernel nil
36 | "Kernels"
37 | :group 'jupyter)
38 |
39 | ;;; Kernel definition
40 |
41 | (cl-defstruct jupyter-kernel
42 | "A Jupyter kernel."
43 | (spec (make-jupyter-kernelspec)
44 | :type jupyter-kernelspec
45 | :documentation "The kernelspec of this kernel.")
46 | ;; FIXME: Remove this slot, used by `jupyter-widget-client'.
47 | (session nil :type jupyter-session))
48 |
49 | (cl-defmethod jupyter-alive-p ((kernel jupyter-kernel))
50 | "Return non-nil if KERNEL has been launched."
51 | (and (jupyter-kernel-session kernel) t))
52 |
53 | (cl-defmethod cl-print-object ((kernel jupyter-kernel) stream)
54 | (princ (format "#"
55 | (jupyter-kernelspec-name (jupyter-kernel-spec kernel))
56 | (if (jupyter-alive-p kernel)
57 | (concat " " (truncate-string-to-width
58 | (jupyter-session-id (jupyter-kernel-session kernel))
59 | 9 nil nil "…"))
60 | ""))
61 | stream))
62 |
63 | (cl-defgeneric jupyter-kernel (&rest args)
64 | "Return a kernel constructed from ARGS.
65 |
66 | This method can be extended with extra primary methods for the
67 | purposes of handling different forms of ARGS."
68 | (let ((server (plist-get args :server))
69 | (conn-info (plist-get args :conn-info))
70 | (spec (plist-get args :spec)))
71 | (cond
72 | (server
73 | (require 'jupyter-server-kernel)
74 | (apply #'jupyter-kernel args))
75 | ((or conn-info spec)
76 | (require 'jupyter-kernel-process)
77 | (apply #'jupyter-kernel args))
78 | (t (cl-call-next-method)))))
79 |
80 | ;;; Kernel management
81 |
82 | (defun jupyter-kernel-name (kernel)
83 | (jupyter-kernelspec-name
84 | (jupyter-kernel-spec kernel)))
85 |
86 | (cl-defmethod jupyter-launch ((kernel jupyter-kernel))
87 | "Launch KERNEL."
88 | (cl-assert (jupyter-alive-p kernel)))
89 |
90 | (cl-defmethod jupyter-launch :before ((kernel jupyter-kernel))
91 | "Notify that the kernel launched."
92 | (message "Launching %s kernel..." (jupyter-kernel-name kernel)))
93 |
94 | (cl-defmethod jupyter-launch :after ((kernel jupyter-kernel))
95 | "Notify that the kernel launched."
96 | (message "Launching %s kernel...done" (jupyter-kernel-name kernel)))
97 |
98 | (cl-defmethod jupyter-shutdown ((kernel jupyter-kernel))
99 | "Shutdown KERNEL.
100 | Once a kernel has been shutdown it has no more connected clients
101 | and the process it represents no longer exists.
102 |
103 | The default implementation of this method disconnects all
104 | connected clients of KERNEL and sets KERNEL's session slot to
105 | nil."
106 | (setf (jupyter-kernel-session kernel) nil))
107 |
108 | (cl-defmethod jupyter-shutdown :before ((kernel jupyter-kernel))
109 | "Notify that the kernel will be shutdown."
110 | (message "%s kernel shutdown..." (jupyter-kernel-name kernel)))
111 |
112 | (cl-defmethod jupyter-shutdown :after ((kernel jupyter-kernel))
113 | "Notify that the kernel launched."
114 | (message "%s kernel shutdown...done" (jupyter-kernel-name kernel)))
115 |
116 | (cl-defmethod jupyter-restart ((kernel jupyter-kernel))
117 | "Restart KERNEL.
118 |
119 | The default implementation shuts down and then re-launches
120 | KERNEL."
121 | (jupyter-shutdown kernel)
122 | (jupyter-launch kernel))
123 |
124 | (cl-defmethod jupyter-interrupt ((_kernel jupyter-kernel))
125 | "Interrupt KERNEL."
126 | (ignore))
127 |
128 | (provide 'jupyter-kernel)
129 |
130 | ;;; jupyter-kernel.el ends here
131 |
--------------------------------------------------------------------------------
/jupyter-python.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-python.el --- Jupyter support for python -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 23 Oct 2018
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Support methods for integration with Python.
26 |
27 | ;;; Code:
28 |
29 | (require 'jupyter-repl)
30 | (require 'jupyter-org-client)
31 |
32 | (declare-function org-babel-python-table-or-string "ob-python")
33 |
34 | (cl-defmethod jupyter-handle-error :after ((client jupyter-repl-client) req msg
35 | &context (jupyter-lang python)
36 | (major-mode jupyter-repl-mode))
37 | "Add spacing between the first occurance of ENAME and \"Traceback\".
38 | Do this only when the traceback of REQ was inserted into the REPL
39 | buffer."
40 | (unless (equal (jupyter-message-parent-type msg) "comm_msg")
41 | (jupyter-with-repl-buffer client
42 | (jupyter-with-message-content msg (ename)
43 | (save-excursion
44 | (jupyter-repl-goto-cell req)
45 | (goto-char (jupyter-repl-cell-code-end-position))
46 | (when (and (search-forward ename nil t)
47 | (looking-at "Traceback"))
48 | (let ((len (- fill-column
49 | jupyter-repl-prompt-margin-width
50 | (- (point) (line-beginning-position))
51 | (- (line-end-position) (point)))))
52 | (insert-and-inherit
53 | (propertize (make-string (if (> len 4) len 4) ? )
54 | 'read-only t)))))))))
55 |
56 | (cl-defmethod jupyter-insert :around ((msg cons)
57 | &context (jupyter-lang python)
58 | &rest _ignore)
59 | "Fontify docstrings after inserting inspect messages."
60 | (let ((mime (cl-call-next-method)))
61 | (prog1 mime
62 | (cond
63 | ((and (eq mime :text/plain)
64 | (string= (jupyter-message-type msg) "inspect_reply"))
65 | (save-excursion
66 | (goto-char (point-min))
67 | (when (re-search-forward "^Docstring:" nil t)
68 | (jupyter-fontify-region-according-to-mode
69 | #'rst-mode (1+ (point))
70 | (or (and (re-search-forward "^\\(File\\|Type\\):" nil t)
71 | (line-beginning-position))
72 | (point-max))))))
73 | (t nil)))))
74 |
75 | (cl-defmethod jupyter-load-file-code (file &context (jupyter-lang python))
76 | (concat "%run \"" file "\""))
77 |
78 | ;;; `jupyter-org'
79 |
80 | (cl-defmethod jupyter-org-result ((_mime (eql :text/plain)) _content params
81 | &context (jupyter-lang python))
82 | (let ((result (cl-call-next-method)))
83 | (cond
84 | ((and (stringp result)
85 | (not (member "scalar" (alist-get :result-params params))))
86 | (org-babel-python-table-or-string result))
87 | (t result))))
88 |
89 | (cl-defmethod jupyter-org-error-location (&context (jupyter-lang python))
90 | (and (or (save-excursion (re-search-forward "^----> \\([0-9]+\\)" nil t))
91 | (re-search-forward "^[\t ]*File.+line \\([0-9]+\\)$" nil t))
92 | (string-to-number (match-string 1))))
93 |
94 | (defun jupyter-python-raw-string (code)
95 | "Construct a Python raw string from CODE.
96 | Return valid Python code that can be interpreted by Python as if
97 | CODE was a raw string in Python."
98 | (mapconcat
99 | (lambda (s)
100 | (let ((begin (if (string-prefix-p "\"" s)
101 | (if (string-prefix-p "\"\"" s)
102 | 2
103 | 1)
104 | 0))
105 | (end (if (string-suffix-p "\"" s)
106 | (if (string-suffix-p "\"\"" s)
107 | -2
108 | -1)
109 | nil)))
110 | (setq s (substring s begin end))
111 | (let ((slashes (when (string-match "\\(\\\\+\\)$" s)
112 | (prog1 (match-string 1 s)
113 | (setq s (substring s 0 (match-beginning 1)))))))
114 | (concat (cond
115 | ((= begin 2) "'\"\"' + ")
116 | ((= begin 1) "'\"' + ")
117 | (t ""))
118 | "r\"\"\"" s "\"\"\""
119 | (if slashes
120 | (concat " + '" (concat slashes slashes) "'")
121 | "")
122 | (cond
123 | ((null end) "")
124 | ((= end -2) " + '\"\"'")
125 | ((= end -1) " + '\"'"))))))
126 | (split-string code "\"\\{3\\}")
127 | " + '\"\"\"' + "))
128 |
129 | (cl-defmethod org-babel-jupyter-transform-code (code changelist &context (jupyter-lang python))
130 | (when (plist-get changelist :dir)
131 | (setq code
132 | (format "\
133 | import os
134 | __JUPY_saved_dir = os.getcwd()
135 | os.chdir(\"%s\")
136 | try:
137 | get_ipython().run_cell(%s)
138 | finally:
139 | os.chdir(__JUPY_saved_dir)"
140 | (plist-get changelist :dir)
141 | (jupyter-python-raw-string code))))
142 | code)
143 |
144 | (provide 'jupyter-python)
145 |
146 | ;;; jupyter-python.el ends here
147 |
--------------------------------------------------------------------------------
/jupyter-channel-ioloop.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-channel-ioloop.el --- Abstract class to communicate with a jupyter-channel in a subprocess -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 27 Jun 2019
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Define a `jupyter-ioloop' that can be sent events to start, stop, or send a
26 | ;; message on a set of `jupyter-channel' objects. For example to start a
27 | ;; `jupyter-channel' in the subprocess environment you would do something like
28 | ;;
29 | ;; (jupyter-send ioloop 'start-channel TYPE ENDPOINT)
30 | ;;
31 | ;; where TYPE and ENDPOINT have the same meaning as in `jupyter-channel'.
32 | ;;
33 | ;; Note by default, no channels are available in the subprocess environment.
34 | ;; You initialize channels by setting the `jupyter-channel-ioloop-channels'
35 | ;; variable in the subprocess environment, e.g. using
36 | ;; `jupyter-ioloop-add-setup', before starting the `jupyter-ioloop'.
37 | ;;
38 | ;; When you call `jupyter-ioloop-start' a `jupyter-session' object needs to
39 | ;; passed as the second argument with whatever object you would like to receive
40 | ;; events as the third. The `jupyter-session-id' will be used as the value of
41 | ;; the :identity key in the call to `jupyter-start' when starting a
42 | ;; channel.
43 | ;;
44 | ;; Each event sent to the subprocess will send back a corresponding
45 | ;; confirmation event, the three events that can be sent and their
46 | ;; corresponding confirmation events are:
47 | ;;
48 | ;; (start-channel TYPE ENDPOINT) -> (start-channel TYPE)
49 | ;; (stop-channel TYPE) -> (stop-channel TYPE)
50 | ;; (send TYPE MSG-TYPE MSG MSG-ID) -> (sent MSG-ID)
51 | ;;
52 | ;; For the send event, the MSG-TYPE, MSG, and MSG-ID have the same meaning as
53 | ;; the TYPE, MSG, and MSG-ID arguments of the `jupyter-send' method of a
54 | ;; `jupyter-channel'.
55 | ;;
56 | ;; Ex.
57 | ;;
58 | ;; (let ((ioloop (jupyter-channel-ioloop))
59 | ;; (session (jupyter-session :id ...)))
60 | ;; (jupyter-start-ioloop ioloop session ...)
61 | ;; ...
62 | ;; (jupyter-send ioloop 'start-channel ...)
63 | ;; ...)
64 |
65 | ;;; Code:
66 |
67 | (require 'jupyter-ioloop)
68 |
69 | (defvar jupyter-channel-ioloop-session nil
70 | "The `jupyter-session' used when initializing Jupyter channels.")
71 |
72 | (defvar jupyter-channel-ioloop-channels nil
73 | "A list of synchronous channels in an ioloop controlling Jupyter channels.")
74 |
75 | (jupyter-ioloop-add-arg-type jupyter-channel
76 | (lambda (arg)
77 | `(or (object-assoc ,arg :type jupyter-channel-ioloop-channels)
78 | (error "Channel not alive (%s)" ,arg))))
79 |
80 | (defclass jupyter-channel-ioloop (jupyter-ioloop)
81 | ()
82 | :abstract t)
83 |
84 | (cl-defmethod initialize-instance ((ioloop jupyter-channel-ioloop) &optional _slots)
85 | (cl-call-next-method)
86 | (jupyter-ioloop-add-setup ioloop
87 | (require 'jupyter-channel-ioloop))
88 | (jupyter-channel-ioloop-add-start-channel-event ioloop)
89 | (jupyter-channel-ioloop-add-stop-channel-event ioloop)
90 | (jupyter-channel-ioloop-add-send-event ioloop)
91 | (jupyter-ioloop-add-teardown ioloop
92 | (mapc #'jupyter-stop jupyter-channel-ioloop-channels)))
93 |
94 | (defun jupyter-channel-ioloop-set-session (ioloop session)
95 | "In the IOLOOP, set SESSION as the `jupyter-channel-ioloop-session'.
96 | Add a form to IOLOOP's setup that sets the variable
97 | `jupyter-channel-ioloop-session' to a `jupyter-session' based on
98 | SESSION's id and key. Remove any top level form in the setup that
99 | sets `jupyter-channel-ioloop-session' via `setq' before doing so."
100 | (cl-callf (lambda (setup)
101 | (cons `(setq jupyter-channel-ioloop-session
102 | (jupyter-session
103 | :id ,(jupyter-session-id session)
104 | :key ,(jupyter-session-key session)))
105 | (cl-remove-if
106 | (lambda (f) (and (eq (car f) 'setq)
107 | (eq (cadr f) 'jupyter-channel-ioloop-session)))
108 | setup)))
109 | (oref ioloop setup)))
110 |
111 | ;;; Channel events
112 |
113 | (defun jupyter-channel-ioloop-add-start-channel-event (ioloop)
114 | "Add a start-channel event handler to IOLOOP.
115 | The event fires when the IOLOOP receives a list with the form
116 |
117 | (start-channel CHANNEL-TYPE ENDPOINT)
118 |
119 | and shall stop any existing channel with CHANNEL-TYPE and start a
120 | new channel with CHANNEL-TYPE connected to ENDPOINT. The
121 | underlying socket IDENTITY is derived from
122 | `jupyter-channel-ioloop-session' in the IOLOOP environment. The
123 | channel will be added to the variable
124 | `jupyter-channel-ioloop-channels' in the IOLOOP environment.
125 |
126 | Note, before sending this event to IOLOOP, the corresponding
127 | channel needs to be available in the
128 | `jupyer-channel-ioloop-channels' variable. You can initialize
129 | this variable in the setup form of IOLOOP.
130 |
131 | A list with the form
132 |
133 | (start-channel CHANNEL-TYPE)
134 |
135 | is returned to the parent process."
136 | (jupyter-ioloop-add-event
137 | ioloop start-channel ((channel jupyter-channel) endpoint)
138 | ;; Stop the channel if it is already alive
139 | (when (jupyter-alive-p channel)
140 | (jupyter-stop channel))
141 | ;; Start the channel
142 | (oset channel endpoint endpoint)
143 | (let ((identity (jupyter-session-id jupyter-channel-ioloop-session)))
144 | (jupyter-start channel :identity identity))
145 | (list 'start-channel (oref channel type))))
146 |
147 | (defun jupyter-channel-ioloop-add-stop-channel-event (ioloop)
148 | "Add a stop-channel event handler to IOLOOP.
149 | The event fires when the IOLOOP receives a list with the form
150 |
151 | (stop-channel CHANNEL-TYPE)
152 |
153 | If a channel with CHANNEL-TYPE exists and is alive, it is stopped.
154 |
155 | A list with the form
156 |
157 | (stop-channel CHANNEL-TYPE)
158 |
159 | is returned to the parent process."
160 | (jupyter-ioloop-add-event ioloop stop-channel (type)
161 | (let ((channel (object-assoc type :type jupyter-channel-ioloop-channels)))
162 | (when (and channel (jupyter-alive-p channel))
163 | (jupyter-stop channel))
164 | (list 'stop-channel type))))
165 |
166 | (defun jupyter-channel-ioloop-add-send-event (ioloop)
167 | "Add a send event handler to IOLOOP.
168 | The event fires when the IOLOOP receives a list of the form
169 |
170 | (send CHANNEL-TYPE MSG-TYPE MSG MSG-ID)
171 |
172 | and calls (jupyter-send CHANNEL MSG-TYPE MSG MSG-ID) using the
173 | channel corresponding to CHANNEL-TYPE in the IOLOOP environment.
174 |
175 | A list of the form
176 |
177 | (sent CHANNEL-TYPE MSG-ID)
178 |
179 | is returned to the parent process."
180 | (jupyter-ioloop-add-event
181 | ioloop send ((channel jupyter-channel) msg-type msg msg-id)
182 | (list 'sent (oref channel type)
183 | (jupyter-send channel msg-type msg msg-id))))
184 |
185 | (provide 'jupyter-channel-ioloop)
186 |
187 | ;;; jupyter-channel-ioloop.el ends here
188 |
--------------------------------------------------------------------------------
/jupyter-env.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-env.el --- Query the jupyter shell command for information -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 27 Jun 2019
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Custom variables and functions related to calling the jupyter shell command
26 | ;; and its sub-commands for information.
27 |
28 | ;;; Code:
29 |
30 | (require 'jupyter-base)
31 | (eval-when-compile (require 'subr-x))
32 |
33 | (defvar jupyter-runtime-directory nil
34 | "The Jupyter runtime directory.
35 | When a new kernel is started through `jupyter-start-kernel', this
36 | directory is where kernel connection files are written to.
37 |
38 | This variable should not be used. To obtain the runtime directory
39 | call the function `jupyter-runtime-directory'.")
40 |
41 | (defcustom jupyter-executable "jupyter"
42 | "The `jupyter` command executable."
43 | :type 'string
44 | :group 'jupyter)
45 |
46 | (defun jupyter-command (&rest args)
47 | "Run a Jupyter shell command synchronously, return its output.
48 | The shell command run is
49 |
50 | jupyter ARGS...
51 |
52 | If the command fails or the jupyter shell command doesn't exist,
53 | return nil."
54 | (let ((stderr-file (make-temp-file "jupyter"))
55 | (stdout (get-buffer-create " *jupyter-command-stdout*")))
56 | (unwind-protect
57 | (let* ((status (apply #'process-file
58 | jupyter-executable
59 | nil
60 | (list stdout stderr-file)
61 | nil
62 | args))
63 | (buffer (find-file-noselect stderr-file)))
64 | (unwind-protect
65 | (with-current-buffer buffer
66 | (unless (eq (point-min) (point-max))
67 | (message "jupyter-command: Content written to stderr stream")
68 | (while (not (eq (point) (point-max)))
69 | (message " %s" (buffer-substring (line-beginning-position)
70 | (line-end-position)))
71 | (forward-line))))
72 | (kill-buffer buffer))
73 | (when (zerop status)
74 | (with-current-buffer stdout
75 | (string-trim-right (buffer-string)))))
76 | (delete-file stderr-file)
77 | (kill-buffer stdout))))
78 |
79 | (defun jupyter-runtime-directory ()
80 | "Return the runtime directory used by Jupyter.
81 | Create the directory if necessary. If `default-directory' is a
82 | remote directory, return the runtime directory on that remote.
83 |
84 | As a side effect, the variable `jupyter-runtime-directory' is set
85 | to the local runtime directory if it is nil."
86 | (unless jupyter-runtime-directory
87 | (setq jupyter-runtime-directory
88 | (let ((default-directory (expand-file-name "~" user-emacs-directory)))
89 | (file-name-as-directory (jupyter-command "--runtime-dir")))))
90 | (let ((dir (if (file-remote-p default-directory)
91 | (jupyter-command "--runtime-dir")
92 | jupyter-runtime-directory)))
93 | (unless dir
94 | (error "Can't obtain runtime directory from jupyter shell command"))
95 | (setq dir (concat (file-remote-p default-directory) dir))
96 | (make-directory dir 'parents)
97 | (file-name-as-directory dir)))
98 |
99 | (defun jupyter-locate-python ()
100 | "Return the path to the python executable in use by Jupyter.
101 | If the `default-directory' is a remote directory, search on that
102 | remote. Raise an error if the executable could not be found.
103 |
104 | The paths examined are the data paths of \"jupyter --paths\" in
105 | the order specified.
106 |
107 | This function always returns the `file-local-name' of the path."
108 | (let* ((remote (file-remote-p default-directory))
109 | (paths (mapcar (lambda (x) (concat remote x))
110 | (or (plist-get
111 | (jupyter-read-plist-from-string
112 | (jupyter-command "--paths" "--json"))
113 | :data)
114 | (error "Can't get search paths"))))
115 | (path nil))
116 | (cl-loop
117 | with programs = '("bin/python3" "bin/python"
118 | ;; Need to also check Windows since paths can be
119 | ;; pointing to local or remote files.
120 | "python3.exe" "python.exe")
121 | with pred = (lambda (dir)
122 | (cl-loop
123 | for program in programs
124 | for spath = (expand-file-name program dir)
125 | thereis (setq path (and (file-exists-p spath) spath))))
126 | for path in paths
127 | thereis (locate-dominating-file path pred)
128 | finally (error "No `python' found in search paths"))
129 | (file-local-name path)))
130 |
131 | (defun jupyter-write-connection-file (session)
132 | "Write a connection file based on SESSION to `jupyter-runtime-directory'.
133 | Return the path to the connection file."
134 | (cl-check-type session jupyter-session)
135 | (let* ((temporary-file-directory (jupyter-runtime-directory))
136 | (json-encoding-pretty-print t)
137 | (file (make-temp-file "emacs-kernel-" nil ".json")))
138 | (prog1 file
139 | (with-temp-file file
140 | (insert (json-encode-plist
141 | (jupyter-session-conn-info session)))))))
142 |
143 | (defun jupyter-session-with-random-ports ()
144 | "Return a `jupyter-session' with random channel ports.
145 | The session can be used to write a connection file, see
146 | `jupyter-write-connection-file'."
147 | ;; The actual work of making the connection file is left up to the
148 | ;; `jupyter kernel` shell command. This is done to support
149 | ;; launching remote kernels via TRAMP. The Jupyter suite of shell
150 | ;; commands probably exist on the remote system, so we rely on them
151 | ;; to figure out a set of open ports on the remote.
152 | (with-temp-buffer
153 | ;; NOTE: On Windows, apparently the "jupyter kernel" command uses something
154 | ;; like an exec shell command to start the process which launches the kernel,
155 | ;; but exec like commands on Windows start a new process instead of replacing
156 | ;; the current one which results in the process we start here exiting after
157 | ;; the new process is launched. We call python directly to avoid this.
158 | (let ((process (start-file-process
159 | "jupyter-session-with-random-ports" (current-buffer)
160 | (jupyter-locate-python) "-c"
161 | "from jupyter_client.kernelapp import main; main()")))
162 | (set-process-query-on-exit-flag process nil)
163 | (jupyter-with-timeout
164 | (nil jupyter-long-timeout
165 | (error "`jupyter kernel` failed to show connection file path"))
166 | (and (process-live-p process)
167 | (goto-char (point-min))
168 | (re-search-forward (rx "Connection file: "
169 | (group (+ any) ".json")
170 | (* whitespace) line-end)
171 | nil t)))
172 | (let* ((conn-file (concat
173 | (save-match-data
174 | (file-remote-p default-directory))
175 | (match-string 1)))
176 | (conn-info (jupyter-read-connection conn-file)))
177 | ;; Tell the `jupyter kernel` process to shutdown itself and
178 | ;; the launched kernel.
179 | (interrupt-process process)
180 | ;; Wait until the connection file is cleaned up before
181 | ;; forgetting about the process completely.
182 | (jupyter-with-timeout
183 | (nil (if (file-remote-p conn-file) 0 jupyter-default-timeout)
184 | (delete-file conn-file))
185 | (not (file-exists-p conn-file)))
186 | (delete-process process)
187 | (let ((new-key (jupyter-new-uuid)))
188 | (plist-put conn-info :key new-key)
189 | (jupyter-session
190 | :conn-info conn-info
191 | :key new-key))))))
192 |
193 | (provide 'jupyter-env)
194 |
195 | ;;; jupyter-env.el ends here
196 |
--------------------------------------------------------------------------------
/jupyter-julia.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-julia.el --- Jupyter support for Julia -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 23 Oct 2018
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Support methods for integration with Julia.
26 |
27 | ;;; Code:
28 |
29 | (eval-when-compile (require 'subr-x))
30 | (require 'jupyter-repl)
31 |
32 | (declare-function julia-indent-line "ext:julia-mode" ())
33 |
34 | (cl-defmethod jupyter-indent-line (&context (major-mode julia-mode))
35 | "Call `julia-indent-line'."
36 | (julia-indent-line))
37 |
38 | (cl-defmethod jupyter-load-file-code (file &context (jupyter-lang julia))
39 | (format "include(\"%s\");" file))
40 |
41 | ;;; Completion
42 |
43 | (cl-defmethod jupyter-completion-prefix (&context (jupyter-lang julia))
44 | (cond
45 | ;; Completing argument lists
46 | ((and (char-before)
47 | (eq (char-syntax (char-before)) ?\()
48 | (or (not (char-after))
49 | (looking-at-p "\\_>")
50 | (not (memq (char-syntax (char-after)) '(?w ?_)))))
51 | (buffer-substring-no-properties
52 | (jupyter-completion-symbol-beginning (1- (point)))
53 | (point)))
54 | (t
55 | (let ((prefix (cl-call-next-method "\\\\\\|\\.\\|::?" 2)))
56 | (prog1 prefix
57 | (when (consp prefix)
58 | (let ((beg (- (point) (length (car prefix)))))
59 | (cond
60 | ;; Include the \ in the prefix so it gets replaced if a canidate is
61 | ;; selected.
62 | ((eq (char-before beg) ?\\)
63 | (setcar prefix (concat "\\" (car prefix))))
64 | ;; Also include : to complete symbols when used as dictionary keys
65 | ((and (eq (char-before beg) ?:)
66 | (not (eq (char-before (1- beg)) ?:))
67 | ;; Except for when it is part of range expressions like 1:len
68 | (not (memq (char-syntax (char-before (1- beg))) '(?w ?_))))
69 | (setcar prefix (concat ":" (car prefix))))))))))))
70 |
71 | (cl-defmethod jupyter-completion-post-completion (candidate
72 | &context (jupyter-lang julia))
73 | "Insert the unicode representation of a LaTeX completion."
74 | (if (eq (aref candidate 0) ?\\)
75 | (when (get-text-property 0 'annot candidate)
76 | (search-backward candidate)
77 | (delete-region (point) (match-end 0))
78 | ;; Alternatively use `julia-latexsub-or-indent', but I have found
79 | ;; problems with that.
80 | (insert (string-trim (get-text-property 0 'annot candidate))))
81 | (cl-call-next-method)))
82 |
83 | ;;; `markdown-mode'
84 |
85 | (cl-defmethod jupyter-markdown-follow-link (link-text url _ref-label _title-text _bang
86 | &context (jupyter-lang julia))
87 | "Send a help query to the Julia REPL for LINK-TEXT if URL is \"@ref\".
88 | If URL is \"@ref \" then open a browser to the Julia
89 | manual for . Otherwise follow the link normally."
90 | (if (string-prefix-p "@ref" url)
91 | (if (string= url "@ref")
92 | ;; Links have the form `fun`
93 | (let ((fun (substring link-text 1 -1)))
94 | (if (not (derived-mode-p 'jupyter-repl-mode))
95 | (jupyter-inspect fun (1- (length fun)))
96 | (goto-char (point-max))
97 | (jupyter-repl-replace-cell-code (concat "?" fun))
98 | (jupyter-repl-ret)))
99 | (let* ((ref (split-string url))
100 | (section (cadr ref)))
101 | (browse-url
102 | (format "https://docs.julialang.org/en/latest/manual/%s/" section))))
103 | (cl-call-next-method)))
104 |
105 | ;;; `jupyter-repl-after-change'
106 |
107 | (defvar ansi-color-names-vector)
108 |
109 | (defun jupyter-julia-add-prompt (prompt color)
110 | "Display PROMPT at the beginning of the cell using COLOR as the foreground.
111 | Make the character after `point' invisible."
112 | (add-text-properties (point) (1+ (point)) '(invisible t rear-nonsticky t))
113 | (let ((ov (make-overlay (point) (1+ (point)) nil t))
114 | (md (propertize prompt
115 | 'fontified t
116 | 'font-lock-face `((:foreground ,color)))))
117 | (overlay-put ov 'after-string (propertize " " 'display md))
118 | (overlay-put ov 'evaporate t)))
119 |
120 | (defun jupyter-julia-pkg-prompt ()
121 | "Return the Pkg prompt.
122 | If the Pkg prompt can't be retrieved from the kernel, return
123 | nil."
124 | (let ((prompt-code "import Pkg; Pkg.REPLMode.promptf()"))
125 | (jupyter-run-with-client jupyter-current-client
126 | (jupyter-mlet* ((msg
127 | (jupyter-reply
128 | (jupyter-execute-request
129 | :code ""
130 | :silent t
131 | :user-expressions (list :prompt prompt-code)))))
132 | (cl-destructuring-bind (&key prompt &allow-other-keys)
133 | (jupyter-message-get msg :user_expressions)
134 | (cl-destructuring-bind (&key status data &allow-other-keys)
135 | prompt
136 | (jupyter-return
137 | (when (equal status "ok")
138 | (plist-get data :text/plain)))))))))
139 |
140 | (cl-defmethod jupyter-repl-after-change ((_type (eql insert)) beg _end
141 | &context (jupyter-lang julia))
142 | "Change the REPL prompt when a REPL mode is entered."
143 | (when (= beg (jupyter-repl-cell-code-beginning-position))
144 | (save-excursion
145 | (goto-char beg)
146 | (when (and (bound-and-true-p blink-paren-function)
147 | (eq (char-syntax (char-after)) ?\)))
148 | ;; Spoof `last-command-event' so that a "No matching paren" message
149 | ;; doesn't happen.
150 | (setq last-command-event ?\[))
151 | (cl-case (char-after)
152 | (?\]
153 | (when-let* ((pkg-prompt (jupyter-julia-pkg-prompt)))
154 | (jupyter-julia-add-prompt
155 | (substring pkg-prompt 1 (1- (length pkg-prompt)))
156 | (aref ansi-color-names-vector 5)))) ; magenta
157 | (?\;
158 | (jupyter-julia-add-prompt
159 | "shell> " (aref ansi-color-names-vector 1))) ; red
160 | (?\?
161 | (jupyter-julia-add-prompt
162 | "help?> " (aref ansi-color-names-vector 3)))))) ; yellow
163 | (cl-call-next-method))
164 |
165 | (cl-defmethod jupyter-repl-after-change ((_type (eql delete)) beg _len
166 | &context (jupyter-lang julia))
167 | "Reset the prompt if needed."
168 | (when (= beg (jupyter-repl-cell-code-beginning-position))
169 | (jupyter-repl-cell-reset-prompt)))
170 |
171 | ;;; REPL font lock
172 |
173 | (defun jupyter-julia--propertize-repl-mode-char (beg end)
174 | (jupyter-repl-map-cells beg end
175 | (lambda ()
176 | ;; Handle Julia package prompt so `syntax-ppss' works properly.
177 | (when (and (eq (char-syntax (char-after (point-min))) ?\))
178 | (= (point-min)
179 | (save-restriction
180 | (widen)
181 | ;; Looks at the position before the narrowed cell-code
182 | ;; which is why the widen is needed here.
183 | (jupyter-repl-cell-code-beginning-position))))
184 | (put-text-property
185 | (point-min) (1+ (point-min)) 'syntax-table '(1 . ?.))))
186 | #'ignore))
187 |
188 | ;;; `jupyter-repl-after-init'
189 |
190 | (defun jupyter-julia--setup-hooks (client)
191 | (jupyter-run-with-client client
192 | (jupyter-sent
193 | (jupyter-execute-request
194 | :handlers nil
195 | :store-history nil
196 | :silent t
197 | ;; This is mainly for supporting the :dir header argument in
198 | ;; `org-mode' source blocks.
199 | :code "\
200 | if !isdefined(Main, :__JUPY_saved_dir)
201 | Core.eval(Main, :(__JUPY_saved_dir = Ref(\"\")))
202 | let popdir = () -> begin
203 | if !isempty(Main.__JUPY_saved_dir[])
204 | cd(Main.__JUPY_saved_dir[])
205 | Main.__JUPY_saved_dir[] = \"\"
206 | end
207 | end
208 | IJulia.push_posterror_hook(popdir)
209 | IJulia.push_postexecute_hook(popdir)
210 | end
211 | end"))))
212 |
213 | (cl-defmethod jupyter-repl-after-init (&context (jupyter-lang julia))
214 | (if syntax-propertize-function
215 | (add-function
216 | :after (local 'syntax-propertize-function)
217 | #'jupyter-julia--propertize-repl-mode-char)
218 | (setq-local syntax-propertize-function #'jupyter-julia--propertize-repl-mode-char))
219 | (jupyter-julia--setup-hooks jupyter-current-client)
220 | ;; Setup hooks after restart as well
221 | (jupyter-add-hook jupyter-current-client 'jupyter-iopub-message-hook
222 | (lambda (client msg)
223 | (when (jupyter-message-status-starting-p msg)
224 | (jupyter-julia--setup-hooks client)))))
225 |
226 | ;;; `jupyter-org'
227 |
228 | (cl-defmethod jupyter-org-error-location (&context (jupyter-lang julia))
229 | (when (and (re-search-forward "^Stacktrace:" nil t)
230 | (re-search-forward "top-level scope" nil t)
231 | (re-search-forward "In\\[[0-9]+\\]:\\([0-9]+\\)" nil t))
232 | (string-to-number (match-string 1))))
233 |
234 | (cl-defmethod org-babel-jupyter-transform-code (code changelist &context (jupyter-lang julia))
235 | (when (plist-get changelist :dir)
236 | (setq code
237 | ;; Stay on one line so that tracebacks will report the right line
238 | ;; numbers
239 | (format "Main.__JUPY_saved_dir[] = pwd(); cd(\"%s\"); %s"
240 | (plist-get changelist :dir) code)))
241 | code)
242 |
243 | (provide 'jupyter-julia)
244 |
245 | ;;; jupyter-julia.el ends here
246 |
--------------------------------------------------------------------------------
/jupyter-zmq-channel.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-zmq-channel.el --- A Jupyter channel implementation using ZMQ sockets -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 27 Jun 2019
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Implements synchronous channel types using ZMQ sockets. Each channel is
26 | ;; essentially a wrapper around a `zmq-socket' constrained to a socket type by
27 | ;; the type of the channel and with an associated `zmq-IDENTITY' obtained from
28 | ;; the `jupyter-session' that must be associated with the channel. A heartbeat
29 | ;; channel is distinct from the other channels in that it is implemented using
30 | ;; a timer which periodically pings the kernel depending on how its configured.
31 | ;; In order for communication to occur on the other channels, one of
32 | ;; `jupyter-send' or `jupyter-recv' must be called after starting the channel
33 | ;; with `jupyter-start'.
34 |
35 | ;;; Code:
36 |
37 | (require 'jupyter-messages)
38 | (require 'zmq)
39 | (eval-and-compile (zmq-load))
40 | (require 'jupyter-channel)
41 | (eval-when-compile (require 'subr-x))
42 |
43 | (declare-function jupyter-ioloop-poller-remove "jupyter-ioloop")
44 | (declare-function jupyter-ioloop-poller-add "jupyter-ioloop")
45 |
46 | (defconst jupyter-socket-types
47 | (list :hb zmq-REQ
48 | :shell zmq-DEALER
49 | :iopub zmq-SUB
50 | :stdin zmq-DEALER
51 | :control zmq-DEALER)
52 | "The socket types for the various channels used by `jupyter'.")
53 |
54 | (cl-deftype zmq-socket () '(satisfies zmq-socket-p))
55 |
56 | (defclass jupyter-zmq-channel (jupyter-channel)
57 | ((socket
58 | :type (or null zmq-socket)
59 | :initform nil
60 | :documentation "The socket used for communicating with the kernel.")))
61 |
62 | (defun jupyter-connect-endpoint (type endpoint &optional identity)
63 | "Create socket with TYPE and connect to ENDPOINT.
64 | If IDENTITY is non-nil, it will be set as the ROUTING-ID of the
65 | socket. Return the created socket."
66 | (let ((sock (zmq-socket (zmq-current-context) type)))
67 | (prog1 sock
68 | (zmq-socket-set sock zmq-LINGER 1000)
69 | (when identity
70 | (zmq-socket-set sock zmq-ROUTING-ID identity))
71 | (zmq-connect sock endpoint))))
72 |
73 | (defun jupyter-connect-channel (ctype endpoint &optional identity)
74 | "Create a socket based on a Jupyter channel type.
75 | CTYPE is one of the symbols `:hb', `:stdin', `:shell',
76 | `:control', or `:iopub' and represents the type of channel to
77 | connect to ENDPOINT. If IDENTITY is non-nil, it will be set as
78 | the ROUTING-ID of the socket. Return the created socket."
79 | (let ((sock-type (plist-get jupyter-socket-types ctype)))
80 | (unless sock-type
81 | (error "Invalid channel type (%s)" ctype))
82 | (jupyter-connect-endpoint sock-type endpoint identity)))
83 |
84 | (cl-defmethod jupyter-start ((channel jupyter-zmq-channel)
85 | &key (identity (jupyter-session-id
86 | (oref channel session))))
87 | (unless (jupyter-alive-p channel)
88 | (let ((socket (jupyter-connect-channel
89 | (oref channel type) (oref channel endpoint) identity)))
90 | (oset channel socket socket)
91 | (cl-case (oref channel type)
92 | (:iopub
93 | (zmq-socket-set socket zmq-SUBSCRIBE ""))))
94 | (when (and (functionp 'jupyter-ioloop-environment-p)
95 | (jupyter-ioloop-environment-p))
96 | (jupyter-ioloop-poller-add (oref channel socket) zmq-POLLIN))))
97 |
98 | (cl-defmethod jupyter-stop ((channel jupyter-zmq-channel))
99 | (when (jupyter-alive-p channel)
100 | (when (and (functionp 'jupyter-ioloop-environment-p)
101 | (jupyter-ioloop-environment-p))
102 | (jupyter-ioloop-poller-remove (oref channel socket)))
103 | (with-slots (socket) channel
104 | (zmq-disconnect socket (zmq-socket-get socket zmq-LAST-ENDPOINT)))
105 | (oset channel socket nil)))
106 |
107 | (cl-defmethod jupyter-alive-p ((channel jupyter-zmq-channel))
108 | (not (null (oref channel socket))))
109 |
110 | (cl-defmethod jupyter-send ((channel jupyter-zmq-channel) type message &optional msg-id)
111 | "Send a message on a ZMQ based Jupyter channel.
112 | CHANNEL is the channel to send MESSAGE on. TYPE is a Jupyter
113 | message type, like :kernel-info-request. Return the message ID
114 | of the sent message."
115 | (cl-destructuring-bind (id . msg)
116 | (jupyter-encode-message (oref channel session) type
117 | :msg-id msg-id
118 | :content message)
119 | (prog1 id
120 | (zmq-send-multipart (oref channel socket) msg))))
121 |
122 | (cl-defmethod jupyter-recv ((channel jupyter-zmq-channel) &optional dont-wait)
123 | "Receive a message on CHANNEL.
124 | Return a cons cell (IDENTS . MSG) where IDENTS are the ZMQ
125 | message identities, as a list, and MSG is the received message.
126 |
127 | If DONT-WAIT is non-nil, return immediately without waiting for a
128 | message if one isn't already available."
129 | (condition-case nil
130 | (let ((session (oref channel session))
131 | (msg (zmq-recv-multipart (oref channel socket)
132 | (and dont-wait zmq-DONTWAIT))))
133 | (when msg
134 | (cl-destructuring-bind (idents . parts)
135 | (jupyter--split-identities msg)
136 | (cons idents (jupyter-decode-message session parts)))))
137 | (zmq-EAGAIN nil)))
138 |
139 | ;;; Heartbeat channel
140 |
141 | (defvar jupyter-hb-max-failures 3
142 | "Number of heartbeat failures until the kernel is considered unreachable.
143 | A ping is sent to the kernel on a heartbeat channel and waits
144 | until `time-to-dead' seconds to see if the kernel sent a ping
145 | back. If the kernel doesn't send a ping back after
146 | `jupyter-hb-max-failures', the callback associated with the
147 | heartbeat channel is called. See `jupyter-hb-on-kernel-dead'.")
148 |
149 | (defclass jupyter-hb-channel (jupyter-zmq-channel)
150 | ((type
151 | :type keyword
152 | :initform :hb
153 | :documentation "The type of this channel is `:hb'.")
154 | (time-to-dead
155 | :type number
156 | :initform 10
157 | :documentation "The time in seconds to wait for a response
158 | from the kernel until the connection is assumed to be dead. Note
159 | that this slot only takes effect when starting the channel.")
160 | (dead-cb
161 | :type function
162 | :initform #'ignore
163 | :documentation "A callback function that takes 0 arguments
164 | and is called when the kernel has not responded for
165 | \(* `jupyter-hb-max-failures' `time-to-dead'\) seconds.")
166 | (beating
167 | :type (or boolean symbol)
168 | :initform t
169 | :documentation "A flag variable indicating that the heartbeat
170 | channel is communicating with the kernel.")
171 | (paused
172 | :type boolean
173 | :initform t
174 | :documentation "A flag variable indicating that the heartbeat
175 | channel is paused and not communicating with the kernel. To
176 | pause the heartbeat channel use `jupyter-hb-pause', to unpause
177 | use `jupyter-hb-unpause'."))
178 | :documentation "A base class for heartbeat channels.")
179 |
180 | (cl-defmethod jupyter-alive-p ((channel jupyter-hb-channel))
181 | "Return non-nil if CHANNEL is alive."
182 | (zmq-socket-p (oref channel socket)))
183 |
184 | (defun jupyter-hb--pingable-p (channel)
185 | (and (not (oref channel paused))
186 | (jupyter-alive-p channel)))
187 |
188 | (cl-defmethod jupyter-hb-beating-p ((channel jupyter-hb-channel))
189 | "Return non-nil if CHANNEL is reachable."
190 | (and (jupyter-hb--pingable-p channel)
191 | (oref channel beating)))
192 |
193 | (cl-defmethod jupyter-hb-pause ((channel jupyter-hb-channel))
194 | "Pause checking for heartbeat events on CHANNEL."
195 | (oset channel paused t))
196 |
197 | (cl-defmethod jupyter-hb-unpause ((channel jupyter-hb-channel))
198 | "Un-pause checking for heatbeat events on CHANNEL."
199 | (when (oref channel paused)
200 | (if (jupyter-alive-p channel)
201 | ;; Consume a pending message from the kernel if there is one. We send a
202 | ;; ping and then schedule a timer which fires TIME-TO-DEAD seconds
203 | ;; later to receive the ping back from the kernel and start the process
204 | ;; all over again. If the channel is paused before TIME-TO-DEAD
205 | ;; seconds, there may still be a ping from the kernel waiting.
206 | (ignore-errors (zmq-recv (oref channel socket) zmq-DONTWAIT))
207 | (jupyter-start channel))
208 | (oset channel paused nil)
209 | (jupyter-hb--send-ping channel)))
210 |
211 | (cl-defgeneric jupyter-hb-on-kernel-dead (channel fun)
212 | (declare (indent 1)))
213 |
214 | (cl-defmethod jupyter-hb-on-kernel-dead ((channel jupyter-hb-channel) fun)
215 | "When the kernel connected to CHANNEL dies, call FUN.
216 | A kernel is considered dead when CHANNEL does not receive a
217 | response after \(* `jupyter-hb-max-failures' `time-to-dead'\)
218 | seconds has elapsed without the kernel sending a ping back."
219 | (oset channel dead-cb fun))
220 |
221 | (defun jupyter-hb--send-ping (channel &optional failed-count)
222 | (when (jupyter-hb--pingable-p channel)
223 | (condition-case nil
224 | (progn
225 | (zmq-send (oref channel socket) "ping")
226 | (run-with-timer
227 | (oref channel time-to-dead) nil
228 | (lambda ()
229 | (when-let* ((sock (and (jupyter-hb--pingable-p channel)
230 | (oref channel socket))))
231 | (oset channel beating
232 | (condition-case nil
233 | (and (zmq-recv sock zmq-DONTWAIT) t)
234 | ((zmq-EINTR zmq-EAGAIN) nil)))
235 | (if (oref channel beating)
236 | (jupyter-hb--send-ping channel)
237 | ;; Reset the socket
238 | (jupyter-stop channel)
239 | (jupyter-start channel)
240 | (or failed-count (setq failed-count 0))
241 | (if (< failed-count jupyter-hb-max-failures)
242 | (jupyter-hb--send-ping channel (1+ failed-count))
243 | (oset channel paused t)
244 | (when (functionp (oref channel dead-cb))
245 | (funcall (oref channel dead-cb)))))))))
246 | ;; FIXME: Should be a part of `jupyter-hb--pingable-p'
247 | (zmq-ENOTSOCK
248 | (jupyter-hb-pause channel)
249 | (oset channel socket nil)))))
250 |
251 | (provide 'jupyter-zmq-channel)
252 |
253 | ;;; jupyter-zmq-channel.el ends here
254 |
--------------------------------------------------------------------------------
/jupyter-kernelspec.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-kernelspec.el --- Jupyter kernelspecs -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 17 Jan 2018
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Functions to work with kernelspecs found by the shell command
26 | ;;
27 | ;; jupyter kernelspec list
28 |
29 | ;;; Code:
30 |
31 | (require 'json)
32 | (require 'jupyter-env)
33 |
34 | (defgroup jupyter-kernelspec nil
35 | "Jupyter kernelspecs"
36 | :group 'jupyter)
37 |
38 | (declare-function jupyter-read-plist "jupyter-base" (file))
39 | (declare-function jupyter-read-plist-from-string "jupyter-base" (file))
40 |
41 | (cl-defstruct jupyter-kernelspec
42 | (name "python"
43 | :type string
44 | :documentation "The name of the kernelspec."
45 | :read-only t)
46 | (plist nil
47 | :type list
48 | :documentation "The kernelspec as a property list."
49 | :read-only t)
50 | (resource-directory nil
51 | :type (or null string)
52 | :documentation "The resource directory."
53 | :read-only t))
54 |
55 | (defvar jupyter--kernelspecs (make-hash-table :test #'equal :size 5)
56 | "A hash table mapping hosts to the kernelspecs available on them.
57 | The top level hash-table maps hosts to nested hash-tables keyed
58 | on virtual environment path or nil for a system-wide Jupyter
59 | install: hosts[hash-table] -> venv[hash-table] -> kernelspecs.")
60 |
61 | (defun jupyter-kernelspecs-ensure-cache (host)
62 | "Return, creating if necessary, the hash-table for HOST."
63 | (let ((cache (gethash host jupyter--kernelspecs)))
64 | (if cache cache
65 | (puthash host (make-hash-table :test #'equal :size 5)
66 | jupyter--kernelspecs))))
67 |
68 | (defun jupyter-kernelspecs-cache-put (host kernelspecs)
69 | "Cache KERNELSPECS available on HOST.
70 | This takes into account any currently active virtual
71 | environment."
72 | (let ((venv (getenv "VIRTUAL_ENV")))
73 | (let ((cache (jupyter-kernelspecs-ensure-cache host)))
74 | (puthash venv kernelspecs cache))))
75 |
76 | (defun jupyter-kernelspecs-cache-get (host)
77 | "Retrieve cached KERNELSPECS available on HOST.
78 | This takes into account any currently active virtual
79 | environment."
80 | (let ((venv (getenv "VIRTUAL_ENV")))
81 | (let ((cache (jupyter-kernelspecs-ensure-cache host)))
82 | (gethash venv cache))))
83 |
84 | (defun jupyter-available-kernelspecs (&optional refresh)
85 | "Return the available kernelspecs.
86 | Return a list of `jupyter-kernelspec's available on the host
87 | associated with the `default-directory'. If `default-directory'
88 | is a remote file name, return the list of available kernelspecs
89 | on the remote system. The kernelspecs on the local system are
90 | returned otherwise (taking into account any currently active
91 | virtual environment).
92 |
93 | On any system, the list is formed by parsing the output of the
94 | shell command
95 |
96 | jupyter kernelspec list --json
97 |
98 | By default the available kernelspecs are cached. To force an
99 | update of the cached kernelspecs, give a non-nil value to
100 | REFRESH."
101 | (let* ((host (or (file-remote-p default-directory) "local"))
102 | (kernelspecs
103 | (or (and (not refresh) (jupyter-kernelspecs-cache-get host))
104 | (let ((specs
105 | (plist-get
106 | (let ((json (or (jupyter-command "kernelspec" "list"
107 | "--json" "--log-level" "ERROR")
108 | (error "\
109 | Can't obtain kernelspecs from jupyter shell command"))))
110 | (condition-case nil
111 | (jupyter-read-plist-from-string json)
112 | (error
113 | (error "\
114 | Jupyter kernelspecs couldn't be parsed from
115 |
116 | jupyter kernelspec list --json
117 |
118 | To investiagate further, run that command in a shell and examine
119 | why it isn't returning valid JSON."))))
120 | :kernelspecs)))
121 | (jupyter-kernelspecs-cache-put
122 | host
123 | (sort
124 | (cl-loop
125 | for (kname spec) on specs by #'cddr
126 | for name = (substring (symbol-name kname) 1)
127 | for dir = (plist-get spec :resource_dir)
128 | collect (make-jupyter-kernelspec
129 | :name name
130 | :resource-directory (concat
131 | (unless (string= host "local") host)
132 | dir)
133 | :plist (plist-get spec :spec)))
134 | (lambda (x y)
135 | (string< (jupyter-kernelspec-name x)
136 | (jupyter-kernelspec-name y)))))))))
137 | kernelspecs))
138 |
139 | (cl-defgeneric jupyter-kernelspecs (host &optional refresh)
140 | "Return a list of kernelspecs on HOST.
141 | If REFRESH is non-nil, then refresh the list of cached
142 | kernelspecs first. Otherwise a cached version of the kernelspecs
143 | may be returned.")
144 |
145 | (cl-defmethod jupyter-kernelspecs ((host string) &optional refresh)
146 | (let ((default-directory host))
147 | (jupyter-available-kernelspecs refresh)))
148 |
149 | (cl-defmethod jupyter-do-refresh-kernelspecs ()
150 | (jupyter-kernelspecs default-directory 'refresh))
151 |
152 | ;;;###autoload
153 | (defun jupyter-refresh-kernelspecs ()
154 | "Refresh the list of available kernelspecs.
155 | Execute this command if the kernelspecs seen by Emacs is out of
156 | sync with those specified on your system or notebook server."
157 | (interactive)
158 | (message "Refreshing kernelspecs...")
159 | (jupyter-do-refresh-kernelspecs)
160 | (message "Refreshing kernelspecs...done"))
161 |
162 | (defun jupyter-get-kernelspec (name &optional specs refresh)
163 | "Get the kernelspec for a kernel named NAME.
164 | If no kernelspec is found, return nil. Otherwise return the
165 | kernelspec for the kernel named NAME.
166 |
167 | If SPECS is provided, it is a list of kernelspecs that will be
168 | searched. Otherwise the kernelspecs associated with the
169 | `default-directory' are used.
170 |
171 | Optional argument REFRESH has the same meaning as in
172 | `jupyter-kernelspecs'."
173 | (cl-loop
174 | for kernelspec in (or specs (jupyter-kernelspecs default-directory refresh))
175 | thereis (when (string= (jupyter-kernelspec-name kernelspec) name)
176 | kernelspec)))
177 |
178 | (defun jupyter-find-kernelspecs (re &optional specs refresh)
179 | "Find all specs of kernels that have names matching RE.
180 | RE is a regular expression use to match the name of a kernel.
181 | Return a list of `jupyter-kernelspec' objects.
182 |
183 | If SPECS is non-nil search SPECS, otherwise search the
184 | kernelspecs associated with the `default-directory'.
185 |
186 | Optional argument REFRESH has the same meaning as in
187 | `jupyter-kernelspecs'."
188 | (cl-remove-if-not
189 | (lambda (kernelspec)
190 | (string-match-p re (jupyter-kernelspec-name kernelspec)))
191 | (or specs (jupyter-kernelspecs default-directory refresh))))
192 |
193 | (defun jupyter-guess-kernelspec (name &optional specs refresh)
194 | "Return the first kernelspec starting with NAME.
195 | Raise an error if no kernelspec could be found.
196 |
197 | SPECS and REFRESH have the same meaning as in
198 | `jupyter-find-kernelspecs'."
199 | (or (car (jupyter-find-kernelspecs (format "^%s" name) specs refresh))
200 | (error "No valid kernelspec for kernel name (%s)" name)))
201 |
202 | (defun jupyter-completing-read-kernelspec (&optional specs refresh)
203 | "Use `completing-read' to select a kernel and return its kernelspec.
204 |
205 | SPECS is a list of kernelspecs that will be used for completion,
206 | if it is nil the kernelspecs associated with the
207 | `default-directory' will be used.
208 |
209 | Optional argument REFRESH has the same meaning as in
210 | `jupyter-kernelspecs'."
211 | (let* ((specs (or specs (jupyter-kernelspecs default-directory refresh)))
212 | (display-names (if (null specs) (error "No kernelspecs available")
213 | (mapcar (lambda (k)
214 | (plist-get
215 | (jupyter-kernelspec-plist k)
216 | :display_name))
217 | specs)))
218 | (name (completing-read "kernel: " display-names nil t)))
219 | (when (equal name "")
220 | (error "No kernelspec selected"))
221 | (nth (- (length display-names)
222 | (length (member name display-names)))
223 | specs)))
224 |
225 | (defun jupyter-expand-environment-variables (var)
226 | "Return VAR with all environment variables expanded.
227 | VAR is a string, if VAR contains a sequence of characters like
228 | ${ENV_VAR}, substitute it with the value of ENV_VAR in
229 | `process-environment'."
230 | (let ((expanded "")
231 | (start 0))
232 | (while (string-match "\\${\\([^}]+\\)}" var start)
233 | (cl-callf concat expanded
234 | (substring var start (match-beginning 0))
235 | (getenv (match-string 1 var)))
236 | (setq start (match-end 0)))
237 | (cl-callf concat expanded (substring var start))))
238 |
239 | (defun jupyter-process-environment (kernelspec)
240 | "Return a list of environment variables contained in KERNELSPEC.
241 | The list of environment variables have the same form as the
242 | entries in `process-environment'.
243 |
244 | The environment variables returned are constructed from those in
245 | the :env key of KERNELSPEC's property list."
246 | (cl-loop
247 | with env = (plist-get (jupyter-kernelspec-plist kernelspec) :env)
248 | for (k v) on env by #'cddr
249 | collect (format "%s=%s" (cl-subseq (symbol-name k) 1)
250 | (jupyter-expand-environment-variables v))))
251 |
252 | (defun jupyter-kernel-argv (kernelspec conn-file)
253 | "Return a list of process arguments contained in KERNELSPEC.
254 | The process arguments are the ones that should be passed to
255 | kernel processes launched using KERNELSPEC.
256 |
257 | CONN-FILE is the file name of a connection file, containing the
258 | IP address and ports (among other things), a
259 | launched kernel should connect to."
260 | (cl-loop
261 | with argv = (plist-get (jupyter-kernelspec-plist kernelspec) :argv)
262 | for arg in (append argv nil)
263 | if (equal arg "{connection_file}")
264 | collect (file-local-name conn-file)
265 | else if (equal arg "{resource_dir}")
266 | collect (file-local-name
267 | (jupyter-kernelspec-resource-directory
268 | kernelspec))
269 | else collect arg))
270 |
271 | (provide 'jupyter-kernelspec)
272 |
273 | ;;; jupyter-kernelspec.el ends here
274 |
--------------------------------------------------------------------------------
/jupyter-widget-client.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-widget-client.el --- Widget support -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2018-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 21 May 2018
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Use an external browser to interact with Jupyter widgets.
26 | ;;
27 | ;; A `jupyter-kernel-client' does not come with any widget support by default,
28 | ;; the purpose of the `jupyter-widget-client' class is to provide such support.
29 | ;; This is done by opening an external browser and serving it the necessary
30 | ;; resources to display widgets using the `simple-httpd' package. Emacs then
31 | ;; acts as an intermediary for the widget comm messages sent between the
32 | ;; browser and the kernel, communicating with the kernel through `zmq' and with
33 | ;; the browser through `websocket'.
34 | ;;
35 | ;; To add widget support to a client, subclass `jupyter-widget-client'.
36 |
37 | ;;; Code:
38 |
39 | (require 'simple-httpd)
40 | (require 'websocket)
41 | (require 'jupyter-client)
42 |
43 | (defvar jupyter-widgets-initialized nil
44 | "A client local variable that is non-nil if a browser for widgets is opened.")
45 |
46 | (defvar jupyter-widgets-server nil
47 | "The `websocket-server' redirecting kernel messages.")
48 |
49 | (defvar jupyter-widgets-port 8090
50 | "The port that `jupyter-widgets-server' listens on.")
51 |
52 | (defvar jupyter-widgets-supported-targets '("jupyter.widget")
53 | "A list of the supported widget target names.")
54 |
55 | (defvar jupyter-widgets-url-format
56 | "http://127.0.0.1:%d/jupyter/widgets?username=%s&clientId=%s&port=%d"
57 | "Format of the URL that will be visited to display widgets.")
58 |
59 | (defclass jupyter-widget-client (jupyter-kernel-client)
60 | ((widget-sock
61 | :type (or null websocket)
62 | :initform nil
63 | :documentation "The `websocket' connected to the browser
64 | displaying the widgets for this client.")
65 | (widget-state
66 | :type string
67 | :initform "null"
68 | :documentation "The JSON encode string representing the
69 | widget state. When a browser displaying the widgets of the client
70 | is closed, the state of the widgets is sent back to Emacs so that
71 | the state can be recovred when a new browser is opened.")
72 | (widget-messages
73 | :type list
74 | :initform nil
75 | :documentation "A list of pending messages to send to the
76 | widget socket."))
77 | :abstract t)
78 |
79 | ;;; Websocket handlers
80 |
81 | (defsubst jupyter-widgets--send-deferred (client)
82 | (cl-loop for msg in (nreverse (oref client widget-messages))
83 | do (websocket-send-text (oref client widget-sock) msg))
84 | (oset client widget-messages nil))
85 |
86 | (defun jupyter-widgets-on-message (ws frame)
87 | "When websocket, WS, receives a message FRAME, handle it.
88 | Send the contents of the message FRAME to the kernel and register
89 | callbacks."
90 | (cl-assert (eq (websocket-frame-opcode frame) 'text))
91 | (let* ((msg (jupyter-read-plist-from-string
92 | (websocket-frame-payload frame)))
93 | (client (jupyter-find-client-for-session
94 | (jupyter-message-session msg))))
95 | (cl-assert client)
96 | (unless (equal ws (oref client widget-sock))
97 | ;; TODO: Handle multiple clients and sending widget state to new clients
98 | (oset client widget-sock ws))
99 | (pcase (jupyter-message-type msg)
100 | ("connect"
101 | (jupyter-widgets--send-deferred client))
102 | (_
103 | ;; Any other message the browser sends is meant for the kernel so do the
104 | ;; redirection and setup the callbacks
105 | (let* ((msg-type (jupyter-message-type msg))
106 | (content (jupyter-message-content msg)))
107 | (jupyter-run-with-client client
108 | (jupyter-sent
109 | (jupyter-message-subscribed
110 | (let ((jupyter-inhibit-handlers
111 | (if (member msg-type '("comm_info_request"))
112 | '("comm_msg" "status" "comm_info_reply")
113 | '("comm_msg"))))
114 | (apply #'jupyter-request msg-type content))
115 | (let ((fn (apply-partially #'jupyter-widgets-send-message client)))
116 | `(("comm_open" ,fn)
117 | ("comm_close" ,fn)
118 | ("comm_info_reply" ,fn)
119 | ("comm_msg" ,fn)
120 | ("status" ,fn)))))))))))
121 |
122 | (defun jupyter-widgets-on-close (ws)
123 | "Uninitialize the client whose widget-sock is WS."
124 | (cl-loop
125 | for client in jupyter--clients
126 | when (and (object-of-class-p client 'jupyter-widget-client)
127 | (equal ws (oref client widget-sock)))
128 | do (oset client widget-sock nil)
129 | (jupyter-set client 'jupyter-widgets-initialized nil)))
130 |
131 | ;;; Working with comm messages
132 |
133 | (defun jupyter-widgets-normalize-comm-msg (msg)
134 | "Ensure that a comm MSG's fields are not ambiguous before encoding.
135 | For example, for fields that are supposed to be arrays, ensure
136 | that they will be encoded as such. In addition, add fields
137 | required by the JupyterLab widget manager."
138 | (prog1 msg
139 | (when (member (jupyter-message-type msg)
140 | '("comm_open" "comm_close" "comm_msg"))
141 | (let ((buffers (plist-member msg :buffers)))
142 | (if (null buffers) (plist-put msg :buffers [])
143 | (when (eq (cadr buffers) nil)
144 | (setcar (cdr buffers) [])))
145 | (unless (equal (cadr buffers) [])
146 | (setq buffers (cadr buffers))
147 | (while (car buffers)
148 | (setcar buffers
149 | (base64-encode-string
150 | (encode-coding-string (car buffers) 'utf-8-auto t) t))
151 | (setq buffers (cdr buffers))))
152 | ;; Needed by WidgetManager
153 | (unless (jupyter-message-metadata msg)
154 | (plist-put msg :metadata '(:version "2.0")))))))
155 |
156 | (cl-defmethod jupyter-widgets-send-message ((client jupyter-widget-client) msg)
157 | "Send a MSG to CLIENT's `widget-sock' `websocket'."
158 | (setq msg (jupyter-widgets-normalize-comm-msg msg))
159 | (let ((msg-type (jupyter-message-type msg)))
160 | (plist-put msg :channel
161 | (cond
162 | ((member msg-type '("status" "comm_msg"
163 | "comm_close" "comm_open"))
164 | :iopub)
165 | ((member msg-type '("comm_info_reply"))
166 | :shell)))
167 | (push (jupyter--encode msg) (oref client widget-messages))
168 | (when (websocket-openp (oref client widget-sock))
169 | (jupyter-widgets--send-deferred client))))
170 |
171 | ;;; Displaying widgets in the browser
172 | ;; NOTE: The "display_model" and "clear_display" messages below are not true
173 | ;; Jupyter messages, but are only used for communication between the browser
174 | ;; and Emacs.
175 |
176 | (cl-defmethod jupyter-widgets-display-model ((client jupyter-widget-client) model-id)
177 | "Display the model with MODEL-ID for the kernel CLIENT is connected to."
178 | ;; (jupyter-widgets-clear-display client)
179 | (jupyter-widgets-send-message
180 | client (list :msg_type "display_model"
181 | :content (list :model_id model-id))))
182 |
183 | (cl-defmethod jupyter-widgets-clear-display ((client jupyter-widget-client))
184 | "Clear the models being displayed for CLIENT."
185 | (jupyter-widgets-send-message client (list :msg_type "clear_display")))
186 |
187 | ;;; `jupyter-kernel-client' methods
188 |
189 | (defun jupyter-widgets-start-websocket-server ()
190 | "Start the `jupyter-widgets-server' if necessary."
191 | (unless (process-live-p jupyter-widgets-server)
192 | (setq jupyter-widgets-server
193 | (websocket-server
194 | jupyter-widgets-port
195 | :host 'local
196 | :on-message #'jupyter-widgets-on-message
197 | :on-close #'jupyter-widgets-on-close))))
198 |
199 | (defun jupyter-widgets--initialize-client (client)
200 | (unless (jupyter-get client 'jupyter-widgets-initialized)
201 | (jupyter-set client 'jupyter-widgets-initialized t)
202 | (unless (get-process "httpd")
203 | (httpd-start))
204 | (browse-url
205 | (format jupyter-widgets-url-format
206 | httpd-port
207 | user-login-name
208 | (jupyter-session-id (oref client session))
209 | jupyter-widgets-port))))
210 |
211 | (cl-defmethod jupyter-handle-comm-open ((client jupyter-widget-client) _req msg)
212 | (jupyter-with-message-content msg (target_name)
213 | (when (member target_name jupyter-widgets-supported-targets)
214 | (jupyter-widgets-start-websocket-server)
215 | (jupyter-widgets--initialize-client client)
216 | (jupyter-widgets-send-message client msg)))
217 | (cl-call-next-method))
218 |
219 | (cl-defmethod jupyter-handle-comm-close ((client jupyter-widget-client) _req msg)
220 | (jupyter-widgets-send-message client msg)
221 | (cl-call-next-method))
222 |
223 | (cl-defmethod jupyter-handle-comm-msg ((client jupyter-widget-client) _req msg)
224 | (jupyter-widgets-send-message client msg)
225 | (cl-call-next-method))
226 |
227 | ;;; `httpd' interface
228 |
229 | (defun httpd/jupyter (proc path _query &rest _args)
230 | "Serve the javascript required for Jupyter widget support.
231 | PROC is the httpd process and PATH is the requested resource
232 | path. Currently no resources are accessible at any PATH other
233 | than the root, which will serve the necessary Javascript to
234 | load."
235 | (let ((split-path (split-string (substring path 1) "/")))
236 | (if (= (length split-path) 1)
237 | (with-httpd-buffer proc "text/javascript; charset=UTF-8"
238 | (insert-file-contents
239 | (expand-file-name "js/built/index.built.js" jupyter-root)))
240 | (error "Not found"))))
241 |
242 | (defun httpd/jupyter/widgets/built (proc path _query &rest _args)
243 | "Serve the resources required by the widgets in the browser.
244 | PROC is the httpd process and PATH is the requested resource
245 | path. Currently this will only serve a file from the js/built
246 | directory if it has one of the extensions woff, woff2, ttf, svg,
247 | or eot. These are used by Jupyter."
248 | (let* ((split-path (split-string (substring path 1) "/"))
249 | (file (car (last split-path)))
250 | (mime (pcase (file-name-extension file)
251 | ((or "woff" "woff2")
252 | "application/font-woff")
253 | ("ttf"
254 | "application/octet-stream")
255 | ("svg"
256 | "image/svg+xml")
257 | ("eot"
258 | "application/vnd.ms-fontobject"))))
259 | (unless mime
260 | (error "Unsupported file type"))
261 | (setq file (expand-file-name (concat "js/built/" file) jupyter-root))
262 | ;; TODO: Fix this, when loading the files through httpd, font awesome
263 | ;; doesnt work
264 | (when (file-exists-p file)
265 | (error "File nonexistent (%s)" (file-name-nondirectory file)))
266 | (with-temp-buffer
267 | (insert-file-contents file)
268 | (httpd-send-header proc mime 200
269 | :Access-Control-Allow-Origin "*"))))
270 |
271 | ;; TODO: Since the path when we instantiate widgets is jupyter/widgets, all
272 | ;; files that are trying to be loaded locally in the javascript will be
273 | ;; referenced to this path. If we encounter a javascript file requesting to be
274 | ;; loaded we can automatically search the jupyter --paths for notebook
275 | ;; extension modules matching it.
276 | (defun httpd/jupyter/widgets (proc &rest _args)
277 | "Serve the HTML page to display widgets.
278 | PROC is the httpd process."
279 | (with-temp-buffer
280 | (insert-file-contents (expand-file-name "widget.html" jupyter-root))
281 | (httpd-send-header
282 | proc "text/html; charset=UTF-8" 200
283 | :Access-Control-Allow-Origin "*")))
284 |
285 | (provide 'jupyter-widget-client)
286 |
287 | ;;; jupyter-widget-client.el ends here
288 |
--------------------------------------------------------------------------------
/js/emacs-jupyter.js:
--------------------------------------------------------------------------------
1 | // NOTE: Info on widgets http://ipywidgets.readthedocs.io/en/latest/examples/Widget%20Low%20Level.html
2 | var disposable = require('@phosphor/disposable');
3 | var coreutils = require('@jupyterlab/coreutils');
4 | // The KernelFutureHandler allows comms to register their callbacks to be
5 | // called when messages are received in response to a request sent to the
6 | // kernel.
7 | var KernelFutureHandler = require('@jupyterlab/services/kernel/future').KernelFutureHandler;
8 | // The CommHandler object handles comm interaction to/from the kernel. It takes
9 | // a target_name, usually jupyter.widget, and a comm_id. It takes care of
10 | // sending comm messages to the kernel and calls the callback methods of a Comm
11 | // when a comm_msg is received from the kernel.
12 | //
13 | // A Comm object is essentially a wrapper around a CommHandler that updates the
14 | // CommHandler callbacks and registers callbacks on the futures created when a
15 | // Comm sends a message on the shell channel.
16 | var CommHandler = require('@jupyterlab/services/kernel/comm').CommHandler;
17 |
18 |
19 | // A CommManager takes care of registering new comm targets and creating new
20 | // comms and holding a list of all the live comms.
21 |
22 | // It looks like I just ned to implement the IKernel interface and pass the
23 | // object that implements it to CommManager, this way I can create new comms
24 | // with CommManager.new_comm when handling comm_open messages. In the IKernel
25 | // interface, I'll just redirect all the message sending functions to Emacs.
26 |
27 | // It looks like widgets send messages through the callbacks of a
28 | // KernelFutureHandler so I will have to redirect all received messages that
29 | // originated from a request generated by skewer.postJSON back to the
30 | // JavaScript environment. Emacs then acts as an intermediary, capturing kernel
31 | // messages and re-packaging them to send to the Javascript environment.
32 | //
33 | // It looks like whenever the kernel receives a message it accesse the correct
34 | // future object using this.futures.get and calls handleMsg function of the
35 | // future.
36 | //
37 | // The flow of message with respect to Comm objects is that Comm object send
38 | // shell messages, then widgets register callbacks on the future.
39 |
40 | var EmacsJupyter = function(options, port) {
41 | var _this = this;
42 |
43 | this.username = options.username || '';
44 | // This is the Jupyter session id
45 | this.clientId = options.clientId;
46 | this.isDisposed = false;
47 | // A mapping from comm_id's to promises that resolve to their open Comm
48 | // objects.
49 | this.commPromises = new Map();
50 | // The targetRegistry is a dictionary mapping target names to target
51 | // functions that are called whenever a new Comm is requested to be open by
52 | // the kernel. The target function gets called with the initial comm_open
53 | // message data and a comm handler for the new Comm.
54 | this.targetRegistry = {};
55 | // A mapping of msg_id's for messages sent to the kernel and their
56 | // KernelFutureHandler objects.
57 | this.futures = new Map();
58 | // The WidgetManager that connects comms to their corresponding widget
59 | // models, construct widget views, load widget modules, and get the current
60 | // widget state.
61 | this.widgetManager = null;
62 | this.widgetState = null;
63 | // The CommManager that registers the target names and their target
64 | // functions handles opening and closing comms for a particular
65 | // target name.
66 | this.commManager = null;
67 | this.messagePromise = new Promise(function (resolve) { resolve(); });
68 |
69 | window.addEventListener("unload", function(event) {
70 | // TODO: Send widget state
71 | });
72 |
73 | // Localhost
74 | this.wsPort = port;
75 | this.ws = new WebSocket("ws://127.0.0.1:" + port);
76 | this.ws.onopen = function () {
77 | // Ensure that Emacs knows which websocket connection corresponds to
78 | // each kernel client
79 | _this.ws.send(JSON.stringify({
80 | header: {
81 | msg_type: "connect",
82 | session: _this.clientId
83 | }
84 | }));
85 | };
86 | this.ws.onmessage = function(event) {
87 | if(_this.isDisposed) {
88 | return;
89 | }
90 | var msg = JSON.parse(event.data);
91 | _this.messagePromise =
92 | _this.messagePromise.then(function () {
93 | if(msg.buffers && msg.buffers.length > 0) {
94 | for(var i = 0; i < msg.buffers.length; i++) {
95 | var bin = atob(msg.buffers[i]);
96 | var len = bin.length;
97 | var buf = new Uint8Array(len);
98 | for(var j = 0; j < len; j++) {
99 | buf[j] = bin.charCodeAt(j);
100 | }
101 | msg.buffers[i] = buf.buffer;
102 | }
103 | }
104 | _this.handleMessage(msg);
105 | });
106 | };
107 | };
108 | exports.EmacsJupyter = EmacsJupyter;
109 |
110 | EmacsJupyter.prototype.dispose = function () {
111 | if (this.isDisposed) {
112 | return;
113 | }
114 | this.isDisposed = true;
115 | this.commPromises.forEach(function (promise, key) {
116 | promise.then(function (comm) {
117 | comm.dispose();
118 | });
119 | });
120 | };
121 |
122 | EmacsJupyter.prototype.registerCommTarget = function(targetName, callback) {
123 | var _this = this;
124 | this.targetRegistry[targetName] = callback;
125 | return new disposable.DisposableDelegate(function () {
126 | if (!_this.isDisposed) {
127 | delete _this.targetRegistry[targetName];
128 | }
129 | });
130 | };
131 |
132 | EmacsJupyter.prototype.connectToComm = function (targetName, commId) {
133 | var _this = this;
134 | var id = commId || coreutils.uuid();
135 | if (this.commPromises.has(id)) {
136 | return this.commPromises.get(id);
137 | }
138 | var promise = Promise.resolve(void 0).then(function () {
139 | return new CommHandler(targetName, id, _this, function () {
140 | _this._unregisterComm(id);
141 | });
142 | });
143 | this.commPromises.set(id, promise);
144 | return promise;
145 | };
146 |
147 | EmacsJupyter.prototype.handleCommOpen = function (msg) {
148 | var _this = this;
149 | var content = msg.content;
150 | if (this.isDisposed) {
151 | return;
152 | }
153 | var promise = this.loadObject(content.target_name,
154 | content.target_module,
155 | this.targetRegistry)
156 | .then(function (target) {
157 | var comm = new CommHandler(content.target_name,
158 | content.comm_id,
159 | _this, function () {
160 | _this._unregisterComm(content.comm_id);
161 | });
162 | var response;
163 | try {
164 | response = target(comm, msg);
165 | }
166 | catch (e) {
167 | comm.close();
168 | console.error('Exception opening new comm');
169 | throw (e);
170 | }
171 | return Promise.resolve(response).then(function () {
172 | if (_this.isDisposed) {
173 | return null;
174 | }
175 | return comm;
176 | });
177 | });
178 | this.commPromises.set(content.comm_id, promise);
179 | };
180 |
181 | EmacsJupyter.prototype.handleCommClose = function (msg) {
182 | var _this = this;
183 | var content = msg.content;
184 | var promise = this.commPromises.get(content.comm_id);
185 | if (!promise) {
186 | console.error('Comm not found for comm id ' + content.comm_id);
187 | return;
188 | }
189 | promise.then(function (comm) {
190 | if (!comm) {
191 | return;
192 | }
193 | _this._unregisterComm(comm.commId);
194 | try {
195 | var onClose = comm.onClose;
196 | if (onClose) {
197 | onClose(msg);
198 | }
199 | comm.dispose();
200 | }
201 | catch (e) {
202 | console.error('Exception closing comm: ', e, e.stack, msg);
203 | }
204 | });
205 | };
206 |
207 | EmacsJupyter.prototype.handleCommMsg = function (msg) {
208 | var promise = this.commPromises.get(msg.content.comm_id);
209 | if (!promise) {
210 | // We do have a registered comm for this comm id, ignore.
211 | return;
212 | }
213 | promise.then(function (comm) {
214 | if (!comm) {
215 | return;
216 | }
217 | try {
218 | var onMsg = comm.onMsg;
219 | if (onMsg) {
220 | onMsg(msg);
221 | }
222 | }
223 | catch (e) {
224 | console.error('Exception handling comm msg: ', e, e.stack, msg);
225 | }
226 | });
227 | };
228 |
229 | EmacsJupyter.prototype.loadObject = function(name, moduleName, registry) {
230 | return new Promise(function (resolve, reject) {
231 | // Try loading the view module using require.js
232 | if (moduleName) {
233 | if (typeof window.require === 'undefined') {
234 | throw new Error('requirejs not found');
235 | }
236 | window.require([moduleName], function (mod) {
237 | if (mod[name] === void 0) {
238 | var msg = "Object '" + name + "' not found in module '" + moduleName + "'";
239 | reject(new Error(msg));
240 | }
241 | else {
242 | resolve(mod[name]);
243 | }
244 | }, reject);
245 | }
246 | else {
247 | if (registry && registry[name]) {
248 | resolve(registry[name]);
249 | }
250 | else {
251 | reject(new Error("Object '" + name + "' not found in registry"));
252 | }
253 | }
254 | });
255 | }
256 |
257 | EmacsJupyter.prototype._unregisterComm = function (commId) {
258 | this.commPromises.delete(commId);
259 | };
260 |
261 | EmacsJupyter.prototype.sendShellMessage = function(msg, expectReply, disposeOnDone) {
262 | var _this = this;
263 | if (expectReply === void 0) { expectReply = false; }
264 | if (disposeOnDone === void 0) { disposeOnDone = true; }
265 |
266 | var future = new KernelFutureHandler(function () {
267 | var msgId = msg.header.msg_id;
268 | _this.futures.delete(msgId);
269 | }, msg, expectReply, disposeOnDone, this);
270 |
271 | this.ws.send(JSON.stringify(msg));
272 | this.futures.set(msg.header.msg_id, future);
273 | return future;
274 | };
275 |
276 | EmacsJupyter.prototype.requestCommInfo = function(targetName) {
277 | var msg = {
278 | channel: 'shell',
279 | msg_type: 'comm_info_request',
280 | // A message ID will be added by Emacs anyway
281 | header: {msg_id: ''},
282 | content: {target_name: targetName}
283 | };
284 | var future = this.sendShellMessage(msg, true);
285 | return new Promise(function (resolve) {
286 | future.onReply = resolve;
287 | });
288 | };
289 |
290 | EmacsJupyter.prototype.handleMessage = function(msg) {
291 | var _this = this;
292 | var parentHeader = msg.parent_header;
293 | var future = parentHeader && this.futures && this.futures.get(parentHeader.msg_id);
294 | if (future) {
295 | return new Promise(function (resolve, reject) {
296 | try {
297 | future.handleMsg(msg);
298 | resolve(msg);
299 | } catch(err) {
300 | reject(err);
301 | }
302 | });
303 | } else {
304 | return new Promise(function (resolve, reject) {
305 | switch(msg.msg_type) {
306 | // Special messages not really a Jupyter message
307 | case 'display_model':
308 | _this.widgetManager.get_model(msg.content.model_id).then(function (model) {
309 | _this.widgetManager.display_model(undefined, model);
310 | });
311 | break;
312 | case 'clear_display':
313 | var widget = _this.widgetManager.area;
314 | while(widget.firstChild) {
315 | widget.removeChild(widget.firstChild);
316 | }
317 | break;
318 | // Regular Jupyter messages
319 | case 'comm_open':
320 | _this.handleCommOpen(msg);
321 | // Periodically get the state of the widgetManager, this gets
322 | // sent to the browser when its unloaded.
323 | // _this.widgetManager.get_state({}).then(function (state) {
324 | // _this.widgetState = state;
325 | // });
326 | break;
327 | case 'comm_close':
328 | _this.handleCommClose(msg);
329 | break;
330 | case 'comm_msg':
331 | _this.handleCommMsg(msg);
332 | break;
333 | case 'status':
334 | // Comes from the comm info messages
335 | break;
336 | default:
337 | reject(new Error('Unhandled message', msg));
338 | };
339 | resolve(msg);
340 | });
341 | }
342 | }
343 |
--------------------------------------------------------------------------------
/test/jupyter-monad-test.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-monad-test.el --- Test monadic I/O -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2020-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 16 May 2020
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;;; Code:
26 |
27 | (require 'jupyter-monads)
28 |
29 | ;; Kernel messages are property list representations of JSON encoded
30 | ;; strings.
31 |
32 | ;; A kernel client is a publisher of requests and subscriber of
33 | ;; kernel messages.
34 |
35 | (ert-deftest jupyter-verify-monad-axioms ()
36 | :tags '(monad)
37 | (let ((f (lambda (n) (jupyter-return (/ n 0.5))))
38 | (g (lambda (n) (jupyter-return (* n 3))))
39 | (m (jupyter-return 9)))
40 | (should (equal
41 | (jupyter-run-with-state '()
42 | (jupyter-bind (jupyter-return 1) f))
43 | (jupyter-run-with-state '()
44 | (funcall f 1))))
45 | (should (equal (jupyter-run-with-state '()
46 | (jupyter-bind m #'jupyter-return))
47 | (jupyter-run-with-state '()
48 | m)))
49 | (should (equal
50 | (jupyter-run-with-state '()
51 | (jupyter-bind
52 | ;; Instead of applying a function, f, to a
53 | ;; value, a, to get b, you bind a delayed value
54 | ;; M a to f to get M b. Binding unboxes M a
55 | ;; into a and then applies f on a.
56 | (jupyter-bind m f) g))
57 | (jupyter-run-with-state '()
58 | (jupyter-bind m
59 | (lambda (x) (jupyter-bind (funcall f x) g))))))))
60 |
61 | (ert-deftest jupyter-mlet* ()
62 | :tags '(monad)
63 | (should (equal (jupyter-mlet* ((a (jupyter-return 1))))
64 | (jupyter-bind (jupyter-return 1)
65 | (lambda (a) jupyter--return-nil))))
66 | (should (equal (jupyter-mlet* ((a (jupyter-return 1)))
67 | a)
68 | (jupyter-bind (jupyter-return 1)
69 | (lambda (a) (progn a)))))
70 | (should (equal (jupyter-mlet* ((a (jupyter-return 2))
71 | (b (jupyter-return (* a 3))))
72 | b)
73 | (jupyter-bind (jupyter-return 2)
74 | (lambda (a)
75 | (jupyter-bind (jupyter-return (* a 3))
76 | (lambda (b) (progn b))))))))
77 |
78 | (ert-deftest jupyter-publisher/subscriber ()
79 | :tags '(monad)
80 | ;; Publisher/subscriber
81 | (ert-info ("Publisher/subscriber")
82 | (let* ((msgs '())
83 | (pub (jupyter-publisher))
84 | (sub (jupyter-subscriber
85 | (lambda (n)
86 | (if (> n 2) (jupyter-unsubscribe)
87 | (push n msgs))))))
88 | (jupyter-run-with-io pub
89 | (jupyter-subscribe sub))
90 | (cl-loop
91 | for x in '(1 2 3)
92 | do (jupyter-run-with-io pub
93 | (jupyter-publish x)))
94 | (should (equal '(2 1) msgs))))
95 | (ert-info ("Subscriber errors")
96 | (ert-info ("`jupyter-subscribed-subscriber' error")
97 | (let* ((n 1)
98 | (sub (jupyter-subscriber
99 | (lambda (x) (setq n (* x 3))))))
100 | (should-error
101 | (jupyter-run-with-io sub
102 | (jupyter-subscribe (jupyter-publisher)))
103 | :type 'jupyter-subscribed-subscriber)
104 | (should-error (funcall sub 'any))))
105 | ;; FIXME: Prevent ert from catching the error
106 | ;; (ert-info ("Keep subscriber on subscriber error")
107 | ;; (let* ((msgs '())
108 | ;; (pub (jupyter-publisher))
109 | ;; (sub (jupyter-subscriber
110 | ;; (lambda (n)
111 | ;; (if (= n 1) (error "(= n 1)")
112 | ;; (push n msgs))))))
113 | ;; (jupyter-run-with-io pub
114 | ;; (jupyter-subscribe sub))
115 | ;; (cl-loop
116 | ;; for x in '(1 2 3)
117 | ;; do (jupyter-run-with-io pub
118 | ;; (jupyter-publish x)))
119 | ;; (should (equal '(3 2) msgs))))
120 | )
121 | ;; Extra for fun
122 | (let* ((lst '(1 2 3 4 5 6 7 8 9 10))
123 | (emitter (lambda (_) (jupyter-content (pop lst))))
124 | (pub (jupyter-publisher emitter))
125 | (collector (lambda (el)
126 | (if el
127 | ;; NOTE: Not a good idea to do in practice
128 | ;; on large lists since it will cause lots
129 | ;; of recursion.
130 | (jupyter-run-with-io pub
131 | (jupyter-publish 'next))
132 | (jupyter-unsubscribe))))
133 | (sub (jupyter-publisher collector)))
134 | (jupyter-run-with-io pub
135 | (jupyter-do
136 | (jupyter-subscribe sub)
137 | (jupyter-publish 'start)))
138 | (should (null lst)))
139 | ;; NOTE: Same as above, creating a subscription cycle can cause
140 | ;; errors when the recursion is too deep. See
141 | ;; `max-lisp-eval-depth'.
142 | (letrec ((n 10)
143 | (ping (jupyter-publisher
144 | (lambda (_) (jupyter-content 'ping))))
145 | (pong (jupyter-publisher
146 | (lambda (_)
147 | (if (< (cl-decf n) 0)
148 | (jupyter-unsubscribe)
149 | (jupyter-content 'pong))))))
150 | (jupyter-run-with-io pong
151 | (jupyter-do
152 | (jupyter-subscribe ping)
153 | (jupyter-with-io ping
154 | (jupyter-do
155 | (jupyter-subscribe pong)
156 | (jupyter-publish 'play)))))
157 | (should (< n 0))))
158 |
159 | (defun jupyter-test-dummy-msgs (req-id)
160 | `((:header (:msg_id ,(jupyter-new-uuid)
161 | :msg_type "status"
162 | :username "nathan"
163 | :session "5c5b72e9-48c4ae02e3eb1ca272fb0275"
164 | :date "2020-05-22T13:29:34.756271Z"
165 | :version "5.3")
166 | :parent_header (:msg_id ,req-id
167 | :msg_type "execute_request"
168 | :version "5.3"
169 | :username "nathan"
170 | :session "7c1d195f-f10f-4c84-b5cc-ddba55e94689"
171 | :date "2020-05-22T08:29:34.744583-05:00")
172 | :metadata nil
173 | :content (:execution_state "busy") :buffers nil :channel "iopub")
174 | (:header (:msg_id ,(jupyter-new-uuid)
175 | :msg_type "execute_reply"
176 | :username "nathan"
177 | :session "5c5b72e9-48c4ae02e3eb1ca272fb0275"
178 | :date "2020-05-22T13:29:34.755354Z"
179 | :version "5.3")
180 | :parent_header (:msg_id ,req-id
181 | :msg_type "execute_request"
182 | :version "5.3"
183 | :username "nathan"
184 | :session "7c1d195f-f10f-4c84-b5cc-ddba55e94689"
185 | :date "2020-05-22T08:29:34.744583-05:00")
186 | :metadata nil
187 | :content nil :buffers nil :channel "shell")
188 | (:header (:msg_id ,(jupyter-new-uuid)
189 | :msg_type "status"
190 | :username "nathan"
191 | :session "5c5b72e9-48c4ae02e3eb1ca272fb0275"
192 | :date "2020-05-22T13:29:34.756271Z"
193 | :version "5.3")
194 | :parent_header (:msg_id ,req-id
195 | :msg_type "execute_request"
196 | :version "5.3"
197 | :username "nathan"
198 | :session "7c1d195f-f10f-4c84-b5cc-ddba55e94689"
199 | :date "2020-05-22T08:29:34.744583-05:00")
200 | :metadata nil
201 | :content (:execution_state "idle") :buffers nil :channel "iopub")))
202 |
203 | (ert-deftest fix-req-unsubscribe-mechanic ()
204 | :tags '(monad)
205 | (let ((unsubed-reqs '()))
206 | (cl-labels ((idle-p
207 | (req)
208 | (jupyter-request-idle-p req))
209 | (req-msg-p
210 | (req msg)
211 | (string= (jupyter-request-id req)
212 | (jupyter-message-parent-id msg)))
213 | (make-req-pub
214 | (req)
215 | (jupyter-publisher
216 | (lambda (msg)
217 | (cond
218 | ((idle-p req)
219 | (push req unsubed-reqs)
220 | (jupyter-unsubscribe))
221 | ((req-msg-p req msg)
222 | (when (jupyter-message-status-idle-p msg)
223 | (setf (jupyter-request-idle-p req) t))
224 | (jupyter-content msg)))))))
225 | (let* ((req1 (make-jupyter-request))
226 | (req2 (make-jupyter-request))
227 | (lst (append (jupyter-test-dummy-msgs
228 | (jupyter-request-id req1))
229 | (jupyter-test-dummy-msgs
230 | (jupyter-request-id req2))))
231 | (initial-msgs (copy-sequence lst))
232 | (kernel-io (jupyter-publisher
233 | (lambda (_)
234 | (when lst
235 | (jupyter-content (pop lst))))))
236 | (client-msgs '())
237 | (client-sub (jupyter-subscriber
238 | (lambda (msg)
239 | (push msg client-msgs)))))
240 |
241 | ;; A subscription chain for REQ1 (KIO -> REQ-PUB -> CLIENT-SUB)
242 | (let ((req-pub (make-req-pub req1)))
243 | (jupyter-run-with-io kernel-io
244 | (jupyter-subscribe req-pub))
245 | (jupyter-run-with-io req-pub
246 | (jupyter-subscribe client-sub)))
247 | ;; Send a message down the chain
248 | (jupyter-run-with-io kernel-io
249 | (jupyter-publish 'emit))
250 | ;; Another chain for REQ2.
251 | (let ((req-pub (make-req-pub req2)))
252 | (jupyter-run-with-io kernel-io
253 | (jupyter-subscribe req-pub))
254 | (jupyter-run-with-io req-pub
255 | (jupyter-subscribe client-sub)))
256 | ;; Send five messages. Now REQ1 and REQ2 messages will be
257 | ;; received by CLIENT-SUB.
258 | (cl-loop
259 | repeat 5
260 | do (jupyter-run-with-io kernel-io
261 | (jupyter-publish 'emit)))
262 | (should (memq req1 unsubed-reqs))
263 | (should (equal initial-msgs (reverse client-msgs)))))))
264 |
265 | (ert-deftest jupyter-subscriber-error ()
266 | :tags '(monad)
267 | ;; Test that a subscriber that raises an error just gets skipped
268 | ;; over, not removed.
269 | (let ((called1 0)
270 | (called2 0))
271 | (let ((sub1 (jupyter-subscriber
272 | (lambda (msg)
273 | (setq called1 (1+ called1)))))
274 | (sub2 (jupyter-subscriber
275 | (lambda (msg)
276 | (setq called2 (1+ called2))
277 | (error "Subscriber error"))))
278 | (pub (jupyter-publisher #'jupyter-content)))
279 | (jupyter-run-with-io pub
280 | (jupyter-do
281 | (jupyter-subscribe sub2)
282 | (jupyter-subscribe sub1)))
283 | (should-error
284 | (jupyter-run-with-io pub
285 | (jupyter-publish 'msg)))
286 | (should (eq called1 1))
287 | (should (eq called2 1))
288 | (should-error
289 | (jupyter-run-with-io pub
290 | (jupyter-publish 'msg)))
291 | (should (eq called1 2))
292 | (should (eq called2 2))
293 | (should-error
294 | (jupyter-run-with-io pub
295 | (jupyter-publish 'msg)))
296 | (should (eq called1 3))
297 | (should (eq called2 3)))))
298 |
299 | (ert-deftest jupyter-subscriber-skipping ()
300 | :tags '(monad)
301 | ;; Test that subscribers evaluation doesn't get skipped whenever a
302 | ;; previous subscriber unsubscribes.
303 | (let* ((count1 0)
304 | (count2 0)
305 | (count3 0)
306 | (sub1
307 | (jupyter-subscriber
308 | (lambda (msg)
309 | (setq count1 (1+ count1)))))
310 | (sub2
311 | (jupyter-subscriber
312 | (lambda (msg)
313 | (setq count2 (1+ count2))
314 | (jupyter-unsubscribe))))
315 | (sub3
316 | (jupyter-subscriber
317 | (lambda (msg)
318 | (setq count3 (1+ count3)))))
319 | (pub (jupyter-publisher #'jupyter-content)))
320 | (jupyter-run-with-io pub
321 | (jupyter-do
322 | (jupyter-subscribe sub3)
323 | (jupyter-subscribe sub2)
324 | (jupyter-subscribe sub1)))
325 | (jupyter-run-with-io pub
326 | (jupyter-publish 'msg))
327 | (should (eq count1 1))
328 | (should (eq count2 1))
329 | (should (eq count3 1))
330 | (jupyter-run-with-io pub
331 | (jupyter-publish 'msg))
332 | (should (eq count1 2))
333 | (should (eq count2 1))
334 | (should (eq count3 2))))
335 |
336 | ;; - `seq-elt'
337 | ;; - `seq-length'
338 | ;; - `seq-do'
339 | ;; - `seqp'
340 | ;; - `seq-subseq'
341 | ;; - `seq-into-sequence'
342 | ;; - `seq-copy'
343 | ;; - `seq-into'
344 | (ert-deftest jupyter-seq-interface ()
345 | :tags '(monad seq)
346 |
347 | )
348 |
349 | ;;; jupyter-monad-test.el ends here
350 |
--------------------------------------------------------------------------------
/test/jupyter-tramp-test.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-tramp-test.el --- Tests for the contents REST API integration with TRAMP -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2019-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 28 May 2019
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 2, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Test integration of Jupyter REST contents API with TRAMP.
26 |
27 | ;;; Code:
28 |
29 | (require 'jupyter-tramp)
30 |
31 | (ert-deftest jupyter-tramp-file-name-p ()
32 | :tags '(tramp server)
33 | (should-not (jupyter-tramp-file-name-p "foobar"))
34 | (should-not (jupyter-tramp-file-name-p "/foobar"))
35 | (should-not (jupyter-tramp-file-name-p "/ssh::foobar"))
36 | (should (equal (jupyter-tramp-file-name-p "/jpy::foobar") "jpy"))
37 | (should (equal (jupyter-tramp-file-name-p "/jpy::/foobar") "jpy"))
38 | (should (equal (jupyter-tramp-file-name-p "/jpys::/foobar") "jpys")))
39 |
40 | (ert-deftest jupyter-tramp-file-directory-p ()
41 | :tags '(tramp server)
42 | (jupyter-test-at-temporary-directory
43 | (let* ((tfile (make-temp-file "file-directory-p"))
44 | (tdir (make-temp-file "file-directory-p" 'directory))
45 | (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/"))
46 | (jpydir (expand-file-name (file-name-nondirectory tdir) "/jpy::/")))
47 | (unwind-protect
48 | (progn
49 | (should (file-exists-p jpyfile))
50 | (should (file-exists-p jpydir))
51 | (should-not (file-directory-p jpyfile))
52 | (should (file-directory-p jpydir)))
53 | (delete-directory tdir)
54 | (delete-file tfile)))))
55 |
56 | (ert-deftest jupyter-tramp-file-writable-p ()
57 | :tags '(tramp server)
58 | (jupyter-test-at-temporary-directory
59 | (let* ((tname (make-temp-name "file-writable-p"))
60 | (jpyfile (expand-file-name tname "/jpy::/")))
61 | (should-not (file-exists-p tname))
62 | ;; TODO: To test this fully we would have to start the Jupyter server in
63 | ;; a less privileged state than the current user.
64 | (should (file-writable-p jpyfile)))))
65 |
66 | (ert-deftest jupyter-tramp-make-directory ()
67 | :tags '(tramp server)
68 | (jupyter-test-at-temporary-directory
69 | (let* ((tdir (make-temp-name "make-directory"))
70 | (jpydir (expand-file-name tdir "/jpy::/")))
71 | (should-not (file-exists-p tdir))
72 | (should-not (file-directory-p tdir))
73 | (unwind-protect
74 | (progn
75 | (make-directory jpydir)
76 | (should (file-exists-p tdir))
77 | (should (file-directory-p tdir)))
78 | (when (file-directory-p tdir)
79 | (delete-directory tdir))))))
80 |
81 | (ert-deftest jupyter-tramp-file-local-copy ()
82 | :tags '(tramp server)
83 | (jupyter-test-at-temporary-directory
84 | (let* ((tfile (make-temp-file "file-local-copy"))
85 | (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/")))
86 | (unwind-protect
87 | (let ((contents (concat "αβ" (jupyter-new-uuid) "λ")))
88 | (with-temp-file tfile
89 | (setq buffer-file-coding-system 'utf-8-auto)
90 | (insert contents))
91 | (let ((lfile (file-local-copy jpyfile)))
92 | (unwind-protect
93 | (with-temp-buffer
94 | (should-not (file-remote-p lfile))
95 | (should-not (equal lfile tfile))
96 | (let ((coding-system-for-read 'utf-8-auto))
97 | (insert-file-contents lfile))
98 | (should (equal (buffer-string) contents)))
99 | (delete-file lfile))))
100 | (delete-file tfile)))))
101 |
102 | (ert-deftest jupyter-tramp-rename-file ()
103 | :tags '(tramp server)
104 | (jupyter-test-at-temporary-directory
105 | (let* ((tfile (make-temp-file "rename-file"))
106 | (tnewname (jupyter-new-uuid))
107 | (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/"))
108 | (jpynewname (expand-file-name tnewname "/jpy::/")))
109 | (ert-info ("Remote to same remote")
110 | (should-not (file-exists-p tnewname))
111 | (unwind-protect
112 | (let ((contents (jupyter-new-uuid)))
113 | (with-temp-file tfile
114 | (insert contents))
115 | (rename-file jpyfile jpynewname)
116 | (should (file-exists-p tnewname))
117 | (unwind-protect
118 | (with-temp-buffer
119 | (insert-file-contents tnewname)
120 | (should (equal (buffer-string) contents)))
121 | (ignore-errors (delete-file tnewname))))
122 | (ignore-errors (delete-file tfile))))
123 | (ert-info ("Local to remote")
124 | (unwind-protect
125 | (let ((contents (jupyter-new-uuid)))
126 | (should-not (file-exists-p tfile))
127 | (should-not (file-exists-p jpyfile))
128 | (with-temp-file tfile
129 | (insert contents))
130 | (should (file-exists-p tfile))
131 | (rename-file tfile jpynewname)
132 | (should-not (file-exists-p tfile))
133 | (should (file-exists-p tnewname)))
134 | (ignore-errors (delete-file tnewname))
135 | (ignore-errors (delete-file tfile)))))))
136 |
137 | (ert-deftest jupyter-tramp-copy-file ()
138 | :tags '(tramp server)
139 | (jupyter-test-at-temporary-directory
140 | (cl-macrolet
141 | ((file-contents
142 | (f) `(with-temp-buffer
143 | (insert-file-contents ,f)
144 | (buffer-string)))
145 | (check-copy
146 | (f1 f2 c)
147 | `(progn
148 | (should-not (file-exists-p ,f1))
149 | (write-region ,c nil ,f1)
150 | (should (file-exists-p ,f1))
151 | (unwind-protect
152 | (unwind-protect
153 | (progn
154 | (copy-file ,f1 ,f2)
155 | (should (file-exists-p ,f2))
156 | (should (equal ,c (file-contents ,f2))))
157 | (ignore-errors (delete-file (file-name-nondirectory ,f2))))
158 | (ignore-errors (delete-file (file-name-nondirectory ,f1)))))))
159 | (ert-info ("Local to remote")
160 | (let ((tf1 (make-temp-name "copy-file"))
161 | (jpy1 (expand-file-name (make-temp-name "copy-file") "/jpy::/"))
162 | (c1 (jupyter-new-uuid)))
163 | (check-copy tf1 jpy1 c1)))
164 | (ert-info ("Remote to local")
165 | (let ((tf1 (make-temp-name "copy-file"))
166 | (jpy1 (expand-file-name (make-temp-name "copy-file") "/jpy::/"))
167 | (c1 (jupyter-new-uuid)))
168 | (check-copy jpy1 tf1 c1)))
169 | (ert-info ("Remote to remote")
170 | (let ((jpy1 (expand-file-name (make-temp-name "copy-file") "/jpy::/"))
171 | (jpy2 (expand-file-name (make-temp-name "copy-file") "/jpy::/"))
172 | (c1 (jupyter-new-uuid)))
173 | (check-copy jpy1 jpy2 c1))))))
174 |
175 | (ert-deftest jupyter-tramp-delete-file ()
176 | :tags '(tramp server)
177 | (jupyter-test-at-temporary-directory
178 | (let* ((tfile (make-temp-file "delete-file"))
179 | (tdir (make-temp-file "delete-file" 'directory))
180 | (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/"))
181 | (jpydir (expand-file-name (file-name-nondirectory tdir) "/jpy::/")))
182 | (should (file-exists-p tfile))
183 | (should (file-exists-p jpyfile))
184 | (should (file-exists-p tdir))
185 | (should (file-exists-p jpydir))
186 | (unwind-protect
187 | (progn
188 | (ert-info ("Error when attempting to delete a directory")
189 | (should-error (delete-file jpydir)))
190 | (ert-info ("Delete a file")
191 | (delete-file jpyfile)
192 | (should-not (file-exists-p tfile))
193 | (ert-info ("Ensure cache is cleared")
194 | (should-not (file-exists-p jpyfile)))))
195 | (when (file-exists-p tfile)
196 | (delete-file tfile))
197 | (when (file-exists-p tdir)
198 | (delete-directory tdir))))))
199 |
200 | (ert-deftest jupyter-delete-directory ()
201 | :tags '(tramp server)
202 | (jupyter-test-at-temporary-directory
203 | (let* ((tfile (make-temp-file "delete-directory"))
204 | (tdir (make-temp-file "delete-directory" 'directory))
205 | (jpyfile (expand-file-name (file-name-nondirectory tfile) "/jpy::/"))
206 | (jpydir (expand-file-name (file-name-nondirectory tdir) "/jpy::/")))
207 | (should (file-exists-p tfile))
208 | (should (file-exists-p jpyfile))
209 | (should (file-exists-p tdir))
210 | (should (file-exists-p jpydir))
211 | (unwind-protect
212 | (progn
213 | (ert-info ("Error when attempting to delete a file")
214 | (should-error (delete-directory jpyfile)))
215 | (ert-info ("Delete a directory")
216 | (let ((jpyfile2 (expand-file-name "foobar" jpydir)))
217 | (write-region "xxx" nil jpyfile2)
218 | (unwind-protect
219 | (progn
220 | (ert-info ("Error when directory contains files")
221 | (should-error (delete-directory jpydir)))
222 | (ert-info ("Unless recursive is specifed")
223 | (delete-directory jpydir t)
224 | (should-not (file-exists-p jpyfile2))
225 | (should-not (file-directory-p tdir))))
226 | (when (file-exists-p jpyfile2)
227 | (delete-file jpyfile2))))
228 | (should-not (file-exists-p tdir))
229 | (ert-info ("Ensure cache is cleared")
230 | (should-not (file-exists-p jpydir)))))
231 | (when (file-exists-p tfile)
232 | (delete-file tfile))
233 | (when (file-exists-p tdir)
234 | (delete-directory tdir t))))))
235 |
236 | (ert-deftest jupyter-tramp-file-attributes ()
237 | :tags '(tramp server)
238 | (jupyter-test-at-temporary-directory
239 | (let* ((file (make-temp-file "file-attributes"))
240 | (jpyfile (expand-file-name
241 | (file-name-nondirectory file) "/jpy::/")))
242 | (set-file-modes file (string-to-number "600" 8))
243 | (write-region (make-string (1+ (random 100)) ?x) nil file)
244 | (unwind-protect
245 | (let ((attrs (file-attributes file 'string))
246 | (jpyattrs (file-attributes jpyfile 'string)))
247 | (should-not (or (null (file-attribute-size attrs))
248 | (zerop (file-attribute-size attrs))))
249 | ;; Remove the usec and psec resolution
250 | (dolist (s '(2 3))
251 | (setf (nth s (file-attribute-modification-time attrs)) 0)
252 | (setf (nth s (file-attribute-status-change-time attrs)) 0)
253 | (setf (nth s (file-attribute-modification-time jpyattrs)) 0)
254 | (setf (nth s (file-attribute-status-change-time jpyattrs)) 0))
255 | (should (equal (nth 0 attrs) (nth 0 jpyattrs)))
256 | (dolist (item '(file-attribute-modification-time
257 | file-attribute-status-change-time
258 | ;; We always use the mode 600 since the file modes
259 | ;; are not accessible by a user. The file should
260 | ;; always be writable when testing since the server
261 | ;; is started by the current Emacs process.
262 | ;; file-attribute-modes
263 | file-attribute-size))
264 | (should (equal (funcall item attrs)
265 | (funcall item jpyattrs)))))
266 | (delete-file file)))))
267 |
268 | (ert-deftest jupyter-tramp-expand-file-name ()
269 | :tags '(tramp server)
270 | (should (equal "/foo" (tramp-drop-volume-letter (expand-file-name "/foo" "/jpy:h:/foo"))))
271 | (should (equal "~/foo" (abbreviate-file-name (expand-file-name "~/foo" "/jpy:h:/foo"))))
272 | (should (equal "/jpy:h:/foo/bar" (expand-file-name "bar" "/jpy:h:/foo")))
273 | (should (equal "/jpy:h:/foo/bar" (expand-file-name "bar" "/jpy:h:/foo/")))
274 | (should (equal "/jpy:h:/foo/bar" (expand-file-name "/jpy:h:/foo/bar")))
275 | (should (equal "/jpy:h:/foo/bar" (expand-file-name "/jpy:h:foo/bar")))
276 | (let ((default-directory "/jpy:h:/"))
277 | (should (equal "/jpy:h:/foo" (expand-file-name "foo"))))
278 | (let ((default-directory nil))
279 | (should (equal "/foo" (tramp-drop-volume-letter
280 | (jupyter-tramp-expand-file-name "foo"))))))
281 |
282 | ;; TODO
283 | (ert-deftest jupyter-tramp-file-name-all-completions ()
284 | :tags '(tramp server))
285 |
286 | ;; TODO
287 | (ert-deftest jupyter-tramp-file-remote-p ()
288 | :tags '(tramp server))
289 |
290 | (ert-deftest jupyter-tramp-write-region ()
291 | :tags '(tramp server)
292 | (jupyter-test-at-temporary-directory
293 | (let* ((file (make-temp-file "write-region"))
294 | (jpyfile (expand-file-name
295 | (file-name-nondirectory file) "/jpy::/")))
296 | (unwind-protect
297 | (cl-macrolet ((file-contents
298 | () `(with-temp-buffer
299 | (insert-file-contents-literally file)
300 | (buffer-string))))
301 | (should-error (write-region "foo" nil jpyfile nil nil nil 'excl))
302 | (ert-info ("Basic write")
303 | (write-region "foo" nil jpyfile)
304 | (should (equal (file-contents) "foo"))
305 | (write-region "foλo" nil jpyfile)
306 | (should (equal (encode-coding-string (file-contents) 'utf-8)
307 | (encode-coding-string "foλo" 'utf-8)))
308 | (with-temp-buffer
309 | (insert "foo")
310 | (write-region nil nil jpyfile)
311 | (should (buffer-modified-p))
312 | (should (equal (file-contents) "foo"))
313 | (insert "bar")
314 | (write-region nil "" jpyfile)
315 | (should (buffer-modified-p))
316 | (should (equal (file-contents) "foobar"))
317 | (should-error (write-region 1 nil jpyfile))
318 | (should-error (write-region (list 1) 1 jpyfile))
319 | (write-region 2 4 jpyfile)
320 | (should (buffer-modified-p))
321 | (should (equal (file-contents) "oo"))))
322 | (ert-info ("Base64 encode binary")
323 | (let ((coding-system-for-write 'binary))
324 | (write-region "\0\1\2\3\4\5\6" nil jpyfile)
325 | (should (equal (file-contents) "\0\1\2\3\4\5\6"))))
326 | (ert-info ("Append")
327 | (write-region "x" nil jpyfile)
328 | (should (equal (file-contents) "x"))
329 | (write-region "y" nil jpyfile t)
330 | (should (equal (file-contents) "xy"))
331 | (write-region "z" nil jpyfile t)
332 | (should (equal (file-contents) "xyz"))
333 | (write-region "a" nil jpyfile 1)
334 | (should (equal (file-contents) "xaz"))
335 | (write-region "β" nil jpyfile 6)
336 | (should (equal (encode-coding-string (file-contents) 'utf-8)
337 | (encode-coding-string "xaz\0\0\0β" 'utf-8))))
338 | (ert-info ("File visiting")
339 | (with-temp-buffer
340 | (insert "foo")
341 | (write-region nil nil jpyfile nil t)
342 | (should-not (buffer-modified-p))
343 | (should (equal jpyfile (buffer-file-name)))
344 | (insert "bar")
345 | (write-region nil nil jpyfile nil "foo")
346 | (should-not (buffer-modified-p))
347 | (should (equal "foo" (file-name-nondirectory
348 | (buffer-file-name)))))))
349 | (delete-file file)))))
350 |
351 | ;; Local Variables:
352 | ;; byte-compile-warnings: (not free-vars)
353 | ;; End:
354 | ;;; jupyter-tramp-test.el ends here
355 |
--------------------------------------------------------------------------------
/jupyter-server-kernel.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-server-kernel.el --- Working with kernels behind a Jupyter server -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2020-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 23 Apr 2020
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Holds the definitions of `jupyter-server', what communicates to the
26 | ;; Jupyter server using the REST API, and `jupyter-kernel-server' a
27 | ;; representation of a kernel on a server.
28 |
29 | ;;; Code:
30 |
31 | (require 'jupyter-kernel)
32 | (require 'jupyter-rest-api)
33 | (require 'jupyter-monads)
34 | (require 'websocket)
35 |
36 | (declare-function jupyter-encode-raw-message "jupyter-messages")
37 | (declare-function jupyter-tramp-server-from-file-name "jupyter-tramp")
38 | (declare-function jupyter-tramp-file-name-p "jupyter-tramp")
39 | (declare-function jupyter-server-kernel-id-from-name "jupyter-server")
40 |
41 | (defgroup jupyter-server-kernel nil
42 | "Kernel behind a Jupyter server"
43 | :group 'jupyter)
44 |
45 | ;;; `jupyter-server'
46 |
47 | (defvar-local jupyter-current-server nil
48 | "The `jupyter-server' associated with the current buffer.
49 | Used in, e.g. a `jupyter-server-kernel-list-mode' buffer.")
50 |
51 | (put 'jupyter-current-server 'permanent-local t)
52 |
53 | (defvar jupyter--servers nil)
54 |
55 | ;; TODO: We should really rename `jupyter-server' to something like
56 | ;; `jupyter-server-client' since it isn't a representation of a server, but a
57 | ;; communication channel with one.
58 | (defclass jupyter-server (jupyter-rest-client eieio-instance-tracker)
59 | ((tracking-symbol :initform 'jupyter--servers)
60 | (kernelspecs
61 | :type json-plist
62 | :initform nil
63 | :documentation "Kernelspecs for the kernels available behind
64 | this gateway. Access them through `jupyter-kernelspecs'.")))
65 |
66 | (cl-defmethod make-instance ((_class (subclass jupyter-server)) &rest slots)
67 | (cl-assert (plist-get slots :url))
68 | (or (cl-loop
69 | with url = (plist-get slots :url)
70 | for server in jupyter--servers
71 | if (equal url (oref server url)) return server)
72 | (cl-call-next-method)))
73 |
74 | (defun jupyter-servers ()
75 | "Return a list of all `jupyter-server's."
76 | (jupyter-gc-servers)
77 | jupyter--servers)
78 |
79 | (defun jupyter-gc-servers ()
80 | "Delete `jupyter-server' instances that are no longer accessible."
81 | (dolist (server jupyter--servers)
82 | (unless (jupyter-api-server-exists-p server)
83 | (jupyter-api-delete-cookies (oref server url))
84 | (delete-instance server))))
85 |
86 | (cl-defmethod jupyter-api-request :around ((server jupyter-server) _method &rest _plist)
87 | (condition-case nil
88 | (cl-call-next-method)
89 | (jupyter-api-unauthenticated
90 | (if (memq jupyter-api-authentication-method '(ask token password))
91 | (oset server auth jupyter-api-authentication-method)
92 | (error "Unauthenticated request, can't attempt re-authentication \
93 | with default `jupyter-api-authentication-method'"))
94 | (prog1 (cl-call-next-method)
95 | (jupyter-reauthenticate-websockets server)))))
96 |
97 | (cl-defmethod jupyter-kernelspecs ((client jupyter-rest-client) &optional _refresh)
98 | (or (jupyter-api-get-kernelspec client)
99 | (error "Can't retrieve kernelspecs from server @ %s"
100 | (oref client url))))
101 |
102 | (cl-defmethod jupyter-kernelspecs ((server jupyter-server) &optional refresh)
103 | "Return the kernelspecs on SERVER.
104 | By default the available kernelspecs are cached. To force an
105 | update of the cached kernelspecs, give a non-nil value to
106 | REFRESH."
107 | (when (or refresh (null (oref server kernelspecs)))
108 | (let ((specs (cl-call-next-method)))
109 | (plist-put specs :kernelspecs
110 | (cl-loop
111 | for (_ spec) on (plist-get specs :kernelspecs) by #'cddr
112 | for name = (plist-get spec :name)
113 | collect (make-jupyter-kernelspec
114 | :name name
115 | :plist (plist-get spec :spec))))
116 | (oset server kernelspecs specs)))
117 | (plist-get (oref server kernelspecs) :kernelspecs))
118 |
119 | (cl-defmethod jupyter-kernelspecs :extra "server" ((host string) &optional refresh)
120 | (if (jupyter-tramp-file-name-p host)
121 | (jupyter-kernelspecs (jupyter-tramp-server-from-file-name host) refresh)
122 | (cl-call-next-method)))
123 |
124 | (cl-defmethod jupyter-server-has-kernelspec-p ((server jupyter-server) name)
125 | "Return non-nil if SERVER can launch kernels with kernelspec NAME."
126 | (jupyter-guess-kernelspec name (jupyter-kernelspecs server)))
127 |
128 | ;;; Kernel definition
129 |
130 | (cl-defstruct (jupyter-server-kernel
131 | (:include jupyter-kernel))
132 | (server jupyter-current-server
133 | :read-only t
134 | :documentation "The kernel server.")
135 | ;; TODO: Make this read only by only allowing creating
136 | ;; representations of kernels that have already been launched and
137 | ;; have a connection to the kernel.
138 | (id nil
139 | :type (or null string)
140 | :documentation "The kernel ID."))
141 |
142 | (cl-defmethod jupyter-alive-p ((kernel jupyter-server-kernel))
143 | (pcase-let (((cl-struct jupyter-server-kernel server id) kernel))
144 | (and id server
145 | ;; TODO: Cache this call
146 | (condition-case err
147 | (jupyter-api-get-kernel server id)
148 | (file-error nil) ; Non-existent server
149 | (jupyter-api-http-error
150 | (unless (= (nth 1 err) 404) ; Not Found
151 | (signal (car err) (cdr err)))))
152 | (cl-call-next-method))))
153 |
154 | (defun jupyter-server-kernel (&rest args)
155 | "Return a `jupyter-server-kernel' initialized with ARGS."
156 | (apply #'make-jupyter-server-kernel args))
157 |
158 | (cl-defmethod jupyter-kernel :extra "server" (&rest args)
159 | "Return a representation of a kernel on a Jupyter server.
160 | If ARGS has a :server key, return a `jupyter-server-kernel'
161 | initialized using ARGS. If ARGS also has a :spec key, whose
162 | value is the name of a kernelspec, the returned kernel's spec
163 | slot will be the corresponding `jupyter-kernelspec'.
164 |
165 | Call the next method if ARGS does not contain :server."
166 | (let ((server (plist-get args :server)))
167 | (if (not server) (cl-call-next-method)
168 | (cl-assert (object-of-class-p server 'jupyter-server))
169 | (let ((spec (plist-get args :spec)))
170 | (when (stringp spec)
171 | (plist-put args :spec
172 | ;; TODO: (jupyter-server-kernelspec server "python3")
173 | ;; which returns an I/O action and then arrange
174 | ;; for that action to be bound by mlet* and set
175 | ;; as the spec value. Or better yet, have
176 | ;; `jupyter-kernel' return a delayed kernel with
177 | ;; the server connection already open and
178 | ;; kernelspecs already retrieved.
179 | (or (jupyter-guess-kernelspec
180 | spec (jupyter-kernelspecs server))
181 | ;; TODO: Return the error to the I/O context.
182 | (error "No kernelspec matching %s @ %s" spec
183 | (oref server url))))))
184 | (apply #'jupyter-server-kernel args))))
185 |
186 | ;;; Websocket IO
187 |
188 | (defvar jupyter--reauth-subscribers (make-hash-table :weakness 'key :test 'eq))
189 |
190 | (defun jupyter-reauthenticate-websockets (server)
191 | "Re-authenticate WebSocket connections of SERVER."
192 | (when-let* ((pub (gethash server jupyter--reauth-subscribers)))
193 | (jupyter-run-with-io pub
194 | (jupyter-publish 'reauthenticate))))
195 |
196 | (defvar jupyter-server-kernel-frame-acc "")
197 |
198 | (defun jupyter-server-kernel-parse-frame (frame)
199 | "Accumulate the WebSocket frame data and return the full response when complete.
200 | Return nil if the frame is not yet complete."
201 | (let ((payload (websocket-frame-payload frame)))
202 | (setq jupyter-server-kernel-frame-acc (concat jupyter-server-kernel-frame-acc payload))
203 | (if (websocket-frame-completep frame)
204 | (let ((full-response jupyter-server-kernel-frame-acc))
205 | (setq jupyter-server-kernel-frame-acc "")
206 | full-response)
207 | nil)))
208 |
209 | (cl-defmethod jupyter-websocket-io ((kernel jupyter-server-kernel))
210 | "Return a list representing an IO connection to KERNEL.
211 | The list is composed of two elements (IO-PUB ACTION-SUB), IO-PUB
212 | is a publisher used to send/receive messages to/from KERNEL and
213 | ACTION-SUB is a subscriber of kernel actions to perform on
214 | KERNEL.
215 |
216 | To send a message to KERNEL, publish a list of the form
217 |
218 | (list \='send CHANNEL MSG-TYPE CONTENT MSG-ID)
219 |
220 | to IO-PUB, e.g.
221 |
222 | (jupyter-run-with-io IO-PUB
223 | (jupyter-publish (list \='send CHANNEL MSG-TYPE CONTENT MSG-ID)))
224 |
225 | To receive messages from KERNEL, subscribe to IO-PUB e.g.
226 |
227 | (jupyter-run-with-io IO-PUB
228 | (jupyter-subscribe
229 | (jupyter-subscriber
230 | (lambda (msg)
231 | ...))))
232 |
233 | The value \='interrupt or \='shutdown can be published to ACTION-SUB
234 | to interrupt or shutdown KERNEL. The value (list \='action FN)
235 | where FN is a single argument function can also be published, in
236 | this case FN will be evaluated on KERNEL."
237 | (jupyter-launch kernel)
238 | (pcase-let* (((cl-struct jupyter-server-kernel server id) kernel))
239 | (letrec ((status-pub (jupyter-publisher))
240 | (reauth-pub (or (gethash server jupyter--reauth-subscribers)
241 | (setf (gethash server jupyter--reauth-subscribers)
242 | (jupyter-publisher))))
243 | (kernel-io
244 | (jupyter-publisher
245 | (lambda (event)
246 | (pcase event
247 | (`(message . ,rest) (jupyter-content rest))
248 | (`(send ,channel ,msg-type ,content ,msg-id)
249 | (let ((send
250 | (lambda ()
251 | (websocket-send-text
252 | ws (let* ((cd (websocket-client-data ws))
253 | (session (plist-get cd :session)))
254 | (jupyter-encode-raw-message session msg-type
255 | :channel channel
256 | :msg-id msg-id
257 | :content content))))))
258 | (condition-case nil
259 | (funcall send)
260 | (websocket-closed
261 | (setq ws (funcall make-websocket))
262 | (funcall send)))))
263 | ('start
264 | (unless (websocket-openp ws)
265 | (setq ws (funcall make-websocket))))
266 | ('stop (websocket-close ws))))))
267 | (ws-failed-to-open t)
268 | (make-websocket
269 | (lambda ()
270 | (jupyter-api-kernel-websocket
271 | server id
272 | :custom-header-alist (jupyter-api-auth-headers server)
273 | :on-open
274 | (lambda (_ws)
275 | (setq ws-failed-to-open nil))
276 | :on-close
277 | (lambda (_ws)
278 | (if ws-failed-to-open
279 | ;; TODO: Retry?
280 | (error "Kernel connection could not be established")
281 | (setq ws-failed-to-open t)))
282 | ;; TODO: on-error publishes to status-pub
283 | :on-message
284 | (lambda (_ws frame)
285 | (when-let ((response (jupyter-server-kernel-parse-frame frame)))
286 | (pcase (websocket-frame-opcode frame)
287 | ((or 'text 'binary 'continuation)
288 | (let ((msg (jupyter-read-plist-from-string response)))
289 | (jupyter-run-with-io kernel-io
290 | (jupyter-publish (cons 'message msg)))))
291 | (_
292 | (jupyter-run-with-io status-pub
293 | (jupyter-publish
294 | (list 'error (websocket-frame-opcode frame)))))))))))
295 | (ws (prog1 (funcall make-websocket)
296 | (jupyter-run-with-io reauth-pub
297 | (jupyter-subscribe
298 | (jupyter-subscriber
299 | (lambda (_reauth)
300 | (if shutdown (jupyter-unsubscribe)
301 | (jupyter-run-with-io kernel-io
302 | (jupyter-do
303 | (jupyter-publish 'stop)
304 | (jupyter-publish 'start)))))))))))
305 | (list kernel-io
306 | (jupyter-subscriber
307 | (lambda (action)
308 | (pcase action
309 | ('interrupt
310 | (jupyter-interrupt kernel))
311 | ((and op (or 'connect 'disconnect))
312 | (if (eq op 'disconnect)
313 | (progn
314 | (or (null ws)
315 | (websocket-close ws))
316 | (setq ws (funcall make-websocket)))
317 | (websocket-close ws)
318 | (setq ws nil)))
319 | ('shutdown
320 | (jupyter-shutdown kernel)
321 | (websocket-close ws)
322 | (setq ws nil))
323 | ('restart
324 | (jupyter-restart kernel))
325 | (`(action ,fn)
326 | (funcall fn kernel)))))))))
327 |
328 | (cl-defmethod jupyter-io ((kernel jupyter-server-kernel))
329 | (jupyter-websocket-io kernel))
330 |
331 | ;;; Kernel management
332 |
333 | ;; The KERNEL argument is optional here so that `jupyter-launch'
334 | ;; does not require more than one argument just to handle this case.
335 | (cl-defmethod jupyter-launch ((server jupyter-server) &optional kernel)
336 | (cl-check-type kernel string)
337 | (let* ((spec (jupyter-guess-kernelspec
338 | kernel (jupyter-kernelspecs server)))
339 | (plist (jupyter-api-start-kernel
340 | server (jupyter-kernelspec-name spec))))
341 | (jupyter-kernel :server server :id (plist-get plist :id) :spec spec)))
342 |
343 | ;; FIXME: Don't allow creating kernels without them being launched.
344 | (cl-defmethod jupyter-launch ((kernel jupyter-server-kernel))
345 | "Launch KERNEL based on its kernelspec.
346 | When KERNEL does not have an ID yet, launch KERNEL on SERVER
347 | using its SPEC."
348 | (pcase-let (((cl-struct jupyter-server-kernel server id spec session) kernel))
349 | (unless session
350 | (and id (setq id (or (jupyter-server-kernel-id-from-name server id) id)))
351 | (if id
352 | ;; When KERNEL already has an ID before it has a session,
353 | ;; assume we are connecting to an already launched kernel. In
354 | ;; this case, make sure the KERNEL's SPEC is the same as the
355 | ;; one being connected to.
356 | ;;
357 | ;; Note, this also has the side effect of raising an error
358 | ;; when the ID does not match one on the server.
359 | (unless spec
360 | (let ((model (jupyter-api-get-kernel server id)))
361 | (setf (jupyter-kernel-spec kernel)
362 | (jupyter-guess-kernelspec
363 | (plist-get model :name)
364 | (jupyter-kernelspecs server)))))
365 | (let ((plist (jupyter-api-start-kernel
366 | server (jupyter-kernelspec-name spec))))
367 | (setf (jupyter-server-kernel-id kernel) (plist-get plist :id))
368 | (sit-for 1)))
369 | ;; TODO: Replace with the real session object
370 | (setf (jupyter-kernel-session kernel) (jupyter-session))))
371 | (cl-call-next-method))
372 |
373 | (cl-defmethod jupyter-shutdown ((kernel jupyter-server-kernel))
374 | (pcase-let (((cl-struct jupyter-server-kernel server id session) kernel))
375 | (cl-call-next-method)
376 | (when session
377 | (jupyter-api-shutdown-kernel server id))))
378 |
379 | (cl-defmethod jupyter-restart ((kernel jupyter-server-kernel))
380 | (pcase-let (((cl-struct jupyter-server-kernel server id session) kernel))
381 | (when session
382 | (jupyter-api-restart-kernel server id))))
383 |
384 | (cl-defmethod jupyter-interrupt ((kernel jupyter-server-kernel))
385 | (pcase-let (((cl-struct jupyter-server-kernel server id) kernel))
386 | (jupyter-api-interrupt-kernel server id)))
387 |
388 | (provide 'jupyter-server-kernel)
389 |
390 | ;;; jupyter-server-kernel.el ends here
391 |
--------------------------------------------------------------------------------
/jupyter-kernel-process.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-kernel-process.el --- Jupyter kernels as Emacs processes -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2020-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 25 Apr 2020
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; Jupyter kernels as Emacs processes.
26 |
27 | ;;; Code:
28 |
29 | (require 'jupyter-kernel)
30 | (require 'jupyter-monads)
31 |
32 | (defgroup jupyter-kernel-process nil
33 | "Jupyter kernels as Emacs processes"
34 | :group 'jupyter)
35 |
36 | (declare-function jupyter-ioloop-start "jupyter-ioloop")
37 | (declare-function jupyter-ioloop-stop "jupyter-ioloop")
38 | (declare-function jupyter-send "jupyter-ioloop")
39 | (declare-function jupyter-ioloop-alive-p "jupyter-ioloop")
40 | (declare-function jupyter-channel-ioloop-set-session "jupyter-channel-ioloop")
41 | (declare-function ansi-color-apply "ansi-color")
42 | (declare-function jupyter-hb-pause "jupyter-zmq-channel")
43 |
44 | (defvar jupyter--kernel-processes '()
45 | "The list of kernel processes launched.
46 | Elements look like (PROCESS CONN-FILE) where PROCESS is a kernel
47 | process and CONN-FILE the associated connection file.
48 |
49 | Cleaning up the list removes elements whose PROCESS is no longer
50 | live. When removing an element, CONN-FILE will be deleted and
51 | PROCESS's buffer killed.
52 |
53 | The list is periodically cleaned up when a new process is
54 | launched.
55 |
56 | Also, just before Emacs exits any connection files that still
57 | exist are deleted.")
58 |
59 | ;;; Kernel definition
60 |
61 | (cl-defstruct (jupyter-kernel-process
62 | (:include jupyter-kernel))
63 | connect-p)
64 |
65 | (cl-defmethod jupyter-process ((kernel jupyter-kernel-process))
66 | "Return the process of KERNEL.
67 | Return nil if KERNEL does not have an associated process."
68 | (car (cl-find-if (lambda (x) (and (processp (car x))
69 | (eq (process-get (car x) :kernel) kernel)))
70 | jupyter--kernel-processes)))
71 |
72 | (cl-defmethod jupyter-alive-p ((kernel jupyter-kernel-process))
73 | (let ((process (jupyter-process kernel)))
74 | (and (process-live-p process)
75 | (cl-call-next-method))))
76 |
77 | (defun jupyter-kernel-process (&rest args)
78 | "Return a `jupyter-kernel-process' initialized with ARGS."
79 | (apply #'make-jupyter-kernel-process args))
80 |
81 | (cl-defmethod jupyter-kernel :extra "process" (&rest args)
82 | "Return a kernel as an Emacs process.
83 | If ARGS contains a :spec key with a value being a
84 | `jupyter-kernelspec', a `jupyter-kernel-process' initialized from
85 | it will be returned. The value can also be a string, in which
86 | case it is considered the name of a kernelspec to use.
87 |
88 | If ARGS contains a :conn-info key, a `jupyter-kernel-process'
89 | with a session initialized from its value, either the name of a
90 | connection file to read or a connection property list itself (see
91 | `jupyter-read-connection'), will be returned. The remaining ARGS
92 | will be used to initialize the returned kernel.
93 |
94 | Call the next method if ARGS does not contain a :spec or
95 | :conn-info key."
96 | (if (plist-get args :server) (cl-call-next-method)
97 | (let ((spec (plist-get args :spec))
98 | (conn-info (plist-get args :conn-info)))
99 | (cond
100 | ((and spec (not conn-info))
101 | (when (stringp spec)
102 | (plist-put args :spec
103 | (or (jupyter-guess-kernelspec spec)
104 | (error "No kernelspec matching name (%s)" spec))))
105 | (cl-check-type (plist-get args :spec) jupyter-kernelspec)
106 | (apply #'jupyter-kernel-process args))
107 | (conn-info
108 | (apply #'jupyter-kernel-process
109 | :session (if (stringp conn-info)
110 | (jupyter-connection-file-to-session conn-info)
111 | conn-info)
112 | (cl-loop
113 | for (k v) on args by #'cddr
114 | unless (eq k :conn-info) collect k and collect v)))
115 | (t
116 | (cl-call-next-method))))))
117 |
118 | ;;; Client connection
119 |
120 | (cl-defmethod jupyter-zmq-io ((kernel jupyter-kernel-process))
121 | (unless (jupyter-kernel-process-connect-p kernel)
122 | (jupyter-launch kernel))
123 | (let ((channels '(:shell :iopub :stdin :control))
124 | session ch-group hb kernel-io ioloop shutdown)
125 | (cl-macrolet ((continue-after
126 | (cond on-timeout)
127 | `(jupyter-with-timeout
128 | (nil jupyter-default-timeout ,on-timeout)
129 | ,cond)))
130 | (cl-labels ((set-session
131 | ()
132 | (or (setq session (jupyter-kernel-session kernel))
133 | (error "A session is needed to connect to a kernel's I/O")))
134 | (set-ch-group
135 | ()
136 | (let ((endpoints (jupyter-session-endpoints (set-session))))
137 | (setq ch-group
138 | (cl-loop
139 | for ch in channels
140 | collect ch
141 | collect (list :endpoint (plist-get endpoints ch)
142 | :alive-p nil)))))
143 | (ch-put
144 | (ch prop value)
145 | (plist-put (plist-get ch-group ch) prop value))
146 | (ch-get
147 | (ch prop)
148 | (plist-get (plist-get ch-group ch) prop))
149 | (ch-alive-p
150 | (ch)
151 | (and ioloop (jupyter-ioloop-alive-p ioloop)
152 | (ch-get ch :alive-p)))
153 | (ch-start
154 | (ch)
155 | (unless (ch-alive-p ch)
156 | (jupyter-send ioloop 'start-channel ch
157 | (ch-get ch :endpoint))
158 | (continue-after
159 | (ch-alive-p ch)
160 | (error "Channel failed to start: %s" ch))))
161 | (ch-stop
162 | (ch)
163 | (when (ch-alive-p ch)
164 | (jupyter-send ioloop 'stop-channel ch)
165 | (continue-after
166 | (not (ch-alive-p ch))
167 | (error "Channel failed to stop: %s" ch))))
168 | (start
169 | ()
170 | (unless ioloop
171 | (require 'jupyter-zmq-channel-ioloop)
172 | (setq ioloop (make-instance 'jupyter-zmq-channel-ioloop))
173 | (jupyter-channel-ioloop-set-session ioloop session))
174 | (unless (jupyter-ioloop-alive-p ioloop)
175 | (jupyter-ioloop-start
176 | ioloop
177 | (lambda (event)
178 | (pcase (car event)
179 | ((and 'start-channel (let ch (cadr event)))
180 | (ch-put ch :alive-p t))
181 | ((and 'stop-channel (let ch (cadr event)))
182 | (ch-put ch :alive-p nil))
183 | ;; TODO: Get rid of this
184 | ('sent nil)
185 | (_
186 | (jupyter-run-with-io kernel-io
187 | (jupyter-publish event))))))
188 | (condition-case err
189 | (cl-loop
190 | for ch in channels
191 | do (ch-start ch))
192 | (error
193 | (jupyter-ioloop-stop ioloop)
194 | (signal (car err) (cdr err)))))
195 | ioloop)
196 | (stop
197 | ()
198 | (when hb
199 | (jupyter-hb-pause hb)
200 | (setq hb nil))
201 | (when ioloop
202 | (when (jupyter-ioloop-alive-p ioloop)
203 | (jupyter-ioloop-stop ioloop))
204 | (setq ioloop nil))))
205 | (set-ch-group)
206 | (setq kernel-io
207 | ;; TODO: (jupyter-publisher :name "Session I/O" :fn ...)
208 | ;;
209 | ;; so that on error in a subscriber, the name can be
210 | ;; displayed to know where to look. This requires a
211 | ;; `jupyter-publisher' struct type.
212 | (jupyter-publisher
213 | (lambda (content)
214 | (if shutdown
215 | (error "Kernel I/O no longer available: %s"
216 | (cl-prin1-to-string session))
217 | (pcase (car content)
218 | ;; ('message channel idents . msg)
219 | ('message
220 | (pop content)
221 | ;; Set the channel key of the message property list
222 | (plist-put
223 | (cddr content) :channel
224 | (substring (symbol-name (car content)) 1))
225 | (jupyter-content (cddr content)))
226 | ('send
227 | ;; Set the channel argument to a keyword so its
228 | ;; recognized by the ioloop
229 | (setq content
230 | (cons (car content)
231 | (cons (intern (concat ":" (cadr content)))
232 | (cddr content))))
233 | (apply #'jupyter-send (start) content))
234 | ('hb
235 | (unless hb
236 | (setq hb
237 | (let ((endpoints (set-session)))
238 | (make-instance
239 | 'jupyter-hb-channel
240 | :session session
241 | :endpoint (plist-get endpoints :hb)))))
242 | (jupyter-run-with-io (cadr content)
243 | (jupyter-publish hb)))
244 | (_ (error "Unhandled I/O: %s" content)))))))
245 | (list kernel-io
246 | (jupyter-subscriber
247 | (lambda (action)
248 | (pcase action
249 | ('interrupt
250 | (jupyter-interrupt kernel))
251 | ('shutdown
252 | (jupyter-shutdown kernel)
253 | (stop)
254 | (setq shutdown t))
255 | ((and op (or 'connect 'disconnect))
256 | (if (eq op 'connect) (stop)
257 | (start)))
258 | ('restart
259 | (setq shutdown nil)
260 | (jupyter-restart kernel)
261 | (stop)
262 | (set-ch-group)
263 | (start))
264 | (`(action ,fn)
265 | (funcall fn kernel))))))))))
266 |
267 | (cl-defmethod jupyter-io ((kernel jupyter-kernel-process))
268 | "Return an I/O connection to KERNEL's session."
269 | (jupyter-zmq-io kernel))
270 |
271 | ;;; Kernel management
272 |
273 | (defun jupyter--gc-kernel-processes ()
274 | (setq jupyter--kernel-processes
275 | (cl-loop for (p conn-file) in jupyter--kernel-processes
276 | if (process-live-p p) collect (list p conn-file)
277 | else do (delete-process p)
278 | (when (file-exists-p conn-file)
279 | (delete-file conn-file))
280 | and when (buffer-live-p (process-buffer p))
281 | do (kill-buffer (process-buffer p)))))
282 |
283 | (defun jupyter-delete-connection-files ()
284 | "Delete all connection files created by Emacs."
285 | ;; Ensure Emacs can be killed on error
286 | (ignore-errors
287 | (cl-loop for (_ conn-file) in jupyter--kernel-processes
288 | do (when (file-exists-p conn-file)
289 | (delete-file conn-file)))))
290 |
291 | (add-hook 'kill-emacs-hook #'jupyter-delete-connection-files)
292 |
293 | (defun jupyter--start-kernel-process (name kernelspec conn-file)
294 | (let* ((process-name (format "jupyter-kernel-%s" name))
295 | (buffer-name (format " *jupyter-kernel[%s]*" name))
296 | (process-environment
297 | (append (jupyter-process-environment kernelspec)
298 | process-environment))
299 | (args (jupyter-kernel-argv kernelspec conn-file))
300 | (atime (nth 4 (file-attributes conn-file)))
301 | (process (apply #'start-file-process process-name
302 | (generate-new-buffer buffer-name)
303 | (car args) (cdr args))))
304 | (set-process-query-on-exit-flag process jupyter--debug)
305 | ;; Wait until the connection file has been read before returning.
306 | ;; This is to give the kernel a chance to setup before sending it
307 | ;; messages.
308 | ;;
309 | ;; TODO: Replace with a check of the heartbeat channel.
310 | (jupyter-with-timeout
311 | ((format "Starting %s kernel process..." name)
312 | jupyter-long-timeout
313 | (unless (process-live-p process)
314 | (error "Kernel process exited:\n%s"
315 | (with-current-buffer (process-buffer process)
316 | (ansi-color-apply (buffer-string))))))
317 | ;; Windows systems may not have good time resolution when retrieving
318 | ;; the last access time of a file so we don't bother with checking that
319 | ;; the kernel has read the connection file and leave it to the
320 | ;; downstream initialization to ensure that we can communicate with a
321 | ;; kernel.
322 | (or (memq system-type '(ms-dos windows-nt cygwin))
323 | (let ((attribs (file-attributes conn-file)))
324 | ;; `file-attributes' can potentially return nil, in this case
325 | ;; just assume it has read the connection file so that we can
326 | ;; know for sure it is not connected if it fails to respond to
327 | ;; any messages we send it.
328 | (or (null attribs)
329 | (not (equal atime (nth 4 attribs)))))))
330 | (jupyter--gc-kernel-processes)
331 | (push (list process conn-file) jupyter--kernel-processes)
332 | process))
333 |
334 | (cl-defmethod jupyter-launch :before ((kernel jupyter-kernel-process))
335 | "Ensure KERNEL has a non-nil SESSION slot.
336 | A `jupyter-session' with random port numbers for the channels and
337 | a newly generated message signing key will be set as the value of
338 | KERNEL's SESSION slot if it is nil."
339 | (pcase-let (((cl-struct jupyter-kernel-process session) kernel))
340 | (unless session
341 | (setf (jupyter-kernel-session kernel) (jupyter-session-with-random-ports))
342 | ;; This is here for stability when running the tests. Sometimes
343 | ;; the kernel ports are not set up fast enough due to the hack
344 | ;; done in `jupyter-session-with-random-ports'. The effect
345 | ;; seems to be messages that are sent but never received by the
346 | ;; kernel.
347 | (sit-for 0.2))))
348 |
349 | (cl-defmethod jupyter-launch ((kernel jupyter-kernel-process))
350 | "Start KERNEL's process.
351 | Do nothing if KERNEL's process is already live.
352 |
353 | The process arguments are constructed from KERNEL's SPEC. The
354 | connection file passed as argument to the process is first
355 | written to file, its contents are generated from KERNEL's SESSION
356 | slot.
357 |
358 | See also https://jupyter-client.readthedocs.io/en/stable/kernels.html#kernel-specs"
359 | (let ((process (jupyter-process kernel)))
360 | (unless (process-live-p process)
361 | (pcase-let (((cl-struct jupyter-kernel-process spec session) kernel))
362 | (let ((conn-file (jupyter-write-connection-file session)))
363 | (setq process (jupyter--start-kernel-process
364 | (jupyter-kernel-name kernel) spec
365 | conn-file))
366 | ;; Make local tunnels to the remote ports when connecting to
367 | ;; remote kernels. Update the session object to reflect
368 | ;; these changes.
369 | (when (file-remote-p conn-file)
370 | (setf (jupyter-kernel-session kernel)
371 | (let ((conn-info (jupyter-tunnel-connection conn-file)))
372 | (jupyter-session
373 | :conn-info conn-info
374 | :key (plist-get conn-info :key)))))))
375 | (setf (process-get process :kernel) kernel)
376 | (setf (process-sentinel process)
377 | (lambda (process _)
378 | (pcase (process-status process)
379 | ('signal
380 | (let ((kernel (process-get process :kernel)))
381 | (when kernel
382 | (warn "Kernel died unexpectedly")
383 | (jupyter-shutdown kernel)))))))))
384 | (cl-call-next-method))
385 |
386 | (cl-defmethod jupyter-shutdown ((kernel jupyter-kernel-process))
387 | "Shutdown KERNEL by killing its process unconditionally."
388 | (let ((process (jupyter-process kernel)))
389 | (when process
390 | (setf (process-get process :kernel) nil)
391 | (delete-process process))
392 | (cl-call-next-method)))
393 |
394 | (cl-defmethod jupyter-restart ((_kernel jupyter-kernel-process))
395 | (cl-call-next-method))
396 |
397 | (cl-defmethod jupyter-interrupt ((kernel jupyter-kernel-process))
398 | "Interrupt KERNEL's process.
399 | The process can be interrupted when the interrupt mode of
400 | KERNEL's spec. is \"signal\" or not specified.
401 |
402 | See also https://jupyter-client.readthedocs.io/en/stable/kernels.html#kernel-specs"
403 | (pcase-let* ((process (jupyter-process kernel))
404 | ((cl-struct jupyter-kernel-process spec) kernel)
405 | ((cl-struct jupyter-kernelspec plist) spec)
406 | (imode (plist-get plist :interrupt_mode)))
407 | (cond
408 | ((or (null imode) (string= imode "signal"))
409 | (when (process-live-p process)
410 | (interrupt-process process t)))
411 | ((string= imode "message")
412 | (error "Send an interrupt_request using a client"))
413 | (t (cl-call-next-method)))))
414 |
415 | (provide 'jupyter-kernel-process)
416 |
417 | ;;; jupyter-kernel-process.el ends here
418 |
419 |
420 |
--------------------------------------------------------------------------------
/jupyter-monads.el:
--------------------------------------------------------------------------------
1 | ;;; jupyter-monads.el --- Monadic Jupyter -*- lexical-binding: t -*-
2 |
3 | ;; Copyright (C) 2020-2024 Nathaniel Nicandro
4 |
5 | ;; Author: Nathaniel Nicandro
6 | ;; Created: 11 May 2020
7 |
8 | ;; This program is free software; you can redistribute it and/or
9 | ;; modify it under the terms of the GNU General Public License as
10 | ;; published by the Free Software Foundation; either version 3, or (at
11 | ;; your option) any later version.
12 |
13 | ;; This program is distributed in the hope that it will be useful, but
14 | ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16 | ;; General Public License for more details.
17 |
18 | ;; You should have received a copy of the GNU General Public License
19 | ;; along with GNU Emacs; see the file COPYING. If not, write to the
20 | ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
21 | ;; Boston, MA 02111-1307, USA.
22 |
23 | ;;; Commentary:
24 |
25 | ;; TODO: Generalize `jupyter-with-io' and `jupyter-do' for any monad,
26 | ;; not just the I/O one.
27 | ;;
28 | ;; TODO: Allow pcase patterns in mlet*
29 | ;;
30 | ;; (jupyter-mlet* ((value (jupyter-server-kernel-io kernel)))
31 | ;; (pcase-let ((`(,kernel-sub ,event-pub) value))
32 | ;; ...))
33 | ;;
34 | ;; into
35 | ;;
36 | ;; (jupyter-mlet* ((`(,kernel-sub ,event-pub)
37 | ;; (jupyter-server-kernel-io kernel)))
38 | ;; ...)
39 |
40 | ;;; Code:
41 |
42 | (require 'jupyter-base)
43 |
44 | (declare-function jupyter-handle-message "jupyter-client")
45 | (declare-function jupyter-kernel-io "jupyter-client")
46 | (declare-function jupyter-generate-request "jupyter-client")
47 | (declare-function jupyter-wait-until-idle "jupyter-client" (req &optional timeout progress-msg))
48 |
49 | (defconst jupyter--return-nil (lambda (state) (cons nil state)))
50 |
51 | (defun jupyter-return (value)
52 | "Return a monadic value wrapping VALUE."
53 | (declare (indent 0)
54 | (compiler-macro
55 | (lambda (exp)
56 | (cond
57 | ((null value)
58 | 'jupyter--return-nil)
59 | ((if (atom value)
60 | (not (symbolp value))
61 | (eq (car value) 'quote))
62 | `(lambda (state) (cons ,value state)))
63 | (t exp)))))
64 | (lambda (state) (cons value state)))
65 |
66 | (defun jupyter-get-state ()
67 | "Return a monadic valid whose unwrapped value is the current state."
68 | (lambda (state) (cons state state)))
69 |
70 | (defun jupyter-put-state (value)
71 | "Return a monadic value that sets the current state to VALUE.
72 | The unwrapped value is nil."
73 | (lambda (_state) (cons nil value)))
74 |
75 | (defun jupyter-bind (mvalue mfn)
76 | "Bind MVALUE to MFN."
77 | (declare (indent 1))
78 | (lambda (state)
79 | (pcase-let* ((`(,value . ,state) (funcall mvalue state)))
80 | (funcall (funcall mfn value) state))))
81 |
82 | (defmacro jupyter-mlet* (varlist &rest body)
83 | "Bind the monadic values in VARLIST, evaluate BODY.
84 | Return the result of evaluating BODY. The result of evaluating
85 | BODY should be another monadic value."
86 | (declare (indent 1) (debug ((&rest (symbolp form)) body)))
87 | (if (null varlist)
88 | (if (zerop (length body)) 'jupyter--return-nil
89 | `(progn ,@body))
90 | (pcase-let ((`(,name ,mvalue) (car varlist)))
91 | `(jupyter-bind ,mvalue
92 | (lambda (,name)
93 | (jupyter-mlet* ,(cdr varlist)
94 | ,@body))))))
95 |
96 | (defmacro jupyter-do (&rest actions)
97 | "Return a monadic value that performs all actions in ACTIONS.
98 | The actions are evaluated in the order given. The result of the
99 | returned action is the result of the last action in ACTIONS."
100 | (declare (indent 0) (debug (body)))
101 | (if (zerop (length actions)) 'jupyter--return-nil
102 | (let ((result (make-symbol "result")))
103 | `(jupyter-mlet*
104 | ,(cl-loop
105 | for action being the elements of actions using (index i)
106 | for sym = (if (= i (1- (length actions))) result '_)
107 | collect `(,sym ,action))
108 | (jupyter-return ,result)))))
109 |
110 | (defun jupyter-run-with-state (state mvalue)
111 | "Pass STATE as the state to MVALUE, return the resulting value."
112 | (declare (indent 1))
113 | ;; Discard the final state
114 | (car (funcall mvalue state)))
115 |
116 | (defmacro jupyter-run-with-io (io &rest body)
117 | "Return the result of evaluating the I/O value BODY evaluates to.
118 | All I/O operations are done in the context of IO."
119 | (declare (indent 1) (debug (form body)))
120 | `(jupyter-run-with-state ,io (progn ,@body)))
121 |
122 | (defmacro jupyter-run-with-client (client &rest body)
123 | "Return the result of evaluating the monadic value BODY evaluates to.
124 | The initial state given to the monadic value is CLIENT."
125 | (declare (indent 1) (debug (form body)))
126 | `(jupyter-run-with-state ,client (progn ,@body)))
127 |
128 | (defmacro jupyter-with-io (io &rest body)
129 | "Return an I/O action evaluating BODY in IO's context.
130 | The result of the returned action is the result of the I/O action
131 | BODY evaluates to."
132 | (declare (indent 1) (debug (form body)))
133 | `(lambda (_)
134 | (jupyter-run-with-io ,io ,@body)))
135 |
136 | ;;; Publisher/subscriber
137 |
138 | (define-error 'jupyter-subscribed-subscriber
139 | "A subscriber cannot be subscribed to.")
140 |
141 | (defun jupyter-subscriber (sub-fn)
142 | "Return a subscriber evaluating SUB-FN on published content.
143 | SUB-FN should return the result of evaluating
144 | `jupyter-unsubscribe' if the subscriber's subscription should be
145 | canceled.
146 |
147 | Ex. Unsubscribe after consuming one message
148 |
149 | (jupyter-subscriber
150 | (lambda (value)
151 | (message \"The published content: %s\" value)
152 | (jupyter-unsubscribe)))
153 |
154 | Used like this, where sub is the above subscriber:
155 |
156 | (jupyter-run-with-io (jupyter-publisher)
157 | (jupyter-subscribe sub)
158 | (jupyter-publish (list \='topic \"today's news\")))"
159 | (declare (indent 0))
160 | (lambda (sub-content)
161 | (pcase sub-content
162 | (`(content ,content) (funcall sub-fn content))
163 | (`(subscribe ,_) (signal 'jupyter-subscribed-subscriber nil))
164 | (_ (error "Unhandled subscriber content: %s" sub-content)))))
165 |
166 | (defun jupyter-content (value)
167 | "Arrange for VALUE to be sent to subscribers of a publisher."
168 | (list 'content value))
169 |
170 | (defsubst jupyter-unsubscribe ()
171 | "Arrange for the current subscription to be canceled.
172 | A subscriber (or publisher with a subscription) can return the
173 | result of this function to cancel its subscription with the
174 | publisher providing content."
175 | (list 'unsubscribe))
176 |
177 | (define-error 'jupyter-publisher-subscribers-had-errors
178 | "Publisher's subscribers had errors")
179 |
180 | (defun jupyter-distribute-content (pub-fn content subs)
181 | "Apply PUB-FN on submitted CONTENT to produce published content.
182 | Call each subscriber in SUBS on the published content. Remove
183 | those subscribers that cancel their subscription.
184 |
185 | When a subscriber signals an error it is noted and the remaining
186 | subscribers are processed. After processing all subscribers, a
187 | `jupyter-publisher-subscribers-had-errors' error is raised with
188 | the data being the list of errors raised when calling
189 | subscribers. Note, when a subscriber errors, it remains in the
190 | list of subscribers."
191 | (pcase (funcall pub-fn content)
192 | ((and `(content ,_) sub-content)
193 | ;; NOTE: The first element of SUBS is ignored here so that the
194 | ;; pointer to the subscriber list remains the same for each
195 | ;; publisher, even when subscribers are being destructively
196 | ;; removed.
197 | (let ((errors nil))
198 | (while (cadr subs)
199 | (condition-case err
200 | ;; Publish subscriber content to subscribers
201 | (pcase (funcall (cadr subs) sub-content)
202 | ;; Destructively remove the subscriber when it returns an
203 | ;; unsubscribe value.
204 | ('(unsubscribe) (setcdr subs (cddr subs)))
205 | (_ (pop subs)))
206 | (error
207 | ;; Skip over any subscribers that raised an error.
208 | (pop subs)
209 | (push err errors))))
210 | ;; Inform about the errors.
211 | (when errors
212 | (signal 'jupyter-publisher-subscribers-had-errors errors)))
213 | nil)
214 | ;; Cancel a publisher's subscription to another publisher.
215 | ('(unsubscribe) '(unsubscribe))
216 | (_ nil)))
217 |
218 | (defun jupyter-publisher (&optional pub-fn)
219 | "Return a publisher function.
220 | A publisher function is a closure, function with a local scope,
221 | that maintains a list of subscribers and distributes the content
222 | that PUB-FN returns to each of them.
223 |
224 | PUB-FN is a function that optionally returns content to
225 | publish (by returning the result of `jupyter-content' on a
226 | value). It's called when a value is submitted for publishing
227 | using `jupyter-publish', like this:
228 |
229 | (let ((pub (jupyter-publisher
230 | (lambda (submitted-value)
231 | (message \"Publishing %s to subscribers\" submitted-value)
232 | (jupyter-content submitted-value)))))
233 | (jupyter-run-with-io pub
234 | (jupyter-publish (list 1 2 3))))
235 |
236 | The default for PUB-FN is `jupyter-content'. See
237 | `jupyter-subscribe' for an example on how to subscribe to a
238 | publisher.
239 |
240 | If no content is returned by PUB-FN, no content is sent to
241 | subscribers.
242 |
243 | A publisher can also be a subscriber of another publisher. In
244 | this case, if PUB-FN returns the result of `jupyter-unsubscribe'
245 | its subscription is canceled.
246 |
247 | Ex. Publish the value 1 regardless of what is given to PUB-FN.
248 |
249 | (jupyter-publisher
250 | (lambda (_)
251 | (jupyter-content 1)))
252 |
253 | Ex. Publish \='app if \='app is given to a publisher, nothing is sent
254 | to subscribers otherwise. In this case, a publisher is a
255 | filter of the value given to it for publishing.
256 |
257 | (jupyter-publisher
258 | (lambda (value)
259 | (if (eq value \='app)
260 | (jupyter-content value))))"
261 | (declare (indent 0))
262 | (let ((subs (list 'subscribers))
263 | (pub-fn (or pub-fn #'jupyter-content)))
264 | ;; A publisher value is either a value representing a subscriber
265 | ;; or a value representing content to send to subscribers.
266 | (lambda (pub-value)
267 | (pcase (car-safe pub-value)
268 | ('content (jupyter-distribute-content pub-fn (cadr pub-value) subs))
269 | ('subscribe (cl-pushnew (cadr pub-value) (cdr subs)))
270 | (_ (error "Unhandled publisher content: %s" pub-value))))))
271 |
272 | (defun jupyter-subscribe (sub)
273 | "Return an I/O action that subscribes SUB to published content.
274 | If a subscriber (or a publisher with a subscription to another
275 | publisher) returns the result of `jupyter-unsubscribe', its
276 | subscription is canceled.
277 |
278 | Ex. Subscribe to a publisher and unsubscribe after receiving two
279 | messages.
280 |
281 | (let* ((msgs \='())
282 | (pub (jupyter-publisher))
283 | (sub (jupyter-subscriber
284 | (lambda (n)
285 | (if (> n 2) (jupyter-unsubscribe)
286 | (push n msgs))))))
287 | (jupyter-run-with-io pub
288 | (jupyter-subscribe sub))
289 | (cl-loop
290 | for x in \='(1 2 3)
291 | do (jupyter-run-with-io pub
292 | (jupyter-publish x)))
293 | (reverse msgs)) ; => \='(1 2)"
294 | (declare (indent 0))
295 | (lambda (io)
296 | (funcall io (list 'subscribe sub))
297 | (cons nil io)))
298 |
299 | (defun jupyter-publish (value)
300 | "Return an I/O action that submits VALUE to publish as content."
301 | (declare (indent 0))
302 | (lambda (io)
303 | (funcall io (jupyter-content value))
304 | (cons nil io)))
305 |
306 | ;;; Working with requests
307 |
308 | (define-error 'jupyter-timeout-before-idle "Timeout before idle")
309 |
310 | (defun jupyter-sent (dreq)
311 | (jupyter-mlet* ((client (jupyter-get-state))
312 | (req dreq))
313 | (let ((type (jupyter-request-type req)))
314 | (jupyter-run-with-io (jupyter-kernel-io client)
315 | (jupyter-do
316 | (jupyter-subscribe (jupyter-request-message-publisher req))
317 | (jupyter-publish
318 | (list 'send
319 | (jupyter-channel-from-request-type type)
320 | type
321 | (jupyter-request-content req)
322 | (jupyter-request-id req))))))
323 | (jupyter-return req)))
324 |
325 | (defun jupyter-idle (dreq &optional timeout)
326 | "Wait until DREQ has become idle, return DREQ.
327 | Signal a `jupyter-timeout-before-idle' error if TIMEOUT seconds
328 | elapses and the request has not become idle yet."
329 | (jupyter-mlet* ((req (jupyter-sent dreq)))
330 | (or (jupyter-wait-until-idle req timeout)
331 | (signal 'jupyter-timeout-before-idle (list req)))
332 | (jupyter-return req)))
333 |
334 | (defun jupyter-messages (dreq &optional timeout)
335 | "Return all the messages of REQ.
336 | TIMEOUT has the same meaning as in `jupyter-idle'."
337 | (jupyter-mlet* ((req (jupyter-idle dreq timeout)))
338 | (jupyter-return (jupyter-request-messages req))))
339 |
340 | (defun jupyter-find-message (msg-type msgs)
341 | "Return a message whose type is MSG-TYPE in MSGS."
342 | (cl-find-if
343 | (lambda (msg)
344 | (let ((type (jupyter-message-type msg)))
345 | (string= type msg-type)))
346 | msgs))
347 |
348 | (defun jupyter-reply (dreq &optional timeout)
349 | "Return the reply message of REQ.
350 | TIMEOUT has the same meaning as in `jupyter-idle'."
351 | (jupyter-mlet* ((msgs (jupyter-messages dreq timeout)))
352 | (jupyter-return
353 | (cl-find-if
354 | (lambda (msg)
355 | (let ((type (jupyter-message-type msg)))
356 | (string-suffix-p "_reply" type)))
357 | msgs))))
358 |
359 | (defun jupyter-result (dreq &optional timeout)
360 | "Return the result message of REQ.
361 | TIMEOUT has the same meaning as in `jupyter-idle'."
362 | (jupyter-mlet* ((msgs (jupyter-messages dreq timeout)))
363 | (jupyter-return
364 | (cl-find-if
365 | (lambda (msg)
366 | (let ((type (jupyter-message-type msg)))
367 | (string-suffix-p "_result" type)))
368 | msgs))))
369 |
370 | (defun jupyter-message-subscribed (dreq cbs)
371 | "Return an IO action that subscribes CBS to a request's message publisher.
372 | IO-REQ is an IO action that evaluates to a sent request. CBS is
373 | an alist mapping message types to callback functions like
374 |
375 | `((\"execute_reply\" ,(lambda (msg) ...))
376 | ...)
377 |
378 | The returned IO action returns the sent request after subscribing
379 | the callbacks."
380 | (jupyter-mlet* ((req dreq))
381 | (jupyter-run-with-io
382 | (jupyter-request-message-publisher req)
383 | (jupyter-subscribe
384 | (jupyter-subscriber
385 | (lambda (msg)
386 | (when-let*
387 | ((msg-type (jupyter-message-type msg))
388 | (fn (car (alist-get msg-type cbs nil nil #'string=))))
389 | (funcall fn msg))))))
390 | (jupyter-return req)))
391 |
392 | ;; When replaying messages, the request message publisher is already
393 | ;; unsubscribed from any upstream publishers.
394 | (defun jupyter--debug-replay-requests ()
395 | (setq jupyter--debug-request-queue (nreverse jupyter--debug-request-queue))
396 | (while jupyter--debug-request-queue
397 | (pcase-let ((`(,client ,req) (pop jupyter--debug-request-queue)))
398 | (cl-loop
399 | for msg in (jupyter-request-messages req)
400 | do (condition-case nil
401 | (jupyter-handle-message
402 | client (plist-get msg :channel)
403 | (cl-list* :parent-request req msg))
404 | (error (setq jupyter--debug-request-queue
405 | (nreverse jupyter--debug-request-queue))))))))
406 |
407 | ;;; Request
408 |
409 | (defun jupyter-message-publisher (req)
410 | (let ((id (jupyter-request-id req)))
411 | (jupyter-publisher
412 | (lambda (msg)
413 | (pcase (jupyter-message-type msg)
414 | ;; Send what doesn't appear to be a message as is.
415 | ((pred null) (jupyter-content msg))
416 | ;; A status message after a request goes idle means there is
417 | ;; a new request and there will, theoretically, be no more
418 | ;; messages for the idle one.
419 | ;;
420 | ;; FIXME: Is that true? Figure out the difference between a
421 | ;; status: busy and a status: idle message.
422 | ((and type (guard (jupyter-request-idle-p req))
423 | (guard (string= type "status")))
424 | (jupyter-unsubscribe))
425 | ;; TODO: `jupyter-message-parent-id' -> `jupyter-parent-id'
426 | ;; and the like.
427 | ((guard (string= id (jupyter-message-parent-id msg)))
428 | (setf (jupyter-request-last-message req) msg)
429 | (cl-callf nconc (jupyter-request-messages req) (list msg))
430 | (when (or (jupyter-message-status-idle-p msg)
431 | ;; Jupyter protocol 5.1, IPython
432 | ;; implementation 7.5.0 doesn't give
433 | ;; status: busy or status: idle messages
434 | ;; on kernel-info-requests. Whereas
435 | ;; IPython implementation 6.5.0 does.
436 | ;; Seen on Appveyor tests.
437 | ;;
438 | ;; TODO: May be related
439 | ;; jupyter/notebook#3705 as the problem
440 | ;; does happen after a kernel restart
441 | ;; when testing.
442 | (string= (jupyter-message-type msg) "kernel_info_reply")
443 | ;; No idle message is received after a
444 | ;; shutdown reply so consider REQ as
445 | ;; having received an idle message in
446 | ;; this case.
447 | (string= (jupyter-message-type msg) "shutdown_reply"))
448 | (setf (jupyter-request-idle-p req) t))
449 | (jupyter-content
450 | (cl-list* :parent-request req msg))))))))
451 |
452 | (defvar jupyter-inhibit-handlers)
453 |
454 | (defun jupyter-request (type &rest content)
455 | "Return an IO action that sends a `jupyter-request'.
456 | TYPE is the message type of the message that CONTENT, a property
457 | list, represents."
458 | (declare (indent 1))
459 | (let ((ih jupyter-inhibit-handlers))
460 | (lambda (client)
461 | (let* ((req (jupyter-generate-request
462 | client
463 | :type type
464 | :content content
465 | :client client
466 | ;; Anything sent to stdin is a reply not a request
467 | ;; so consider the "request" completed.
468 | :idle-p (string= "stdin"
469 | (jupyter-channel-from-request-type type))
470 | :inhibited-handlers ih))
471 | (pub (jupyter-message-publisher req)))
472 | (setf (jupyter-request-message-publisher req) pub)
473 | (if (eq jupyter--debug 'message)
474 | (push (list client req) jupyter--debug-request-queue)
475 | (when (string= (jupyter-request-type req)
476 | "execute_request")
477 | (jupyter-server-mode-set-client client))
478 | (jupyter-run-with-io pub
479 | (jupyter-subscribe
480 | (jupyter-subscriber
481 | (lambda (msg)
482 | ;; Only handle what looks to be a Jupyter message.
483 | (when (jupyter-message-type msg)
484 | (let ((channel (plist-get msg :channel)))
485 | (jupyter-handle-message client channel msg))))))))
486 | (cons req client)))))
487 |
488 | (provide 'jupyter-monads)
489 |
490 | ;;; jupyter-monads.el ends here
491 |
--------------------------------------------------------------------------------