evserver 0.1.0__tar.gz

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.
@@ -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,142 @@
1
+ # EasyVersion
2
+
3
+ ### Goals
4
+
5
+ - **Many Archives:** Can store in many archives at once.
6
+ - **Emergent Complexity:** Small implementation surface but reasonable usage requirements.
7
+
8
+ ## Commands
9
+
10
+ - **`ev [COMMANDS | OPTIONS | ARGUMENTS | FLAGS]... [--help | -h]`:** List all sub commands + a small description of the intended usage.
11
+ - **`ev archive <archive:URL> register [user:ID]`:** Request to claim a new user ID on the given archive URL.
12
+ - **`ev archive <archive:URL> unregister <user:ID>`:** Request to be forgotten with the passed user ID on the given archive URL.
13
+ - **`ev <workspace:PATH> login <api:URL> <user:ID>`:** Add this api URL + user ID to the list of active archives for the specified workspace.
14
+ - **`ev <workspace:PATH> logout <api:URL> <user:ID>`:** Remove this api URL + user ID from the list of active archives for the specified workspace.
15
+ - **`ev <workspace:PATH> logout <api:URL> <user:ID>`:** Remove this api URL + user ID from the list of active archives for the specified workspace.
16
+ - **`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.
17
+ - **`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.
18
+ - **`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.
19
+ - **`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.
20
+
21
+ ## Archive
22
+
23
+ - **URL Specified:** Each archive only be accessed programatically by users through its URL.
24
+
25
+ ### Endpoints
26
+
27
+ #### Accounts
28
+
29
+ ##### `POST /user/register`
30
+
31
+ - Claims an available user.
32
+ - If successful returns the claimed user.
33
+
34
+ ##### `POST /user/register:id`
35
+
36
+ - Attempts to claim the specfied user.
37
+
38
+ ##### `DELETE /user/:user_id/`
39
+
40
+ - Attempts to forget the provided user.
41
+
42
+ ##### `GET /user/:user_id/`
43
+
44
+ - Returns an object containing:
45
+ - An array of all workspace IDs for the given user.
46
+
47
+ #### Workspace
48
+
49
+ ##### `GET /user/:user_id/workspace/:workspace_id`
50
+
51
+ - Returns a workspace object containing:
52
+ - An array of all snapshot IDs for the given user's workspace.
53
+
54
+ ##### `PUT /user/:user_id/workspace/:workspace_id`
55
+
56
+ - Replace the user's specified workspace with the new workspace object containing:
57
+ - An array of all snapshot IDs for the given user's workspace.
58
+
59
+ ##### `DELETE /user/:user_id/workspace/:workspace_id`
60
+
61
+ - Forget the user's specified workspace.
62
+
63
+ #### Snapshot
64
+
65
+ ##### `GET /user/:user_id/snapshot/:snapshot_id`
66
+
67
+ - Returns an object containing:
68
+ - An array of all manifest IDs for the given user's snapshot.
69
+ - An optional note string for the given user's snapshot.
70
+
71
+ ##### `PUT /user/:user_id/snapshot/:snapshot_id`
72
+
73
+ - Create the user's specified snapshot from the provided snapshot object containing:
74
+ - A manifest IDs for the given user's snapshot.
75
+ - An optional note string for the given user's snapshot.
76
+
77
+ ##### `DELETE /user/:user_id/snapshot/:snapshot_id`
78
+
79
+ - Forget the user's specified snapshot.
80
+
81
+ #### Manifest
82
+
83
+ ##### `GET /user/:user_id/manifest/:manifest_id`
84
+
85
+ - Returns an object containing:
86
+ - An array of all reference IDs for the given user's manifest.
87
+
88
+ ##### `PUT /user/:user_id/manifest/:manifest_id`
89
+
90
+ - Create the user's specified manifest from the provided manifest object containing:
91
+ - An array of all reference IDs for the given user's manifest.
92
+
93
+ ##### `DELETE /user/:user_id/manifest/:manifest_id`
94
+
95
+ - Forget the user's specified manifest.
96
+
97
+ #### Reference
98
+
99
+ ##### `GET /user/:user_id/reference/:reference_id`
100
+
101
+ - Returns an object containing:
102
+ - The reference's relative file path.
103
+ - The reference's content digest.
104
+
105
+ ##### `PUT /user/:user_id/reference/:reference_id`
106
+
107
+ - Create the user's specified reference from the provided reference object containing:
108
+ - The reference's relative file path.
109
+ - The reference's content digest.
110
+
111
+ ##### `DELETE /user/:user_id/reference/:reference_id`
112
+
113
+ - Forget the user's specified reference.
114
+
115
+ #### Content
116
+
117
+ ##### `GET /user/:user_id/content/:content_id`
118
+
119
+ - Returns a byte stream containing:
120
+ - The encoded content data.
121
+
122
+ ##### `PUT /user/:user_id/content/:content_id`
123
+
124
+ - Create the user's specified reference from the provided content byte stream containing:
125
+ - The encoded content data.
126
+
127
+ ##### `DELETE /user/:user_id/content/:content_id`
128
+
129
+ - Forget the user's specified reference.
130
+
131
+ ### Invariants
132
+
133
+ - All non user IDs are blake3 hashes.
134
+
135
+ ## Client
136
+
137
+ ### Invariants
138
+
139
+ - All config information is stored in the `.ev` folder at the workspace root.
140
+ - The workspace login command appends the provided archive configuration to the currently active archive configurations in `.ev/archives`.
141
+ - If an internal error happens the program should never continue.
142
+ - User IDs are provided and used as is by the archive. No extra mapping.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "evserver"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Wannes Vantorre", email = "vantorrewannes@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "aiofiles>=25.1.0",
12
+ "blake3>=1.0.9",
13
+ "dill>=0.4.1",
14
+ "fastapi[standard]>=0.140.7",
15
+ "pydantic>=2.13.4",
16
+ ]
17
+
18
+ [project.scripts]
19
+ evserver = "evserver:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.11.32,<0.12.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.ruff.lint]
26
+ select = ["ALL"]
27
+ ignore = ["D203", "D213", "COM812", "ISC001", "CPY001", "D419", "D100", "D101", "D102", "D103", "D104", "D107"]
28
+
29
+ [tool.ruff.lint.per-file-ignores]
30
+ "tests/**" = ["S101"]
31
+ "tests/unit/**" = ["SLF001"]
@@ -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)
@@ -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()
@@ -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())