Skip to content

Commit 654f31a

Browse files
committed
Add YDrive
1 parent 190a70e commit 654f31a

File tree

8 files changed

+269
-10
lines changed

8 files changed

+269
-10
lines changed

.github/workflows/test.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,12 @@ jobs:
5959
python3 -m pip install --upgrade pip
6060
python3 -m pip install hatch
6161
62-
- name: Create jupyterlab-auth dev environment
63-
run: hatch env create dev.jupyterlab-auth
62+
- name: Create jupyterlab-auth and jupyterlab-noauth dev environments
63+
run: |
64+
hatch env create dev.jupyterlab-auth
65+
hatch env create dev.jupyterlab-noauth
6466
6567
- name: Run tests
66-
run: hatch run dev.jupyterlab-auth:test
68+
run: |
69+
hatch run dev.jupyterlab-noauth:pytest plugins/yjs/tests -v --color=yes
70+
hatch run dev.jupyterlab-auth:test

jupyverse_api/jupyverse_api/contents/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import asyncio
22
from abc import ABC, abstractmethod
33
from pathlib import Path
4-
from typing import Dict, List, Optional, Union
4+
from typing import Any, Dict, List, Optional, Union
55

66
from fastapi import APIRouter, Depends, Request, Response
77

@@ -15,6 +15,7 @@
1515
class FileIdManager(ABC):
1616
stop_watching_files: asyncio.Event
1717
stopped_watching_files: asyncio.Event
18+
Change: Any
1819

1920
@abstractmethod
2021
async def get_path(self, file_id: str) -> str:

plugins/contents/fps_contents/fileid.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ class FileIdManager(metaclass=Singleton):
3535
initialized: asyncio.Event
3636
watchers: Dict[str, List[Watcher]]
3737
lock: asyncio.Lock
38+
Change = Change
3839

39-
def __init__(self, db_path: str = ".fileid.db"):
40+
def __init__(self, db_path: str = ".fileid.db", root_dir: str = "."):
4041
self.db_path = db_path
42+
self.root_dir = Path(root_dir).resolve()
4143
self.initialized = asyncio.Event()
4244
self.watchers = {}
4345
self.watch_files_task = asyncio.create_task(self.watch_files())
@@ -90,7 +92,7 @@ async def watch_files(self):
9092
# index files
9193
async with self.lock:
9294
async with aiosqlite.connect(self.db_path) as db:
93-
async for path in Path().rglob("*"):
95+
async for path in self.root_dir.rglob("*"):
9496
idx = uuid4().hex
9597
mtime = (await path.stat()).st_mtime
9698
await db.execute(
@@ -99,14 +101,16 @@ async def watch_files(self):
99101
await db.commit()
100102
self.initialized.set()
101103

102-
async for changes in awatch(".", stop_event=self.stop_watching_files):
104+
async for changes in awatch(self.root_dir, stop_event=self.stop_watching_files):
103105
async with self.lock:
104106
async with aiosqlite.connect(self.db_path) as db:
105107
deleted_paths = set()
106108
added_paths = set()
107109
for change, changed_path in changes:
108110
# get relative path
109-
changed_path = Path(changed_path).relative_to(await Path().absolute())
111+
changed_path = Path(changed_path).relative_to(
112+
await self.root_dir.absolute()
113+
)
110114
changed_path_str = str(changed_path)
111115

112116
if change == Change.deleted:
@@ -156,9 +160,16 @@ async def watch_files(self):
156160
for change in changes:
157161
changed_path = change[1]
158162
# get relative path
159-
relative_changed_path = str(Path(changed_path).relative_to(await Path().absolute()))
163+
relative_changed_path = Path(changed_path).relative_to(
164+
await self.root_dir.absolute()
165+
)
160166
relative_change = (change[0], relative_changed_path)
161-
for watcher in self.watchers.get(relative_changed_path, []):
167+
all_watchers = []
168+
for path, watchers in self.watchers.items():
169+
p = Path(path)
170+
if p == relative_changed_path or p in relative_changed_path.parents:
171+
all_watchers += watchers
172+
for watcher in all_watchers:
162173
watcher.notify(relative_change)
163174

164175
self.stopped_watching_files.set()

plugins/yjs/fps_yjs/ydocs/ydrive.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
from contextlib import AsyncExitStack
4+
from functools import partial
5+
from pathlib import Path
6+
from typing import Any, Callable
7+
8+
from anyio import create_task_group
9+
from anyio.abc import TaskGroup
10+
from pycrdt import Doc, Map, MapEvent
11+
12+
from jupyverse_api.auth import User
13+
from jupyverse_api.contents import Contents
14+
15+
from .ybasedoc import YBaseDoc
16+
17+
18+
class YDrive(YBaseDoc):
19+
_starting: bool
20+
_task_group: TaskGroup | None
21+
22+
def __init__(
23+
self,
24+
contents: Contents,
25+
ydoc: Doc | None = None,
26+
root_dir: Path | str | None = None,
27+
):
28+
super().__init__(ydoc)
29+
self._root_dir = Path() if root_dir is None else Path(root_dir)
30+
self._ydoc["content"] = self._ycontent = self._new_dir_content()
31+
self._ycontent.observe_deep(self._callback)
32+
self._user = User()
33+
self._starting = False
34+
self._task_group = None
35+
self._contents = contents
36+
self._watcher = contents.file_id_manager.watch(".")
37+
38+
async def __aenter__(self) -> YDrive:
39+
if self._task_group is not None:
40+
raise RuntimeError("YDrive already running")
41+
42+
async with AsyncExitStack() as exit_stack:
43+
tg = create_task_group()
44+
self._task_group = await exit_stack.enter_async_context(tg)
45+
self._exit_stack = exit_stack.pop_all()
46+
47+
assert self._task_group is not None
48+
self._task_group.start_soon(self._process_file_changes)
49+
50+
return self
51+
52+
async def _process_file_changes(self):
53+
async for change in self._watcher:
54+
change_, path = change
55+
if change_ == self._contents.file_id_manager.Change.deleted:
56+
parent_content = self._get(path.parent)
57+
del parent_content["content"][path.name]
58+
59+
async def __aexit__(self, exc_type, exc_value, exc_tb):
60+
if self._task_group is None:
61+
raise RuntimeError("YDrive not running")
62+
63+
self._task_group.cancel_scope.cancel()
64+
self._task_group = None
65+
return await self._exit_stack.__aexit__(exc_type, exc_value, exc_tb)
66+
67+
def _callback(self, events):
68+
for event in events:
69+
if isinstance(event, MapEvent):
70+
current = self._ycontent
71+
for path in event.path:
72+
current = current[path]
73+
for key, val in event.keys.items():
74+
if val.get("action") == "delete":
75+
path = "/".join(event.path[1::2] + [key])
76+
self._task_group.start_soon(self._contents.delete_content, path, self._user)
77+
78+
@property
79+
def version(self) -> str:
80+
return "1.0.0"
81+
82+
def _new_dir_content(self) -> Map:
83+
return Map({"is_dir": True, "content": None})
84+
85+
def _new_file_content(self, size: int) -> Map:
86+
return Map({"is_dir": False, "size": size})
87+
88+
def _get_directory_content(self, path: Path) -> Map:
89+
res = {}
90+
for entry in (self._root_dir / path).iterdir():
91+
if entry.is_dir():
92+
res[entry.name] = self._new_dir_content()
93+
else:
94+
stat = entry.stat()
95+
res[entry.name] = self._new_file_content(
96+
size=stat.st_size,
97+
)
98+
return Map(res)
99+
100+
def _maybe_populate_dir(self, path: Path, content: Map):
101+
if content["content"] is None:
102+
content["content"] = self._get_directory_content(path)
103+
104+
def _get(self, path: Path | str | None = None) -> Map:
105+
path = Path() if path is None else Path(path)
106+
current_content = self._ycontent
107+
self._maybe_populate_dir(path, self._ycontent)
108+
cwd = Path()
109+
last_idx = len(path.parts) - 1
110+
for idx, part in enumerate(path.parts):
111+
try:
112+
current_content = current_content["content"][part]
113+
except KeyError:
114+
raise FileNotFoundError(f'No entry "{part}" in "{cwd}".')
115+
if current_content["is_dir"]:
116+
cwd /= part
117+
self._maybe_populate_dir(cwd, current_content)
118+
elif idx < last_idx:
119+
raise RuntimeError(f'Entry "{part}" in "{cwd}" is not a directory.')
120+
return current_content
121+
122+
def get(self, path: Path | str | None = None) -> dict:
123+
return dict(self._get(path))
124+
125+
def delete(self, path: Path | str):
126+
path = Path(path) if isinstance(path, str) else path
127+
if not path.parts:
128+
raise RuntimeError("Cannot delete root directory")
129+
parent_content = self._get(path.parent)
130+
del parent_content["content"][path.name]
131+
132+
def set(self, value) -> None:
133+
raise RuntimeError("Cannot set a YDrive")
134+
135+
def observe(self, callback: Callable[[str, Any], None]) -> None:
136+
self.unobserve()
137+
self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state"))
138+
self._subscriptions[self._ycontent] = self._ycontent.observe_deep(
139+
partial(callback, "content")
140+
)

plugins/yjs/pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ description = "An FPS plugin for the Yjs API"
88
keywords = [ "jupyter", "server", "fastapi", "plugins" ]
99
requires-python = ">=3.8"
1010
dependencies = [
11+
"anyio >=3.6.2,<5",
1112
"pycrdt >=0.7.2,<0.8.0",
1213
"jupyverse-api >=0.1.2,<1",
1314
]
1415
dynamic = [ "version",]
16+
17+
[project.optional-dependencies]
18+
test = [
19+
"pytest",
20+
"fps-contents",
21+
]
22+
1523
[[project.authors]]
1624
name = "Jupyter Development Team"
1725

plugins/yjs/tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def anyio_backend():
6+
return "asyncio"

plugins/yjs/tests/fake_contents.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from anyio import create_memory_object_stream
2+
from anyio.streams.stapled import StapledObjectStream
3+
from fps_contents.fileid import FileIdManager
4+
5+
6+
class Contents:
7+
def __init__(self, db_path, root_dir):
8+
send_stream, recv_stream = create_memory_object_stream[str]()
9+
self.event_stream = StapledObjectStream(send_stream, recv_stream)
10+
self.file_id_manager = FileIdManager(db_path=db_path, root_dir=root_dir)
11+
self.watcher = self.file_id_manager.watch(".")
12+
13+
async def delete_content(self, path, user):
14+
await self.event_stream.send(f"delete {path}")

plugins/yjs/tests/test_ydocs.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import tempfile
2+
from pathlib import Path
3+
4+
import pytest
5+
from anyio import sleep
6+
from fake_contents import Contents
7+
from fps_yjs.ydocs.ydrive import YDrive
8+
9+
10+
@pytest.mark.anyio
11+
async def test_ydrive():
12+
with tempfile.TemporaryDirectory() as tmp_dir:
13+
tmp_dir = Path(tmp_dir)
14+
(tmp_dir / "file0").write_text(" " * 1)
15+
(tmp_dir / "file1").write_text(" " * 2)
16+
(tmp_dir / "dir0").mkdir()
17+
(tmp_dir / "dir0" / "file2").write_text(" " * 3)
18+
(tmp_dir / "dir1").mkdir()
19+
(tmp_dir / "dir1" / "dir2").mkdir()
20+
(tmp_dir / "dir1" / "dir2" / "file3").write_text(" " * 4)
21+
(tmp_dir / "dir1" / "dir2" / "file4").write_text(" " * 5)
22+
23+
contents = Contents(db_path=str(tmp_dir / ".fileid.db"), root_dir=str(tmp_dir))
24+
25+
async with YDrive(contents=contents, root_dir=tmp_dir) as ydrive:
26+
27+
with pytest.raises(FileNotFoundError):
28+
ydrive.get("doesnt_exist")
29+
30+
root_dir = ydrive.get()
31+
assert len(root_dir["content"]) == 4
32+
assert "file0" in root_dir["content"]
33+
assert "file1" in root_dir["content"]
34+
assert "dir0" in root_dir["content"]
35+
assert "dir1" in root_dir["content"]
36+
37+
dir0 = ydrive.get("dir0")
38+
assert len(dir0["content"]) == 1
39+
assert "file2" in dir0["content"]
40+
41+
dir1 = ydrive.get("dir1")
42+
assert len(dir1["content"]) == 1
43+
assert "dir2" in dir1["content"]
44+
45+
dir2 = ydrive.get("dir1/dir2")
46+
assert len(dir2["content"]) == 2
47+
assert "file3" in dir2["content"]
48+
assert "file4" in dir2["content"]
49+
assert dict(dir1["content"]["dir2"]["content"]["file3"]) == {"is_dir": False, "size": 4}
50+
51+
# the fake contents actually doesn't delete files
52+
path = "file0"
53+
ydrive.delete(path)
54+
assert await contents.event_stream.receive() == f"delete {path}"
55+
path = "dir1/dir2/file3"
56+
ydrive.delete(path)
57+
assert await contents.event_stream.receive() == f"delete {path}"
58+
59+
await contents.file_id_manager.initialized.wait()
60+
await sleep(10)
61+
assert "file1" in root_dir["content"]
62+
(tmp_dir / "file1").unlink()
63+
for _ in range(100): # wait a total of 10s
64+
await sleep(0.1)
65+
if "file1" not in root_dir["content"]:
66+
break
67+
assert "file1" not in root_dir["content"]
68+
69+
assert "file4" in dir2["content"]
70+
(tmp_dir / "dir1" / "dir2" / "file4").unlink()
71+
for _ in range(100): # wait a total of 10s
72+
await sleep(0.1)
73+
if "file4" not in dir2["content"]:
74+
break
75+
assert "file4" not in dir2["content"]

0 commit comments

Comments
 (0)