evserver 0.1.0__tar.gz → 0.3.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,65 @@
1
+ Metadata-Version: 2.3
2
+ Name: evserver
3
+ Version: 0.3.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: dill>=0.4.1
9
+ Requires-Dist: fastapi[standard]>=0.140.7
10
+ Requires-Dist: pydantic>=2.13.4
11
+ Requires-Python: >=3.14
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Easysnapshot
15
+
16
+ ### Goals
17
+
18
+ - **Many Archives:** Store in many archives at once.
19
+ - **Emergent Complexity:** Small implementation surface with reasonable usage requirements.
20
+
21
+ ## Client
22
+
23
+ Clients gather and send information to archive endpoints.
24
+
25
+ ### Commands
26
+
27
+ - All commands accept `--help` / `-h`.
28
+ - All commands accept `--verbose` / `-v`.
29
+ - **`ev archive register <archive:URL> [user:ID]`:** Claim a specific User ID on the given archive. User ID is random if omitted.
30
+ - **`ev archive unregister <archive:URL> <user:ID>`:** Requests the archive to forget this User ID.
31
+ - **`ev archive login <archive:URL> <user:ID> [workspace:PATH]`:** Sets this combination of Archive URL + User ID as active for this workspace.
32
+ - **`ev archive logout <archive:URL> <user:ID> [workspace:PATH]`:** Sets this combination of Archive URL + User ID as unactive for this workspace.
33
+ - **`ev save <--note <note:TEXT> | -n <note:TEXT> [workspace:PATH]`:** Save the workspace's current state as a new Snapshot, with the note when provided.
34
+ - **`ev list [workspace:PATH]`:** List the Workspace's Snapshots. Also showing notes where set.
35
+ - **`ev clone <source:PATH> <target:PATH> [--snapshot <snapshot:ID> | -s <snapshot:ID>]`:** Clone the source Workspace to the target path, which may not exist yet. Clone the current workspace state if omitted.
36
+ - **`ev forget {--snapshot <snapshot:ID> | -v <snapshot:ID> | --all | -a} [workspace:PATH]`:** Forget the specified snapshot(s) for the given Workspace.
37
+
38
+ ## Archive
39
+
40
+ Clients recieve and save information from clients through their URL.
41
+
42
+ ### Types
43
+
44
+ Five distinct types, forming one chain from workspace to content:
45
+
46
+ | Type | Contents |
47
+ | ----------- | --------------------------------------- |
48
+ | `user` | Metadata about the user account |
49
+ | `workspace` | An array of snapshot hashes |
50
+ | `snapshot` | A manifest hash and an optional note |
51
+ | `manifest` | An array of reference hashes |
52
+ | `reference` | A relative file path and a content hash |
53
+ | `content` | Raw byte data |
54
+
55
+ ### Endpoints
56
+
57
+ - **`HEAD /type/<type:ID>`:** Check whether the Type ID already exists.
58
+ - **`GET /type/<type:ID>`:** Returns an object with the Type's data.
59
+ - **`PUT /type/<type:ID>`:** Sets the object with the Type's data.
60
+ - **`DELETE /type/<type:ID>`:** Forget the given Type ID.
61
+
62
+ ### Invariants
63
+
64
+ - All in workspace configuration, will be done under `./ev` in the workspace root.
65
+ - Archive URL + User ID login configurations are unique per workspace.
@@ -0,0 +1,52 @@
1
+ # Easysnapshot
2
+
3
+ ### Goals
4
+
5
+ - **Many Archives:** Store in many archives at once.
6
+ - **Emergent Complexity:** Small implementation surface with reasonable usage requirements.
7
+
8
+ ## Client
9
+
10
+ Clients gather and send information to archive endpoints.
11
+
12
+ ### Commands
13
+
14
+ - All commands accept `--help` / `-h`.
15
+ - All commands accept `--verbose` / `-v`.
16
+ - **`ev archive register <archive:URL> [user:ID]`:** Claim a specific User ID on the given archive. User ID is random if omitted.
17
+ - **`ev archive unregister <archive:URL> <user:ID>`:** Requests the archive to forget this User ID.
18
+ - **`ev archive login <archive:URL> <user:ID> [workspace:PATH]`:** Sets this combination of Archive URL + User ID as active for this workspace.
19
+ - **`ev archive logout <archive:URL> <user:ID> [workspace:PATH]`:** Sets this combination of Archive URL + User ID as unactive for this workspace.
20
+ - **`ev save <--note <note:TEXT> | -n <note:TEXT> [workspace:PATH]`:** Save the workspace's current state as a new Snapshot, with the note when provided.
21
+ - **`ev list [workspace:PATH]`:** List the Workspace's Snapshots. Also showing notes where set.
22
+ - **`ev clone <source:PATH> <target:PATH> [--snapshot <snapshot:ID> | -s <snapshot:ID>]`:** Clone the source Workspace to the target path, which may not exist yet. Clone the current workspace state if omitted.
23
+ - **`ev forget {--snapshot <snapshot:ID> | -v <snapshot:ID> | --all | -a} [workspace:PATH]`:** Forget the specified snapshot(s) for the given Workspace.
24
+
25
+ ## Archive
26
+
27
+ Clients recieve and save information from clients through their URL.
28
+
29
+ ### Types
30
+
31
+ Five distinct types, forming one chain from workspace to content:
32
+
33
+ | Type | Contents |
34
+ | ----------- | --------------------------------------- |
35
+ | `user` | Metadata about the user account |
36
+ | `workspace` | An array of snapshot hashes |
37
+ | `snapshot` | A manifest hash and an optional note |
38
+ | `manifest` | An array of reference hashes |
39
+ | `reference` | A relative file path and a content hash |
40
+ | `content` | Raw byte data |
41
+
42
+ ### Endpoints
43
+
44
+ - **`HEAD /type/<type:ID>`:** Check whether the Type ID already exists.
45
+ - **`GET /type/<type:ID>`:** Returns an object with the Type's data.
46
+ - **`PUT /type/<type:ID>`:** Sets the object with the Type's data.
47
+ - **`DELETE /type/<type:ID>`:** Forget the given Type ID.
48
+
49
+ ### Invariants
50
+
51
+ - All in workspace configuration, will be done under `./ev` in the workspace root.
52
+ - Archive URL + User ID login configurations are unique per workspace.
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "evserver"
3
+ version = "0.3.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "aiofiles>=25.1.0",
9
+ "dill>=0.4.1",
10
+ "fastapi[standard]>=0.140.7",
11
+ "pydantic>=2.13.4",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "Wannes Vantorre"
16
+ email = "vantorrewannes@gmail.com"
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 = [
28
+ "D203",
29
+ "D213",
30
+ "COM812",
31
+ "ISC001",
32
+ "CPY001",
33
+ "D419",
34
+ "D100",
35
+ "D101",
36
+ "D102",
37
+ "D103",
38
+ "D104",
39
+ "D107",
40
+ ]
41
+
42
+ [tool.ruff.lint.per-file-ignores]
43
+ "tests/**" = ["S101"]
44
+ "tests/unit/**" = ["SLF001"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "evserver"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,7 +9,6 @@ authors = [
9
9
  requires-python = ">=3.14"
10
10
  dependencies = [
11
11
  "aiofiles>=25.1.0",
12
- "blake3>=1.0.9",
13
12
  "dill>=0.4.1",
14
13
  "fastapi[standard]>=0.140.7",
15
14
  "pydantic>=2.13.4",
@@ -0,0 +1,351 @@
1
+ from collections.abc import AsyncGenerator # noqa: TC003
2
+ from contextlib import asynccontextmanager
3
+ from pathlib import Path
4
+ from typing import Annotated, TypedDict
5
+
6
+ import dill
7
+ import uvicorn
8
+ from fastapi import Depends, FastAPI, HTTPException, Request, Response
9
+
10
+ from evserver.stores import DirectoryStore
11
+ from evserver.types import (
12
+ Content,
13
+ ContentId,
14
+ Manifest,
15
+ ManifestId,
16
+ Reference,
17
+ ReferenceId,
18
+ Snapshot,
19
+ SnapshotId,
20
+ User,
21
+ UserId,
22
+ Workspace,
23
+ WorkspaceId,
24
+ )
25
+
26
+
27
+ class State(TypedDict):
28
+ user_store: DirectoryStore[UserId, User]
29
+ workspace_store: DirectoryStore[WorkspaceId, Workspace]
30
+ snapshot_store: DirectoryStore[SnapshotId, Snapshot]
31
+ manifest_store: DirectoryStore[ManifestId, Manifest]
32
+ reference_store: DirectoryStore[ReferenceId, Reference]
33
+ content_store: DirectoryStore[ContentId, Content]
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(_: FastAPI) -> AsyncGenerator[State]:
38
+ root_path = Path("data")
39
+ user_store = DirectoryStore[UserId, User](root_path / "users")
40
+ workspace_store = DirectoryStore[WorkspaceId, Workspace](root_path / "workspaces")
41
+ snapshot_store = DirectoryStore[SnapshotId, Snapshot](root_path / "snapshots")
42
+ manifest_store = DirectoryStore[ManifestId, Manifest](root_path / "manifests")
43
+ reference_store = DirectoryStore[ReferenceId, Reference](root_path / "references")
44
+ content_store = DirectoryStore[ContentId, Content](root_path / "contents")
45
+ yield State(
46
+ user_store=user_store,
47
+ workspace_store=workspace_store,
48
+ snapshot_store=snapshot_store,
49
+ manifest_store=manifest_store,
50
+ reference_store=reference_store,
51
+ content_store=content_store,
52
+ )
53
+
54
+
55
+ application = FastAPI(lifespan=lifespan)
56
+
57
+
58
+ def get_user_store(request: Request) -> DirectoryStore[UserId, User]:
59
+ return request.state.user_store
60
+
61
+
62
+ def get_workspace_store(request: Request) -> DirectoryStore[WorkspaceId, Workspace]:
63
+ return request.state.workspace_store
64
+
65
+
66
+ def get_snapshot_store(request: Request) -> DirectoryStore[SnapshotId, Snapshot]:
67
+ return request.state.snapshot_store
68
+
69
+
70
+ def get_manifest_store(request: Request) -> DirectoryStore[ManifestId, Manifest]:
71
+ return request.state.manifest_store
72
+
73
+
74
+ def get_reference_store(request: Request) -> DirectoryStore[ReferenceId, Reference]:
75
+ return request.state.reference_store
76
+
77
+
78
+ def get_content_store(request: Request) -> DirectoryStore[ContentId, Content]:
79
+ return request.state.content_store
80
+
81
+
82
+ UserStoreDependency = Annotated[DirectoryStore[UserId, User], Depends(get_user_store)]
83
+ WorkspaceStoreDependency = Annotated[
84
+ DirectoryStore[WorkspaceId, Workspace], Depends(get_workspace_store)
85
+ ]
86
+ SnapshotDependency = Annotated[
87
+ DirectoryStore[SnapshotId, Snapshot], Depends(get_snapshot_store)
88
+ ]
89
+ ManifestStoreDependency = Annotated[
90
+ DirectoryStore[ManifestId, Manifest], Depends(get_manifest_store)
91
+ ]
92
+ ReferenceStoreDependency = Annotated[
93
+ DirectoryStore[ReferenceId, Reference], Depends(get_reference_store)
94
+ ]
95
+ ContentStoreDependency = Annotated[
96
+ DirectoryStore[ContentId, Content], Depends(get_content_store)
97
+ ]
98
+
99
+
100
+ @application.head(
101
+ "/user/{user_id}",
102
+ )
103
+ async def head_user(user_id: UserId, user_store: UserStoreDependency) -> None:
104
+ if not await user_store.contains(user_id):
105
+ raise HTTPException(404)
106
+
107
+
108
+ @application.get(
109
+ "/user/{user_id}",
110
+ )
111
+ async def get_user(user_id: UserId, user_store: UserStoreDependency) -> Response:
112
+ if await user_store.contains(user_id):
113
+ return Response(dill.dumps(await user_store.get(user_id)))
114
+ raise HTTPException(404)
115
+
116
+
117
+ @application.put(
118
+ "/user/{user_id}",
119
+ )
120
+ async def put_user(
121
+ user_id: UserId, request: Request, user_store: UserStoreDependency
122
+ ) -> None:
123
+ user: User = dill.loads(await request.body()) # noqa: S301
124
+ await user_store.set(user_id, user)
125
+
126
+
127
+ @application.delete(
128
+ "/user/{user_id}",
129
+ )
130
+ async def delete_user(user_id: UserId, user_store: UserStoreDependency) -> None:
131
+ if user_store.contains(user_id):
132
+ await user_store.delete(user_id)
133
+
134
+
135
+ @application.head(
136
+ "/workspace/{workspace_id}",
137
+ )
138
+ async def head_workspace(
139
+ workspace_id: WorkspaceId, workspace_store: WorkspaceStoreDependency
140
+ ) -> None:
141
+ if not await workspace_store.contains(workspace_id):
142
+ raise HTTPException(404)
143
+
144
+
145
+ @application.get(
146
+ "/workspace/{workspace_id}",
147
+ )
148
+ async def get_workspace(
149
+ workspace_id: WorkspaceId, workspace_store: WorkspaceStoreDependency
150
+ ) -> Response:
151
+ if await workspace_store.contains(workspace_id):
152
+ return Response(dill.dumps(await workspace_store.get(workspace_id)))
153
+ raise HTTPException(404)
154
+
155
+
156
+ @application.put(
157
+ "/workspace/{workspace_id}",
158
+ )
159
+ async def put_workspace(
160
+ workspace_id: WorkspaceId,
161
+ request: Request,
162
+ workspace_store: WorkspaceStoreDependency,
163
+ ) -> None:
164
+ workspace: Workspace = dill.loads(await request.body()) # noqa: S301
165
+ await workspace_store.set(workspace_id, workspace)
166
+
167
+
168
+ @application.delete(
169
+ "/workspace/{workspace_id}",
170
+ )
171
+ async def delete_workspace(
172
+ workspace_id: WorkspaceId, workspace_store: WorkspaceStoreDependency
173
+ ) -> None:
174
+ if workspace_store.contains(workspace_id):
175
+ await workspace_store.delete(workspace_id)
176
+
177
+
178
+ @application.head(
179
+ "/snapshot/{snapshot_id}",
180
+ )
181
+ async def head_snapshot(
182
+ snapshot_id: SnapshotId, snapshot_store: SnapshotDependency
183
+ ) -> None:
184
+ if not await snapshot_store.contains(snapshot_id):
185
+ raise HTTPException(404)
186
+
187
+
188
+ @application.get(
189
+ "/snapshot/{snapshot_id}",
190
+ )
191
+ async def get_snapshot(
192
+ snapshot_id: SnapshotId, snapshot_store: SnapshotDependency
193
+ ) -> Response:
194
+ if await snapshot_store.contains(snapshot_id):
195
+ return Response(dill.dumps(await snapshot_store.get(snapshot_id)))
196
+ raise HTTPException(404)
197
+
198
+
199
+ @application.put(
200
+ "/snapshot/{snapshot_id}",
201
+ )
202
+ async def put_snapshot(
203
+ snapshot_id: SnapshotId,
204
+ request: Request,
205
+ snapshot_store: SnapshotDependency,
206
+ ) -> None:
207
+ snapshot: Snapshot = dill.loads(await request.body()) # noqa: S301
208
+ await snapshot_store.set(snapshot_id, snapshot)
209
+
210
+
211
+ @application.delete(
212
+ "/snapshot/{snapshot_id}",
213
+ )
214
+ async def delete_snapshot(
215
+ snapshot_id: SnapshotId, snapshot_store: SnapshotDependency
216
+ ) -> None:
217
+ if snapshot_store.contains(snapshot_id):
218
+ await snapshot_store.delete(snapshot_id)
219
+
220
+
221
+ @application.head(
222
+ "/manifest/{manifest_id}",
223
+ )
224
+ async def head_manifest(
225
+ manifest_id: ManifestId, manifest_store: ManifestStoreDependency
226
+ ) -> None:
227
+ if not await manifest_store.contains(manifest_id):
228
+ raise HTTPException(404)
229
+
230
+
231
+ @application.get(
232
+ "/manifest/{manifest_id}",
233
+ )
234
+ async def get_manifest(
235
+ manifest_id: ManifestId, manifest_store: ManifestStoreDependency
236
+ ) -> Response:
237
+ if await manifest_store.contains(manifest_id):
238
+ return Response(dill.dumps(await manifest_store.get(manifest_id)))
239
+ raise HTTPException(404)
240
+
241
+
242
+ @application.put(
243
+ "/manifest/{manifest_id}",
244
+ )
245
+ async def put_manifest(
246
+ manifest_id: ManifestId,
247
+ request: Request,
248
+ manifest_store: ManifestStoreDependency,
249
+ ) -> None:
250
+ manifest: Manifest = dill.loads(await request.body()) # noqa: S301
251
+ await manifest_store.set(manifest_id, manifest)
252
+
253
+
254
+ @application.delete(
255
+ "/manifest/{manifest_id}",
256
+ )
257
+ async def delete_manifest(
258
+ manifest_id: ManifestId, manifest_store: ManifestStoreDependency
259
+ ) -> None:
260
+ if manifest_store.contains(manifest_id):
261
+ await manifest_store.delete(manifest_id)
262
+
263
+
264
+ @application.head(
265
+ "/reference/{reference_id}",
266
+ )
267
+ async def head_reference(
268
+ reference_id: ReferenceId, reference_store: ReferenceStoreDependency
269
+ ) -> None:
270
+ if not await reference_store.contains(reference_id):
271
+ raise HTTPException(404)
272
+
273
+
274
+ @application.get(
275
+ "/reference/{reference_id}",
276
+ )
277
+ async def get_reference(
278
+ reference_id: ReferenceId, reference_store: ReferenceStoreDependency
279
+ ) -> Response:
280
+ if await reference_store.contains(reference_id):
281
+ return Response(dill.dumps(await reference_store.get(reference_id)))
282
+ raise HTTPException(404)
283
+
284
+
285
+ @application.put(
286
+ "/reference/{reference_id}",
287
+ )
288
+ async def put_reference(
289
+ reference_id: ReferenceId,
290
+ request: Request,
291
+ reference_store: ReferenceStoreDependency,
292
+ ) -> None:
293
+ reference: Reference = dill.loads(await request.body()) # noqa: S301
294
+ await reference_store.set(reference_id, reference)
295
+
296
+
297
+ @application.delete(
298
+ "/reference/{reference_id}",
299
+ )
300
+ async def delete_reference(
301
+ reference_id: ReferenceId, reference_store: ReferenceStoreDependency
302
+ ) -> None:
303
+ if reference_store.contains(reference_id):
304
+ await reference_store.delete(reference_id)
305
+
306
+
307
+ @application.head(
308
+ "/content/{content_id}",
309
+ )
310
+ async def head_content(
311
+ content_id: ContentId, content_store: ContentStoreDependency
312
+ ) -> None:
313
+ if not await content_store.contains(content_id):
314
+ raise HTTPException(404)
315
+
316
+
317
+ @application.get(
318
+ "/content/{content_id}",
319
+ )
320
+ async def get_content(
321
+ content_id: ContentId, content_store: ContentStoreDependency
322
+ ) -> Response:
323
+ if await content_store.contains(content_id):
324
+ return Response(dill.dumps(await content_store.get(content_id)))
325
+ raise HTTPException(404)
326
+
327
+
328
+ @application.put(
329
+ "/content/{content_id}",
330
+ )
331
+ async def put_content(
332
+ content_id: ContentId,
333
+ request: Request,
334
+ content_store: ContentStoreDependency,
335
+ ) -> None:
336
+ content: Content = dill.loads(await request.body()) # noqa: S301
337
+ await content_store.set(content_id, content)
338
+
339
+
340
+ @application.delete(
341
+ "/content/{content_id}",
342
+ )
343
+ async def delete_content(
344
+ content_id: ContentId, content_store: ContentStoreDependency
345
+ ) -> None:
346
+ if content_store.contains(content_id):
347
+ await content_store.delete(content_id)
348
+
349
+
350
+ def main() -> None:
351
+ uvicorn.run("evserver:application")
@@ -0,0 +1,33 @@
1
+ from pathlib import Path # noqa: TC003
2
+ from typing import TYPE_CHECKING
3
+
4
+ import dill
5
+
6
+ if TYPE_CHECKING:
7
+ from evserver.types import Id
8
+
9
+
10
+ class DirectoryStore[K, V]:
11
+ def __init__(self, root: Path) -> None:
12
+ root.mkdir(parents=True, exist_ok=True)
13
+ self.root = root
14
+
15
+ def _path(self, key: Id) -> Path:
16
+ return self.root / key
17
+
18
+ async def contains(self, key: Id) -> bool:
19
+ return self._path(key).exists()
20
+
21
+ async def set(self, key: Id, value: V) -> None:
22
+ path = self._path(key)
23
+ with path.open("wb") as f:
24
+ dill.dump(value, f)
25
+
26
+ async def get(self, key: Id) -> V:
27
+ path = self._path(key)
28
+ with path.open("rb") as f:
29
+ return dill.load(f) # noqa: S301
30
+
31
+ async def delete(self, key: Id) -> None:
32
+ path = self._path(key)
33
+ path.unlink()
@@ -0,0 +1,70 @@
1
+ from hashlib import sha3_512
2
+ from pathlib import Path # noqa: TC003
3
+
4
+ from pydantic.dataclasses import dataclass
5
+
6
+ type Id = str
7
+ UserId = Id
8
+ WorkspaceId = Id
9
+ SnapshotId = Id
10
+ ManifestId = Id
11
+ ReferenceId = Id
12
+ ContentId = Id
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class User:
17
+ id: UserId
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Content:
22
+ data: bytes
23
+
24
+ @property
25
+ def id(self) -> ContentId:
26
+ return sha3_512(self.data).hexdigest()
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class Reference:
31
+ file_path: Path
32
+ content_id: ContentId
33
+
34
+ @property
35
+ def id(self) -> ReferenceId:
36
+ return sha3_512(
37
+ str(self.file_path).encode() + self.content_id.encode()
38
+ ).hexdigest()
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class Manifest:
43
+ reference_ids: set[ReferenceId]
44
+
45
+ @property
46
+ def id(self) -> ManifestId:
47
+ manifest_id = sha3_512()
48
+ for reference_id in self.reference_ids:
49
+ manifest_id.update(reference_id.encode())
50
+ return manifest_id.hexdigest()
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class Snapshot:
55
+ manifest_id: ManifestId
56
+ note: str | None
57
+
58
+ @property
59
+ def id(self) -> SnapshotId:
60
+ snapshot_id = sha3_512()
61
+ snapshot_id.update(self.manifest_id.encode())
62
+ if self.note:
63
+ snapshot_id.update(self.note.encode())
64
+ return snapshot_id.hexdigest()
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class Workspace:
69
+ snapshot_ids: set[SnapshotId]
70
+ id: WorkspaceId