evserver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evserver/__init__.py +308 -0
- evserver/stores.py +152 -0
- evserver/types.py +67 -0
- evserver-0.1.0.dist-info/METADATA +156 -0
- evserver-0.1.0.dist-info/RECORD +7 -0
- evserver-0.1.0.dist-info/WHEEL +4 -0
- evserver-0.1.0.dist-info/entry_points.txt +3 -0
evserver/__init__.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from http.client import HTTPException
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Annotated, TypedDict
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, FastAPI
|
|
8
|
+
from starlette.responses import Content
|
|
9
|
+
|
|
10
|
+
from evserver.stores import DirectoryStore, FileStore, Store
|
|
11
|
+
from evserver.types import (
|
|
12
|
+
ContentId,
|
|
13
|
+
Manifest,
|
|
14
|
+
ManifestId,
|
|
15
|
+
Reference,
|
|
16
|
+
ReferenceId,
|
|
17
|
+
Snapshot,
|
|
18
|
+
SnapshotId,
|
|
19
|
+
User,
|
|
20
|
+
UserId,
|
|
21
|
+
Workspace,
|
|
22
|
+
WorkspaceId,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import AsyncGenerator
|
|
27
|
+
|
|
28
|
+
from fastapi.requests import HTTPConnection
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
UserKey = UserId
|
|
32
|
+
WorkspaceKey = tuple[UserId, WorkspaceId]
|
|
33
|
+
SnapshotKey = tuple[UserId, SnapshotId]
|
|
34
|
+
ManifestKey = tuple[UserId, ManifestId]
|
|
35
|
+
ReferenceKey = tuple[UserId, ReferenceId]
|
|
36
|
+
ContentKey = tuple[UserId, ContentId]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
UserStore = Store[UserId, User]
|
|
40
|
+
WorkspaceStore = Store[WorkspaceKey, Workspace]
|
|
41
|
+
SnapshotStore = Store[SnapshotKey, Snapshot]
|
|
42
|
+
ManifestStore = Store[ManifestKey, Manifest]
|
|
43
|
+
ReferenceStore = Store[ReferenceKey, Reference]
|
|
44
|
+
ContentStore = Store[ContentKey, Content]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class State(TypedDict):
|
|
48
|
+
user_store: UserStore
|
|
49
|
+
workspace_store: WorkspaceStore
|
|
50
|
+
snapshot_store: SnapshotStore
|
|
51
|
+
manifest_store: ManifestStore
|
|
52
|
+
reference_store: ReferenceStore
|
|
53
|
+
content_store: ContentStore
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def lifespan(_: FastAPI) -> AsyncGenerator[State]:
|
|
58
|
+
user_store = FileStore(Path("data/users.dill"))
|
|
59
|
+
workspace_store = FileStore(Path("data/workspaces.dill"))
|
|
60
|
+
snapshot_store = FileStore(Path("data/snapshots.dill"))
|
|
61
|
+
manifest_store = FileStore(Path("data/manifests.dill"))
|
|
62
|
+
reference_store = FileStore(Path("data/references.dill"))
|
|
63
|
+
content_store = DirectoryStore(Path("data/contents"))
|
|
64
|
+
yield State(
|
|
65
|
+
user_store=user_store,
|
|
66
|
+
workspace_store=workspace_store,
|
|
67
|
+
snapshot_store=snapshot_store,
|
|
68
|
+
manifest_store=manifest_store,
|
|
69
|
+
reference_store=reference_store,
|
|
70
|
+
content_store=content_store,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
app = FastAPI(lifespan=lifespan)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_user_store(httpconnection: HTTPConnection) -> UserStore:
|
|
78
|
+
return httpconnection.state.user_store
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_workspace_store(httpconnection: HTTPConnection) -> WorkspaceStore:
|
|
82
|
+
return httpconnection.state.workspace_store
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_snapshot_store(httpconnection: HTTPConnection) -> SnapshotStore:
|
|
86
|
+
return httpconnection.state.snapshot_store
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_manifest_store(httpconnection: HTTPConnection) -> ManifestStore:
|
|
90
|
+
return httpconnection.state.manifest_store
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_reference_store(httpconnection: HTTPConnection) -> ReferenceStore:
|
|
94
|
+
return httpconnection.state.referenc_store
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_content_store(httpconnection: HTTPConnection) -> ContentStore:
|
|
98
|
+
return httpconnection.state.content_store
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.post("/user/register")
|
|
102
|
+
async def register_user(
|
|
103
|
+
user_store: Annotated[UserStore, Depends(get_user_store)],
|
|
104
|
+
) -> UserId:
|
|
105
|
+
user_id = uuid.uuid4().hex
|
|
106
|
+
user = User(user_id, set())
|
|
107
|
+
await user_store.set(user_id, user)
|
|
108
|
+
return user_id
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.post("/user/register/{user_id}")
|
|
112
|
+
async def claim_user(
|
|
113
|
+
user_id: UserId,
|
|
114
|
+
user_store: Annotated[UserStore, Depends(get_user_store)],
|
|
115
|
+
) -> None:
|
|
116
|
+
if await user_store.contains(user_id):
|
|
117
|
+
raise HTTPException(404, f'User: "{user_id}" already exists.')
|
|
118
|
+
await user_store.set(user_id, User.from_id(user_id))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.delete("/user/{user_id}")
|
|
122
|
+
async def delete_user(
|
|
123
|
+
user_id: UserId,
|
|
124
|
+
user_store: Annotated[UserStore, Depends(get_user_store)],
|
|
125
|
+
) -> None:
|
|
126
|
+
if not await user_store.contains(user_id):
|
|
127
|
+
raise HTTPException(404, f'User: "{user_id}" does not exists.')
|
|
128
|
+
await user_store.delete(user_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.get("/user/{user_id}")
|
|
132
|
+
async def get_user(
|
|
133
|
+
user_id: UserId,
|
|
134
|
+
user_store: Annotated[UserStore, Depends(get_user_store)],
|
|
135
|
+
) -> User:
|
|
136
|
+
if not await user_store.contains(user_id):
|
|
137
|
+
raise HTTPException(404, f'User: "{user_id}" does not exists.')
|
|
138
|
+
return await user_store.get(user_id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.get("/user/{user_id}/workspace/{workspace_id}")
|
|
142
|
+
async def get_workspace(
|
|
143
|
+
user_id: UserId,
|
|
144
|
+
workspace_id: WorkspaceId,
|
|
145
|
+
workspace_store: Annotated[WorkspaceStore, Depends(get_workspace_store)],
|
|
146
|
+
) -> Workspace:
|
|
147
|
+
workspace_key = (user_id, workspace_id)
|
|
148
|
+
if not await workspace_store.contains(workspace_key):
|
|
149
|
+
raise HTTPException(404, f'Workspace: "{workspace_key}" does not exists.')
|
|
150
|
+
return await workspace_store.get(workspace_key)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.put("/user/{user_id}/workspace/{workspace_id}")
|
|
154
|
+
async def set_workspace(
|
|
155
|
+
user_id: UserId,
|
|
156
|
+
workspace_id: WorkspaceId,
|
|
157
|
+
workspace: Workspace,
|
|
158
|
+
workspace_store: Annotated[WorkspaceStore, Depends(get_workspace_store)],
|
|
159
|
+
) -> None:
|
|
160
|
+
await workspace_store.set((user_id, workspace_id), workspace)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.delete("/user/{user_id}/workspace/{workspace_id}")
|
|
164
|
+
async def delete_workspace(
|
|
165
|
+
user_id: UserId,
|
|
166
|
+
workspace_id: WorkspaceId,
|
|
167
|
+
workspace_store: Annotated[WorkspaceStore, Depends(get_workspace_store)],
|
|
168
|
+
) -> None:
|
|
169
|
+
workspace_key = (user_id, workspace_id)
|
|
170
|
+
if not await workspace_store.contains(workspace_key):
|
|
171
|
+
raise HTTPException(404, f'Workspace: "{workspace_key}" does not exists.')
|
|
172
|
+
await workspace_store.delete(workspace_key)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.get("/user/{user_id}/snapshot/{snapshot_id}")
|
|
176
|
+
async def get_snapshot(
|
|
177
|
+
user_id: UserId,
|
|
178
|
+
snapshot_id: SnapshotId,
|
|
179
|
+
snapshot_store: Annotated[SnapshotStore, Depends(get_snapshot_store)],
|
|
180
|
+
) -> Snapshot:
|
|
181
|
+
snapshot_key = (user_id, snapshot_id)
|
|
182
|
+
if not await snapshot_store.contains(snapshot_key):
|
|
183
|
+
raise HTTPException(404, f'Snapshot: "{snapshot_key}" does not exists.')
|
|
184
|
+
return await snapshot_store.get(snapshot_key)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.put("/user/{user_id}/snapshot/{snapshot_id}")
|
|
188
|
+
async def set_snapshot(
|
|
189
|
+
user_id: UserId,
|
|
190
|
+
snapshot_id: SnapshotId,
|
|
191
|
+
snapshot: Snapshot,
|
|
192
|
+
snapshot_store: Annotated[SnapshotStore, Depends(get_snapshot_store)],
|
|
193
|
+
) -> None:
|
|
194
|
+
await snapshot_store.set((user_id, snapshot_id), snapshot)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.delete("/user/{user_id}/snapshot/{snapshot_id}")
|
|
198
|
+
async def delete_snapshot(
|
|
199
|
+
user_id: UserId,
|
|
200
|
+
snapshot_id: SnapshotId,
|
|
201
|
+
snapshot_store: Annotated[SnapshotStore, Depends(get_snapshot_store)],
|
|
202
|
+
) -> None:
|
|
203
|
+
snapshot_key = (user_id, snapshot_id)
|
|
204
|
+
if not await snapshot_store.contains(snapshot_key):
|
|
205
|
+
raise HTTPException(404, f'Snapshot: "{snapshot_key}" does not exists.')
|
|
206
|
+
await snapshot_store.delete(snapshot_key)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.get("/user/{user_id}/manifest/{manifest_id}")
|
|
210
|
+
async def get_manifest(
|
|
211
|
+
user_id: UserId,
|
|
212
|
+
manifest_id: ManifestId,
|
|
213
|
+
manifest_store: Annotated[ManifestStore, Depends(get_manifest_store)],
|
|
214
|
+
) -> Manifest:
|
|
215
|
+
manifest_key = (user_id, manifest_id)
|
|
216
|
+
if not await manifest_store.contains(manifest_key):
|
|
217
|
+
raise HTTPException(404, f'Manifest: "{manifest_key}" does not exists.')
|
|
218
|
+
return await manifest_store.get(manifest_key)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.put("/user/{user_id}/manifest/{manifest_id}")
|
|
222
|
+
async def set_manifest(
|
|
223
|
+
user_id: UserId,
|
|
224
|
+
manifest_id: ManifestId,
|
|
225
|
+
manifest: Manifest,
|
|
226
|
+
manifest_store: Annotated[ManifestStore, Depends(get_manifest_store)],
|
|
227
|
+
) -> None:
|
|
228
|
+
await manifest_store.set((user_id, manifest_id), manifest)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.delete("/user/{user_id}/manifest/{manifest_id}")
|
|
232
|
+
async def delete_manifest(
|
|
233
|
+
user_id: UserId,
|
|
234
|
+
manifest_id: ManifestId,
|
|
235
|
+
manifest_store: Annotated[ManifestStore, Depends(get_manifest_store)],
|
|
236
|
+
) -> None:
|
|
237
|
+
manifest_key = (user_id, manifest_id)
|
|
238
|
+
if not await manifest_store.contains(manifest_key):
|
|
239
|
+
raise HTTPException(404, f'Manifest: "{manifest_key}" does not exists.')
|
|
240
|
+
await manifest_store.delete(manifest_key)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.get("/user/{user_id}/manifest/{manifest_id}")
|
|
244
|
+
async def get_reference(
|
|
245
|
+
user_id: UserId,
|
|
246
|
+
reference_id: ReferenceId,
|
|
247
|
+
reference_store: Annotated[ReferenceStore, Depends(get_reference_store)],
|
|
248
|
+
) -> Reference:
|
|
249
|
+
reference_key = (user_id, reference_id)
|
|
250
|
+
if not await reference_store.contains(reference_key):
|
|
251
|
+
raise HTTPException(404, f'Reference: "{reference_key}" does not exists.')
|
|
252
|
+
return await reference_store.get(reference_key)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.put("/user/{user_id}/reference/{reference_id}")
|
|
256
|
+
async def set_reference(
|
|
257
|
+
user_id: UserId,
|
|
258
|
+
reference_id: ReferenceId,
|
|
259
|
+
reference: Reference,
|
|
260
|
+
reference_store: Annotated[ReferenceStore, Depends(get_reference_store)],
|
|
261
|
+
) -> None:
|
|
262
|
+
await reference_store.set((user_id, reference_id), reference)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.delete("/user/{user_id}/reference/{reference_id}")
|
|
266
|
+
async def delete_reference(
|
|
267
|
+
user_id: UserId,
|
|
268
|
+
reference_id: ReferenceId,
|
|
269
|
+
reference_store: Annotated[ManifestStore, Depends(get_manifest_store)],
|
|
270
|
+
) -> None:
|
|
271
|
+
manifest_key = (user_id, reference_id)
|
|
272
|
+
if not await reference_store.contains(manifest_key):
|
|
273
|
+
raise HTTPException(404, f'Reference: "{manifest_key}" does not exists.')
|
|
274
|
+
await reference_store.delete(manifest_key)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.get("/user/{user_id}/manifest/{manifest_id}")
|
|
278
|
+
async def get_content(
|
|
279
|
+
user_id: UserId,
|
|
280
|
+
content_id: ContentId,
|
|
281
|
+
content_store: Annotated[ContentStore, Depends(get_content_store)],
|
|
282
|
+
) -> Content:
|
|
283
|
+
content_key = (user_id, content_id)
|
|
284
|
+
if not await content_store.contains(content_key):
|
|
285
|
+
raise HTTPException(404, f'Content: "{content_key}" does not exists.')
|
|
286
|
+
return await content_store.get(content_key)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.put("/user/{user_id}/content/{content_id}")
|
|
290
|
+
async def set_content(
|
|
291
|
+
user_id: UserId,
|
|
292
|
+
content_id: ContentId,
|
|
293
|
+
content: Content,
|
|
294
|
+
content_store: Annotated[ContentStore, Depends(get_content_store)],
|
|
295
|
+
) -> None:
|
|
296
|
+
await content_store.set((user_id, content_id), content)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.delete("/user/{user_id}/content/{content_id}")
|
|
300
|
+
async def delete_content(
|
|
301
|
+
user_id: UserId,
|
|
302
|
+
content_id: ContentId,
|
|
303
|
+
content_store: Annotated[ManifestStore, Depends(get_manifest_store)],
|
|
304
|
+
) -> None:
|
|
305
|
+
manifest_key = (user_id, content_id)
|
|
306
|
+
if not await content_store.contains(manifest_key):
|
|
307
|
+
raise HTTPException(404, f'Content: "{manifest_key}" does not exists.')
|
|
308
|
+
await content_store.delete(manifest_key)
|
evserver/stores.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Hashable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, override
|
|
6
|
+
|
|
7
|
+
import aiofiles
|
|
8
|
+
import dill
|
|
9
|
+
from blake3 import blake3
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Awaitable
|
|
13
|
+
|
|
14
|
+
from evserver.types import Digest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Store[K, V](Protocol):
|
|
18
|
+
def get(self, key: K) -> Awaitable[V]: ...
|
|
19
|
+
|
|
20
|
+
def set(self, key: K, value: V) -> Awaitable[None]: ...
|
|
21
|
+
|
|
22
|
+
def delete(self, key: K) -> Awaitable[None]: ...
|
|
23
|
+
|
|
24
|
+
def keys(self) -> Awaitable[tuple[K, ...]]: ...
|
|
25
|
+
|
|
26
|
+
def contains(self, key: K) -> Awaitable[bool]: ...
|
|
27
|
+
|
|
28
|
+
def values(self) -> Awaitable[tuple[V, ...]]: ...
|
|
29
|
+
|
|
30
|
+
def length(self) -> Awaitable[int]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseStore[K, V](ABC):
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def get(self, key: K) -> Awaitable[V]: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def set(self, key: K, value: V) -> Awaitable[None]: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def delete(self, key: K) -> Awaitable[None]: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def keys(self) -> Awaitable[tuple[K, ...]]: ...
|
|
45
|
+
|
|
46
|
+
async def contains(self, key: K) -> bool:
|
|
47
|
+
return key in await self.keys()
|
|
48
|
+
|
|
49
|
+
async def values(self) -> tuple[V, ...]:
|
|
50
|
+
return tuple(await asyncio.gather(*[self.get(k) for k in await self.keys()]))
|
|
51
|
+
|
|
52
|
+
async def length(self) -> int:
|
|
53
|
+
return len(await self.keys())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MemoryStore[K: Hashable, V](BaseStore[K, V]):
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._store: dict[K, V] = {}
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
async def get(self, key: K) -> V:
|
|
62
|
+
return self._store[key]
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
async def set(self, key: K, value: V) -> None:
|
|
66
|
+
self._store[key] = value
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
async def delete(self, key: K) -> None:
|
|
70
|
+
self._store.pop(key, None)
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
async def keys(self) -> tuple[K, ...]:
|
|
74
|
+
return tuple(self._store.keys())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FileStore[K: Hashable, V](BaseStore[K, V]):
|
|
78
|
+
def __init__(self, path: Path) -> None:
|
|
79
|
+
self.path = path
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def load(cls, path: Path) -> dict[K, V]:
|
|
83
|
+
if path.exists():
|
|
84
|
+
return dill.loads(path.read_bytes()) # noqa: S301
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def save(cls, path: Path, key_values: dict[K, V]) -> None:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
path.write_bytes(dill.dumps(key_values))
|
|
91
|
+
|
|
92
|
+
@override
|
|
93
|
+
async def get(self, key: K) -> V:
|
|
94
|
+
key_values = FileStore[K, V].load(self.path)
|
|
95
|
+
return key_values[key]
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
async def set(self, key: K, value: V) -> None:
|
|
99
|
+
key_values = FileStore[K, V].load(self.path)
|
|
100
|
+
key_values[key] = value
|
|
101
|
+
FileStore[K, V].save(self.path, key_values)
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
async def delete(self, key: K) -> None:
|
|
105
|
+
key_values = FileStore[K, V].load(self.path)
|
|
106
|
+
key_values.pop(key)
|
|
107
|
+
FileStore[K, V].save(self.path, key_values)
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
async def keys(self) -> tuple[K, ...]:
|
|
111
|
+
return tuple(FileStore[K, V].load(self.path).keys())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DirectoryStore[K, V](BaseStore[K, V]):
|
|
115
|
+
def __init__(self, directory: Path) -> None:
|
|
116
|
+
self.directory = directory
|
|
117
|
+
self.key_store = FileStore[K, Path](directory / "index.dill")
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def digest(cls, key: K) -> Digest:
|
|
121
|
+
return blake3(dill.dumps(key)).hexdigest()
|
|
122
|
+
|
|
123
|
+
def filepath(self, key: K) -> Path:
|
|
124
|
+
return self.directory / self.digest(key)
|
|
125
|
+
|
|
126
|
+
@override
|
|
127
|
+
async def get(self, key: K) -> V:
|
|
128
|
+
filepath = await self.key_store.get(key)
|
|
129
|
+
async with aiofiles.open(filepath, mode="rb") as f:
|
|
130
|
+
data = await f.read()
|
|
131
|
+
return dill.loads(data) # noqa: S301
|
|
132
|
+
|
|
133
|
+
@override
|
|
134
|
+
async def set(self, key: K, value: V) -> None:
|
|
135
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
filepath = self.filepath(key)
|
|
137
|
+
await self.key_store.set(key, filepath)
|
|
138
|
+
data = dill.dumps(value)
|
|
139
|
+
async with aiofiles.open(filepath, mode="wb") as f:
|
|
140
|
+
await f.write(data)
|
|
141
|
+
await self.key_store.set(key, filepath)
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
async def delete(self, key: K) -> None:
|
|
145
|
+
filepath = await self.key_store.get(key)
|
|
146
|
+
if filepath and filepath.exists():
|
|
147
|
+
filepath.unlink()
|
|
148
|
+
await self.key_store.delete(key)
|
|
149
|
+
|
|
150
|
+
@override
|
|
151
|
+
async def keys(self) -> tuple[K, ...]:
|
|
152
|
+
return await self.key_store.keys()
|
evserver/types.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from pathlib import Path # noqa: TC003
|
|
3
|
+
|
|
4
|
+
import dill
|
|
5
|
+
from blake3 import blake3
|
|
6
|
+
from pydantic.dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
Digest = str
|
|
9
|
+
UserId = str
|
|
10
|
+
WorkspaceId = Digest
|
|
11
|
+
SnapshotId = Digest
|
|
12
|
+
ManifestId = Digest
|
|
13
|
+
ReferenceId = Digest
|
|
14
|
+
ContentId = Digest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Reference:
|
|
19
|
+
file_path: Path
|
|
20
|
+
content_digest: ContentId
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def id(self) -> ReferenceId:
|
|
24
|
+
return blake3(dill.dumps((self.file_path, self.content_digest))).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Manifest:
|
|
29
|
+
reference_ids: tuple[ReferenceId, ...]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def id(self) -> ManifestId:
|
|
33
|
+
return blake3(dill.dumps(self.reference_ids)).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Snapshot:
|
|
38
|
+
comment: str | None
|
|
39
|
+
manifest_id: ManifestId
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def id(self) -> SnapshotId:
|
|
43
|
+
return blake3(dill.dumps(self.comment, self.manifest_id)).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class Workspace:
|
|
48
|
+
directory: Path
|
|
49
|
+
snapshot_ids: tuple[SnapshotId, ...]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def id(self) -> SnapshotId:
|
|
53
|
+
return blake3(dill.dumps(self.directory)).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class User:
|
|
58
|
+
id: UserId
|
|
59
|
+
workspace_ids: set[WorkspaceId]
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def random(cls) -> User:
|
|
63
|
+
return User(uuid.uuid4().hex, set())
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_id(cls, user_id: UserId) -> User:
|
|
67
|
+
return User(user_id, set())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: evserver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Wannes Vantorre
|
|
6
|
+
Author-email: Wannes Vantorre <vantorrewannes@gmail.com>
|
|
7
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
8
|
+
Requires-Dist: blake3>=1.0.9
|
|
9
|
+
Requires-Dist: dill>=0.4.1
|
|
10
|
+
Requires-Dist: fastapi[standard]>=0.140.7
|
|
11
|
+
Requires-Dist: pydantic>=2.13.4
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# EasyVersion
|
|
16
|
+
|
|
17
|
+
### Goals
|
|
18
|
+
|
|
19
|
+
- **Many Archives:** Can store in many archives at once.
|
|
20
|
+
- **Emergent Complexity:** Small implementation surface but reasonable usage requirements.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
- **`ev [COMMANDS | OPTIONS | ARGUMENTS | FLAGS]... [--help | -h]`:** List all sub commands + a small description of the intended usage.
|
|
25
|
+
- **`ev archive <archive:URL> register [user:ID]`:** Request to claim a new user ID on the given archive URL.
|
|
26
|
+
- **`ev archive <archive:URL> unregister <user:ID>`:** Request to be forgotten with the passed user ID on the given archive URL.
|
|
27
|
+
- **`ev <workspace:PATH> login <api:URL> <user:ID>`:** Add this api URL + user ID to the list of active archives for the specified workspace.
|
|
28
|
+
- **`ev <workspace:PATH> logout <api:URL> <user:ID>`:** Remove this api URL + user ID from the list of active archives for the specified workspace.
|
|
29
|
+
- **`ev <workspace:PATH> logout <api:URL> <user:ID>`:** Remove this api URL + user ID from the list of active archives for the specified workspace.
|
|
30
|
+
- **`ev <workspace:PATH> save [--note <comment:TEXT> | -n <comment:TEXT>]`:** Save the current state of this workspace as a new version. With the specified comment if provided.
|
|
31
|
+
- **`ev <workspace:PATH> list [--version <version:NUMBER> | -v <version:NUMBER>]`:** List every version of the passed workspace so far or just the specific one requested. With their respective notes if set.
|
|
32
|
+
- **`ev <source:PATH> clone <target:PATH> [--version <version:NUMBER> | -v <version:NUMBER>]`:** Clone the source workspace to the target workspace. Target may not exist yet. Clone the workspace as it was on the specified version if passed.
|
|
33
|
+
- **`ev <source:PATH> forget {--version <version:NUMBER> | -v <version:NUMBER> | --all | -a}`:** Request the active workspaces to forget the selected version. Or all if requested. Does not clear the current workspace state.
|
|
34
|
+
|
|
35
|
+
## Archive
|
|
36
|
+
|
|
37
|
+
- **URL Specified:** Each archive only be accessed programatically by users through its URL.
|
|
38
|
+
|
|
39
|
+
### Endpoints
|
|
40
|
+
|
|
41
|
+
#### Accounts
|
|
42
|
+
|
|
43
|
+
##### `POST /user/register`
|
|
44
|
+
|
|
45
|
+
- Claims an available user.
|
|
46
|
+
- If successful returns the claimed user.
|
|
47
|
+
|
|
48
|
+
##### `POST /user/register:id`
|
|
49
|
+
|
|
50
|
+
- Attempts to claim the specfied user.
|
|
51
|
+
|
|
52
|
+
##### `DELETE /user/:user_id/`
|
|
53
|
+
|
|
54
|
+
- Attempts to forget the provided user.
|
|
55
|
+
|
|
56
|
+
##### `GET /user/:user_id/`
|
|
57
|
+
|
|
58
|
+
- Returns an object containing:
|
|
59
|
+
- An array of all workspace IDs for the given user.
|
|
60
|
+
|
|
61
|
+
#### Workspace
|
|
62
|
+
|
|
63
|
+
##### `GET /user/:user_id/workspace/:workspace_id`
|
|
64
|
+
|
|
65
|
+
- Returns a workspace object containing:
|
|
66
|
+
- An array of all snapshot IDs for the given user's workspace.
|
|
67
|
+
|
|
68
|
+
##### `PUT /user/:user_id/workspace/:workspace_id`
|
|
69
|
+
|
|
70
|
+
- Replace the user's specified workspace with the new workspace object containing:
|
|
71
|
+
- An array of all snapshot IDs for the given user's workspace.
|
|
72
|
+
|
|
73
|
+
##### `DELETE /user/:user_id/workspace/:workspace_id`
|
|
74
|
+
|
|
75
|
+
- Forget the user's specified workspace.
|
|
76
|
+
|
|
77
|
+
#### Snapshot
|
|
78
|
+
|
|
79
|
+
##### `GET /user/:user_id/snapshot/:snapshot_id`
|
|
80
|
+
|
|
81
|
+
- Returns an object containing:
|
|
82
|
+
- An array of all manifest IDs for the given user's snapshot.
|
|
83
|
+
- An optional note string for the given user's snapshot.
|
|
84
|
+
|
|
85
|
+
##### `PUT /user/:user_id/snapshot/:snapshot_id`
|
|
86
|
+
|
|
87
|
+
- Create the user's specified snapshot from the provided snapshot object containing:
|
|
88
|
+
- A manifest IDs for the given user's snapshot.
|
|
89
|
+
- An optional note string for the given user's snapshot.
|
|
90
|
+
|
|
91
|
+
##### `DELETE /user/:user_id/snapshot/:snapshot_id`
|
|
92
|
+
|
|
93
|
+
- Forget the user's specified snapshot.
|
|
94
|
+
|
|
95
|
+
#### Manifest
|
|
96
|
+
|
|
97
|
+
##### `GET /user/:user_id/manifest/:manifest_id`
|
|
98
|
+
|
|
99
|
+
- Returns an object containing:
|
|
100
|
+
- An array of all reference IDs for the given user's manifest.
|
|
101
|
+
|
|
102
|
+
##### `PUT /user/:user_id/manifest/:manifest_id`
|
|
103
|
+
|
|
104
|
+
- Create the user's specified manifest from the provided manifest object containing:
|
|
105
|
+
- An array of all reference IDs for the given user's manifest.
|
|
106
|
+
|
|
107
|
+
##### `DELETE /user/:user_id/manifest/:manifest_id`
|
|
108
|
+
|
|
109
|
+
- Forget the user's specified manifest.
|
|
110
|
+
|
|
111
|
+
#### Reference
|
|
112
|
+
|
|
113
|
+
##### `GET /user/:user_id/reference/:reference_id`
|
|
114
|
+
|
|
115
|
+
- Returns an object containing:
|
|
116
|
+
- The reference's relative file path.
|
|
117
|
+
- The reference's content digest.
|
|
118
|
+
|
|
119
|
+
##### `PUT /user/:user_id/reference/:reference_id`
|
|
120
|
+
|
|
121
|
+
- Create the user's specified reference from the provided reference object containing:
|
|
122
|
+
- The reference's relative file path.
|
|
123
|
+
- The reference's content digest.
|
|
124
|
+
|
|
125
|
+
##### `DELETE /user/:user_id/reference/:reference_id`
|
|
126
|
+
|
|
127
|
+
- Forget the user's specified reference.
|
|
128
|
+
|
|
129
|
+
#### Content
|
|
130
|
+
|
|
131
|
+
##### `GET /user/:user_id/content/:content_id`
|
|
132
|
+
|
|
133
|
+
- Returns a byte stream containing:
|
|
134
|
+
- The encoded content data.
|
|
135
|
+
|
|
136
|
+
##### `PUT /user/:user_id/content/:content_id`
|
|
137
|
+
|
|
138
|
+
- Create the user's specified reference from the provided content byte stream containing:
|
|
139
|
+
- The encoded content data.
|
|
140
|
+
|
|
141
|
+
##### `DELETE /user/:user_id/content/:content_id`
|
|
142
|
+
|
|
143
|
+
- Forget the user's specified reference.
|
|
144
|
+
|
|
145
|
+
### Invariants
|
|
146
|
+
|
|
147
|
+
- All non user IDs are blake3 hashes.
|
|
148
|
+
|
|
149
|
+
## Client
|
|
150
|
+
|
|
151
|
+
### Invariants
|
|
152
|
+
|
|
153
|
+
- All config information is stored in the `.ev` folder at the workspace root.
|
|
154
|
+
- The workspace login command appends the provided archive configuration to the currently active archive configurations in `.ev/archives`.
|
|
155
|
+
- If an internal error happens the program should never continue.
|
|
156
|
+
- User IDs are provided and used as is by the archive. No extra mapping.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
evserver/__init__.py,sha256=b0DUOaLzIPxj2TnwCUVejUUktK5ZQeukIBrUGoXKZiI,9958
|
|
2
|
+
evserver/stores.py,sha256=8mmk2HbSMLEwcWEEtrSiToB-SNcaloodllJZwZblCKY,4315
|
|
3
|
+
evserver/types.py,sha256=cAWigocXGlDkPnPzMB8gCGJF8R5sO608rBtwFVytzR4,1460
|
|
4
|
+
evserver-0.1.0.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
|
|
5
|
+
evserver-0.1.0.dist-info/entry_points.txt,sha256=rUTLIaSTSJNKeXwZZKPQD4oujonzFjiknApX90NoG4o,44
|
|
6
|
+
evserver-0.1.0.dist-info/METADATA,sha256=S37JRrWBcgGq0It3pzZS4cIUGkhBSya6pzJD5CrKk4I,5554
|
|
7
|
+
evserver-0.1.0.dist-info/RECORD,,
|