From de4a0ad16b39b8b175dfd20e00fc248b782a0f08 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 31 Oct 2023 00:05:04 -0500 Subject: [PATCH 1/2] basic contentsmanager strawman --- examples/contents.ipynb | 93 ++++++++++++++++++++++++++++++++++ ipylab/contents.py | 97 +++++++++++++++++++++++++++++++++++ ipylab/jupyterfrontend.py | 3 ++ src/plugin.ts | 2 + src/widget.ts | 3 ++ src/widgets/contents.ts | 103 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+) create mode 100644 examples/contents.ipynb create mode 100644 ipylab/contents.py create mode 100644 src/widgets/contents.ts diff --git a/examples/contents.ipynb b/examples/contents.ipynb new file mode 100644 index 00000000..d2225a88 --- /dev/null +++ b/examples/contents.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b0fe59fc-2f42-4ee5-b1cf-d3a73ccd80b2", + "metadata": {}, + "source": [ + "# Contents" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6451c811-83be-45fc-be07-73eb363b4239", + "metadata": {}, + "outputs": [], + "source": [ + "from ipylab import JupyterFrontEnd" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db015374-352c-442b-af4e-81bc8d5e6db8", + "metadata": {}, + "outputs": [], + "source": [ + "app = JupyterFrontEnd()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aff5288d-2cf6-4304-ba94-966480174534", + "metadata": {}, + "outputs": [], + "source": [ + "app.contents" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3dfcf18-c84d-4d5c-b4c9-1d1d37ad15ca", + "metadata": {}, + "outputs": [], + "source": [ + "readme = app.contents.get(\"README.md\", content=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7838972-dde8-40c8-b08a-61ea05e70143", + "metadata": {}, + "outputs": [], + "source": [ + "readme.size" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21ca52e3-2f07-4c2a-8fbc-d812702e7b98", + "metadata": {}, + "outputs": [], + "source": [ + "readme.content = \"bar\"" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ipylab/contents.py b/ipylab/contents.py new file mode 100644 index 00000000..5fec4cf2 --- /dev/null +++ b/ipylab/contents.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# coding: utf-8 + +# Copyright (c) ipylab contributors. +# Distributed under the terms of the Modified BSD License. + +import typing as t +from uuid import uuid4 +from ipywidgets import Widget, register +from traitlets import HasTraits, Unicode, Any, Instance, observe, Int, Bool +from ._frontend import module_name, module_version + + +@register +class ContentsManager(Widget): + _model_name = Unicode("ContentsManagerModel").tag(sync=True) + _model_module = Unicode(module_name).tag(sync=True) + _model_module_version = Unicode(module_version).tag(sync=True) + + _requests: t.Dict[str, t.Callable] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._requests = {} + self.on_msg(self._on_frontend_msg) + + def _on_frontend_msg(self, _, content, buffers): + if content.get("event", "") == "got": + _id = content.get("_id") + callback = self._requests.pop(_id, None) + if callback: + callback(content) + + def get(self, path: str, content: t.Optional[bool] = None) -> "ContentsModel": + _id = str(uuid4()) + self.send( + { + "_id": _id, + "func": "get", + "payload": {"path": path, "options": {"content": content}}, + } + ) + + model = ContentsModel(path=path, _contents_manager=self) + + self._requests[_id] = model._on_get + + return model + + def save(self, model: "ContentsModel"): + _id = str(uuid4()) + self.send( + { + "_id": _id, + "func": "save", + "payload": { + "path": model.path, + "options": { + "content": model.content, + "type": model.type, + "format": model.format, + }, + }, + } + ) + + self._requests[_id] = model._on_get + + return model + + +@register +class ContentsModel(Widget): + _model_name = Unicode("ContentsModelModel").tag(sync=True) + _model_module = Unicode(module_name).tag(sync=True) + _model_module_version = Unicode(module_version).tag(sync=True) + _contents_manager = Instance(ContentsManager) + + name = Unicode() + path = Unicode() + last_modified = Unicode() + created = Unicode() + format = Unicode() + mimetype = Unicode() + size = Int() + writeable = Bool() + type = Unicode() + content = Any() + + def _on_get(self, content): + with self.hold_trait_notifications(): + for key, value in content.get("model", {}).items(): + setattr(self, key, value) + + @observe("content") + def _on_content(self, change) -> None: + self._contents_manager.save(self) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 47f97c48..eb3f5844 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -13,6 +13,7 @@ from .commands import CommandRegistry from .shell import Shell from .sessions import SessionManager +from .contents import ContentsManager @register @@ -25,6 +26,7 @@ class JupyterFrontEnd(Widget): shell = Instance(Shell).tag(sync=True, **widget_serialization) commands = Instance(CommandRegistry).tag(sync=True, **widget_serialization) sessions = Instance(SessionManager).tag(sync=True, **widget_serialization) + contents = Instance(ContentsManager).tag(sync=True, **widget_serialization) def __init__(self, *args, **kwargs): super().__init__( @@ -32,6 +34,7 @@ def __init__(self, *args, **kwargs): shell=Shell(), commands=CommandRegistry(), sessions=SessionManager(), + contents=ContentsManager(), **kwargs, ) self._ready_event = asyncio.Event() diff --git a/src/plugin.ts b/src/plugin.ts index 98aca1fe..6be2dcb4 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -37,6 +37,8 @@ const extension: JupyterFrontEndPlugin = { // add globals widgetExports.JupyterFrontEndModel.app = app; + widgetExports.ContentsManagerModel.contentsManager = + app.serviceManager.contents; widgetExports.ShellModel.shell = app.shell; widgetExports.ShellModel.labShell = labShell; widgetExports.CommandRegistryModel.commands = app.commands; diff --git a/src/widget.ts b/src/widget.ts index e5cb76f6..c617e053 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -5,6 +5,7 @@ import { CommandRegistryModel } from './widgets/commands'; import { CommandPaletteModel } from './widgets/palette'; import { SessionManagerModel } from './widgets/sessions'; import { JupyterFrontEndModel } from './widgets/frontend'; +import { ContentsManagerModel, ContentsModelModel } from './widgets/contents'; import { PanelModel } from './widgets/panel'; import { ShellModel } from './widgets/shell'; import { SplitPanelModel, SplitPanelView } from './widgets/split_panel'; @@ -14,6 +15,8 @@ import { IconView, IconModel } from './widgets/icon'; export { CommandRegistryModel, CommandPaletteModel, + ContentsManagerModel, + ContentsModelModel, JupyterFrontEndModel, PanelModel, ShellModel, diff --git a/src/widgets/contents.ts b/src/widgets/contents.ts new file mode 100644 index 00000000..8e9be8b5 --- /dev/null +++ b/src/widgets/contents.ts @@ -0,0 +1,103 @@ +// Copyright (c) ipylab contributors +// Distributed under the terms of the Modified BSD License. + +import { WidgetModel } from '@jupyter-widgets/base'; +import { Contents } from '@jupyterlab/services'; + +import { MODULE_NAME, MODULE_VERSION } from '../version'; + +export class ContentsManagerModel extends WidgetModel { + /** + * The default attributes. + */ + defaults(): any { + return { + ...super.defaults(), + _model_name: ContentsManagerModel.model_name, + _model_module: ContentsManagerModel.model_module, + _model_module_version: ContentsManagerModel.model_module_version + }; + } + + /** + * Initialize a ContentsManagerModel instance. + * + * @param attributes The base attributes. + * @param options The initialization options. + */ + initialize(attributes: any, options: any): void { + super.initialize(attributes, options); + this.on('msg:custom', this._onMessage.bind(this)); + } + + /** + * Handle a custom message from the backend. + * + * @param msg The message to handle. + */ + private async _onMessage(msg: any): Promise { + switch (msg.func) { + case 'get': + void this._get(msg._id, msg.payload); + break; + case 'save': + void this._save(msg._id, msg.payload); + break; + default: + break; + } + } + + private async _get( + _id: string, + payload: { path: string; options: Record } + ) { + const model = await ContentsManagerModel.contentsManager.get( + payload.path, + payload.options + ); + this.send({ event: 'got', _id, model: model as any }); + } + + private async _save( + _id: string, + payload: { path: string; options: Record } + ) { + const model = await ContentsManagerModel.contentsManager.save( + payload.path, + payload.options + ); + this.send({ event: 'saved', _id, model: model as any }); + } + + static model_name = 'ContentsManagerModel'; + static model_module = MODULE_NAME; + static model_module_version = MODULE_VERSION; + static view_name: string = null; + static view_module: string = null; + static view_module_version = MODULE_VERSION; + + static contentsManager: Contents.IManager; +} + +export class ContentsModelModel extends WidgetModel { + /** + * The default attributes. + */ + defaults(): any { + return { + ...super.defaults(), + contents: null, + _model_name: ContentsModelModel.model_name, + _model_module: ContentsModelModel.model_module, + _model_module_version: ContentsModelModel.model_module_version + }; + } + + static model_name = 'ContentsModelModel'; + static model_module = MODULE_NAME; + static model_module_version = MODULE_VERSION; + static view_name: string = null; + static view_module: string = null; + static view_module_version = MODULE_VERSION; +} From 076ba50aa3ed4b58ba7bcb937bb2b414f8a69aac Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 31 Oct 2023 08:55:12 -0500 Subject: [PATCH 2/2] expand content example --- examples/contents.ipynb | 105 +++++++++++++++++++++++++++++++++++++--- ipylab/contents.py | 66 +++++++++++++++---------- src/widgets/contents.ts | 48 +++++++----------- 3 files changed, 158 insertions(+), 61 deletions(-) diff --git a/examples/contents.ipynb b/examples/contents.ipynb index d2225a88..13cc5519 100644 --- a/examples/contents.ipynb +++ b/examples/contents.ipynb @@ -8,10 +8,35 @@ "# Contents" ] }, + { + "cell_type": "markdown", + "id": "531ea466-29a1-43b9-87bd-99432eaf9239", + "metadata": {}, + "source": [ + "### ContentsManager" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "6451c811-83be-45fc-be07-73eb363b4239", + "id": "3854222d-d955-416f-8c19-acda05c940bc", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import uuid\n", + "from IPython.display import JSON\n", + "\n", + "path = \"examples/contents.ipynb\"\n", + "if \"pyodide\" in sys.modules:\n", + " %pip install ipylab\n", + " path = \"contents.ipynb\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1bf0d356-1e8b-4486-bf87-6086919c629e", "metadata": {}, "outputs": [], "source": [ @@ -45,27 +70,95 @@ "metadata": {}, "outputs": [], "source": [ - "readme = app.contents.get(\"README.md\", content=True)" + "this_notebook = app.contents.get(path, content=True)\n", + "this_notebook" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23cdb103-2b14-4528-b13f-567c0afa38bc", + "metadata": {}, + "outputs": [], + "source": [ + "JSON(this_notebook.content)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec496589-101e-48f8-ac60-0c84238cd62a", + "metadata": {}, + "outputs": [], + "source": [ + "new_content = dict(this_notebook.content.items())\n", + "new_content.update(\n", + " cells=[\n", + " {\n", + " 'cell_type': 'code',\n", + " 'execution_count': None,\n", + " 'id': str(__import__(\"uuid\").uuid4()),\n", + " 'metadata': {},\n", + " 'outputs': [],\n", + " 'source': 'print(\"HELLO WORLD\")'\n", + " },\n", + " *new_content[\"cells\"]\n", + " ]\n", + ")\n", + "JSON(new_content)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61c0a006-d40d-43fb-b67e-c2308da92344", + "metadata": {}, + "outputs": [], + "source": [ + "this_notebook.content = new_content" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad137fa0-a5f8-48ac-ada0-0dbda9cb803e", + "metadata": {}, + "outputs": [], + "source": [ + "new_content = dict(this_notebook.content.items())\n", + "new_content.update(\n", + " cells=[\n", + " {\n", + " 'cell_type': 'code',\n", + " 'execution_count': None,\n", + " 'id': str(__import__(\"uuid\").uuid4()),\n", + " 'metadata': {},\n", + " 'outputs': [],\n", + " 'source': 'print(\"HELLO WORLD\")'\n", + " },\n", + " *new_content[\"cells\"]\n", + " ]\n", + ")" ] }, { "cell_type": "code", "execution_count": null, - "id": "e7838972-dde8-40c8-b08a-61ea05e70143", + "id": "65053177-480d-4d94-a08f-93d3f25e13f7", "metadata": {}, "outputs": [], "source": [ - "readme.size" + "this_notebook.content = new_content" ] }, { "cell_type": "code", "execution_count": null, - "id": "21ca52e3-2f07-4c2a-8fbc-d812702e7b98", + "id": "4f632caa-6625-46af-bb95-2fcd812e2919", "metadata": {}, "outputs": [], "source": [ - "readme.content = \"bar\"" + "this_notebook.content" ] } ], diff --git a/ipylab/contents.py b/ipylab/contents.py index 5fec4cf2..44ce4122 100644 --- a/ipylab/contents.py +++ b/ipylab/contents.py @@ -25,11 +25,10 @@ def __init__(self, *args, **kwargs): self.on_msg(self._on_frontend_msg) def _on_frontend_msg(self, _, content, buffers): - if content.get("event", "") == "got": - _id = content.get("_id") - callback = self._requests.pop(_id, None) - if callback: - callback(content) + _id = content.get("_id") + callback = self._requests.pop(_id, None) + if callback: + callback(content) def get(self, path: str, content: t.Optional[bool] = None) -> "ContentsModel": _id = str(uuid4()) @@ -56,9 +55,11 @@ def save(self, model: "ContentsModel"): "payload": { "path": model.path, "options": { - "content": model.content, - "type": model.type, - "format": model.format, + k: getattr(model, k) + for k in model.class_trait_names() + if not ( + k.startswith("_") or k == "error" or k in Widget._traits + ) }, }, } @@ -75,23 +76,38 @@ class ContentsModel(Widget): _model_module = Unicode(module_name).tag(sync=True) _model_module_version = Unicode(module_version).tag(sync=True) _contents_manager = Instance(ContentsManager) + _syncing: t.Optional[bool] + + error = Unicode(allow_none=True) + + name = Unicode(allow_none=True) + path = Unicode(allow_none=True).tag(sync=True) + last_modified = Unicode(allow_none=True) + created = Unicode(allow_none=True) + format = Unicode(allow_none=True) + mimetype = Unicode(allow_none=True) + size = Int(allow_none=True) + writeable = Bool(allow_none=True) + type = Unicode(allow_none=True) + content = Any(allow_none=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.observe(self._on_content, "content") + + def _on_get(self, msg): + if "error" in msg: + self.error = msg["error"] + return + func = msg.get("func") + self._syncing = True + for key, value in msg.get("model", {}).items(): + if func == "save" and key == "content": + continue + setattr(self, key, value) + self._syncing = False - name = Unicode() - path = Unicode() - last_modified = Unicode() - created = Unicode() - format = Unicode() - mimetype = Unicode() - size = Int() - writeable = Bool() - type = Unicode() - content = Any() - - def _on_get(self, content): - with self.hold_trait_notifications(): - for key, value in content.get("model", {}).items(): - setattr(self, key, value) - - @observe("content") def _on_content(self, change) -> None: + if self._syncing: + return self._contents_manager.save(self) diff --git a/src/widgets/contents.ts b/src/widgets/contents.ts index 8e9be8b5..4258f3ac 100644 --- a/src/widgets/contents.ts +++ b/src/widgets/contents.ts @@ -36,38 +36,26 @@ export class ContentsManagerModel extends WidgetModel { * @param msg The message to handle. */ private async _onMessage(msg: any): Promise { - switch (msg.func) { - case 'get': - void this._get(msg._id, msg.payload); - break; - case 'save': - void this._save(msg._id, msg.payload); - break; - default: - break; - } - } + const { _id, payload, func } = msg; + const { contentsManager } = ContentsManagerModel; - private async _get( - _id: string, - payload: { path: string; options: Record } - ) { - const model = await ContentsManagerModel.contentsManager.get( - payload.path, - payload.options - ); - this.send({ event: 'got', _id, model: model as any }); - } + let model: any; - private async _save( - _id: string, - payload: { path: string; options: Record } - ) { - const model = await ContentsManagerModel.contentsManager.save( - payload.path, - payload.options - ); - this.send({ event: 'saved', _id, model: model as any }); + try { + switch (func) { + case 'get': + model = await contentsManager.get(payload.path, payload.options); + break; + case 'save': + model = await contentsManager.save(payload.path, payload.options); + break; + default: + break; + } + this.send({ _id, func, model }); + } catch (err: any) { + this.send({ _id, func, error: `${err}` }); + } } static model_name = 'ContentsManagerModel';