Abstract

You can edit a Jupyter notebook as markdown without ever leaving text mode. This can work around accessibility issues or be used on non-graphical machines.

Introduction

Trying to use Orca with Jupyter is nigh on impossible. It doesn’t even let you open a file without using the mouse, and mouse routing buttons don’t seem to work for me. The same applies to JupyterLab, and I’ve heard similar stories for them under JAWS.

A lot of great work was started to improve this1, but at the moment I wasn’t able to make any headway, and I couldn’t find any sign of ongoing work with a quick search.

Using VSCodium may be an alternative but didn’t work for me. I heard it was heavy and slow to navigate, but your mileage may vary.

I wanted to find a different way to use and author Jupyter notebooks with different a screen reader and keyboard navigation. This is likely to be less accessible to many people, but by introducing more options, more people will find something that suits them.

I found a plethora of tooling for Jupyter2 which led to the discovery of Bookbook, a tool that sounded cool but one-way and unmaintained. JupyText is a very cool two-way converter, but it doesn’t seem to include cell outputs by default so would preclude easy access to data results. I also found Notedown and its de facto successor nbconvert.

Solution

This covers the end-to-end workflow of downloading and modifying a notebook from a JupyterLab instance, using nbconvert and pandoc.

  1. Install dependencies
  2. Download all the files from the JupyterLab server
  3. Convert notebook to markdown
  4. Edit and convert back again to execute
  5. Iterate as usual

Dependency installation

Downloading notebooks

Update: I have written some JavaScript that automatically downloads a zip file from a JupyterLab instance. To use it, open the JupyterLab instance and press F12 to open developer tools. Press Escape to open the Console, then paste the code. After about 2 minutes, it will download a zip file which contains the entire notebook so that you can use it locally in your preferred software. I have only tested this in Firefox so your mileage may vary. If it doesn’t work, try the old annoying way below.

(() => {
  document.evaluate('//*[@class="jp-LauncherCard"][@title="Python (Pyodide)"][@data-category="Console"]', document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue.click();
  console.log("created console");
  const d = new DataTransfer();
  const f = "Jupyter "+new Date().toISOString()+".zip";
  const t = 'import zipfile, os\n'                      +
             'zf = zipfile.ZipFile("'+f+'", "w")\n'   +
             'for dp, _, fns in os.walk("."):\n'        +
             '    for fn in fns:\n'                     +
             '        if fn == "'+f+'":\n'            +
             '            continue\n'                   +
             '        zf.write(os.path.join(dp, fn))\n' +
             'zf.close()'                               ;
  d.setData("text/plain", t);
  const i = setInterval((() => {
    if (!document.activeElement.classList.contains("cm-content")) {
      return;
    }
    document.activeElement.dispatchEvent(new ClipboardEvent("paste", { clipboardData: d, dataType: "text/plain", data: t }));
    console.log("pasted script");
    document.getElementsByClassName("jp-CodeConsole-promptCell")[0].getElementsByClassName("cm-content")[0].dispatchEvent(new FocusEvent("focus"));
    clearInterval(i);
    console.log("focused input");
    const j = setInterval((() => {
      const e = document.evaluate('//*[@data-command="console:run-forced"]', document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue;
      if (e === null) {
        return;
      }
      if ("disabled" in e.attributes) {
        return;
      }
      if (!document.getElementsByClassName("jp-CodeConsole-promptCell")[0].getElementsByClassName("cm-content")[0].innerText) {
        return;
      }
      e.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
      e.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
      clearInterval(j);
      console.log("clicked run");
      const k = setInterval((() => {
        const s = document.evaluate('//*[contains(concat(" ",normalize-space(@class)," ")," jp-InputPrompt ")][text()="[*]:"]', document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue;
        if (s !== null) {
          console.log("waiting for execution");
          return;
        }
        const e = document.evaluate('//*[@class="jp-DirListing-itemText"]//*[text()="'+f+'"]', document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue;
        if (e === null) {
          return;
        }
        const e2 = e.closest(".jp-DirListing-item");
        e2.dispatchEvent(new PointerEvent("mousedown", { bubbles: true, cancelable: true }));
        e2.dispatchEvent(new PointerEvent("mouseup", { bubbles: true, cancelable: true }));
        e.dispatchEvent(new PointerEvent("contextmenu", { bubbles: true, cancelable: true }));
        clearInterval(k);
        console.log("right-clicked file");
        const l = setInterval((() => {
          const e = document.evaluate('//*[@data-command="filebrowser:download"]', document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue;
          if (e === null) {
            return;
          }
          e.dispatchEvent(new FocusEvent("focus"));
          console.log("focused download button");
          e.dispatchEvent(new PointerEvent("mousedown", { bubbles: true, cancelable: true }));
          e.dispatchEvent(new PointerEvent("mouseup", { bubbles: true, cancelable: true }));
          console.log("clicked download button");
          clearInterval(l);
        }), 200);
      }), 200);
    }), 200);
  }), 200);
})();
The old annoying way to download the notebook

This code converts an online Jupyter notebook into a zip file that you can download. To run it, you need to launch a Python interpreter in the inaccessible web interface and paste this code, then press Shift+Enter. There will probably be no output that a screen reader can understand, but a file named “lab.zip” will appear in the “file explorer” in the web interface, which you can download by right-clicking and choosing Download.

import zipfile, os
zf = zipfile.ZipFile("lab.zip", "w")
for dp, _, fns in os.walk("."):
  for fn in fns:
    if fn == "lab.zip":
      continue
    zf.write(os.path.join(dp, fn))
zf.close()

Now decompress the files by double-clicking them (or using a terminal as below).

mkdir lab
cd lab
unzip ../lab.zip

Note that if you don’t entirely trust the files, you’ll need to remove the --execute in the next section. If you really don’t trust them at all, don’t download them!

Converting to markdown

The magic command is:

jupyter nbconvert --execute --allow-errors --inplace notebook.ipynb && pandoc --extract-media=images notebook.ipynb -o notebook.md

This will run the code (--execute) and save the readable file into notebook.md which you can edit using your favourite text editor. Images such as graphs will be saved into the images directory.

If you don’t entirely trust the code, remove the --execute flag (technically you can skip the entire first part of the pipeline but that’s harder to explain)

If you run into an error about NoSuchKernel3, try the following command, which will tell it to use the default kernel. This will fix the error but may change behaviour. You will still need to run the conversion command again afterwards.

jq -e '.metadata.kernelspec.name = "python"' notebook.ipynb > notebook.ipynb.tmp && mv notebook.ipynb.tmp notebook.ipynb

Converting back to Jupyter format

To test your code, first turn it back into a Jupyter notebook:

pandoc notebook.md -o notebook.ipynb

Then just turn it back into Markdown like before, and any errors will show up below the corresponding code (you can search “.output .error” to find them). You could also remove the –allow-errors to have the errors show up without updating the Markdown file, but I think this would be more confusing.

Limitations

The Jupyter web interfaces allow re-running individual blocks of code. I haven’t looked into this for nbconvert, but this feature can accelerate development, so finding a way to add it would be useful. One can imagine that only cells that have changed since the last run would be evaluated by default by a tool incorporating this feature.

The process described is quite clunky and requires considerable technical knowledge. Markdown is a fairly easy language to learn, so with the advent of less expensive multi-line Braille displays4 there is scope for a simple application that ties the whole workflow together into a simple editor with sensible keyboard shortcuts and search functionality.

Conclusion

This workflow can help with Jupyter usage for some people whose accessibility needs are not met by the existing solutions, but it’s not perfect. People who just love their machines in text-mode may prefer jut. People who want to commit plain-text versions of Jupyter files may fare better with Jupytext.

P.S.

Wondering why the writing style is so weird? Reading too many academic papers while trying to write casually at one in the morning don’t mix well.

All code in this post is licensed under CC BY 4.0

  1. See the grant roadmap 

  2. Under the name Awesome Jupyter 

  3. The Papermill documentation has a nice explanation of this error. 

  4. Like the Canute 360