├── .ert-runner ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.org ├── COPYING ├── Eldev ├── MAINTAINERS.org ├── Makefile ├── README.org ├── appveyor.yml ├── js ├── Makefile ├── emacs-jupyter.js ├── index.js ├── manager.js ├── package.json └── webpack.config.js ├── jupyter-R.el ├── jupyter-base.el ├── jupyter-c++.el ├── jupyter-channel-ioloop.el ├── jupyter-channel.el ├── jupyter-client.el ├── jupyter-env.el ├── jupyter-ioloop.el ├── jupyter-javascript.el ├── jupyter-julia.el ├── jupyter-kernel-process.el ├── jupyter-kernel.el ├── jupyter-kernelspec.el ├── jupyter-messages.el ├── jupyter-mime.el ├── jupyter-monads.el ├── jupyter-org-client.el ├── jupyter-org-extensions.el ├── jupyter-python.el ├── jupyter-repl.el ├── jupyter-rest-api.el ├── jupyter-server-kernel.el ├── jupyter-server.el ├── jupyter-tramp.el ├── jupyter-widget-client.el ├── jupyter-zmq-channel-ioloop.el ├── jupyter-zmq-channel.el ├── jupyter.el ├── jupyter.png ├── ob-jupyter.el ├── test ├── jupyter-monad-test.el ├── jupyter-server-test.el ├── jupyter-test.el ├── jupyter-tramp-test.el └── test-helper.el └── widget.html /.ert-runner: -------------------------------------------------------------------------------- 1 | -L . -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: nnic 2 | open_collective: emacs-jupyter 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.elc 2 | .projectile 3 | flycheck_* 4 | node_modules/ 5 | *lock* 6 | built/ 7 | .cask/ 8 | .eldev/ 9 | Eldev-local 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /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-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-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 | -------------------------------------------------------------------------------- /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 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-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 | -------------------------------------------------------------------------------- /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-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-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-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-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 | -------------------------------------------------------------------------------- /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-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-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 | -------------------------------------------------------------------------------- /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-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.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.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emacs-jupyter/jupyter/3615c2de16988c4dd9d1978bfa10ee3092e85b33/jupyter.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /widget.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jupyter Client 5 | 6 | 7 | 12 | 30 | 31 | 32 | 33 | 34 | --------------------------------------------------------------------------------