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 f4cbdd5..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 @@ -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) 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..e0a631a 100644 --- a/jupyterlab_deepnote/handlers.py +++ b/jupyterlab_deepnote/handlers.py @@ -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) diff --git a/src/deepnote-content-provider.ts b/src/deepnote-content-provider.ts index 540abb6..cc9a047 100644 --- a/src/deepnote-content-provider.ts +++ b/src/deepnote-content-provider.ts @@ -1,18 +1,19 @@ 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() + }) }); export class DeepnoteContentProvider extends RestContentProvider { @@ -20,40 +21,40 @@ export class DeepnoteContentProvider extends RestContentProvider { localPath: string, options?: Contents.IFetchOptions ): Promise { - 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); + if (!parsed.success) { + console.error('Invalid API response shape', parsed.error); + throw new Error('Invalid API response shape'); } + 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 }; - return transformedModel; + return model; } } 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(