Skip to content

Commit c5750e6

Browse files
Merge pull request #4 from deepnote/andy/oss-88-extend-jupyter-server-api-endpoint-to-serve-deepnote-files
feat: convert Deepnote file to Jupyter on the frontend
2 parents e0156a9 + 09a1e0a commit c5750e6

16 files changed

+412
-138
lines changed

.github/workflows/build.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,15 @@ jobs:
2929
set -eux
3030
jlpm
3131
jlpm run lint:check
32+
env:
33+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3234

3335
- name: Test the extension
3436
run: |
3537
set -eux
3638
jlpm run test
39+
env:
40+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3741

3842
- name: Build the extension
3943
run: |
@@ -47,6 +51,8 @@ jobs:
4751
jupyter labextension list
4852
jupyter labextension list 2>&1 | grep -ie "jupyterlab-deepnote.*OK"
4953
python -m jupyterlab.browser_check
54+
env:
55+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5056

5157
- name: Package the extension
5258
run: |
@@ -55,6 +61,8 @@ jobs:
5561
pip install build
5662
python -m build
5763
pip uninstall -y "jupyterlab_deepnote" jupyterlab
64+
env:
65+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5866

5967
- name: Upload extension packages
6068
uses: actions/upload-artifact@v4

.github/workflows/check-release.yml

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
name: Check Release
22
on:
33
push:
4-
branches: ["main"]
4+
branches: ['main']
55
pull_request:
6-
branches: ["*"]
6+
branches: ['*']
7+
8+
env:
9+
NODE_VERSION: 22.x
710

811
concurrency:
912
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -15,13 +18,25 @@ jobs:
1518
steps:
1619
- name: Checkout
1720
uses: actions/checkout@v4
21+
22+
- name: Setup Node.js
23+
uses: actions/setup-node@v5
24+
with:
25+
cache: 'npm'
26+
node-version: ${{ env.NODE_VERSION }}
27+
registry-url: 'https://npm.pkg.github.com'
28+
scope: '@deepnote'
29+
always-auth: true
1830
- name: Base Setup
1931
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
32+
2033
- name: Check Release
2134
uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2
2235
with:
23-
2436
token: ${{ secrets.GITHUB_TOKEN }}
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2540

2641
- name: Upload Distributions
2742
uses: actions/upload-artifact@v4

.yarnrc.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
nodeLinker: node-modules
2+
npmScopes:
3+
deepnote:
4+
npmRegistryServer: 'https://npm.pkg.github.com'
5+
npmAlwaysAuth: true
6+
npmAuthToken: '${GITHUB_TOKEN}'

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,22 @@ Install `jupyterlab`. The extension package itself doesn’t depend on `jupyterl
7272
uv pip install jupyterlab
7373
```
7474

75+
**Configure Access to @deepnote/blocks Package**
76+
77+
The `@deepnote/blocks` package is published on GitHub Packages. To install it, you'll need to authenticate with GitHub:
78+
79+
1. Create a GitHub Personal Access Token (classic) with `read:packages` scope:
80+
- Go to https://github.com/settings/tokens
81+
- Click "Generate new token (classic)"
82+
- Select the `read:packages` scope
83+
- Generate and copy the token
84+
85+
2. Set the `GITHUB_TOKEN` environment variable to ensure `jlpm` (which is a wrapper around Yarn) can download the `@deepnote/blocks` package from the GitHub package registry. You can set the variable in `.zshrc` or manually like:
86+
```shell
87+
export GITHUB_TOKEN=your_token_here
88+
```
89+
Replace `YOUR_TOKEN_HERE` with your actual token.
90+
7591
Install the extension package in editable mode. It installs the package’s dependencies, too:
7692

7793
```shell

jupyterlab_deepnote/contents.py

Lines changed: 4 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,7 @@
22
from jupyter_server.services.contents.filemanager import FileContentsManager
33
from typing import cast
44

5-
import yaml
6-
from nbformat.v4 import new_notebook, new_code_cell, new_markdown_cell
7-
8-
9-
def yaml_to_ipynb(yaml_text: str):
10-
"""Convert Deepnote YAML into a minimal Jupyter nbformat v4 notebook."""
11-
try:
12-
data = yaml.safe_load(yaml_text)
13-
except Exception:
14-
return new_notebook(cells=[])
15-
16-
notebooks = (
17-
data.get("project", {}).get("notebooks", []) if isinstance(data, dict) else []
18-
)
19-
20-
if not notebooks:
21-
return new_notebook(cells=[])
22-
23-
# Collect notebook names
24-
notebook_names = [nb.get("name", "") for nb in notebooks]
25-
26-
# Build all_notebooks dict: name -> full nbformat notebook JSON
27-
all_notebooks = {}
28-
for nb in notebooks:
29-
nb_blocks = nb.get("blocks", [])
30-
nb_cells = []
31-
for block in sorted(nb_blocks, key=lambda b: b.get("sortingKey", "")):
32-
btype = block.get("type", "code")
33-
content = block.get("content", "")
34-
if btype == "code":
35-
nb_cells.append(new_code_cell(content))
36-
else:
37-
nb_cells.append(new_markdown_cell(content))
38-
# Use the notebook name as key
39-
nb_name = nb.get("name", "")
40-
all_notebooks[nb_name] = new_notebook(cells=nb_cells)
41-
42-
# Use first notebook's cells to render initially
43-
nb0 = notebooks[0]
44-
blocks = nb0.get("blocks", [])
45-
cells = []
46-
for block in sorted(blocks, key=lambda b: b.get("sortingKey", "")):
47-
btype = block.get("type", "code")
48-
content = block.get("content", "")
49-
if btype == "code":
50-
cells.append(new_code_cell(content))
51-
else:
52-
cells.append(new_markdown_cell(content))
53-
54-
metadata = {
55-
"deepnote": {"notebook_names": notebook_names, "notebooks": all_notebooks}
56-
}
57-
return new_notebook(cells=cells, metadata=metadata)
58-
59-
60-
def yaml_to_ipynb_dummy(yaml_text: str) -> dict:
61-
return {"nbformat": 4, "nbformat_minor": 5, "metadata": {}, "cells": []}
5+
from nbformat.v4 import new_notebook
626

637

648
class DeepnoteContentsManager(FileContentsManager):
@@ -74,15 +18,13 @@ def get(self, path, content=True, type=None, format=None, require_hash=False):
7418
else:
7519
yaml_text = cast(str, _content)
7620

77-
nb_node = yaml_to_ipynb(yaml_text)
78-
7921
model = self._base_model(path)
8022
model["type"] = "notebook"
8123
model["format"] = "json"
82-
model["content"] = nb_node
24+
model["content"] = new_notebook(
25+
cells=[], metadata={"deepnote": {"rawYamlString": yaml_text}}
26+
)
8327
model["writable"] = False
84-
self.mark_trusted_cells(nb_node, path)
85-
self.validate_notebook_model(model, validation_error={})
8628

8729
if require_hash:
8830
# Accept 2- or 3-tuple; we only need the bytes

package.json

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,23 @@
5858
"watch:labextension": "jupyter labextension watch ."
5959
},
6060
"dependencies": {
61+
"@deepnote/blocks": "^1.1.0",
6162
"@jupyterlab/application": "^4.0.0",
6263
"@jupyterlab/coreutils": "^6.0.0",
6364
"@jupyterlab/notebook": "^4.4.7",
6465
"@jupyterlab/services": "^7.0.0",
6566
"@jupyterlab/settingregistry": "^4.0.0",
66-
"@lumino/widgets": "^2.7.1"
67+
"@lumino/widgets": "^2.7.1",
68+
"lodash": "^4.17.21",
69+
"yaml": "^2.8.1",
70+
"zod": "^4.1.11"
6771
},
6872
"devDependencies": {
6973
"@jupyterlab/builder": "^4.0.0",
7074
"@jupyterlab/testutils": "^4.0.0",
7175
"@types/jest": "^29.2.0",
7276
"@types/json-schema": "^7.0.11",
77+
"@types/lodash": "^4.17.20",
7378
"@types/react": "^18.0.26",
7479
"@types/react-addons-linked-state-mixin": "^0.14.22",
7580
"@typescript-eslint/eslint-plugin": "^6.1.0",
@@ -141,19 +146,6 @@
141146
"@typescript-eslint"
142147
],
143148
"rules": {
144-
"@typescript-eslint/naming-convention": [
145-
"error",
146-
{
147-
"selector": "interface",
148-
"format": [
149-
"PascalCase"
150-
],
151-
"custom": {
152-
"regex": "^I[A-Z]",
153-
"match": true
154-
}
155-
}
156-
],
157149
"@typescript-eslint/no-unused-vars": [
158150
"warn",
159151
{

src/index.tsx renamed to src/components/NotebookPicker.tsx

Lines changed: 4 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,9 @@
1-
import {
2-
JupyterFrontEnd,
3-
JupyterFrontEndPlugin
4-
} from '@jupyterlab/application';
51
import React from 'react';
6-
import { IToolbarWidgetRegistry, ReactWidget } from '@jupyterlab/apputils';
7-
import {
8-
INotebookWidgetFactory,
9-
NotebookPanel,
10-
NotebookWidgetFactory
11-
} from '@jupyterlab/notebook';
12-
import { Widget } from '@lumino/widgets';
2+
import { ReactWidget } from '@jupyterlab/apputils';
3+
import { NotebookPanel } from '@jupyterlab/notebook';
134
import { HTMLSelect } from '@jupyterlab/ui-components';
145

15-
const plugin: JupyterFrontEndPlugin<void> = {
16-
id: 'jupyterlab-deepnote:plugin',
17-
description: 'Open .deepnote files as notebooks.',
18-
autoStart: true,
19-
requires: [INotebookWidgetFactory, IToolbarWidgetRegistry],
20-
activate: (
21-
app: JupyterFrontEnd,
22-
notebookWidgetFactory: NotebookWidgetFactory,
23-
toolbarRegistry: IToolbarWidgetRegistry
24-
) => {
25-
app.docRegistry.addFileType(
26-
{
27-
name: 'deepnote',
28-
displayName: 'Deepnote Notebook',
29-
extensions: ['.deepnote'],
30-
mimeTypes: ['text/yaml', 'application/x-yaml'],
31-
fileFormat: 'text',
32-
contentType: 'file'
33-
},
34-
[notebookWidgetFactory.name]
35-
);
36-
37-
app.docRegistry.setDefaultWidgetFactory(
38-
'deepnote',
39-
notebookWidgetFactory.name
40-
);
41-
42-
toolbarRegistry.addFactory<NotebookPanel>(
43-
notebookWidgetFactory.name,
44-
'deepnote:switch-notebook',
45-
panel => {
46-
if (!panel.context.path.endsWith('.deepnote')) {
47-
return new Widget(); // don’t render for .ipynb or others
48-
}
49-
50-
return new NotebookPicker(panel);
51-
}
52-
);
53-
}
54-
};
55-
56-
class NotebookPicker extends ReactWidget {
6+
export class NotebookPicker extends ReactWidget {
577
private selected: string | null = null;
588

599
constructor(private panel: NotebookPanel) {
@@ -68,7 +18,7 @@ class NotebookPicker extends ReactWidget {
6818
? metadataNames
6919
: [];
7020

71-
this.selected = names.length > 0 ? names[0] : null;
21+
this.selected = names.length === 0 ? null : (names[0] ?? null);
7222
this.update();
7323
});
7424
}
@@ -115,7 +65,6 @@ class NotebookPicker extends ReactWidget {
11565

11666
return (
11767
<HTMLSelect
118-
id="deepnote-notebook-picker"
11968
value={this.selected ?? '-'}
12069
onChange={this.handleChange}
12170
onKeyDown={() => {}}
@@ -141,5 +90,3 @@ class NotebookPicker extends ReactWidget {
14190
);
14291
}
14392
}
144-
145-
export default plugin;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
createMarkdown,
3+
createPythonCode,
4+
DeepnoteBlock
5+
} from '@deepnote/blocks';
6+
import _cloneDeep from 'lodash/cloneDeep';
7+
import { ICodeCell, IMarkdownCell } from '@jupyterlab/nbformat';
8+
import { convertDeepnoteBlockTypeToJupyter } from './convert-deepnote-block-type-to-jupyter';
9+
10+
export function convertDeepnoteBlockToJupyterCell(block: DeepnoteBlock) {
11+
const blockCopy = _cloneDeep(block);
12+
const jupyterCellMetadata = { ...blockCopy.metadata, cell_id: blockCopy.id };
13+
const jupyterCellType = convertDeepnoteBlockTypeToJupyter(blockCopy.type);
14+
15+
if (jupyterCellType === 'code') {
16+
const blockOutputs = blockCopy.outputs ?? [];
17+
18+
if (Array.isArray(blockOutputs)) {
19+
blockOutputs.forEach(output => {
20+
delete output.truncated;
21+
});
22+
}
23+
24+
const source = createPythonCode(blockCopy);
25+
26+
const jupyterCell: ICodeCell = {
27+
cell_type: 'code',
28+
metadata: jupyterCellMetadata,
29+
execution_count: blockCopy.executionCount ?? null,
30+
outputs: blockOutputs,
31+
source
32+
};
33+
return jupyterCell;
34+
} else {
35+
// Markdown cell
36+
const source = createMarkdown(blockCopy);
37+
const jupyterCell: IMarkdownCell = {
38+
cell_type: 'markdown',
39+
metadata: {},
40+
source
41+
};
42+
return jupyterCell;
43+
}
44+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export function convertDeepnoteBlockTypeToJupyter(blockType: string) {
2+
switch (blockType) {
3+
case 'big-number':
4+
case 'code':
5+
case 'sql':
6+
case 'notebook-function':
7+
case 'input-text':
8+
case 'input-checkbox':
9+
case 'input-textarea':
10+
case 'input-file':
11+
case 'input-select':
12+
case 'input-date-range':
13+
case 'input-date':
14+
case 'input-slider':
15+
case 'visualization':
16+
return 'code';
17+
18+
case 'markdown':
19+
case 'text-cell-h1':
20+
case 'text-cell-h3':
21+
case 'text-cell-h2':
22+
case 'text-cell-p':
23+
case 'text-cell-bullet':
24+
case 'text-cell-todo':
25+
case 'text-cell-callout':
26+
case 'image':
27+
case 'button':
28+
case 'separator':
29+
default:
30+
return 'markdown';
31+
}
32+
}

0 commit comments

Comments
 (0)