Skip to content

Commit ea98a58

Browse files
feat: add custom API route for .deepnote files (#27)
* Delete noop assignment This doesn’t seem to do anything: `server_app.contents_manager = DeepnoteContentsManager(parent=server_app)`. What does seem to work is adding this field to a config file, and pointing `jupyterlab` at this config: `"contents_manager_class": "jupyterlab_deepnote.contents.DeepnoteContentsManager"` Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> * Add custom API route for .deepnote files Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> * Update src/deepnote-content-provider.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add error handling Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> * Validate API response against Zod schema Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> * Lowercase path in .deepnote file check Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> * Drop unused exception aliases Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> --------- Signed-off-by: Andy Jakubowski <hello@andyjakubowski.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent a569fc6 commit ea98a58

File tree

7 files changed

+90
-92
lines changed

7 files changed

+90
-92
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,12 @@ jlpm run watch
117117

118118
The `jlpm` command is JupyterLab's pinned version of
119119
[yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
120-
`yarn` or `npm` in lieu of `jlpm` below.
120+
`yarn` or `npm` instead of `jlpm` below.
121121

122-
In a separate terminal, run `jupyter lab` with the `--config` option to register our custom file contents manager for the `.deepnote` extension. The `--debug` option lets you see HTTP requests in the logs, which is helpful for debugging.
122+
In a separate terminal, run `jupyter lab`. You can add the `--debug` option to see HTTP requests in the logs, which can be helpful for debugging.
123123

124124
```shell
125-
jupyter lab --debug --config="$(pwd)/jupyter-config/server-config/jupyter_server_config.json"
125+
jupyter lab --debug
126126
```
127127

128128
You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.

jupyter-config/server-config/jupyter_server_config.json

Lines changed: 0 additions & 5 deletions
This file was deleted.

jupyterlab_deepnote/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
warnings.warn("Importing 'jupyterlab_deepnote' outside a proper installation.")
1010
__version__ = "dev"
11-
from jupyterlab_deepnote.contents import DeepnoteContentsManager
1211
from .handlers import setup_handlers
1312

1413

@@ -31,4 +30,3 @@ def _load_jupyter_server_extension(server_app):
3130
setup_handlers(server_app.web_app)
3231
name = "jupyterlab_deepnote"
3332
server_app.log.info(f"Registered {name} server extension")
34-
server_app.contents_manager = DeepnoteContentsManager(parent=server_app)

jupyterlab_deepnote/contents.py

Lines changed: 0 additions & 40 deletions
This file was deleted.

jupyterlab_deepnote/handlers.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,68 @@
1+
from datetime import datetime
12
import json
23

34
from jupyter_server.base.handlers import APIHandler
45
from jupyter_server.utils import url_path_join
6+
from jupyter_core.utils import ensure_async
57
import tornado
68

9+
710
class RouteHandler(APIHandler):
811
# The following decorator should be present on all verb methods (head, get, post,
912
# patch, put, delete, options) to ensure only authorized user can request the
1013
# Jupyter server
1114
@tornado.web.authenticated
12-
def get(self):
13-
self.finish(json.dumps({
14-
"data": "This is /jupyterlab-deepnote/get-example endpoint!"
15-
}))
15+
async def get(self):
16+
path = self.get_query_argument("path", default=None)
17+
if not path:
18+
self.set_status(400)
19+
self.set_header("Content-Type", "application/json")
20+
self.finish(
21+
json.dumps(
22+
{
23+
"code": 400,
24+
"message": "Missing required 'path' parameter",
25+
}
26+
)
27+
)
28+
return
29+
try:
30+
model = await ensure_async(
31+
self.contents_manager.get(
32+
path, type="file", format="text", content=True
33+
)
34+
)
35+
except FileNotFoundError:
36+
self.set_status(404)
37+
self.set_header("Content-Type", "application/json")
38+
self.finish(json.dumps({"code": 404, "message": "File not found"}))
39+
return
40+
except PermissionError:
41+
self.set_status(403)
42+
self.set_header("Content-Type", "application/json")
43+
self.finish(json.dumps({"code": 403, "message": "Permission denied"}))
44+
return
45+
except Exception:
46+
self.log.exception("Error retrieving file")
47+
self.set_status(500)
48+
self.set_header("Content-Type", "application/json")
49+
self.finish(json.dumps({"code": 500, "message": "Internal server error"}))
50+
return
51+
# Convert datetimes to strings so JSON can handle them
52+
for key in ("created", "last_modified"):
53+
if isinstance(model.get(key), datetime):
54+
model[key] = model[key].isoformat()
55+
56+
# Return everything, including YAML content
57+
result = {"deepnoteFileModel": model}
58+
59+
self.finish(json.dumps(result))
1660

1761

1862
def setup_handlers(web_app):
1963
host_pattern = ".*$"
2064

2165
base_url = web_app.settings["base_url"]
22-
route_pattern = url_path_join(base_url, "jupyterlab-deepnote", "get-example")
66+
route_pattern = url_path_join(base_url, "jupyterlab-deepnote", "file")
2367
handlers = [(route_pattern, RouteHandler)]
2468
web_app.add_handlers(host_pattern, handlers)

src/deepnote-content-provider.ts

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,60 @@
11
import { Contents, RestContentProvider } from '@jupyterlab/services';
2-
import { z } from 'zod';
32
import { transformDeepnoteYamlToNotebookContent } from './transform-deepnote-yaml-to-notebook-content';
3+
import { requestAPI } from './handler';
4+
import { z } from 'zod';
45

56
export const deepnoteContentProviderName = 'deepnote-content-provider';
67

7-
const deepnoteFileFromServerSchema = z.object({
8-
cells: z.array(z.any()), // or refine further with nbformat
9-
metadata: z.object({
10-
deepnote: z.object({
11-
rawYamlString: z.string()
12-
})
13-
}),
14-
nbformat: z.number(),
15-
nbformat_minor: z.number()
8+
const deepnoteFileResponseSchema = z.object({
9+
deepnoteFileModel: z.object({
10+
name: z.string(),
11+
path: z.string(),
12+
created: z.string(),
13+
last_modified: z.string(),
14+
content: z.string(),
15+
mimetype: z.string().optional()
16+
})
1617
});
1718

1819
export class DeepnoteContentProvider extends RestContentProvider {
1920
async get(
2021
localPath: string,
2122
options?: Contents.IFetchOptions
2223
): Promise<Contents.IModel> {
23-
const model = await super.get(localPath, options);
24-
const isDeepnoteFile =
25-
localPath.endsWith('.deepnote') && model.type === 'notebook';
24+
const isDeepnoteFile = localPath.toLowerCase().endsWith('.deepnote');
2625

2726
if (!isDeepnoteFile) {
2827
// Not a .deepnote file, return as-is
29-
return model;
28+
const nonDeepnoteModel = await super.get(localPath, options);
29+
return nonDeepnoteModel;
3030
}
3131

32-
const validatedModelContent = deepnoteFileFromServerSchema.safeParse(
33-
model.content
34-
);
35-
36-
if (!validatedModelContent.success) {
37-
console.error(
38-
'Invalid .deepnote file content:',
39-
validatedModelContent.error
40-
);
41-
// Return an empty notebook instead of throwing an error
42-
model.content.cells = [];
43-
return model;
32+
// Call custom API route to fetch the Deepnote file content
33+
const data = await requestAPI(`file?path=${encodeURIComponent(localPath)}`);
34+
const parsed = deepnoteFileResponseSchema.safeParse(data);
35+
if (!parsed.success) {
36+
console.error('Invalid API response shape', parsed.error);
37+
throw new Error('Invalid API response shape');
4438
}
39+
const modelData = parsed.data.deepnoteFileModel;
4540

4641
// Transform the Deepnote YAML to Jupyter notebook content
47-
const transformedModelContent =
48-
await transformDeepnoteYamlToNotebookContent(
49-
validatedModelContent.data.metadata.deepnote.rawYamlString
50-
);
51-
52-
const transformedModel = {
53-
...model,
54-
content: transformedModelContent
42+
const notebookContent = await transformDeepnoteYamlToNotebookContent(
43+
modelData.content
44+
);
45+
46+
const model: Contents.IModel = {
47+
name: modelData.name,
48+
path: modelData.path,
49+
type: 'notebook',
50+
writable: false,
51+
created: modelData.created,
52+
last_modified: modelData.last_modified,
53+
mimetype: 'application/x-ipynb+json',
54+
format: 'json',
55+
content: notebookContent
5556
};
5657

57-
return transformedModel;
58+
return model;
5859
}
5960
}

src/handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { ServerConnection } from '@jupyterlab/services';
99
* @param init Initial values for the request
1010
* @returns The response body interpreted as JSON
1111
*/
12-
export async function requestAPI<T>(
12+
export async function requestAPI(
1313
endPoint = '',
1414
init: RequestInit = {}
15-
): Promise<T> {
15+
): Promise<unknown> {
1616
// Make request to Jupyter API
1717
const settings = ServerConnection.makeSettings();
1818
const requestUrl = URLExt.join(

0 commit comments

Comments
 (0)