vesta-client 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.
- vesta_client-0.1.0/.gitignore +67 -0
- vesta_client-0.1.0/LICENSE +21 -0
- vesta_client-0.1.0/PKG-INFO +229 -0
- vesta_client-0.1.0/README.md +208 -0
- vesta_client-0.1.0/pyproject.toml +43 -0
- vesta_client-0.1.0/tests/test_device_groups.py +126 -0
- vesta_client-0.1.0/tests/test_federation.py +139 -0
- vesta_client-0.1.0/tests/test_limits.py +212 -0
- vesta_client-0.1.0/tests/test_outbox.py +376 -0
- vesta_client-0.1.0/tests/test_projections.py +188 -0
- vesta_client-0.1.0/tests/test_relay.py +215 -0
- vesta_client-0.1.0/tests/test_snapshots.py +165 -0
- vesta_client-0.1.0/vesta_client/__init__.py +173 -0
- vesta_client-0.1.0/vesta_client/connection.py +706 -0
- vesta_client-0.1.0/vesta_client/device_groups.py +303 -0
- vesta_client-0.1.0/vesta_client/events.py +53 -0
- vesta_client-0.1.0/vesta_client/federation.py +212 -0
- vesta_client-0.1.0/vesta_client/identity.py +132 -0
- vesta_client-0.1.0/vesta_client/limits.py +71 -0
- vesta_client-0.1.0/vesta_client/projection_store.py +141 -0
- vesta_client-0.1.0/vesta_client/projections.py +302 -0
- vesta_client-0.1.0/vesta_client/py.typed +0 -0
- vesta_client-0.1.0/vesta_client/relay.py +347 -0
- vesta_client-0.1.0/vesta_client/signing.py +125 -0
- vesta_client-0.1.0/vesta_client/storage.py +415 -0
- vesta_client-0.1.0/vesta_client/types.py +156 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
## Build results
|
|
2
|
+
[Bb]in/
|
|
3
|
+
[Oo]bj/
|
|
4
|
+
[Dd]ebug/
|
|
5
|
+
[Rr]elease/
|
|
6
|
+
x64/
|
|
7
|
+
x86/
|
|
8
|
+
build/
|
|
9
|
+
publish/
|
|
10
|
+
|
|
11
|
+
## .NET
|
|
12
|
+
*.user
|
|
13
|
+
*.userosscache
|
|
14
|
+
*.sln.docstates
|
|
15
|
+
*.suo
|
|
16
|
+
project.lock.json
|
|
17
|
+
|
|
18
|
+
## NuGet
|
|
19
|
+
*.nupkg
|
|
20
|
+
*.snupkg
|
|
21
|
+
**/[Pp]ackages/*
|
|
22
|
+
artifacts/
|
|
23
|
+
!**/[Pp]ackages/build/
|
|
24
|
+
|
|
25
|
+
## IDE
|
|
26
|
+
.vs/
|
|
27
|
+
*.swp
|
|
28
|
+
*~
|
|
29
|
+
.idea/
|
|
30
|
+
|
|
31
|
+
## OS
|
|
32
|
+
.DS_Store
|
|
33
|
+
Thumbs.db
|
|
34
|
+
desktop.ini
|
|
35
|
+
|
|
36
|
+
## User secrets
|
|
37
|
+
secrets.json
|
|
38
|
+
|
|
39
|
+
## Test results
|
|
40
|
+
TestResults/
|
|
41
|
+
coverage/
|
|
42
|
+
*.coverage
|
|
43
|
+
*.coveragexml
|
|
44
|
+
|
|
45
|
+
## Node (for vesta-client-ts)
|
|
46
|
+
node_modules/
|
|
47
|
+
dist/
|
|
48
|
+
*.tsbuildinfo
|
|
49
|
+
|
|
50
|
+
## Python (for vesta-client-py)
|
|
51
|
+
__pycache__/
|
|
52
|
+
*.py[cod]
|
|
53
|
+
*$py.class
|
|
54
|
+
*.egg-info/
|
|
55
|
+
.venv/
|
|
56
|
+
venv/
|
|
57
|
+
.pytest_cache/
|
|
58
|
+
.mypy_cache/
|
|
59
|
+
.ruff_cache/
|
|
60
|
+
|
|
61
|
+
## Docker
|
|
62
|
+
docker-compose.override.yml
|
|
63
|
+
|
|
64
|
+
## Environment
|
|
65
|
+
.env
|
|
66
|
+
.env.local
|
|
67
|
+
.env.*.local
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jesper Andersson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vesta-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client library for the Vesta protocol
|
|
5
|
+
Project-URL: Homepage, https://github.com/jesperandersson89/Vesta
|
|
6
|
+
Project-URL: Repository, https://github.com/jesperandersson89/Vesta.git
|
|
7
|
+
Project-URL: Issues, https://github.com/jesperandersson89/Vesta/issues
|
|
8
|
+
Author: Jesper Andersson
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: event-sourcing,protocol,vesta,websocket
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: cryptography>=42.0
|
|
19
|
+
Requires-Dist: websockets>=13.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# vesta-client (Python)
|
|
23
|
+
|
|
24
|
+
Python client library for the [Vesta protocol](../../PLANNING.md).
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install vesta-client
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import asyncio
|
|
36
|
+
from vesta_client import VestaConnection, create_event, load_or_create_identity
|
|
37
|
+
|
|
38
|
+
async def main():
|
|
39
|
+
client_id = load_or_create_identity("myapp-main-alice")
|
|
40
|
+
|
|
41
|
+
conn = VestaConnection(
|
|
42
|
+
server_url="ws://localhost:5150/ws",
|
|
43
|
+
client_id=client_id,
|
|
44
|
+
channels=["myapp/chat"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
conn.on_event(lambda msg: print(f"Event: {msg.event.event_type}"))
|
|
48
|
+
conn.on_connected(lambda welcome: print(f"Connected to {welcome.server_id}"))
|
|
49
|
+
|
|
50
|
+
await conn.connect()
|
|
51
|
+
|
|
52
|
+
# Publish
|
|
53
|
+
event = create_event(
|
|
54
|
+
channel_id="myapp/chat",
|
|
55
|
+
client_id=client_id,
|
|
56
|
+
event_type="app.chat.message",
|
|
57
|
+
payload={"text": "Hello!", "username": "alice"},
|
|
58
|
+
)
|
|
59
|
+
await conn.publish(event)
|
|
60
|
+
|
|
61
|
+
# Keep running
|
|
62
|
+
await asyncio.Event().wait()
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `VestaConnection`
|
|
70
|
+
|
|
71
|
+
Async WebSocket connection with auto-reconnect.
|
|
72
|
+
|
|
73
|
+
#### Constructor
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
VestaConnection(
|
|
77
|
+
server_url: str,
|
|
78
|
+
client_id: str,
|
|
79
|
+
channels: list[str],
|
|
80
|
+
auto_reconnect: bool = True,
|
|
81
|
+
initial_reconnect_delay: float = 1.0,
|
|
82
|
+
max_reconnect_delay: float = 30.0,
|
|
83
|
+
last_sequences: dict[str, int] | None = None,
|
|
84
|
+
public_key: str | None = None,
|
|
85
|
+
identity: VestaIdentity | None = None, # enables device-group helpers
|
|
86
|
+
local_store: ClientEventStore | None = None, # offline outbox + event cache
|
|
87
|
+
relay_directory: RelayDirectory | None = None, # manifest verification + relay failover
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### Methods
|
|
92
|
+
|
|
93
|
+
- `await connect()` — Open connection and handshake
|
|
94
|
+
- `await disconnect()` — Gracefully close
|
|
95
|
+
- `await dispose()` — Permanently dispose
|
|
96
|
+
- `await publish(event)` — Publish a VestaEvent
|
|
97
|
+
- `await subscribe(channel_id, from_sequence=None)` — Subscribe
|
|
98
|
+
- `await unsubscribe(channel_id)` — Unsubscribe
|
|
99
|
+
- `await fetch(channel_id, from_sequence, to_sequence=None, limit=None)` — Fetch history
|
|
100
|
+
- `update_sequence(channel_id, sequence)` — Update catch-up position
|
|
101
|
+
- `await delete_channel(channel_id)` — Soft-delete a channel
|
|
102
|
+
- `await register_app(app_id)` — Register an app namespace (needed when the relay requires app registration)
|
|
103
|
+
- `set_user_relay_override(url)` / `clear_user_relay_override()` — Persist or clear the user's manual relay choice (requires `relay_directory`)
|
|
104
|
+
- `await switch_relay(url)` — Switch to a specific relay from the current candidate list and reconnect
|
|
105
|
+
|
|
106
|
+
**Device group helpers** (require `identity` in constructor):
|
|
107
|
+
|
|
108
|
+
- `await create_device_group(device_name=None)` — Create a new group, publish an announce, return `group_id`
|
|
109
|
+
- `await link_device(group_id, target_public_key, reason=None)` — Vouch for another device
|
|
110
|
+
- `await join_device_group(group_id, device_name=None)` — Announce this device joining an existing group
|
|
111
|
+
- `await unlink_device(group_id, target_public_key, reason=None)` — Remove a device from the group
|
|
112
|
+
- `await get_device_group_members(group_id, timeout=5.0)` — Replay the identity channel and return current membership as `DeviceGroup`
|
|
113
|
+
|
|
114
|
+
#### Event callbacks
|
|
115
|
+
|
|
116
|
+
- `on_event(callback)` — Real-time event received
|
|
117
|
+
- `on_events_batch(callback)` — Batch of events received
|
|
118
|
+
- `on_ack(callback)` — Publish acknowledged
|
|
119
|
+
- `on_error(callback)` — Raw server error
|
|
120
|
+
- `on_limited(callback)` — Semantic "your app is being limited" signal — see "Limit notices" below
|
|
121
|
+
- `on_connected(callback)` — Connection established
|
|
122
|
+
- `on_reconnected(callback)` — Fired instead of/alongside `on_connected` on a *subsequent* WELCOME
|
|
123
|
+
- `on_disconnected(callback)` — Connection lost
|
|
124
|
+
- `on_relay_switched(callback)` — The active relay changed
|
|
125
|
+
- `on_manifest_applied(callback)` — A newer owner-signed relay manifest was adopted
|
|
126
|
+
|
|
127
|
+
### Limit notices
|
|
128
|
+
|
|
129
|
+
When the relay refuses a publish (quota, rate limit, unregistered app, ACL), the raw error is
|
|
130
|
+
still delivered via `on_error`, but `classify_error_code(code)` (also run internally) tells you
|
|
131
|
+
whether it's worth surfacing to the user and whether retrying can ever succeed:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
conn.on_limited(lambda notice: print(notice.code, notice.message, "transient:", notice.is_transient))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
When `local_store` is configured and the limit is **not transient** (e.g. `QUOTA_EXCEEDED`,
|
|
138
|
+
`UNKNOWN_APP`, `ACCESS_DENIED`), the matching outbox entry is automatically marked `"rejected"`
|
|
139
|
+
via `mark_outbox_rejected` so it is dead-lettered instead of retried forever.
|
|
140
|
+
|
|
141
|
+
### Offline & persistence
|
|
142
|
+
|
|
143
|
+
`ClientEventStore` caches received events and queues outbox publishes made while disconnected.
|
|
144
|
+
The package ships `InMemoryClientEventStore` (non-persistent) and `SqliteClientEventStore`
|
|
145
|
+
(stdlib `sqlite3`, durable across restarts):
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from vesta_client import SqliteClientEventStore
|
|
149
|
+
|
|
150
|
+
local_store = SqliteClientEventStore("my-app-cache.db")
|
|
151
|
+
conn = VestaConnection(server_url="ws://localhost:5150/ws", client_id=client_id,
|
|
152
|
+
channels=["myapp/chat"], local_store=local_store)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Projection snapshots
|
|
156
|
+
|
|
157
|
+
Every built-in reducer (`AppendOnlyLog`, `LwwRegister`, `LwwMap`) supports `snapshot()` /
|
|
158
|
+
`restore()` so a projection can resume from its last sequence instead of replaying the whole
|
|
159
|
+
channel on cold start. Persist snapshots with a `ProjectionStore` — `InMemoryProjectionStore` or
|
|
160
|
+
`SqliteProjectionStore`:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from vesta_client import LwwMap, restore_projection, save_projection
|
|
164
|
+
from vesta_client.projection_store import SqliteProjectionStore
|
|
165
|
+
|
|
166
|
+
store = SqliteProjectionStore("my-app-snapshots.db")
|
|
167
|
+
presence = LwwMap(project)
|
|
168
|
+
|
|
169
|
+
await restore_projection(store, channel_id, "presence", presence)
|
|
170
|
+
await conn.fetch(channel_id, presence.last_sequence + 1)
|
|
171
|
+
# ...later, e.g. on shutdown:
|
|
172
|
+
await save_projection(store, channel_id, "presence", presence)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A reducer that hasn't opted in raises `SnapshotNotSupportedError` — override `snapshot()` /
|
|
176
|
+
`_restore_state()` on a custom `EventReducer` subclass to add support.
|
|
177
|
+
|
|
178
|
+
### Relay independence
|
|
179
|
+
|
|
180
|
+
`RelayDirectory` resolves an ordered relay candidate list from a user override, the latest
|
|
181
|
+
verified owner-signed manifest, and the app's compiled-in defaults (`VestaAppConfig`). Attach one
|
|
182
|
+
to get manifest verification/adoption and `set_user_relay_override()` / `clear_user_relay_override()`:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from vesta_client import RelayDirectory, VestaAppConfig, FileRelayOverrideStore, FileManifestStore
|
|
186
|
+
|
|
187
|
+
app_config = VestaAppConfig(app_id="myapp", owner_public_key="...", default_relays=["wss://relay.example/ws"])
|
|
188
|
+
relay_directory = RelayDirectory(
|
|
189
|
+
app_config,
|
|
190
|
+
FileRelayOverrideStore("~/.vesta/relays/myapp.override.json"),
|
|
191
|
+
FileManifestStore("~/.vesta/relays/myapp.manifest.json"),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
conn = VestaConnection(
|
|
195
|
+
relays=relay_directory.resolve_candidates(),
|
|
196
|
+
client_id=client_id, channels=["myapp/chat"],
|
|
197
|
+
relay_directory=relay_directory,
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`InMemoryRelayOverrideStore` / `InMemoryManifestStore` are also available for tests or transient
|
|
202
|
+
sessions.
|
|
203
|
+
|
|
204
|
+
### Federation (server-to-server discovery)
|
|
205
|
+
|
|
206
|
+
When every relay in the candidate list is failing and no fresher manifest is available,
|
|
207
|
+
`FederationClient` asks any reachable discovery-enabled relay which relays host your app:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from vesta_client import FederationClient
|
|
211
|
+
|
|
212
|
+
federation = FederationClient(app_config)
|
|
213
|
+
base = FederationClient.to_federation_base_url("wss://relay.example/ws") # "https://relay.example/"
|
|
214
|
+
relays = await federation.discover_relays_for_app(base)
|
|
215
|
+
# Show-only: the user adopts one manually via conn.set_user_relay_override(url).
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Every descriptor is verified (self-signature) and cross-checked against the app's owner
|
|
219
|
+
(`owner_client_id` must match `derive_client_id(app_config.owner_public_key)`) — a relay cannot
|
|
220
|
+
spoof hosting your app under a different owner. Uses the stdlib `urllib.request` internally — no
|
|
221
|
+
extra dependency.
|
|
222
|
+
|
|
223
|
+
### `create_event(channel_id, client_id, event_type, payload, **kwargs)`
|
|
224
|
+
|
|
225
|
+
Create a `VestaEvent` with a UUID and current timestamp.
|
|
226
|
+
|
|
227
|
+
### `load_or_create_identity(prefix)`
|
|
228
|
+
|
|
229
|
+
Persist a stable clientId in `~/.vesta/{prefix}-identity.json`.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# vesta-client (Python)
|
|
2
|
+
|
|
3
|
+
Python client library for the [Vesta protocol](../../PLANNING.md).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install vesta-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from vesta_client import VestaConnection, create_event, load_or_create_identity
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
client_id = load_or_create_identity("myapp-main-alice")
|
|
19
|
+
|
|
20
|
+
conn = VestaConnection(
|
|
21
|
+
server_url="ws://localhost:5150/ws",
|
|
22
|
+
client_id=client_id,
|
|
23
|
+
channels=["myapp/chat"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
conn.on_event(lambda msg: print(f"Event: {msg.event.event_type}"))
|
|
27
|
+
conn.on_connected(lambda welcome: print(f"Connected to {welcome.server_id}"))
|
|
28
|
+
|
|
29
|
+
await conn.connect()
|
|
30
|
+
|
|
31
|
+
# Publish
|
|
32
|
+
event = create_event(
|
|
33
|
+
channel_id="myapp/chat",
|
|
34
|
+
client_id=client_id,
|
|
35
|
+
event_type="app.chat.message",
|
|
36
|
+
payload={"text": "Hello!", "username": "alice"},
|
|
37
|
+
)
|
|
38
|
+
await conn.publish(event)
|
|
39
|
+
|
|
40
|
+
# Keep running
|
|
41
|
+
await asyncio.Event().wait()
|
|
42
|
+
|
|
43
|
+
asyncio.run(main())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
### `VestaConnection`
|
|
49
|
+
|
|
50
|
+
Async WebSocket connection with auto-reconnect.
|
|
51
|
+
|
|
52
|
+
#### Constructor
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
VestaConnection(
|
|
56
|
+
server_url: str,
|
|
57
|
+
client_id: str,
|
|
58
|
+
channels: list[str],
|
|
59
|
+
auto_reconnect: bool = True,
|
|
60
|
+
initial_reconnect_delay: float = 1.0,
|
|
61
|
+
max_reconnect_delay: float = 30.0,
|
|
62
|
+
last_sequences: dict[str, int] | None = None,
|
|
63
|
+
public_key: str | None = None,
|
|
64
|
+
identity: VestaIdentity | None = None, # enables device-group helpers
|
|
65
|
+
local_store: ClientEventStore | None = None, # offline outbox + event cache
|
|
66
|
+
relay_directory: RelayDirectory | None = None, # manifest verification + relay failover
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### Methods
|
|
71
|
+
|
|
72
|
+
- `await connect()` — Open connection and handshake
|
|
73
|
+
- `await disconnect()` — Gracefully close
|
|
74
|
+
- `await dispose()` — Permanently dispose
|
|
75
|
+
- `await publish(event)` — Publish a VestaEvent
|
|
76
|
+
- `await subscribe(channel_id, from_sequence=None)` — Subscribe
|
|
77
|
+
- `await unsubscribe(channel_id)` — Unsubscribe
|
|
78
|
+
- `await fetch(channel_id, from_sequence, to_sequence=None, limit=None)` — Fetch history
|
|
79
|
+
- `update_sequence(channel_id, sequence)` — Update catch-up position
|
|
80
|
+
- `await delete_channel(channel_id)` — Soft-delete a channel
|
|
81
|
+
- `await register_app(app_id)` — Register an app namespace (needed when the relay requires app registration)
|
|
82
|
+
- `set_user_relay_override(url)` / `clear_user_relay_override()` — Persist or clear the user's manual relay choice (requires `relay_directory`)
|
|
83
|
+
- `await switch_relay(url)` — Switch to a specific relay from the current candidate list and reconnect
|
|
84
|
+
|
|
85
|
+
**Device group helpers** (require `identity` in constructor):
|
|
86
|
+
|
|
87
|
+
- `await create_device_group(device_name=None)` — Create a new group, publish an announce, return `group_id`
|
|
88
|
+
- `await link_device(group_id, target_public_key, reason=None)` — Vouch for another device
|
|
89
|
+
- `await join_device_group(group_id, device_name=None)` — Announce this device joining an existing group
|
|
90
|
+
- `await unlink_device(group_id, target_public_key, reason=None)` — Remove a device from the group
|
|
91
|
+
- `await get_device_group_members(group_id, timeout=5.0)` — Replay the identity channel and return current membership as `DeviceGroup`
|
|
92
|
+
|
|
93
|
+
#### Event callbacks
|
|
94
|
+
|
|
95
|
+
- `on_event(callback)` — Real-time event received
|
|
96
|
+
- `on_events_batch(callback)` — Batch of events received
|
|
97
|
+
- `on_ack(callback)` — Publish acknowledged
|
|
98
|
+
- `on_error(callback)` — Raw server error
|
|
99
|
+
- `on_limited(callback)` — Semantic "your app is being limited" signal — see "Limit notices" below
|
|
100
|
+
- `on_connected(callback)` — Connection established
|
|
101
|
+
- `on_reconnected(callback)` — Fired instead of/alongside `on_connected` on a *subsequent* WELCOME
|
|
102
|
+
- `on_disconnected(callback)` — Connection lost
|
|
103
|
+
- `on_relay_switched(callback)` — The active relay changed
|
|
104
|
+
- `on_manifest_applied(callback)` — A newer owner-signed relay manifest was adopted
|
|
105
|
+
|
|
106
|
+
### Limit notices
|
|
107
|
+
|
|
108
|
+
When the relay refuses a publish (quota, rate limit, unregistered app, ACL), the raw error is
|
|
109
|
+
still delivered via `on_error`, but `classify_error_code(code)` (also run internally) tells you
|
|
110
|
+
whether it's worth surfacing to the user and whether retrying can ever succeed:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
conn.on_limited(lambda notice: print(notice.code, notice.message, "transient:", notice.is_transient))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
When `local_store` is configured and the limit is **not transient** (e.g. `QUOTA_EXCEEDED`,
|
|
117
|
+
`UNKNOWN_APP`, `ACCESS_DENIED`), the matching outbox entry is automatically marked `"rejected"`
|
|
118
|
+
via `mark_outbox_rejected` so it is dead-lettered instead of retried forever.
|
|
119
|
+
|
|
120
|
+
### Offline & persistence
|
|
121
|
+
|
|
122
|
+
`ClientEventStore` caches received events and queues outbox publishes made while disconnected.
|
|
123
|
+
The package ships `InMemoryClientEventStore` (non-persistent) and `SqliteClientEventStore`
|
|
124
|
+
(stdlib `sqlite3`, durable across restarts):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from vesta_client import SqliteClientEventStore
|
|
128
|
+
|
|
129
|
+
local_store = SqliteClientEventStore("my-app-cache.db")
|
|
130
|
+
conn = VestaConnection(server_url="ws://localhost:5150/ws", client_id=client_id,
|
|
131
|
+
channels=["myapp/chat"], local_store=local_store)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Projection snapshots
|
|
135
|
+
|
|
136
|
+
Every built-in reducer (`AppendOnlyLog`, `LwwRegister`, `LwwMap`) supports `snapshot()` /
|
|
137
|
+
`restore()` so a projection can resume from its last sequence instead of replaying the whole
|
|
138
|
+
channel on cold start. Persist snapshots with a `ProjectionStore` — `InMemoryProjectionStore` or
|
|
139
|
+
`SqliteProjectionStore`:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from vesta_client import LwwMap, restore_projection, save_projection
|
|
143
|
+
from vesta_client.projection_store import SqliteProjectionStore
|
|
144
|
+
|
|
145
|
+
store = SqliteProjectionStore("my-app-snapshots.db")
|
|
146
|
+
presence = LwwMap(project)
|
|
147
|
+
|
|
148
|
+
await restore_projection(store, channel_id, "presence", presence)
|
|
149
|
+
await conn.fetch(channel_id, presence.last_sequence + 1)
|
|
150
|
+
# ...later, e.g. on shutdown:
|
|
151
|
+
await save_projection(store, channel_id, "presence", presence)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A reducer that hasn't opted in raises `SnapshotNotSupportedError` — override `snapshot()` /
|
|
155
|
+
`_restore_state()` on a custom `EventReducer` subclass to add support.
|
|
156
|
+
|
|
157
|
+
### Relay independence
|
|
158
|
+
|
|
159
|
+
`RelayDirectory` resolves an ordered relay candidate list from a user override, the latest
|
|
160
|
+
verified owner-signed manifest, and the app's compiled-in defaults (`VestaAppConfig`). Attach one
|
|
161
|
+
to get manifest verification/adoption and `set_user_relay_override()` / `clear_user_relay_override()`:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from vesta_client import RelayDirectory, VestaAppConfig, FileRelayOverrideStore, FileManifestStore
|
|
165
|
+
|
|
166
|
+
app_config = VestaAppConfig(app_id="myapp", owner_public_key="...", default_relays=["wss://relay.example/ws"])
|
|
167
|
+
relay_directory = RelayDirectory(
|
|
168
|
+
app_config,
|
|
169
|
+
FileRelayOverrideStore("~/.vesta/relays/myapp.override.json"),
|
|
170
|
+
FileManifestStore("~/.vesta/relays/myapp.manifest.json"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
conn = VestaConnection(
|
|
174
|
+
relays=relay_directory.resolve_candidates(),
|
|
175
|
+
client_id=client_id, channels=["myapp/chat"],
|
|
176
|
+
relay_directory=relay_directory,
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`InMemoryRelayOverrideStore` / `InMemoryManifestStore` are also available for tests or transient
|
|
181
|
+
sessions.
|
|
182
|
+
|
|
183
|
+
### Federation (server-to-server discovery)
|
|
184
|
+
|
|
185
|
+
When every relay in the candidate list is failing and no fresher manifest is available,
|
|
186
|
+
`FederationClient` asks any reachable discovery-enabled relay which relays host your app:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from vesta_client import FederationClient
|
|
190
|
+
|
|
191
|
+
federation = FederationClient(app_config)
|
|
192
|
+
base = FederationClient.to_federation_base_url("wss://relay.example/ws") # "https://relay.example/"
|
|
193
|
+
relays = await federation.discover_relays_for_app(base)
|
|
194
|
+
# Show-only: the user adopts one manually via conn.set_user_relay_override(url).
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Every descriptor is verified (self-signature) and cross-checked against the app's owner
|
|
198
|
+
(`owner_client_id` must match `derive_client_id(app_config.owner_public_key)`) — a relay cannot
|
|
199
|
+
spoof hosting your app under a different owner. Uses the stdlib `urllib.request` internally — no
|
|
200
|
+
extra dependency.
|
|
201
|
+
|
|
202
|
+
### `create_event(channel_id, client_id, event_type, payload, **kwargs)`
|
|
203
|
+
|
|
204
|
+
Create a `VestaEvent` with a UUID and current timestamp.
|
|
205
|
+
|
|
206
|
+
### `load_or_create_identity(prefix)`
|
|
207
|
+
|
|
208
|
+
Persist a stable clientId in `~/.vesta/{prefix}-identity.json`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vesta-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client library for the Vesta protocol"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"websockets>=13.0",
|
|
9
|
+
"cryptography>=42.0",
|
|
10
|
+
]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Jesper Andersson" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["vesta", "protocol", "websocket", "event-sourcing"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/jesperandersson89/Vesta"
|
|
27
|
+
Repository = "https://github.com/jesperandersson89/Vesta.git"
|
|
28
|
+
Issues = "https://github.com/jesperandersson89/Vesta/issues"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling>=1.26"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["vesta_client"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = [
|
|
39
|
+
"vesta_client",
|
|
40
|
+
"tests",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Tests for vesta_client.device_groups — mirrors VestaCore.Tests.Identity.DeviceGroupProjectionTests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from vesta_client import (
|
|
8
|
+
DeviceGroupProjection,
|
|
9
|
+
PairingPayload,
|
|
10
|
+
VestaIdentity,
|
|
11
|
+
build_announce,
|
|
12
|
+
build_link,
|
|
13
|
+
build_unlink,
|
|
14
|
+
device_group_channel,
|
|
15
|
+
generate_group_id,
|
|
16
|
+
is_protocol_channel,
|
|
17
|
+
)
|
|
18
|
+
from vesta_client.identity import b64url_encode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GenerateGroupIdTests(unittest.TestCase):
|
|
22
|
+
def test_returns_32_hex_chars(self):
|
|
23
|
+
gid = generate_group_id()
|
|
24
|
+
self.assertEqual(32, len(gid))
|
|
25
|
+
self.assertTrue(all(c in "0123456789abcdef" for c in gid))
|
|
26
|
+
|
|
27
|
+
def test_channel_id_uses_reserved_prefix(self):
|
|
28
|
+
channel = device_group_channel("abc123")
|
|
29
|
+
self.assertEqual("vesta/identity/abc123", channel)
|
|
30
|
+
self.assertTrue(is_protocol_channel(channel))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeviceGroupProjectionTests(unittest.TestCase):
|
|
34
|
+
def test_founder_announce_becomes_trusted_member(self):
|
|
35
|
+
founder = VestaIdentity.generate()
|
|
36
|
+
gid = generate_group_id()
|
|
37
|
+
projection = DeviceGroupProjection(gid)
|
|
38
|
+
|
|
39
|
+
projection.apply_local(build_announce(founder, gid))
|
|
40
|
+
|
|
41
|
+
state = projection.state
|
|
42
|
+
self.assertEqual(1, len(state.members))
|
|
43
|
+
self.assertTrue(state.is_member(founder.client_id))
|
|
44
|
+
|
|
45
|
+
def test_link_from_trusted_member_adds_target(self):
|
|
46
|
+
founder = VestaIdentity.generate()
|
|
47
|
+
new_device = VestaIdentity.generate()
|
|
48
|
+
gid = generate_group_id()
|
|
49
|
+
projection = DeviceGroupProjection(gid)
|
|
50
|
+
|
|
51
|
+
projection.apply_local(build_announce(founder, gid))
|
|
52
|
+
projection.apply_local(build_link(founder, gid, new_device.public_key))
|
|
53
|
+
|
|
54
|
+
state = projection.state
|
|
55
|
+
self.assertEqual(2, len(state.members))
|
|
56
|
+
self.assertTrue(state.is_member(founder.client_id))
|
|
57
|
+
self.assertTrue(state.is_member(new_device.client_id))
|
|
58
|
+
|
|
59
|
+
def test_link_from_untrusted_device_is_ignored(self):
|
|
60
|
+
founder = VestaIdentity.generate()
|
|
61
|
+
outsider = VestaIdentity.generate()
|
|
62
|
+
new_device = VestaIdentity.generate()
|
|
63
|
+
gid = generate_group_id()
|
|
64
|
+
projection = DeviceGroupProjection(gid)
|
|
65
|
+
|
|
66
|
+
projection.apply_local(build_announce(founder, gid))
|
|
67
|
+
projection.apply_local(build_link(outsider, gid, new_device.public_key))
|
|
68
|
+
|
|
69
|
+
state = projection.state
|
|
70
|
+
self.assertEqual(1, len(state.members))
|
|
71
|
+
self.assertFalse(state.is_member(new_device.client_id))
|
|
72
|
+
self.assertFalse(state.is_member(outsider.client_id))
|
|
73
|
+
|
|
74
|
+
def test_transitive_link_propagates_trust(self):
|
|
75
|
+
founder = VestaIdentity.generate()
|
|
76
|
+
device_b = VestaIdentity.generate()
|
|
77
|
+
device_c = VestaIdentity.generate()
|
|
78
|
+
gid = generate_group_id()
|
|
79
|
+
projection = DeviceGroupProjection(gid)
|
|
80
|
+
|
|
81
|
+
projection.apply_local(build_announce(founder, gid))
|
|
82
|
+
projection.apply_local(build_link(founder, gid, device_b.public_key))
|
|
83
|
+
projection.apply_local(build_link(device_b, gid, device_c.public_key))
|
|
84
|
+
|
|
85
|
+
state = projection.state
|
|
86
|
+
self.assertEqual(3, len(state.members))
|
|
87
|
+
self.assertTrue(state.is_member(device_c.client_id))
|
|
88
|
+
|
|
89
|
+
def test_unlink_removes_target(self):
|
|
90
|
+
founder = VestaIdentity.generate()
|
|
91
|
+
device_b = VestaIdentity.generate()
|
|
92
|
+
gid = generate_group_id()
|
|
93
|
+
projection = DeviceGroupProjection(gid)
|
|
94
|
+
|
|
95
|
+
projection.apply_local(build_announce(founder, gid))
|
|
96
|
+
projection.apply_local(build_link(founder, gid, device_b.public_key))
|
|
97
|
+
self.assertTrue(projection.state.is_member(device_b.client_id))
|
|
98
|
+
|
|
99
|
+
projection.apply_local(build_unlink(founder, gid, device_b.public_key))
|
|
100
|
+
self.assertFalse(projection.state.is_member(device_b.client_id))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PairingPayloadTests(unittest.TestCase):
|
|
104
|
+
def test_round_trip(self):
|
|
105
|
+
identity = VestaIdentity.generate()
|
|
106
|
+
payload = PairingPayload(
|
|
107
|
+
group_id="abc123",
|
|
108
|
+
public_key=b64url_encode(identity.public_key),
|
|
109
|
+
server_url="wss://vesta.example/ws",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
encoded = payload.to_base64()
|
|
113
|
+
decoded = PairingPayload.from_base64(encoded)
|
|
114
|
+
|
|
115
|
+
self.assertEqual(payload.group_id, decoded.group_id)
|
|
116
|
+
self.assertEqual(payload.public_key, decoded.public_key)
|
|
117
|
+
self.assertEqual(payload.server_url, decoded.server_url)
|
|
118
|
+
|
|
119
|
+
def test_round_trip_without_server_url(self):
|
|
120
|
+
payload = PairingPayload(group_id="g", public_key="pk")
|
|
121
|
+
decoded = PairingPayload.from_base64(payload.to_base64())
|
|
122
|
+
self.assertIsNone(decoded.server_url)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
unittest.main()
|