Fragment – Custom display_formatter Rendering of Python Types & Rendering Custom MimeTypes in JupyterLab and RetroLab

From a passing tweet, I notice a post on Fine tuning your Jupyter notebook’s displays which includes a reminder of how to roll your own custom __repr__ methods:

and this rather neat treat for creating customised rich displays around built-in types using the IPython display_formatter, which lets you define a custom formatter for a typed object:

Also in passing, I note from the JupyterLab vega5-extension that you can create a simple extension to define a custom mimi-type renderer, allowing you to do things like:

from IPython.display import display

display({
    "application/vnd.vegalite.v3+json": {
        "$schema": "https://vega.github.io/schema/vega-lite/v3.json",
        "description": "A simple bar chart with embedded data.",
        "data": {
            "values": [
                {"a": "A", "b": 28}, {"a": "B", "b": 55}, {"a": "C", "b": 43},
                {"a": "D", "b": 91}, {"a": "E", "b": 81}, {"a": "F", "b": 53},
                {"a": "G", "b": 19}, {"a": "H", "b": 87}, {"a": "I", "b": 52}
            ]
        },
        "mark": "bar",
        "encoding": {
            "x": {"field": "a", "type": "ordinal"},
            "y": {"field": "b", "type": "quantitative"}
        }
    }
}, raw=True

This suggests the possiblility of custom renderers for different JSON objects / Python dicts etc implement via an extension rather than eg a (simpler?) IPython _repr_ method.

Agood example of a custom mime-type renderer is the deshaw/jupyterlab-skip-traceback extension. This extension mimics the behaviour of the classic notebook skip-traceback nbextension which provided a simplified, collapsible view onto Python traceback error messages.

The JupyterLab extension works by defining a custom handler for the application/vnd.jupyter.error mime-type, parsing the result and rendering the improved output.

Notes on the JupyterLab Notebook HTML DOM Model, Part 9: Building and Distributing a Pre-Built Extension

So finally, finally… we’re at the point we can try to build an installable pre-built extension.

To test the build, it first makes sense to uninstall the version we’ve been developing to date. Running pip uninstall doesn’t necessarily do the job, as described in the previous post, so you may have to scrabble around searching for where the extension was installed so you can delete it yourself.

Building the extension is then relatively straightforward. In the project directory, make sure all the build tools are available:

pip3 install build

And then build the distribution, creating a wheel in dist/ directory:

python3 -m build

Usefully, it’s a platform agnostic wheel which means it should be installable in JupyterLite.

I noticed that changing adding stricter checks to tsconfig.json didn’t seem to get picked up after a successful build using my “let everything through” casual build, but the following command did rebuild the extension with all the re-enabled error checking switched back on:

jlpm clean && jlpm build:lib && jlpm build:labextension

Not surprisingly, my hacky Python flavoured Javascript style of Typescript raised all sorts of errors and suggests I really need to settle down to read a good (and recent) TypeScript book for a vcouple of days, but instead of that, I just switched all the error checking back off again:

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "composite": true,
    "declaration": true,
    "esModuleInterop": true,
    "incremental": true,
    "jsx": "react",
    "module": "esnext",
    "moduleResolution": "node",
    "noEmitOnError": true,
    "noImplicitAny": false,
    "noUnusedLocals": false,
    "preserveWatchOutput": true,
    "resolveJsonModule": true,
    "outDir": "lib",
    "rootDir": "src",
    "strict": true,
    "strictNullChecks": false,
    "target": "es2017",
    "types": []
  },
  "include": ["src/*"]
}

I’m not sure if a (re)start of the JupyterLab server is required to see the updated extension in action, or whether we can get away with just refreshing the JupyterLab browser window.

To distribute the newly built wheel via the project repository, we need to commit it: remove the dist/ path from the .gitgnore file and the wheel should now be visible.

My example repo (and you have been warned in advance about the state of the “TypeScript”) can be found here: innovationOUtside/jupyterlab_empinken_extension

To install the wheel:

pip3 install --upgrade https://raw.githubusercontent.com/innovationOUtside/jupyterlab_empinken_extension/main/jupyterlab_empinken_extension-0.1.1-py3-none-any.whl

The extension by default enables four buttons that can be used to toggle cell tags. The tags are parsed and used to class the notebook cells and then coloured accordingly (I really need to do something about the default colours!).

The tag state is saved in the notebook document so should persist. The actual convention used to define the tags is user customisable via extension settings, as are the background colours.

I reckon it’s taken me a couple of years and four and a half days to get this far. The code is not TypeScript, but a hacky version of Javascript, mostly, from somoneone who only ever tends to write casual Python, with odd bits of copy and pasted TypeScript from other extensions. I note that many TypeScript programmes seem to be rather slack in terms of TypeScript formalism too, so it’s not just me… I also note that trying to search for good examples on TypeScript sucks. I’m not sure if this is becuase websearch rankings broke since TypeScript became a thing, or because there aren’t many good TypeScript resources out there.

Even with occasional moments of success, I found the whole process really dispiriting. I am not convinced that I came up with an effective strategy for making sense of or navigating the docs, or the examples. As ever, I get the sense that the most useful resources are other extensions written by people who aren’t developers, because the code tends to be simpler, even though it can also be a bit ropey in terms of code quality. But the overheads of getting started mean that you need to be quite resilient to get as far as even a simple extension that works.

The current extension is limited to just reading state, processing tags and classing DOM elements. I’ll try one more (a port of innovationOUtside/nb_cell_execution_status) which will attempt to react to cell execution status signals: I think I have enough cribs to make a start, although I haven’t (yet?) found any really good resources on the message responses that might be expected or how to parse them. When that’s done, and if I can then get things working in a JupyterLab environment, I may try to get innovationOUtside/nbev3devsim working in a meaningful way in JupyterLab, and then I’ll be able to quit the whole JupyterLab space and hopefully make a start playing with thebe and JupyterLite kernels in Jupyter Book, which I think is a far more interesting space to work in.

Notes on the JupyterLab Notebook HTML DOM Model, Part 8.5: A Reproducible Development Process

Across the course of the earlier posts in this series, I’ve been trying to place a series of stepping stones that demonstrate in a reasonably complete way how to create a simple JupyterLab notebook extension. At this point, I was hoping to share a repo that would demo the extension in a MyBinder environment, and act as an endpoint for installing a pre-built extension via a pip install (and then it’d be a simple step to pop it onto PyPi (at this point, I still donlt know how to build a pre-built extension). The next step would then be to also figure out a way of bundling the extension into a JupyterLite distribution. But. there’s always a but…

Here’s what my local file browser tells me my working directory looks like:

The directory was created by the Github App synch-ing a new repo I’d created from Github. This approach has the benefit that I don’t have to worry about any of the Github settings – I create a simple repo on Github then let the app figure out how to set up a directory I can work from on my local machine.

Here’s what Github tells me the structure looks like:

That is, top level is a jupyterlab_empinken directory. But that is not what I was expecting to see, because the ./Github/jupyerlab_empinken directory is supposed to map onto the top-level of the repo. And looking at my desktop file browser, that’s what looks like should be visible. I also note that lots of files are not added to the repo, and are ignored in the Github app file viewer: seems likes there’s a new .gitignore file in there somewhere too…

The nested directory structure visible in the Github repo is easily explained: when I ran the cookiecutter to seed the project (cookiecutter https://github.com/jupyterlab/extension-cookiecutter-ts), I did so in the ./Github/jupyerlab_empinken directory. I don’t recall if the cookiecutter had the option to install the files into the ./ directory, but it would be useful if it did.

As for how I managed to map the files to give the view in the local file browser, presumably setting an alias somewhere, I’m not sure what I did do?!

But I some point I did the following, which may or may not make sense…

# ================================================
#                  DON'T TRY THIS...
# ================================================

# I'm just trying to keep a note of what I originally did...

# I'm guessing is is where files were popped into
# the top level directory?
pip3 install --upgrade ./jupyterlab_empinken/

# ...

# At some point, I then went into the subdir
# created by the cookiecutter
cd jupyterlab_empinken/

# At this point, I was floundering because I 
# couldn't find a way to update the extension
# in my JupyterLab environment...

# So I tried everything I could find...
jupyter labextension develop . --overwrite
jlpm run build
jlpm run rebuild
python3 -m pip install -e .
jupyter labextension develop . --overwrite

# Something in that chain appeared to do the trick,
# because from this point, and ny whatever process,
# the following steps then reliably rebuilt 
# the extension and ran a version of JupyterLab 
# with the updated extension installed...
jlpm run build
jupyter lab

I also had an issue of being unable to uninstall the extension. This may or may not relate to this issue which hints at the need to manually remove files but doesn’t give a path as to where to remove files from. If the recommended dev route installs uninstallable files, it would be useful to provide a set of explicit manual removal instruction steps. In the end, I found a set of extension related files left over in /usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/share/jupyter/labextensions (the jupyter --paths command gives a list of places to search amongst the data: paths. Removing these (rm -r /usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/share/jupyter/labextensions/jupyterlab-empinken) and reloading JupyterLab seemed to do the trick of removing the extension from the running application.

In passing, I note this recent JupyterLab issue — extension-cookiecutter-ts out of sync with doc. I use plugin, and things seem to break if I just change it to extension if I do nothing else? O rmaybe I missed something… From the issue, I also don’t know if this means there’ll be a breaking change at some point?

So, let’s start again from the beginning, and see if the extension tutorial docs give us a minimal, exact and reproducible set of commands that will:

  • put the files in new directory in the locations I want them;
  • let me rebuild and preview the extension in a JupyterLab environment each time I edit the src/index.ts file, or the style/base.css file, or the schema/plugin.json file.

I’ll then try to remember to retrofit instructions back into earlier posts in this series…

Then, as I’d intended to do for this post before it became an interstitial post, I’ll have a go at creating a prebuilt extension that can be easily distributed in the next post.

But before we start, let’s just review the architecture, at least as I understand it. The core JupyterLab application runs in a browser and can be extended using JupyterLab extensions. An extension is a Javascript package that exports one more plugins that extend JupyterLab’s behaviour. The cookiecutter Python package provides a way of distributing a build environment for a JupyterLab extension. In part, installing the Python package installs front end component files into JupyterLab (somehow!). The cookiecutter package also bundlles various project files that support the compilation of JupyterLab extension Javascript files from source Typescript files. Building the JupyterLab extension creates the files that JupyterLab needs from the source files. When we actually distribute a JupyterLab extension, we can use the Python package machinery to bundle and install the JupyterLab extension Javascript, CSS and other sundry files into JupyterLab.

So let’s go back to the beginning, and to the cookiecutter package. The first question I need to answer is a really simple one: how do I get the cookiecutter to unpack its wares into the top level of the directory into which I want the files to go, rather than in a subdirectory?

Running the cookiecutter, it seems by default as if it won’t overwrite a pre-existing directory. However the -for --overwrite-if-exists flag will allow you to write over the pre-existing directory (the .git files are left untouched). If you don’t want files in the directory that already exists (such as a LICENSE file) overwriting, then also add the -s or--skip-if-file-exists flag. I note that the default license generated by the cookiecutter is the BSD 3-Clause License.

So if I am in my Github directory (assuming I used a repo (directory) name with underscores rather then - separators: the cookie-cutter converts all dashes to underscores…), I can run the cookiecutter with and then specify the repo directory name as the extension name to unpack the files in that directory.

cookiecutter https://github.com/jupyterlab/extension-cookiecutter-ts --overwrite-if-exists--skip-if-file-exists

Or more concisely, cookiecutter https://github.com/jupyterlab/extension-cookiecutter-ts -fs

When prompted for what sort of extension we want, select the default extension type, [1] - frontend; also set settings as y which will presumably seed a settings file for us.

Note that the cookiecutter adds a rather comprehensive .gitignore file to the directory. If you are looking to build and distribute an extension via the repo (see the next post in this series, hopefully!), then you will probably want to remove the dist/ entry from the automatically created .gitignore file so you can commit the disribution wheel to the repository.

The next step is to see if we can set the directory up so that we can easily update JupyterLab. The extension tutorial docs suggest the following approach.

First, we need to install the extension using pip. Installing the package “copies the frontend part of the extension into JupyterLab”. The docs suggest the flags -ve, but from the pip docs I can’t see the -v flag at all. The -e flag (--editable) installs the project in an editable mode (docs). This is like a faux install, in that rather than installing the package, a special .egg-link file is created that allows Python to treat the development repo as if it were the installed Python package. As the package files are updated in the directory, the changes are available as if the package had been updated via pip.

pip3 install -e .

Installing the package creates and downloads a large number of files to the package directory that are used to support the building of the JupyterLab extension package.

When I ran this command, it took a ridiculously long time (several minutes, or long enough to think it must have got stuck or failed somewhow) to install.

The development mode package can supposedly be uninstalled in the normal way:

pip3 uninstall PACKAGENAME

However, files may still be languishing… See the note above about tracking down copies of the extension files that are not removed by the uninstaller.

The docs suggest that “[w]e can run this pip install command again every time we make a change to copy the change into JupyterLab” which suggests that simply making “live” editable changes to the Python package (via the --editable mode) is not enough for the changes to be reflected in the JupyterLab environment. What we additionally need to to do is create a way that allows JupyterLab to make use of any updated JupyterLab package files, akin to the way we give the Python access to the “live” --editable package updates.

It’s not totally clear to me what files we need to “make live” in the Python package, or how and when the Python environment interacts with the cookiecutter generated Python package files.

The following command ensures that as we rebuild JupyterLab extension package files, they become available to JuptyerLab:

jupyter labextension develop --overwrite .

We now have a “live” development environment. As we update the extension package files, we run the following command to (re)buid the extension:

jlpm run build

Refreshing JupyterLab in the browser should now display the updated extension.

The JupyterLab Package Manager (jlpm) is a JupyterLab-provided, locked version of the yarn package manager intended to simplify JuptyerLab development. Handy commands include jlpm install to ensure that required node packages are installed and jlpm run build to build extension files.

In the next post (and hopefully the final post!) in this series, I’ll try to pull together all the pieces to show how to build and disitrubute a pre-built JupyterLab extension.

Notes on the JupyterLab Notebook HTML DOM Model, Part 8: Setting CSS Variable Values from an Extension

In the previous post in this series, I looked at how we can make use of an extension’s user settings to access persistent state that we might use to modify the behaviour of an extension. In this post, I’ll look at how we can use extension settings to tune CSS properties such as a the background colour we might apply to tag classed cells based on a crib from jtpio/jupyterlab-cell-flash.

We can define a color property in our settings file in the following way (presumably we could also specifiy a colour name or use any other appropriate CSS colour formulation):

    "activity_color": {
      "type": "string",
      "title": "Color",
      "description": "The base color for the activity background",
      "default": "rgba(173, 216, 230, 1.0)"
    },

The original class CSS was defined literally:

.iou-solution-node {
    background-color: lightgreen !important;
}

However, we can parameterise the values and then set new values onto the CSS parameters based on extension settings:

:root {
    --iou-activity-bg-color: lightblue;
  }

.iou-activity-node {
    background-color: var(--iou-activity-bg-color) !important;
}

How, then, do we expose these values as a setting? The following example loads the settings in as part of our extension activation, identifies a desired class color value from the settings file, and then uses it to set the value of the CSS variable.

function activate(
   app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null): void {

    // Example of how to read settings from cookiecutter
    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
          // Handle the background colours
          // The document object seems to be magically available?
          const root = document.documentElement;
          const updateSettings = (): void => {
            const color = settings.get('activity_color').composite as string;
            root.style.setProperty('--iou-activity-bg-color', color);
          };
          updateSettings();
          // We can auto update the color
          settings.changed.connect(updateSettings);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }
}

In the control panel, we then have a dialog of the form:

A couple of things to note about that; first, it would be neater if we could have a colour-picker; secondly, the extensions panel seems overly aggressive on save, saving after every keystroke if you change a string value, which means you need to type very, very, slowly, which sucks in terms of UX because it makes you think you’ve broken something.

We can now set colours for the different backgrounds via the extensions settings panel. In addition, the background colours should update immediately in the notebook if we change the setting.

In the next post in this series, I will review how the various components can all work together to give us a JupyterLab flavoured version of the classic notebook empinken extension.

Notes on the JupyterLab Notebook HTML DOM Model, Part 7: Extension User Settings

In the previous posts in this series, I have reviewed the JupyterLab notebook DOM model and described an extension that can be used to set class tags on cell DOM elements based on cell tag metadata. In this post, we will review how JupyterLab extension user settings can be used to enable and disable cell toolbar buttons and identify which tags should be considered for “tag to class” processing.

JupyterLab extension user settings can be used to provide a persistent way of storing user settings. For an installed extension, settings are accessed from the JupyterLab Settings > Advanced Settings Editor menu:

The settings for a particular extension can be modified from the settings editor for that extension. Settings include options for setting boolean values via checkboxes, strings and numeric values. (I’m not sure if you can also raise dialogues for things like colour pickers, as you used to be able to do in the classic notebook extensions configurator?)

The defualt settings are defined in the schema/plugin.json JSON file (note this can also be edited manually via the JSON Settings Editor).

Each user setting is defined in a structured way via the properties element in the schema/plugin.json file. For example, here is a string definition and a boolean defintion:

"properties": {
    "tagprefix": {
      "type": "string",
      "title": "Tag prefix",
      "description": "Prefix to identify tag2class tags",
      "default": "iou-"
    },
    "activity_button": {
      "type": "boolean",
      "title": "Activity button",
      "description": "Whether to display the \"Activity [A]\" button",
      "default": false
    }
}

Numerics can be defined as a “number” type, as for example this setting taken from the jupyterlab-cell-flash extension:

"duration": {
      "type": "number",
      "title": "Duration (seconds)",
      "description": "The duration of the flash effect animation (in seconds)",
      "default": 0.5
    }

The extension-cookiecutter-ts gives an example of how to access and read settings data as part of an extension’s activation, but is not overly helpful when trying to make sense of how to use that data the scope of another widget (it might be obvious to someone who speaks Typescript; I’m at the level of riffing from examples provided with zero knowledge other than inspection of the example generated file). However, the ocordes/jupyterlab_notebookbuttons extension did provide a useful crib, and which forms the basis of the following example.

To start with, we make sure that the ISettingRegistry is available. We can then pass it to one or more registered extensions widgets of our ou creation that might want to make use of the settings:

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry]
};

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
 function activate(
   app: JupyterFrontEnd,
   settingRegistry: ISettingRegistry | null): void 

  // Pass the settingRegistry as a parameter to the new widget extensions
  // app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension(settingRegistry));
  // app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension(settingRegistry));
}

export default plugin;

So how do we then need to define our widgets? To begin with, we need to make sure we accept the settings parameter; a constructor then calls a function that actually reads in the settings. The settings themselves are read in from a settings file addressed behind the scenes using the plugin.id from the plugin named object we created in the previous step :

export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  //----  
  // Settings crib via:
  // https://github.com/ocordes/jupyterlab_notebookbuttons/blob/main/src/index.ts

  settings: ISettingRegistry.ISettings;

  constructor(protected settingRegistry: ISettingRegistry) {
    console.log('constructor');
    // read the settings
    this.setup_settings();
  }

  setup_settings(): void {
    Promise.all([this.settingRegistry.load(plugin.id)])
      .then(([settings]) => {
        console.log('reading settings');
        this.settings = settings;
        // update of settings is done automatically
        //settings.changed.connect(() => {
        //  this.update_settings(settings);
        //});
      })
      .catch((reason: Error) => {
        console.error(reason.message);
      });
  }
}

We can now access values from the settings, for example:

    // Create a tag prefix
    // Retrieve the tagprefix value from the extension settings file
    const tag_prefix =  this.settings.get('tagprefix').composite.toString();

    //Suppose we have four tag types 
    // that we want to toggle the behaviour of
    let tag_types: string[] = ['activity', 'solution', 'learner', 'tutor'];

    // Create an array to hold button definitions
    var tagButtonSpec = new Array();

    // Iterate over the tag_types
    // Note the use of *of* rather than *in*
    for (let typ of tag_types) {
      console.log("Setup on "+typ);
      tagButtonSpec[typ] = new Array();

      // Set array values based on settings retrieved from settings file
      tagButtonSpec[typ]['enabled'] = this.settings.get(typ.toString() +'_button').composite;
      // We can then test on: 
      //if (tagButtonSpec[typ]['enabled']) {}
    }

The setting of the tag_prefix value provides us with a way to limit which families of tags we might want to use as the basis for cell DOM classing.

If we already have the keys of an array defined, we can iterate over them in the following way:

// Get the key values from the array
let typ: keyof typeof tagButtonSpec;

// We can then iterate through them
for (typ in tagButtonSpec) {
  // The typ is actually a symbol object of some sort;
  // if we need the string value, use: typ.toString()
  tagButtonSpec[typ]['render'] = this.settings.get(typ.toString() +'_render').composite;
}

We can use the setting state to decide whether or not we want to render a toggle button on the UI, or render the coloured background for a particular tag.

User environments can have custom settings applied via entries in an overrides.json settings file (eg <sys.prefix>/local/share/jupyter/lab/settings/overrides.json; see the docs).

In the next post in this series, we’ll look at how we might be able to use settings to when setting the colours used to define the backgrounds for each empinken style tag.

Notes on the JupyterLab Notebook HTML DOM Model, Part 6: Pulling an Extension Together

Following on from ..

To set up the project directory, I used the jupyterlab/extension-cookiecutter-ts. As part of the set-up prompt, this aked if I wanted to support settings, so I optimistically said yes and we’ll see if I can figure out how to make use of them!

For guidance on setting up the development environment, see

The cookie cutter creates lots of files, but I’ll try to ignore as many of them as I can for as long as I can and we’ll see how far we get…

There’s a single index.ts file in the src/ directory, so I’ll see if I can get by just using that. The style/base.css looks like the place to pop the style.

Let’s start with the style file. I’m going to create colour backgrounds for four activity types and pop the style into style/base.css:

.iou-activity-node {
    background-color: lightblue !important;
}

.iou-solution-node {
    background-color: lightgreen !important;
}

.iou-learner-node {
    background-color: yellow !important;
}

.iou-tutor-node {
    background-color: lightpink !important;
}

For the code itself, we can reuse building blocks we have already produced. This includes:

  • buttons to toggle cells;
  • notebook loader to class cells based on metadata.

The code will all be in src/index.ts to keep things as simple as possible. Here’s what the cookiecutter gives as a starter for ten, noting there is some new code I’ve not met before that will relate to handling settings files…

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

/**
 * Initialization data for the jupyterlab-empinken extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry],
  activate: (app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null) => {
    console.log('JupyterLab extension jupyterlab-empinken is activated!');

    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }
  }
};

export default plugin;

Cribbing from the settings example extension, it looks like we can .get from the settings (as well as setting defaults in the script in advance). I’ve no idea where default config settings go in a settings file? In the example, there’s a schema/settings-example.json file. The cookiecutter created a schema/plugin.json file with a similar structure, and by inspection it looks like we can put default settings into the properties attribute.

Here’s a first attempt at my extension code, based on fragments retrieved from earlier posts in this series, and without any attempt at retrieving any properties:

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

// START: TH added:
import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import { ToolbarButton } from '@jupyterlab/apputils';
import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';
// END: TH added

/**
 * Initialization data for the jupyterlab-empinken extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry]
};

export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    // Create a tag prefix
    // (this should be pulled from a setting()
    const tag_prefix = 'iou-';

    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
         
        // Get a single selected cell
        const activeCell = notebook.activeCell;
         
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...

        console.log("in and metadata is "+activeCell.model.metadata)
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList) {
          console.log("setting metadata..")
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
            console.log(" metadata is now "+activeCell.model.metadata)
        }
        console.log("continuing with metadata  "+activeCell.model.metadata)
         
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
         
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
         
        /*
        We can also toggle tags...
         
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
         
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
         
        // Set the tag name
        const tagname = tag_prefix + typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)
 
            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
             
            // Remove class
            activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
             
            // Add class
            activeCell.node.classList.add(tagname + "-node")
        }
         
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
         
    };
 
    // If we specify the buttons we want in an array
    // we can then construct them all via the same function
     
    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
     
    tagButtonSpec['activity'] = {'typ': 'activity', 'label': 'A', 'location': 10 };
    tagButtonSpec['solution'] = {'typ': 'solution', 'label': 'S', 'location': 11 }; 
    tagButtonSpec['learner'] = {'typ': 'learner', 'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor', 'label': 'T', 'location': 13 };
     
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ' + tag_prefix + typ + ' metadata tag on a cell',
        })
         
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }
     
    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
  }
}

/*
The most relevant docs appear to be:
https://jupyterlab.readthedocs.io/en/stable/api/modules/notebook.notebookactions.html
 
*/
export class ClassDemoExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
    /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    // Create a tag prefix
    // (this should be pulled from a setting()
    const tag_prefix = 'iou-';

    console.log("Tag prefix is " + tag_prefix);
    
    function check_tags(notebook){
        /*
        Iterate through all the cells
        If we see a cell with a tag that starts with class-
        then add a corresponding tag to differnt elements in the
        notebook HTML DOM
        */

        console.log("Checking to see if there are class tags to set on...")
        for (const cell of notebook.widgets) {
            let tagList = cell.model.metadata.get('tags') as string[];
            if (tagList) {
              for (let i = 0; i < tagList.length; i++) {
                var tag = tagList[i];
                console.log("I see tag " + tag);
                if (tag.startsWith(tag_prefix)) {
                  // class the cell
                  cell.node.classList.add(tag + "-node");
                } // end: if tag starts with prefix...
              } // end: tags iterator
            } //end: if there are tags
        } // end: iterate cells
    } // end: check_tags function definition
     
    notebook.modelChanged.connect((notebook) => {
        console.log("I think we're changed");
        // This doesn't seem to do anything on notebook load
        // iterate cells and toggle DOM classes as needed, e.g.
        //check_tags(notebook);
         
    });
         
    notebook.fullyRendered.connect((notebook) => {
        /*
        I don't think this means fully rendered on a cells becuase it seems
        like we try to add the tags mutliple times on notebook load
        which is really inefficient.
        This may be unstable anyway, eg the following comment:
            https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107
        I'll with go with in now in the expectation it will be break
        and I will hopefully be able to find another even to fire from.
         
        I get the impression the UI is some some of signal hell and
        the solution is just to keep redoing things on everything
        if anything changes. Who needs efficiency or elegance anyway...!
        After all, this is just an end-user hack-from-a-position-of-ignorance
        and works sort of enough to do something that I couldn't do before...
        */
         
        console.log("I think we're fullyRendered...(?!)");
        // iterate cells and toggle DOM classes as needed, e.g.
        check_tags(notebook);
    });
     
    return new DisposableDelegate(() => {
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
 function activate(app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null): void {
    console.log('JupyterLab extension jupyterlab-empinken is activated!');
    console.log("YES WE'RE UPDATED 3")
    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }

  app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension());
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

export default plugin;

Of course, it failed to compile on first run, in part because of an unfound package, in part because of some compiler errors. Poking around, it looks as if you have to declare required packages in the package.json file under dependencies. These look like they need versioning, and I haven’t a clue. The latest version on npm is yesterday, which is probably not what I’m running, and from past experience I know that JupyterLab extensions can be really brittle at suffering from version conflicts. I feel I am in touching distance of getting an extension together, but even at these final steps there are blockers that just make you want to give and say f**k it. So assuming ^ means “at least version”, I’ll set a low one and hopefully be done with it given that things seem to work in the plugin playground. Which is to say, I added the dependency "@jupyterlab/notebook@jupyterlab/notebook": "^3.1.0" which was a version number in keeping with others…

As far as the other errors were concerned, these in part because of some unusused variables and some implicit any type declarations that the compiler took offence. If @martinRenou hadn’t noticed some of my frustration earlier in the week about the hostility of JupyterLab to have-a-go end-user developers and made a PR onto the cookiecutter extension (Disable noUnusedLocals option), I’d have possibly been a bit stuck at this point, but he did, so I was able to spot that the tsconfig.json file has various settings that set the level of compiler strictness, so I turned them off (e.g. setting "noImplicitAny": false, "noUnusedLocals": false and "strictNullChecks": false). FWIW, the last I saw the PR wasnlt being accepted; if I can get this extension working, and perhaps a cell status indicator extension, I’m going to go back to keeping as far as way as possible from JupyterLab extensions — it’s too hostile an environment for me!;-)

And then (after grief in the rebuild – I done a simple pip install --upgrade . on various rebuilds expecting that to work but of course it doesn’t. The cookie cutter README doesn’t give any adviace, but the jupyterlab/extension-example suggests the following dev process:

# go to the hello world example
cd EXTENSION_PROJECT_DIR

# install the extension in editable mode
python -m pip install -e .

# install your development version of the extension with JupyterLab
jupyter labextension develop . --overwrite

# build the TypeScript source after making changes
jlpm run build

# start JupyterLab
jupyter lab

I’m hoping when I restart my “daily” jupyter server up again the latest version of the extension is installed into it.

When it works, it looks like this (tagged cells highlighted); I need to tune the colours, but enough for now or I’ll miss the fish and chip shop, Friday as it is!

Looking at the code, it’s not that complex, but without @krassowksi answering my Stack Overflow question, and going further in the comments, and @martinRenou picking up on my ire and giving me a crib I needed in PR he added that seemed otherwise to get the thumbs down from folk, I would have walked away from this aagin, again, for another three months; so I am hugely grateful to them and will get a round in anytime our paths cross:-)

Of course, at the same time I am hugely grumpy and p****d off by the myriad tiny blockers that might be “just” minor inconveniences for folk who work with this stuff everyday.

In the next post in this series, I’ll take a look at how to manage user settings in an extension.

Next up, other extension wise, I will try to port something approximating the cell execution status extension, though I really need to figure out settings for that if I try to add things like the audio accessibility elements. And once that’s done, I think that might be enough for our classic notebooks to run with our styling in JupyterLab. And then I will hopefllu be able to walk away from JupyterLab until they break, and look for alternative authoring and execution environments for MyST style documents. Long live Jupyter protocols, may the devil take JupyterLab!;-)

PS Argghh… forgot… I need to add OUr logo, but various issues languishing for years etc; if you know a hack, please share it via the comments; in classic notebook my solution was just to dodge the old logo out of the way with a bit of CSS and add our one in… Here are a couple of possible cribs: adding a custom splash screen to JuyterLab; Jupyterlite logo extension (issue and PR). See also this PR on updating the JupyterLab logo.

PPS more JupyterLab logo cribs: DOM cell ID is jp-MainLogo ; creating a logo widget: https://github.com/jupyterlab/jupyterlab/blob/7f87d89dc88ad86e84fb2245c873139ce60dec3c/packages/application-extension/src/index.tsx#L1034 ; uses import {jupyterIcon} from @jupyterlab/ui-components. Defined at https://github.com/jupyterlab/jupyterlab/blob/95fc6e74a21953c44a17a73c3d6741ac66320979/packages/ui-components/src/icon/iconimports.ts#L146 by way of import jupyterSvgstr from '../../style/icons/jupyter/jupyter.svg'; So it looks like we should crate an extension to add a “CustomLogo: JupyterFrontEndPlugin” based on const JupyterLogo: JupyterFrontEndPlugin<void>, remove the current Logo somehow, and apply the new logo. There’s also an old hack similar to one I used in classic notebook: https://discourse.jupyter.org/t/change-icons-in-theme-extension/3851/8 .

Notes on the JupyterLab Notebook HTML DOM Model, Part 5: Setting DOM Classes and Cell Tags From Notebook Toolbar Buttons

Following on from the previous posts in this series, let’s now see how we can toggle cell HTML DOM classes, as well as persistent metadata and tag state, from a notebook toolbar button.

The code for this recipe is taken pretty much wholesale from the Toolbar button example in the jupyterlab/extension-examples repo (one of the two notebook examples I could find.

Given pretty much all the classic notebook extensions were notebook related (that’s all there was), I think this demonstrates how low down the list of interested concerns notebook documents fall in JupyterLab. Maybe there needs to be a RetroLab extensions examples repo to collect exampls of working with notebooks and bringing them back to the fore a bit more?

Once again, I’ll use the JupyterLab Plugin Playground to test out the code.

To begin with, here is a minimal recipe to add a single button to the toolbar that can toggle a metadata tag on a selected cell.

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { ToolbarButton } from '@jupyterlab/apputils';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = () => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // If the tag exists...
        if (tagList.includes("TOGGLETAG")) {
            console.log("I see TOGGLETAG")

            // ...then remove it
            const index = tagList.indexOf("TOGGLETAG", 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
        }
        // ...else add it
        else {
            console.log("I don't see TOGGLETAG")
            tagList.push("TOGGLETAG")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);

    };

    const button = new ToolbarButton({
      className: 'my-action-button',
      label: 'Tagger',
      //iconClass: 'fa fa-exclamation-circle' 
      //https://github.com/jupyterlab/jupyterlab/issues/8277
      onClick: toggleTagButtonAction,
      tooltip: 'Toggle a persistent metadata tag on a cell',
    });

    panel.toolbar.insertItem(10, 'toggleTagAction', button);
    return new DisposableDelegate(() => {
      button.dispose();
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

And here’s how it works:

There also seems to be an option to add an icon for the toolbar button. I’ve previously used FontAwesome icons (e.g. in classic notebook extensions), and whilst it appears that these were once supported off-the-shelf in JupyterLab (pehaps with something like iconClass: 'fa fa-exclamation-circle') on the grounds (I think?) that things might not render in other themes. So in the face of that hostility to half-hour-hack end-user-developers, I will add multiple buttons with obscure single character labels that the user will have to try to guess at to remember what they do, rather than go hunting down how to create and style my own icons.

The classic notebook empinken extension supports four toggle class buttons (the three shown below plus a fourth one with a default green colour):

So let’s extend the extension to add four buttons which add the tags ou-activity, ou-solution, ou-student and ou-commentate.

We might also want to add an updater routine that checks for the original empinken extension style tags and converts those to the new tags.

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { ToolbarButton } from '@jupyterlab/apputils';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // Set the tag name
        const tagname = "ou-"+typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)

            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
            
            // Remove class
            //activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
            
            // Add class
            //activeCell.node.classList.add(tagname + "-node")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
        
    };

    // The following really needs to be simplified to a loop
    // that iterates over activity, solution, learner and tutor
    
    const activityButton = new ToolbarButton({
      className: 'tagger-activity-button',
      label: 'A',
      // The following construction lets us attach a 
      // parameterised function to the onclick event
      onClick: () => toggleTagButtonAction("activity"),
      tooltip: 'Toggle ou-activity metadata tag on a cell',
    });

    const solutionButton = new ToolbarButton({
      className: 'tagger-solution-button',
      label: 'S',
      onClick: () => toggleTagButtonAction("solution"),
      tooltip: 'Toggle ou-solution metadata tag on a cell',
    });
        
    const learnerButton = new ToolbarButton({
      className: 'tagger-learner-button',
      label: 'L',
      onClick: () => toggleTagButtonAction("learner"),
      tooltip: 'Toggle ou-learner metadata tag on a cell',
    });
        
    const tutorButton = new ToolbarButton({
      className: 'tagger-tutor-button',
      label: 'T',
      onClick: () => toggleTagButtonAction("tutor"),
      tooltip: 'Toggle ou-tutor metadata tag on a cell',
    });

    panel.toolbar.insertItem(10, 'toggleActivityTagButtonAction', activityButton);
    panel.toolbar.insertItem(11, 'toggleSolutionTagButtonAction', solutionButton);
    panel.toolbar.insertItem(12, 'toggleLearnerTagButtonAction', learnerButton);
    panel.toolbar.insertItem(13, 'toggleTutorTagButtonAction', tutorButton); 
    
    return new DisposableDelegate(() => {
      activityButton.dispose();
      solutionButton.dispose();
      learnerButton.dispose();
      tutorButton.dispose();
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

If we define style based on corresponding “generated” HTML DOM tag2class attributes, and ensure that such classes are added in synch with the metadata elements, we pretty much now have all the ingredients in place for a JuptyerLab empinken extension.

For example, we can add a class to the active (selected) node with constructions of the form activeCell.node.classList.add(typ + "-node") and activeCell.node.classList.remove(typ + "-node") and then style on activity-node etc.

We can simplify the code a little by creating a function that will handle the creation of buttons from a simple button config array:

    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
    
    tagButtonSpec['activity'] = {'typ': 'activity',
                                 'label': 'A', 'location': 10 };

    tagButtonSpec['solution'] = {'typ': 'solution',
                                 'label': 'S', 'location': 11 }; 

    tagButtonSpec['learner'] = {'typ': 'learner',
                                'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor',
                              'label': 'T', 'location': 13 };
    
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ou-' + typ + ' metadata tag on a cell',
        })
        
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }

    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
   

The whole script then becomes:

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { ToolbarButton } from '@jupyterlab/apputils';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // Set the tag name
        const tagname = "ou-"+typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)

            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
            
            // Remove class
            activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
            
            // Add class
            activeCell.node.classList.add(tagname + "-node")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
        
    };

    // If we specify the buttons we want in an array
    // we can then construct them all via the same function
    
    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
    
    tagButtonSpec['activity'] = {'typ': 'activity',
                                 'label': 'A', 'location': 10 };

    tagButtonSpec['solution'] = {'typ': 'solution',
                                 'label': 'S', 'location': 11 }; 

    tagButtonSpec['learner'] = {'typ': 'learner',
                                'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor',
                              'label': 'T', 'location': 13 };
    
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ou-' + typ + ' metadata tag on a cell',
        })
        
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }
    
    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

Given we now have a set-up array, we might contemplate using an extension settings file (for example, cribbing from the settings example in jupyterlab/extension-examples) to contain the button definitions and perhaps also the tag and class prefix. I don’t know if the Plugin Playground gives us access to settings the extension can use, so let’s save this unil we have an actual extension to hand.

It might also be handy to be able to use a settings file to custom set the colours used to with the tag classes, but I’m not sure how we can propagate values as CSS variable definitions?

In the next post in the series, we’ll pull all the pieces together and see if we can actually build a JupyterLab empinken extension.

Notes on the JupyterLab Notebook HTML DOM Model, Part 4: Styling Custom Classes

In the previous posts in this series, I explored the HTML DOM structure of rendered notebook markdown and code cells, and looked at how we might set DOM element class tags based on notebook cell metadata tags. In this post, I’ll see how we might style notebook cells using custom CSS rules.

To simplify testing new CSS rules, I’ll do all the work in the borwser. In my Chrome browser, the developer tools panel (raised from the View > Developer menu) allows you to inspect elements in the web page, view the CSS that applies to them, and edit and create custom CSS rules that are applied without reloading the page:

This means its easy enough to just mess around with rules and see how they work.

To cell cell backgrounds that “join up” across contiguous cells, we can apply a new background rule to the .tag-TAG-markdown-node and .tag-TAG-code-node elements, as shown above. The !important phrase allows us to over-ride the background settings that are applied onto elements further down the DOM branch. Obviously, this is not a theme sensitive solution, but this is my extension, and if it only works in certain themes, then that’s only where it works.

Thinking about how we might style things for a code cell execution status indicator extension, the input area prompt looks sensible:

As to how to get a custom CSS file into an extension, I’ve already got a sketch for that here.

So that’s pretty much it for how to actually add rendered custom style…

In the next post in this series, I’ll look at how we can add some buttons to the browser that will toggle metadata and tag state on the notebook so that we can add tags at the click of a button that can be classed as class attributes for which have have pre-defined style rules. (This is essentially the last step we need for something like an empinken extension.)

Notes on the JupyterLab Notebook HTML DOM Model, Part 3: Setting Classes Based on Cell Tags From a JupyterLab Extension

In the previous two posts in this series, I reviewed the HTML DOM structure or rendered notebook markdown and code cells in JupyterLab (I assume the same structure exists in RetroLab, but need to check…). In this post, I’ll review one way in which we can set classes on those elements from a JupyterLab extension.

To simplify the extension building process, the following recipe has been tested using the JupyterLab Plugin Playground, a JupyterLab extension that lets you write and test simple extensions purely within a JupyterLab context (i.e. without any need to build and install an extension from the command line).

Note that this is a playground with caveats: you need to be strict in your Javascript or things will fail to run, even though they could be executed in a more forgiving environment. I’m also not sure if this is a supersitious behaviour or a necessary one: each time I modified the script, I closed my test notebook, saved the script, reloaded the browser page, clicked the jigsaw icon in the playground editor to “install” the extension, then opened the test notebook to see if things had worked. Certainly, I often had no success in effecting changes without the above steps. But it’s probably quicker than rebuilding JupyterLab each time.

To begin with, I’ll see if I can create a simple extension to add class tags to various parts of the cell HTML DOM based on the presence of cell metadata tags.

Docs for how to drive changes from notebook events seem to be in JupyterLab API notebook module notebookactions, but they aren’t relevant if you want to take action over a notebook on load, as, for example, when cells have been rendered into the DOM and are available for classing.

The notebook class docs briefly reference various accessor methods that may be connected to. I thought a promising one (from the name of it) might be the fullyrendered accessor, which seems to be “[a] signal emitted when the notebook is fully rendered” but a Stack Overflow comment suggests otherwise:

notebook.fullyRendered is fairly new and may not be what you are looking for. Previously there was no concept of “notebook.ready” (it was just cells getting added one by one on load), but with the virtualized rendering – if configured to rely solely on content intersection) users may never see a fully rendered notebook (if they do not scroll down to the very last cell); in any case this is all subject to change based on the feedback we got about problems with this feature (a lot of changes were already made), so personally I would just go one-by-one on every change.

@krassowski

That said, I found the fullyRendered signal did fire, albeit multiple times, when I loaded a notebook and could be used to class the cells based on metadata, but the modelChanged signal did not seem to do anyhting when the notebook loaded, when I added metadata to a cell, or when I moved a cell? (I note there is also a stateChanged signal?)

Just as an aside, I note some signals that may be useful for a cell execution status extension in the notebookActions class, specifically the executed (“A signal that emits whenever a cell execution is scheduled.”) and executionScheduled (“A signal that emits whenever a cell execution is scheduled”) signals, although I note that selectionExecuted also claims to be “A signal that emits whenever a cell execution is scheduled”? An example of connecting to the NotebookActions.executed signal can be found in the jupyterlab-cell-flash extension. I’m not sure if there’s a way of detecting whether the execution ran successfully or returned an error? There NotebookActions.executed signal does seem to return an error? (yes, the ? is there…) KernelError object and a success boolean, so maybe we can use those. In the classic cell execution status extension, it looks like there was a code cell _handle_execute_reply action that returned various status messages (ok, aborted) that could be reasoned around.

Another possibly useful crib is the agoose77/jupyterlab-imarkdown extension which resembles the classic notebook Python Markdown extension which renders variables into markdown content (via this discourse forum post by @agoose77).

Anywhere, here’s the code I used, which was largely inspired by @krassowski’s response to my Stack Overflow question on Accessing notebook cell metadata and HTML class attributes in JupyterLab Extensions.

// Most of the code cribbed from @krassowski
// https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/*
The most relevant docs appear to be:
https://jupyterlab.readthedocs.io/en/stable/api/modules/notebook.notebookactions.html

*/
export class ClassDemoExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
    /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    function check_tags(notebook){
        /*
        Iterate through all the cells
        If we see a cell with a tag that starts with class-
        then add a corresponding tag to differnt elements in the
        notebook HTML DOM
        */
        for (const cell of notebook.widgets) {
            let tagList = cell.model.metadata.get('tags') as string[];
            if (tagList) {
              for (let i = 0; i < tagList.length; i++) {
                var tag = tagList[i];
                if tag.startsWith("class-") {
                    /* 
                    The plugin playground extension is rather hostile 
                    to have-a-go end-user devs (it's rather strict in 
                    its Javascript epxectations) so if you don't 
                    declare things with let, const or var then an 
                    error of the following form is likely to be thrown 
                    that stops further execution:
                    
                    ReferenceError: class_name is not definred
                    */
                    
                    //This will replace just the first instance of class-
                    const class_name = tag.replace("class-", "tag-")
                    
                    // Add class tags to various parts of the DOM
                    // What sort of cell are we?
                    console.log("Cell type: " + cell.model.type)

                    // Try to see what's going on...
                    console.log("Try node...")
                    cell.node.classList.add(class_name + "-" + cell.model.type + "-node");
                    
                    console.log("Try inputArea.node...")
                    cell.inputArea.node.classList.add(class_name + "-" + cell.model.type + "-inputArea_node");
                    
                    console.log("Try editor.node...")
                    cell.editor.host.classList.add(class_name + "-" + cell.model.type + "-editor_host");
                    
                    console.log("Try .inputArea.promptNode...")
                    cell.inputArea.promptNode.classList.add(class_name + "-" + cell.model.type + "-inputArea_promptNode");
                    
                    if (cell.model.type=="markdown") {
                        //console.log("Try RenderedHTMLCommon.node...")
                        //cell.RenderedHTMLCommon.node.classList.add(class_name + "-" + cell.model.type + "-RenderedHTMLCommon.node")
                        //TO DO - access lm-Widget p-Widget jp-RenderedHTMLCommon jp-RenderedMarkdown jp-MarkdownOutput
                        // DOESN'T WORK: cell.MarkdownOutput.node, MarkdownOutput.host, RenderedMarkdown.node, RenderedHTMLCommon.node
                    }
                    else {
                        console.log("Try outputArea.node...")
                        cell.outputArea.node.classList.add(class_name + "-" + cell.model.type + "-outputArea_node")
                    }
                } // end: if class starts with tag...
              } // end: tags iterator
            } //end: if there are tags
        } // end: iterate cells
    } // end: check_tags function definition
    
    notebook.modelChanged.connect((notebook) => {
        console.log("I think we're changed");
        // This doesn't seem to do anything on notebook load
        // iterate cells and toggle DOM classes as needed, e.g.
        //check_tags(notebook);
        
    });
        
    notebook.fullyRendered.connect((notebook) => {
        /*
        I don't think this means fully rendered on a cells becuase it seems
        like we try to add the tags mutliple times on notebook load
        which is really inefficient.
        This may be unstable anyway, eg the following comment:
            https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107
        I'll with go with in now in the expectation it will be break
        and I will hopefully be able to find another even to fire from.
        
        I get the impression the UI is some some of signal hell and
        the solution is just to keep redoing things on everything
        if anything changes. Who needs efficiency or elegance anyway...!
        After all, this is just an end-user hack-from-a-position-of-ignorance
        and works sort of enough to do something that I couldn't do before...
        */
        
        console.log("I think we're fullyRendered...(?!)");
        // iterate cells and toggle DOM classes as needed, e.g.
        check_tags(notebook);
    });
    
    return new DisposableDelegate(() => {
    });
  }
}


/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

With the extension, when I open a notebook with some class-TEST tags on various markdown and code cells, the following class attributes are set on a markdown cell:

And the following class attributes are set on a code cell:

Having set classes on various elements, we now need to style them. An example of how to do that will (hopefully!) be provided in the next post in this series…

PS Just as an aside, one interesting possibility that occurs to me is if we can intercept a cell run command and swap in our own execution call, we could define “magic-tags” that, if detected on a cell, allow us to grab the cell content and then treat it in a magic way. That is, rather than use a block magic at the start of a cell, magic-tag the cell and treat its contents via some tag powered magic. Various bits of signalling around cell execution scheduling requests might be relevant here, eg in the context of trying to set up a pipeline around the processing of code in a code cell before (and after) its execution.

Notes on the JupyterLab Notebook HTML DOM Model, Part 2: Code Cells

For a review of markdown cell DOM structure, see here.

Code Cell Structure

The .ipynb JSON format for a markdown cell is defined as follows:

{
  "cell_type" : "code",
  "execution_count": 1, # integer or null
  "metadata" : {
      "collapsed" : True, # whether the output of the cell is collapsed
      "autoscroll": False, # any of true, false or "auto"
      "tags": []
  },
  "source" : ["some code"],
  "outputs": [{
      # list of output dicts (described below)
      "output_type": "stream",
      ...
  }],
}

When rendered as an (empty) code cell, the following HTML DOM structure is evident:

<div class="lm-Widget p-Widget jp-Cell jp-CodeCell jp-mod-noOutputs jp-Notebook-cell jp-mod-active jp-mod-selected">
    <div class="lm-Widget p-Widget jp-CellHeader jp-Cell-header"></div>
    <div class="lm-Widget p-Widget lm-Panel p-Panel jp-Cell-inputWrapper">
        <div class="lm-Widget p-Widget jp-Collapser jp-InputCollapser jp-Cell-inputCollapser">
            <div class="jp-Collapser-child"></div>
        </div>
        <div class="lm-Widget p-Widget jp-InputArea jp-Cell-inputArea">
            <div class="lm-Widget p-Widget jp-InputPrompt jp-InputArea-prompt">[ ]:</div>
            <div class="lm-Widget p-Widget jp-CodeMirrorEditor jp-Editor jp-InputArea-editor" data-type="inline">
                <div class="CodeMirror cm-s-jupyter">
                    <div style="overflow: hidden; position: relative; width: 3px; height: 0px; top: 5px; left: 4px;"><textarea autocorrect="off" autocapitalize="off" spellcheck="false" tabindex="0" style="position: absolute; bottom: -1em; padding: 0px; width: 1000px; height: 1em; outline: none;"></textarea></div>
                    <div class="CodeMirror-vscrollbar" tabindex="-1" cm-not-content="true">
                        <div style="min-width: 1px; height: 0px;"></div>
                    </div>
                    <div class="CodeMirror-hscrollbar" tabindex="-1" cm-not-content="true">
                        <div style="height: 100%; min-height: 1px; width: 0px;"></div>
                    </div>
                    <div class="CodeMirror-scrollbar-filler" cm-not-content="true"></div>
                    <div class="CodeMirror-gutter-filler" cm-not-content="true"></div>
                    <div class="CodeMirror-scroll" tabindex="-1">
                        <div class="CodeMirror-sizer" style="margin-left: 0px; min-width: 7px; margin-bottom: -15px; border-right-width: 35px; min-height: 27px; padding-right: 0px; padding-bottom: 0px;">
                            <div style="position: relative; top: 0px;">
                                <div class="CodeMirror-lines" role="presentation">
                                    <div role="presentation" style="position: relative; outline: none;">
                                        <div class="CodeMirror-measure"></div>
                                        <div class="CodeMirror-measure"></div>
                                        <div style="position: relative; z-index: 1;"></div>
                                        <div class="CodeMirror-cursors">
                                            <div class="CodeMirror-cursor" style="left: 4px; top: 0px; height: 17px;">&nbsp;</div>
                                        </div>
                                        <div class="CodeMirror-code" role="presentation"><pre class=" CodeMirror-line " role="presentation"><span role="presentation" style="padding-right: 0.1px;"><span cm-text="">​</span></span></pre></div>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <div style="position: absolute; height: 35px; width: 1px; border-bottom: 0px solid transparent; top: 27px;"></div>
                        <div class="CodeMirror-gutters" style="display: none; height: 62px;"></div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div class="lm-Widget p-Widget jp-CellResizeHandle"></div>
    <div class="lm-Widget p-Widget lm-Panel p-Panel jp-Cell-outputWrapper">
        <div class="lm-Widget p-Widget jp-Collapser jp-OutputCollapser jp-Cell-outputCollapser">
            <div class="jp-Collapser-child"></div>
        </div>
        <div class="lm-Widget p-Widget jp-OutputArea jp-Cell-outputArea"></div>
    </div>
    <div class="lm-Widget p-Widget jp-CellFooter jp-Cell-footer"></div>
</div>

As in the previous post, the colouring of the block elements corresponds to a particular CSS element scope:

Let’s start at the top, with the <div class="lm-Widget p-Widget jp-Cell jp-CodeCell jp-mod-noOutputs jp-Notebook-cell jp-mod-active jp-mod-selected"> element which covers everything. As with the mardown cell, note the padded area around the cell.

Inside the block are five child elements, including a header and a footer, as with the markdown cell. But in this case, there are two panel elements, (a Cell-inputWrapper and a Cell-outputwrapper), separated by a CellResizeHandle element.

To ground this review in a little more detail, let’s consider the following cell:

The first panel element, the Cell-inputWrapper, covers the whole of the “input” area, which is to say, the whole extent of the block that contains the cell source.

The block contains two elements, an InputCollapser and a the InputArea:

The InputCollapser follows the model used in the markdown cell. One of its most notable features is that it denotes the selected cell. The InputCollapser area is also the one targeted by the nbsafety extension, which provides warning status indications around cells that have been run out of order.

The InputArea cell only ever has two child elements, the InputPrompt interstitial area between the InputCollapser and the Editor, and the Editor itself.

Not how the InputPrompt covers the cell run history count indicator:

The InputPrompt is the one we should target if we were to try to port something like the valuable classic notebook cell execution status extension, which provides a visual indication of cell run statuses (executing, awaiting-exectution, executed-success, executed-error).

The Cell-outputWrapper is the area that contains the cell outputs. Note the upper padding defined on the cell block.

The Cell_outputWrapper contains two elements, the by now familiar Collapser and an OutputArea:

The OutputArea covers the various sorts of output, “printed” output and code cell returned output:

The OutputArea is thus composed of two OutputArea-child elements, only the second of which carries an element distinguishing class, OutputArea-executeResult:

The first child, for printed display output, covers the area to the right of the collapser:

As such, it includes an OutputPrompt and a RenderedText area:

The RenderedText area extends to the right of the gutter area; note the padded area at the start of the element.

The OutputArea-output then contains the printed output content, the styling of which extends the right.

The second OutputArea-child, which is to say, the OutputArea-executeResult element also contains an OutputArea-prompt and a RenderedText area:

The OutputPrompt area includes the cell run history count indicator for the output:

The RenderedText/OutputArea-output element contains the rendered output returned from the executed code.

In the next post in this series, we will look at how we can set classes on the DOM from a JupyterLab extension.