Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 0 additions & 5 deletions jupyter-config/server-config/jupyter_server_config.json

This file was deleted.

2 changes: 0 additions & 2 deletions jupyterlab_deepnote/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -31,4 +30,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)
40 changes: 0 additions & 40 deletions jupyterlab_deepnote/contents.py

This file was deleted.

54 changes: 49 additions & 5 deletions jupyterlab_deepnote/handlers.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,68 @@
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", 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:
self.set_status(404)
self.set_header("Content-Type", "application/json")
self.finish(json.dumps({"code": 404, "message": "File not found"}))
return
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:
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):
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)
71 changes: 36 additions & 35 deletions src/deepnote-content-provider.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,60 @@
import { Contents, RestContentProvider } from '@jupyterlab/services';
import { z } from 'zod';
import { transformDeepnoteYamlToNotebookContent } from './transform-deepnote-yaml-to-notebook-content';
import { requestAPI } from './handler';
import { z } from 'zod';

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()
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()
})
});
Comment on lines +8 to 17
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Tighten schema (reject unknowns; validate timestamps)

Make both objects strict and validate ISO‑ish timestamps to fail early on bad data.

-const deepnoteFileResponseSchema = z.object({
-  deepnoteFileModel: z.object({
+const deepnoteFileResponseSchema = z.object({
+  deepnoteFileModel: z
+    .object({
       name: z.string(),
       path: z.string(),
-    created: z.string(),
-    last_modified: z.string(),
+      created: z.string().refine(v => !Number.isNaN(Date.parse(v)), 'invalid datetime'),
+      last_modified: z.string().refine(v => !Number.isNaN(Date.parse(v)), 'invalid datetime'),
       content: z.string(),
       mimetype: z.string().optional()
-  })
-});
+    })
+    .strict()
+}).strict();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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()
})
});
const deepnoteFileResponseSchema = z.object({
deepnoteFileModel: z
.object({
name: z.string(),
path: z.string(),
created: z.string().refine(v => !Number.isNaN(Date.parse(v)), 'invalid datetime'),
last_modified: z.string().refine(v => !Number.isNaN(Date.parse(v)), 'invalid datetime'),
content: z.string(),
mimetype: z.string().optional()
})
.strict()
}).strict();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that’s way too intense. What we have is sufficient.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andyjakubowski, understood. I'll keep the validation as-is.


✏️ Learnings added
Learnt from: andyjakubowski
PR: deepnote/jupyterlab-deepnote#27
File: src/deepnote-content-provider.ts:8-17
Timestamp: 2025-10-15T09:33:59.400Z
Learning: In the jupyterlab-deepnote project, basic Zod schema validation without `.strict()` mode or datetime refinement is preferred for API response validation in TypeScript files.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


export class DeepnoteContentProvider extends RestContentProvider {
async get(
localPath: string,
options?: Contents.IFetchOptions
): Promise<Contents.IModel> {
const model = await super.get(localPath, options);
const isDeepnoteFile =
localPath.endsWith('.deepnote') && model.type === 'notebook';
const isDeepnoteFile = localPath.toLowerCase().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=${encodeURIComponent(localPath)}`);
const parsed = deepnoteFileResponseSchema.safeParse(data);
Comment on lines +33 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Prefer URLSearchParams for query composition

Safer and clearer than manual string interpolation/encoding.

-const data = await requestAPI(`file?path=${encodeURIComponent(localPath)}`);
+const params = new URLSearchParams({ path: localPath });
+const data = await requestAPI(`file?${params.toString()}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = await requestAPI(`file?path=${encodeURIComponent(localPath)}`);
const parsed = deepnoteFileResponseSchema.safeParse(data);
const params = new URLSearchParams({ path: localPath });
const data = await requestAPI(`file?${params.toString()}`);
const parsed = deepnoteFileResponseSchema.safeParse(data);
🤖 Prompt for AI Agents
In src/deepnote-content-provider.ts around lines 33 to 34, the current query is
composed via manual string interpolation and encodeURIComponent; replace that
with URLSearchParams to build the query safely and clearly: create a
URLSearchParams instance with the path parameter, use its toString() when
calling requestAPI (e.g. requestAPI(`file?${params.toString()}`)), and remove
the manual encodeURIComponent call so the query encoding is handled by
URLSearchParams.

if (!parsed.success) {
console.error('Invalid API response shape', parsed.error);
throw new Error('Invalid API response shape');
}
Comment on lines +35 to 38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Include path in error to aid debugging

Surface which file failed and keep the zod error in logs.

-if (!parsed.success) {
-  console.error('Invalid API response shape', parsed.error);
-  throw new Error('Invalid API response shape');
-}
+if (!parsed.success) {
+  const msg = `Invalid API response shape for ${localPath}`;
+  console.error(msg, parsed.error);
+  throw new Error(msg);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!parsed.success) {
console.error('Invalid API response shape', parsed.error);
throw new Error('Invalid API response shape');
}
if (!parsed.success) {
const msg = `Invalid API response shape for ${localPath}`;
console.error(msg, parsed.error);
throw new Error(msg);
}
🤖 Prompt for AI Agents
In src/deepnote-content-provider.ts around lines 35 to 38, the error handling
for a failed Zod parse should include the path of the file that failed and
preserve the Zod error details; update the console.error call to log a clear
message containing the filePath (or relevant identifier) plus the full
parsed.error object, and throw a new Error that includes the file path (and
optionally a short summary) so callers can see which file failed while detailed
Zod info remains in the logs.

const modelData = parsed.data.deepnoteFileModel;

// Transform the Deepnote YAML to Jupyter notebook content
const transformedModelContent =
await transformDeepnoteYamlToNotebookContent(
validatedModelContent.data.metadata.deepnote.rawYamlString
);

const transformedModel = {
...model,
content: transformedModelContent
const notebookContent = await transformDeepnoteYamlToNotebookContent(
modelData.content
);

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
};
Comment on lines 41 to 56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Respect options.content to avoid unnecessary heavy transforms

Skip YAML→notebook conversion and omit content/mime/format when content isn’t requested.

-// Transform the Deepnote YAML to Jupyter notebook content
-const notebookContent = await transformDeepnoteYamlToNotebookContent(
-  modelData.content
-);
+// Respect options?.content === false to avoid heavy transform
+const wantContent = options?.content !== false;
+const notebookContent = wantContent
+  ? await transformDeepnoteYamlToNotebookContent(modelData.content)
+  : null;
@@
-  mimetype: 'application/x-ipynb+json',
-  format: 'json',
+  mimetype: wantContent ? 'application/x-ipynb+json' : null,
+  format: wantContent ? 'json' : null,
   content: notebookContent
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Transform the Deepnote YAML to Jupyter notebook content
const transformedModelContent =
await transformDeepnoteYamlToNotebookContent(
validatedModelContent.data.metadata.deepnote.rawYamlString
);
const transformedModel = {
...model,
content: transformedModelContent
const notebookContent = await transformDeepnoteYamlToNotebookContent(
modelData.content
);
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
};
// Respect options?.content === false to avoid heavy transform
const wantContent = options?.content !== false;
const notebookContent = wantContent
? await transformDeepnoteYamlToNotebookContent(modelData.content)
: null;
const model: Contents.IModel = {
name: modelData.name,
path: modelData.path,
type: 'notebook',
writable: false,
created: modelData.created,
last_modified: modelData.last_modified,
mimetype: wantContent ? 'application/x-ipynb+json' : null,
format: wantContent ? 'json' : null,
content: notebookContent
};
🤖 Prompt for AI Agents
In src/deepnote-content-provider.ts around lines 41 to 56, the code always
transforms Deepnote YAML to notebook JSON and always sets
content/mimetype/format; change it to first check options?.content (or the
equivalent Jupyter Contents API options flag) and only run
transformDeepnoteYamlToNotebookContent when content is requested. If
options.content is falsy, omit the content property from the returned model and
do not set mimetype or format (or set them to undefined), and avoid calling the
heavy transform function; if content is requested, perform the transform and
populate content, mimetype ('application/x-ipynb+json') and format ('json') as
before.


return transformedModel;
return model;
}
}
4 changes: 2 additions & 2 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
export async function requestAPI(
endPoint = '',
init: RequestInit = {}
): Promise<T> {
): Promise<unknown> {
// Make request to Jupyter API
const settings = ServerConnection.makeSettings();
const requestUrl = URLExt.join(
Expand Down