Skip to content

Commit

Permalink
Fix document example (#272)
Browse files Browse the repository at this point in the history
* Fix linter on document example

* Fix ExampleModel

* Bump ydoc and collaboration

* Fix README

* Bump base deps

* Update snapshot

---------

Co-authored-by: Frédéric Collonval <[email protected]>
  • Loading branch information
fcollonval and fcollonval authored Dec 31, 2024
1 parent 6a166f1 commit b06410d
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 75 deletions.
75 changes: 39 additions & 36 deletions documents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ Developers can provide new extensions to support additional documents (or replac
The model, the shared model and the view will be provided through new factories and the file type will be registered directly.
For that you will need to access the [`DocumentRegistry`](https://jupyterlab.readthedocs.io/en/latest/api/classes/docregistry.DocumentRegistry-1.html) to register new [`FileType`s](https://jupyterlab.readthedocs.io/en/latest/api/interfaces/rendermime_interfaces.IRenderMime.IFileType.html), models and views. This way, when opening a new file, the [`DocumentManager`](https://jupyterlab.readthedocs.io/en/latest/api/classes/docmanager.DocumentManager-1.html) will look into the file metadata and create an instance of `Context` with the right model for this file. To register new documents, you can create factories, either a [`IModelFactory`](https://jupyterlab.readthedocs.io/en/latest/api/interfaces/docregistry.DocumentRegistry.IModelFactory.html) for the model and/or a [`IWidgetFactory`](https://jupyterlab.readthedocs.io/en/latest/api/interfaces/docregistry.DocumentRegistry.IWidgetFactory.html) for the view.

The shared model needs to be registered only if your file must be collaborative. For that you will need to register it in the [`ICollaborativeDrive`](https://jupyterlab-realtime-collaboration.readthedocs.io/en/latest/api/interfaces/docprovider.ICollaborativeDrive.html) token provided by the `@jupyter/docprovider` package.
The shared model needs to be registered only if your file must be collaborative. For that you will need to register it in the [`ICollaborativeDrive`](https://jupyterlab-realtime-collaboration.readthedocs.io/en/latest/api/interfaces/collaborative_drive.ICollaborativeDrive.html) token provided by the `@jupyter/collaborative-drive` package.

> Packaging note: when using an optional external extension (here
> `@jupyter/docprovider` from `jupyter-collaboration`), you must
> `@jupyter/collaborative-drive` from `jupyter-collaboration`), you must
> tell JupyterLab to include that package in the current extension by
> adding the following configuration in `package.json`.:
```json5
// package.json#L108-L113

"sharedPackages": {
"@jupyter/docprovider": {
"@jupyter/collaborative-drive": {
"bundled": true,
"singleton": true
}
Expand Down Expand Up @@ -229,7 +229,7 @@ The `DocumentModel` represents the file content in the frontend. Through the mod
## Make it collaborative

In JupyterLab v3.1, we introduced the package `@jupyterlab/shared-models` to swap `ModelDB` as a data storage to make the notebooks collaborative. We implemented these shared models using [Yjs](https://yjs.dev), a high-performance CRDT for building collaborative applications that automatically sync. You can find all the documentation of Yjs [here](https://docs.yjs.dev).
In JupyterLab v3.1, we switched from `ModelDB` as a data storage to shared models. We implemented these shared models using [Yjs](https://yjs.dev), a high-performance CRDT for building collaborative applications that automatically sync. You can find all the documentation of Yjs [here](https://docs.yjs.dev).

Yjs documents (`Y.Doc`) are the main class of Yjs. They represent a shared document between clients and hold multiple shared objects. Yjs documents enable you to share different [data types like text, Array, Map or set](https://docs.yjs.dev/getting-started/working-with-shared-types), which makes it possible to create not only collaborative text editors but also diagrams, drawings,... .

Expand All @@ -255,7 +255,7 @@ In this extension, we created:

<!-- prettier-ignore-start -->
```ts
// src/model.ts#L354-L354
// src/model.ts#L344-L344

export class ExampleDoc extends YDocument<ExampleDocChange> {
```
Expand All @@ -265,7 +265,7 @@ To create a new shared object, you have to use the `ydoc`. The new attribute wil
<!-- prettier-ignore-start -->
```ts
// src/model.ts#L358-L359
// src/model.ts#L348-L349

this._content = this.ydoc.getMap('content');
this._content.observe(this._contentObserver);
Expand All @@ -278,7 +278,7 @@ we provide helpers `get` and `set` to hide the complexity of `position` being st
<!-- prettier-ignore-start -->
```ts
// src/model.ts#L390-L399
// src/model.ts#L408-L417

get(key: 'content'): string;
get(key: 'position'): Position;
Expand All @@ -288,12 +288,12 @@ get(key: string): any {
? data
? JSON.parse(data)
: { x: 0, y: 0 }
: data ?? '';
: (data ?? '');
}
```
```ts
// src/model.ts#L407-L411
// src/model.ts#L425-L429

set(key: 'content', value: string): void;
set(key: 'position', value: PartialJSONObject): void;
Expand All @@ -315,7 +315,7 @@ this.sharedModel.awareness.setLocalStateField('mouse', pos);
```
```ts
// src/model.ts#L289-L289
// src/model.ts#L279-L279

const clients = this.sharedModel.awareness.getStates();
```
Expand All @@ -335,11 +335,11 @@ Every time you modify a shared property, this property triggers an event in all
<!-- prettier-ignore-start -->
```ts
// src/model.ts#L233-L236
// src/model.ts#L376-L379

this.sharedModel.transact(() => {
this.sharedModel.set('position', { x: obj.x, y: obj.y });
this.sharedModel.set('content', obj.content);
this.transact(() => {
this.set('position', { x: obj.x, y: obj.y });
this.set('content', obj.content);
});
```
<!-- prettier-ignore-end -->
Expand All @@ -350,13 +350,13 @@ That client is responsible for loading, saving and watching the file on disk and
to propagate all changes to all clients. This makes collaboration much more robust
in case of flaky connection, file rename,... .
In Python, Yjs protocol is implemented in the library [`y-py`](https://github.com/y-crdt/ypy). But as we provide `@jupyterlab/shared-models` helpers for the frontend, we
In Python, Yjs protocol is implemented in the library [`ycrdt`](https://github.com/jupyter-server/pycrdt). But as we provide `@jupyter/ydoc` helpers for the frontend, we
provide [`jupyter-ydoc`](https://github.com/jupyter-server/jupyter_ydoc) helpers for Python.
A shared model must inherit from `YBaseDoc`, here:
```py
# jupyterlab_examples_documents/document.py#L4-L7
# jupyterlab_examples_documents/document.py#L6-L9

from jupyter_ydoc.ybasedoc import YBaseDoc

Expand All @@ -368,9 +368,9 @@ The shared map is added to the model like this:

<!-- prettier-ignore-start -->
```py
# jupyterlab_examples_documents/document.py#L10-L10
# jupyterlab_examples_documents/document.py#L12-L12

self._content = self._ydoc.get_map('content')
self._content = self._ydoc.get("content", type=pycrdt.Map)
```
<!-- prettier-ignore-end -->

Expand All @@ -379,23 +379,19 @@ must be defined:

<!-- prettier-ignore-start -->
```py
# jupyterlab_examples_documents/document.py#L16-L49
# jupyterlab_examples_documents/document.py#L18-L49

def get(self) -> str:
"""
Returns the content of the document as saved by the contents manager.

:return: Document's content.
"""
data = json.loads(self._content.to_json())
data = self._content.to_py()
position = json.loads(data["position"])
return json.dumps(
{
"x": position["x"],
"y": position["y"],
"content": data["content"]
},
indent=2
{"x": position["x"], "y": position["y"], "content": data["content"]},
indent=2,
)

def set(self, raw_value: str) -> None:
Expand All @@ -405,15 +401,17 @@ def set(self, raw_value: str) -> None:
:param raw_value: The content of the document.
"""
value = json.loads(raw_value)
with self._ydoc.begin_transaction() as t:
with self._ydoc.transaction():
# clear document
for key in self._content:
self._content.pop(t, key)
for key in [k for k in self._ystate if k not in ("dirty", "path")]:
self._ystate.pop(t, key)
for key in self._content.keys():
self._content.pop(key)
for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]:
self._ystate.pop(key)

self._content["position"] = {"x": value["x"], "y": value["y"]}

self._content["content"] = value["content"]

self._content.set(t, "position", json.dumps({"x": value["x"], "y": value["y"]}))
self._content.set(t, "content", value["content"])
#
```
<!-- prettier-ignore-end -->
Expand All @@ -423,7 +421,7 @@ reacting to a document changes:
<!-- prettier-ignore-start -->
```py
# jupyterlab_examples_documents/document.py#L51-L60
# jupyterlab_examples_documents/document.py#L51-L65

def observe(self, callback: "Callable[[str, Any], None]") -> None:
"""
Expand All @@ -432,8 +430,13 @@ def observe(self, callback: "Callable[[str, Any], None]") -> None:
:param callback: Callback that will be called when the document changes.
"""
self.unobserve()
self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state"))
self._subscriptions[self._content] = self._content.observe(partial(callback, "content"))
self._subscriptions[self._ystate] = self._ystate.observe(
partial(callback, "state")
)
self._subscriptions[self._content] = self._content.observe(
partial(callback, "content")
)

#
```
<!-- prettier-ignore-end -->
Expand Down
41 changes: 23 additions & 18 deletions documents/jupyterlab_examples_documents/document.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
import json
from functools import partial
from typing import Any, Callable

import pycrdt
from jupyter_ydoc.ybasedoc import YBaseDoc


class YExampleDoc(YBaseDoc):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._content = self._ydoc.get_map('content')
self._content = self._ydoc.get("content", type=pycrdt.Map)

@property
def version(self) -> str:
return '0.1.0'
return "0.1.0"

def get(self) -> str:
"""
Returns the content of the document as saved by the contents manager.
:return: Document's content.
"""
data = json.loads(self._content.to_json())
data = self._content.to_py()
position = json.loads(data["position"])
return json.dumps(
{
"x": position["x"],
"y": position["y"],
"content": data["content"]
},
indent=2
{"x": position["x"], "y": position["y"], "content": data["content"]},
indent=2,
)

def set(self, raw_value: str) -> None:
Expand All @@ -37,15 +35,17 @@ def set(self, raw_value: str) -> None:
:param raw_value: The content of the document.
"""
value = json.loads(raw_value)
with self._ydoc.begin_transaction() as t:
with self._ydoc.transaction():
# clear document
for key in self._content:
self._content.pop(t, key)
for key in [k for k in self._ystate if k not in ("dirty", "path")]:
self._ystate.pop(t, key)
for key in self._content.keys():
self._content.pop(key)
for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]:
self._ystate.pop(key)

self._content["position"] = {"x": value["x"], "y": value["y"]}

self._content["content"] = value["content"]

self._content.set(t, "position", json.dumps({"x": value["x"], "y": value["y"]}))
self._content.set(t, "content", value["content"])
#

def observe(self, callback: "Callable[[str, Any], None]") -> None:
Expand All @@ -55,6 +55,11 @@ def observe(self, callback: "Callable[[str, Any], None]") -> None:
:param callback: Callback that will be called when the document changes.
"""
self.unobserve()
self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state"))
self._subscriptions[self._content] = self._content.observe(partial(callback, "content"))
self._subscriptions[self._ystate] = self._ystate.observe(
partial(callback, "state")
)
self._subscriptions[self._content] = self._content.observe(
partial(callback, "content")
)

#
6 changes: 3 additions & 3 deletions documents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
"watch:labextension": "jupyter labextension watch ."
},
"dependencies": {
"@jupyter/docprovider": "^1.0.0",
"@jupyter/ydoc": "^1.0.0",
"@jupyter/collaborative-drive": "^3.0.0",
"@jupyter/ydoc": "^3.0.0",
"@jupyterlab/application": "^4.0.0",
"@jupyterlab/apputils": "^4.0.0",
"@jupyterlab/coreutils": "^6.0.0",
Expand Down Expand Up @@ -106,7 +106,7 @@
"extension": true,
"outputDir": "jupyterlab_examples_documents/labextension",
"sharedPackages": {
"@jupyter/docprovider": {
"@jupyter/collaborative-drive": {
"bundled": true,
"singleton": true
}
Expand Down
2 changes: 1 addition & 1 deletion documents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
"jupyter_ydoc>=1.0.1,<2.0.0"
"jupyter_ydoc>=3.0.0,<4.0.0"
]
dynamic = ["version", "description", "authors", "urls", "keywords"]

Expand Down
2 changes: 1 addition & 1 deletion documents/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ICollaborativeDrive } from '@jupyter/docprovider';
import { ICollaborativeDrive } from '@jupyter/collaborative-drive';

import {
JupyterFrontEnd,
Expand Down
44 changes: 31 additions & 13 deletions documents/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,7 @@ export class ExampleDocModel implements DocumentRegistry.IModel {
* @returns The data
*/
toString(): string {
const pos = this.sharedModel.get('position');
const obj = {
x: pos?.x ?? 10,
y: pos?.y ?? 10,
content: this.sharedModel.get('content') ?? ''
};
return JSON.stringify(obj, null, 2);
return this.sharedModel.getSource();
}

/**
Expand All @@ -229,11 +223,7 @@ export class ExampleDocModel implements DocumentRegistry.IModel {
* @param data Serialized data
*/
fromString(data: string): void {
const obj = JSON.parse(data);
this.sharedModel.transact(() => {
this.sharedModel.set('position', { x: obj.x, y: obj.y });
this.sharedModel.set('content', obj.content);
});
this.sharedModel.setSource(data);
}

/**
Expand Down Expand Up @@ -361,6 +351,34 @@ export class ExampleDoc extends YDocument<ExampleDocChange> {

readonly version: string = '1.0.0';

/**
* Get the document source
*
* @returns The source
*/
getSource(): string {
const pos = this.get('position');
const obj = {
x: pos?.x ?? 10,
y: pos?.y ?? 10,
content: this.get('content') ?? ''
};
return JSON.stringify(obj, null, 2);
}

/**
* Set the document source
*
* @param value The source to set
*/
setSource(value: string): void {
const obj = JSON.parse(value);
this.transact(() => {
this.set('position', { x: obj.x, y: obj.y });
this.set('content', obj.content);
});
}

/**
* Dispose of the resources.
*/
Expand Down Expand Up @@ -395,7 +413,7 @@ export class ExampleDoc extends YDocument<ExampleDocChange> {
? data
? JSON.parse(data)
: { x: 0, y: 0 }
: data ?? '';
: (data ?? '');
}

/**
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit b06410d

Please sign in to comment.