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.
@@ -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()