From 7f654bc03f7af43d938bd7419bb381f2cf6a6613 Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Tue, 14 Oct 2025 15:27:19 +0200 Subject: [PATCH 1/7] Delete noop assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- jupyterlab_deepnote/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/jupyterlab_deepnote/__init__.py b/jupyterlab_deepnote/__init__.py index f4cbdd5..7454fa2 100644 --- a/jupyterlab_deepnote/__init__.py +++ b/jupyterlab_deepnote/__init__.py @@ -31,4 +31,3 @@ def _load_jupyter_server_extension(server_app): setup_handlers(server_app.web_app) name = "jupyterlab_deepnote" server_app.log.info(f"Registered {name} server extension") - server_app.contents_manager = DeepnoteContentsManager(parent=server_app) From 87d62f69df8e18413630b2eb493325ea216a3408 Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Wed, 15 Oct 2025 10:36:26 +0200 Subject: [PATCH 2/7] Add custom API route for .deepnote files Signed-off-by: Andy Jakubowski --- README.md | 6 +- .../server-config/jupyter_server_config.json | 5 -- jupyterlab_deepnote/__init__.py | 1 - jupyterlab_deepnote/contents.py | 40 ------------- jupyterlab_deepnote/handlers.py | 24 ++++++-- src/deepnote-content-provider.ts | 59 +++++++------------ 6 files changed, 43 insertions(+), 92 deletions(-) delete mode 100644 jupyter-config/server-config/jupyter_server_config.json delete mode 100644 jupyterlab_deepnote/contents.py diff --git a/README.md b/README.md index 7454d17..04c34f2 100644 --- a/README.md +++ b/README.md @@ -114,12 +114,12 @@ jlpm run watch The `jlpm` command is JupyterLab's pinned version of [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use -`yarn` or `npm` in lieu of `jlpm` below. +`yarn` or `npm` instead of `jlpm` below. -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. +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. ```shell -jupyter lab --debug --config="$(pwd)/jupyter-config/server-config/jupyter_server_config.json" +jupyter lab --debug ``` 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. diff --git a/jupyter-config/server-config/jupyter_server_config.json b/jupyter-config/server-config/jupyter_server_config.json deleted file mode 100644 index 2487c6e..0000000 --- a/jupyter-config/server-config/jupyter_server_config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ServerApp": { - "contents_manager_class": "jupyterlab_deepnote.contents.DeepnoteContentsManager" - } -} diff --git a/jupyterlab_deepnote/__init__.py b/jupyterlab_deepnote/__init__.py index 7454fa2..5c7c36d 100644 --- a/jupyterlab_deepnote/__init__.py +++ b/jupyterlab_deepnote/__init__.py @@ -8,7 +8,6 @@ warnings.warn("Importing 'jupyterlab_deepnote' outside a proper installation.") __version__ = "dev" -from jupyterlab_deepnote.contents import DeepnoteContentsManager from .handlers import setup_handlers diff --git a/jupyterlab_deepnote/contents.py b/jupyterlab_deepnote/contents.py deleted file mode 100644 index fc9aec0..0000000 --- a/jupyterlab_deepnote/contents.py +++ /dev/null @@ -1,40 +0,0 @@ -# deepnote_jupyter_extension/contents.py -from jupyter_server.services.contents.filemanager import FileContentsManager -from typing import cast - -from nbformat.v4 import new_notebook - - -class DeepnoteContentsManager(FileContentsManager): - def get(self, path, content=True, type=None, format=None, require_hash=False): - if path.endswith(".deepnote") and (content == 1): - os_path = self._get_os_path(path) - - # _read_file may return 2- or 3-tuple depending on raw flag in implementation hints - _content, _fmt, *_ = self._read_file(os_path, "text") # type: ignore[misc] - # Coerce to str for converter - if isinstance(_content, bytes): - yaml_text = _content.decode("utf-8", errors="replace") - else: - yaml_text = cast(str, _content) - - model = self._base_model(path) - model["type"] = "notebook" - model["format"] = "json" - model["content"] = new_notebook( - cells=[], metadata={"deepnote": {"rawYamlString": yaml_text}} - ) - model["writable"] = False - - if require_hash: - # Accept 2- or 3-tuple; we only need the bytes - bytes_content, *_ = self._read_file(os_path, "byte") # type: ignore[misc] - if isinstance(bytes_content, str): - bytes_content = bytes_content.encode("utf-8", errors="replace") - model.update(**self._get_hash(bytes_content)) # type: ignore[arg-type] - - return model - - return super().get( - path, content=content, type=type, format=format, require_hash=require_hash - ) diff --git a/jupyterlab_deepnote/handlers.py b/jupyterlab_deepnote/handlers.py index ba420f4..ac4fdb7 100644 --- a/jupyterlab_deepnote/handlers.py +++ b/jupyterlab_deepnote/handlers.py @@ -1,24 +1,38 @@ +from datetime import datetime import json from jupyter_server.base.handlers import APIHandler from jupyter_server.utils import url_path_join +from jupyter_core.utils import ensure_async import tornado + class RouteHandler(APIHandler): # The following decorator should be present on all verb methods (head, get, post, # patch, put, delete, options) to ensure only authorized user can request the # Jupyter server @tornado.web.authenticated - def get(self): - self.finish(json.dumps({ - "data": "This is /jupyterlab-deepnote/get-example endpoint!" - })) + async def get(self): + path = self.get_query_argument("path") + # Use Jupyter Server’s contents_manager, not direct filesystem access. + model = await ensure_async( + self.contents_manager.get(path, type="file", format="text", content=True) + ) + # Convert datetimes to strings so JSON can handle them + for key in ("created", "last_modified"): + if isinstance(model.get(key), datetime): + model[key] = model[key].isoformat() + + # Return everything, including YAML content + result = {"deepnoteFileModel": model} + + self.finish(json.dumps(result)) def setup_handlers(web_app): host_pattern = ".*$" base_url = web_app.settings["base_url"] - route_pattern = url_path_join(base_url, "jupyterlab-deepnote", "get-example") + route_pattern = url_path_join(base_url, "jupyterlab-deepnote", "file") handlers = [(route_pattern, RouteHandler)] web_app.add_handlers(host_pattern, handlers) diff --git a/src/deepnote-content-provider.ts b/src/deepnote-content-provider.ts index 540abb6..373de29 100644 --- a/src/deepnote-content-provider.ts +++ b/src/deepnote-content-provider.ts @@ -1,59 +1,42 @@ import { Contents, RestContentProvider } from '@jupyterlab/services'; -import { z } from 'zod'; import { transformDeepnoteYamlToNotebookContent } from './transform-deepnote-yaml-to-notebook-content'; +import { requestAPI } from './handler'; export const deepnoteContentProviderName = 'deepnote-content-provider'; - -const deepnoteFileFromServerSchema = z.object({ - cells: z.array(z.any()), // or refine further with nbformat - metadata: z.object({ - deepnote: z.object({ - rawYamlString: z.string() - }) - }), - nbformat: z.number(), - nbformat_minor: z.number() -}); - export class DeepnoteContentProvider extends RestContentProvider { async get( localPath: string, options?: Contents.IFetchOptions ): Promise { - const model = await super.get(localPath, options); - const isDeepnoteFile = - localPath.endsWith('.deepnote') && model.type === 'notebook'; + const isDeepnoteFile = localPath.endsWith('.deepnote'); if (!isDeepnoteFile) { // Not a .deepnote file, return as-is - return model; + const nonDeepnoteModel = await super.get(localPath, options); + return nonDeepnoteModel; } - const validatedModelContent = deepnoteFileFromServerSchema.safeParse( - model.content - ); - - if (!validatedModelContent.success) { - console.error( - 'Invalid .deepnote file content:', - validatedModelContent.error - ); - // Return an empty notebook instead of throwing an error - model.content.cells = []; - return model; - } + // Call custom API route to fetch the Deepnote file content + const data = await requestAPI(`file?path=${localPath}`); + const modelData = data.deepnoteFileModel; // Transform the Deepnote YAML to Jupyter notebook content - const transformedModelContent = - await transformDeepnoteYamlToNotebookContent( - validatedModelContent.data.metadata.deepnote.rawYamlString - ); + const notebookContent = await transformDeepnoteYamlToNotebookContent( + modelData.content + ); - const transformedModel = { - ...model, - content: transformedModelContent + const model: Contents.IModel = { + name: modelData.name, + path: modelData.path, + type: 'notebook', + writable: false, + created: modelData.created, + last_modified: modelData.last_modified, + mimetype: 'application/x-ipynb+json', + format: 'json', + content: notebookContent }; - return transformedModel; + return model; } } From 689036e5189a72cd1de58c125a841ac42856470b Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Wed, 15 Oct 2025 10:45:55 +0200 Subject: [PATCH 3/7] Update src/deepnote-content-provider.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/deepnote-content-provider.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/deepnote-content-provider.ts b/src/deepnote-content-provider.ts index 373de29..56c01f2 100644 --- a/src/deepnote-content-provider.ts +++ b/src/deepnote-content-provider.ts @@ -17,7 +17,23 @@ export class DeepnoteContentProvider extends RestContentProvider { } // Call custom API route to fetch the Deepnote file content - const data = await requestAPI(`file?path=${localPath}`); + let data: any; + + try { + data = await requestAPI( + `file?path=${encodeURIComponent(localPath)}` + ); + } catch (error) { + console.error(`Failed to fetch Deepnote file: ${localPath}`, error); + throw new Error(`Failed to fetch .deepnote file: ${error}`); + } + + if (!data.deepnoteFileModel) { + throw new Error( + `Invalid API response: missing deepnoteFileModel for ${localPath}` + ); + } + const modelData = data.deepnoteFileModel; // Transform the Deepnote YAML to Jupyter notebook content From 83879a8f683f29539de3206e171d3e79bb2bea4b Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Wed, 15 Oct 2025 10:58:38 +0200 Subject: [PATCH 4/7] Add error handling Signed-off-by: Andy Jakubowski --- jupyterlab_deepnote/handlers.py | 40 ++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/jupyterlab_deepnote/handlers.py b/jupyterlab_deepnote/handlers.py index ac4fdb7..26241c7 100644 --- a/jupyterlab_deepnote/handlers.py +++ b/jupyterlab_deepnote/handlers.py @@ -13,11 +13,41 @@ class RouteHandler(APIHandler): # Jupyter server @tornado.web.authenticated async def get(self): - path = self.get_query_argument("path") - # Use Jupyter Server’s contents_manager, not direct filesystem access. - model = await ensure_async( - self.contents_manager.get(path, type="file", format="text", content=True) - ) + path = self.get_query_argument("path", default=None) + if not path: + self.set_status(400) + self.set_header("Content-Type", "application/json") + self.finish( + json.dumps( + { + "code": 400, + "message": "Missing required 'path' parameter", + } + ) + ) + return + try: + model = await ensure_async( + self.contents_manager.get( + path, type="file", format="text", content=True + ) + ) + except FileNotFoundError as e: + self.set_status(404) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"code": 404, "message": "File not found"})) + return + except PermissionError as e: + self.set_status(403) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"code": 403, "message": "Permission denied"})) + return + except Exception as e: + self.log.exception("Error retrieving file") + self.set_status(500) + self.set_header("Content-Type", "application/json") + self.finish(json.dumps({"code": 500, "message": "Internal server error"})) + return # Convert datetimes to strings so JSON can handle them for key in ("created", "last_modified"): if isinstance(model.get(key), datetime): From 2c44ef32f2f229c336376133309409537bf7f19d Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Wed, 15 Oct 2025 11:20:45 +0200 Subject: [PATCH 5/7] Validate API response against Zod schema Signed-off-by: Andy Jakubowski --- src/deepnote-content-provider.ts | 36 +++++++++++++++++--------------- src/handler.ts | 4 ++-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/deepnote-content-provider.ts b/src/deepnote-content-provider.ts index 56c01f2..fd35d04 100644 --- a/src/deepnote-content-provider.ts +++ b/src/deepnote-content-provider.ts @@ -1,8 +1,21 @@ import { Contents, RestContentProvider } from '@jupyterlab/services'; import { transformDeepnoteYamlToNotebookContent } from './transform-deepnote-yaml-to-notebook-content'; import { requestAPI } from './handler'; +import { z } from 'zod'; export const deepnoteContentProviderName = 'deepnote-content-provider'; + +const deepnoteFileResponseSchema = z.object({ + deepnoteFileModel: z.object({ + name: z.string(), + path: z.string(), + created: z.string(), + last_modified: z.string(), + content: z.string(), + mimetype: z.string().optional() + }) +}); + export class DeepnoteContentProvider extends RestContentProvider { async get( localPath: string, @@ -17,24 +30,13 @@ export class DeepnoteContentProvider extends RestContentProvider { } // Call custom API route to fetch the Deepnote file content - let data: any; - - try { - data = await requestAPI( - `file?path=${encodeURIComponent(localPath)}` - ); - } catch (error) { - console.error(`Failed to fetch Deepnote file: ${localPath}`, error); - throw new Error(`Failed to fetch .deepnote file: ${error}`); + const data = await requestAPI(`file?path=${encodeURIComponent(localPath)}`); + const parsed = deepnoteFileResponseSchema.safeParse(data); + if (!parsed.success) { + console.error('Invalid API response shape', parsed.error); + throw new Error('Invalid API response shape'); } - - if (!data.deepnoteFileModel) { - throw new Error( - `Invalid API response: missing deepnoteFileModel for ${localPath}` - ); - } - - const modelData = data.deepnoteFileModel; + const modelData = parsed.data.deepnoteFileModel; // Transform the Deepnote YAML to Jupyter notebook content const notebookContent = await transformDeepnoteYamlToNotebookContent( diff --git a/src/handler.ts b/src/handler.ts index 06cdd74..d4c619b 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -9,10 +9,10 @@ import { ServerConnection } from '@jupyterlab/services'; * @param init Initial values for the request * @returns The response body interpreted as JSON */ -export async function requestAPI( +export async function requestAPI( endPoint = '', init: RequestInit = {} -): Promise { +): Promise { // Make request to Jupyter API const settings = ServerConnection.makeSettings(); const requestUrl = URLExt.join( From 16222c2045acefafd5950a3b2cf2fb21b5d925d1 Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Wed, 15 Oct 2025 11:22:03 +0200 Subject: [PATCH 6/7] Lowercase path in .deepnote file check Signed-off-by: Andy Jakubowski --- src/deepnote-content-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deepnote-content-provider.ts b/src/deepnote-content-provider.ts index fd35d04..cc9a047 100644 --- a/src/deepnote-content-provider.ts +++ b/src/deepnote-content-provider.ts @@ -21,7 +21,7 @@ export class DeepnoteContentProvider extends RestContentProvider { localPath: string, options?: Contents.IFetchOptions ): Promise { - const isDeepnoteFile = localPath.endsWith('.deepnote'); + const isDeepnoteFile = localPath.toLowerCase().endsWith('.deepnote'); if (!isDeepnoteFile) { // Not a .deepnote file, return as-is From 63b35192d21f3ad56410e2e757a70e9fb98759c0 Mon Sep 17 00:00:00 2001 From: Andy Jakubowski Date: Wed, 15 Oct 2025 11:27:13 +0200 Subject: [PATCH 7/7] Drop unused exception aliases Signed-off-by: Andy Jakubowski --- jupyterlab_deepnote/handlers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jupyterlab_deepnote/handlers.py b/jupyterlab_deepnote/handlers.py index 26241c7..e0a631a 100644 --- a/jupyterlab_deepnote/handlers.py +++ b/jupyterlab_deepnote/handlers.py @@ -32,17 +32,17 @@ async def get(self): path, type="file", format="text", content=True ) ) - except FileNotFoundError as e: + except FileNotFoundError: self.set_status(404) self.set_header("Content-Type", "application/json") self.finish(json.dumps({"code": 404, "message": "File not found"})) return - except PermissionError as e: + except PermissionError: self.set_status(403) self.set_header("Content-Type", "application/json") self.finish(json.dumps({"code": 403, "message": "Permission denied"})) return - except Exception as e: + except Exception: self.log.exception("Error retrieving file") self.set_status(500) self.set_header("Content-Type", "application/json")