2 min read

Making An Evil Jupyter Notebook

I recently came back to an idea I started playing with back in January: sticking a game into the Jupyter notebook interface. The key problem to solve here is that, while Jupyter is designed to run the code you see and explain it, for game purposes I want the code to do the opposite: control the interface itself (and do so while being at least lightly obfuscated from the user).

Adding, Running, and Removing Cells With IPyLab

IPyLab has been a great (and really fun) tool for this purpose. You can use it to execute Jupyter app commands directly within your notebook, which allows for things like this function that creates and runs a cell:

from ipylab import JupyterFrontEnd
app = JupyterFrontEnd()
def create_and_execute_code_cell(code):
    self.app.commands.execute('notebook:move-cursor-down')
    self.app.commands.execute('notebook:replace-selection', text=code)
    self.app.commands.execute('notebook:run-cell')

If we run this function in a notebook, it does what we want!

It's worth playing around with JupyterFrontEnd and the app commands list inside your own notebook - there's lots you can control that wasn't readily apparent to me.

Still, at a certain point I wanted to do even more. Specifically I wanted to remove and replace parts of the Notebook menu - part of the story I want to tell involves the program taking over the notebook, and if you're able to just stop the kernel while that's happening it kind of loses its narrative effect. This might be possible with ipylab, but I couldn't find much documentation to that end, which is where JavaScript comes in.

Modifying Notebook HTML and CSS with IPython.display

This felt a bit less "pure" to me - I'm literally just injecting my own JS into the notebook - but hey, it works. You can simply insert vanilla JS into the display using the Ipython library:

from IPython import display

def inject_js(code):
  display.display(display.Javascript(code))

JS_CODE = """
    const shutDownButtons = document.querySelectorAll('.jp-RunningSessions-shutdownAll');
    shutDownButtons.forEach(button => {
     button.addEventListener('mouseenter', (event) => {
        console.log('Shutdown button hovered')
        event.stopPropagation(); // Prevent the default action
        button.disabled = true; // Disable the shutdown button
        button.innerText = "I'M AFRAID I CAN'T DO THAT, JAKE"
      });
    });
"""

inject_js(JS_CODE)

In this case, I wanted to disable and replace the text of the various kernel-related and shutdown buttons in the side menu, which are distinguished by having the CSS class jp-RunningSessions-ShutdownAll. Run this function in the notebook, and we can have our HAL-style failed menu interaction:

0:00
/0:09

All of these functions work fine when imported from a python file or package in the repo, so you can easily obfuscate what's happening and hide them inside a normal-sounding function. It's never been so simple to make an Evil Notebook.