hivellm-thunder 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.
- hivellm_thunder-0.1.0/.gitignore +40 -0
- hivellm_thunder-0.1.0/PKG-INFO +154 -0
- hivellm_thunder-0.1.0/README.md +138 -0
- hivellm_thunder-0.1.0/pyproject.toml +35 -0
- hivellm_thunder-0.1.0/requirements.txt +4 -0
- hivellm_thunder-0.1.0/tests/conftest.py +12 -0
- hivellm_thunder-0.1.0/tests/mockserver.py +121 -0
- hivellm_thunder-0.1.0/tests/test_client_async.py +579 -0
- hivellm_thunder-0.1.0/tests/test_client_sync.py +578 -0
- hivellm_thunder-0.1.0/tests/test_corpus.py +179 -0
- hivellm_thunder-0.1.0/tests/test_endpoint.py +95 -0
- hivellm_thunder-0.1.0/tests/test_errors.py +79 -0
- hivellm_thunder-0.1.0/tests/test_standard_config.py +136 -0
- hivellm_thunder-0.1.0/tests/test_wire.py +171 -0
- hivellm_thunder-0.1.0/thunder_rpc/__init__.py +86 -0
- hivellm_thunder-0.1.0/thunder_rpc/_handshake.py +103 -0
- hivellm_thunder-0.1.0/thunder_rpc/aio.py +461 -0
- hivellm_thunder-0.1.0/thunder_rpc/client.py +496 -0
- hivellm_thunder-0.1.0/thunder_rpc/client_config.py +74 -0
- hivellm_thunder-0.1.0/thunder_rpc/config.py +214 -0
- hivellm_thunder-0.1.0/thunder_rpc/endpoint.py +115 -0
- hivellm_thunder-0.1.0/thunder_rpc/errors.py +152 -0
- hivellm_thunder-0.1.0/thunder_rpc/value.py +200 -0
- hivellm_thunder-0.1.0/thunder_rpc/wire.py +271 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Rulebook - ignore runtime data, keep specs and tasks
|
|
2
|
+
/.rulebook/*
|
|
3
|
+
!/.rulebook/specs/
|
|
4
|
+
!/.rulebook/tasks/
|
|
5
|
+
!/.rulebook/tasks/**/*.md
|
|
6
|
+
!/.rulebook/rulebook.json
|
|
7
|
+
|
|
8
|
+
# Patterns added by @hivellm/rulebook
|
|
9
|
+
# Added at: 2026-07-17T04:49:22.423Z
|
|
10
|
+
|
|
11
|
+
*.log
|
|
12
|
+
*.swo
|
|
13
|
+
*.swp
|
|
14
|
+
*~
|
|
15
|
+
.DS_Store
|
|
16
|
+
.cache/
|
|
17
|
+
.env
|
|
18
|
+
.env.*.local
|
|
19
|
+
.env.local
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
Thumbs.db
|
|
23
|
+
logs/
|
|
24
|
+
temp/
|
|
25
|
+
tmp/
|
|
26
|
+
|
|
27
|
+
CLAUDE.local.md
|
|
28
|
+
.rulebook/backup/
|
|
29
|
+
|
|
30
|
+
# Build artifacts
|
|
31
|
+
rust/target/
|
|
32
|
+
target/
|
|
33
|
+
node_modules/
|
|
34
|
+
dist/
|
|
35
|
+
__pycache__/
|
|
36
|
+
*.pyc
|
|
37
|
+
.venv/
|
|
38
|
+
*.egg-info/
|
|
39
|
+
csharp/**/bin/
|
|
40
|
+
csharp/**/obj/
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivellm-thunder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HiveLLM binary RPC (Thunder) - wire v1 codec, family profiles, sync/async multiplexed clients
|
|
5
|
+
Project-URL: Homepage, https://github.com/hivellm/thunder
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: hivellm,messagepack,msgpack,rpc,thunder
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: msgpack>=1.1
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Requires-Dist: pyyaml>=6; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# hivellm-thunder
|
|
18
|
+
|
|
19
|
+
**⚡ The HiveLLM binary RPC protocol for Python — wire v1 (frozen), one configurable standard, sync + async multiplexed clients**
|
|
20
|
+
|
|
21
|
+
Thunder is the shared home of the HiveLLM binary RPC standard: a length-prefixed
|
|
22
|
+
MessagePack protocol (`u32 LE length` + body) multiplexing concurrent requests over one
|
|
23
|
+
persistent TCP connection. This package is the Python implementation — import name
|
|
24
|
+
`thunder_rpc`, one runtime dependency (`msgpack`), conformance-tested against the
|
|
25
|
+
language-neutral golden-vector corpus in [`conformance/`](../conformance/).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install hivellm-thunder
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart — sync
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from thunder_rpc import Client, ClientConfig, Config, Credentials, Value
|
|
37
|
+
|
|
38
|
+
# Your application's identity on top of the family standard.
|
|
39
|
+
config = Config.standard().with_scheme("myapp").with_port(9000)
|
|
40
|
+
|
|
41
|
+
client_config = ClientConfig(credentials=Credentials.api_key("secret-key"))
|
|
42
|
+
with Client.connect("myapp://localhost", config, client_config) as client:
|
|
43
|
+
pong = client.call("PING")
|
|
44
|
+
assert pong.as_str() == "PONG"
|
|
45
|
+
|
|
46
|
+
hits = client.call("SEARCH", [Value.str("docs"), Value.bytes(embedding)], timeout=5.0)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quickstart — async
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from thunder_rpc import AsyncClient, Config
|
|
53
|
+
|
|
54
|
+
config = Config.standard().with_scheme("myapp").with_port(9000)
|
|
55
|
+
|
|
56
|
+
async with await AsyncClient.connect("myapp://localhost", config) as client:
|
|
57
|
+
pong = await client.call("PING")
|
|
58
|
+
assert pong.as_str() == "PONG"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both clients implement the identical SPEC-003 contract: pipelined out-of-order
|
|
62
|
+
completion, monotonic u32 ids (skipping `PUSH_ID`), serialized writes, `max_in_flight`
|
|
63
|
+
backpressure, per-call timeouts (default 30 s), lazy 2-attempt reconnect with
|
|
64
|
+
re-handshake, typed errors, and push-frame routing. The asyncio client additionally
|
|
65
|
+
honors task cancellation by removing the pending entry (CLT-021).
|
|
66
|
+
|
|
67
|
+
## One standard, zero product knowledge
|
|
68
|
+
|
|
69
|
+
Thunder is a protocol library, not a product catalogue: there is no registry of named
|
|
70
|
+
configurations, because a library that must serve implementations which do not exist yet
|
|
71
|
+
cannot ship a hardcoded list of the ones that did. Instead `Config.standard()` is **the**
|
|
72
|
+
family standard (data, not behavior — SPEC-002), pinned to
|
|
73
|
+
[`conformance/standard.yaml`](../conformance/standard.yaml) by the test suite so all four
|
|
74
|
+
language implementations agree on what "standard" means:
|
|
75
|
+
|
|
76
|
+
| Dimension | Standard | Why |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `handshake` | `HELLO_MANDATORY` | the only shape that negotiates `proto` and advertises capabilities |
|
|
79
|
+
| `hello_style` | `MAP_PAYLOAD` | `{version, token \| api_key, client_name}`; reply carries proto + capabilities |
|
|
80
|
+
| `push` | `RESERVED` | emitting push is a capability an application opts into |
|
|
81
|
+
| `max_frame_bytes` | 64 MiB | checked before allocation (WIRE-020) |
|
|
82
|
+
| `max_in_flight` | 256 | per-connection request bound |
|
|
83
|
+
| `error_codes` | `BOTH` | `"[code] message"` superset recognizing the RESP3 auth tokens — needs no negotiation |
|
|
84
|
+
| `tls` | `OFF` | additive capability a deployment turns on, never a dialect |
|
|
85
|
+
|
|
86
|
+
`scheme` and `default_port` are deliberately **not** part of the standard: identity is
|
|
87
|
+
your application's, and Thunder has no opinion about it. An application that matches the
|
|
88
|
+
standard writes its identity and nothing else:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
config = Config.standard().with_scheme("myapp").with_port(9000)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
An application that still diverges says so in its own repository, where that knowledge
|
|
95
|
+
belongs — every dimension is a `with_*` override returning a new frozen `Config`
|
|
96
|
+
(plain `Config(...)` construction works too):
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
config = (
|
|
100
|
+
Config.standard()
|
|
101
|
+
.with_scheme("legacy")
|
|
102
|
+
.with_port(15501)
|
|
103
|
+
.with_handshake(Handshake.AUTH_COMMAND) # AUTH-command auth, no HELLO handler
|
|
104
|
+
.with_hello_style(HelloStyle.NOT_USED)
|
|
105
|
+
.with_push(PushPolicy.ENABLED) # ships a push-producing command
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Convergence is then visible and per-application: delete overrides until only identity
|
|
110
|
+
remains. Nobody waits on a Thunder release for a row in a registry (PRO-020).
|
|
111
|
+
|
|
112
|
+
Endpoints accept `scheme://host[:port]` — where the scheme is **your** `config.scheme` —
|
|
113
|
+
or bare `host:port`; `http(s)://` is rejected (Thunder is RPC-only).
|
|
114
|
+
|
|
115
|
+
## Error classes
|
|
116
|
+
|
|
117
|
+
All errors derive from `thunder_rpc.ThunderError`; branch on the class and `code`, never
|
|
118
|
+
on message text (CLT-052):
|
|
119
|
+
|
|
120
|
+
| Class | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `AuthError` | Handshake rejected, or `NOAUTH`/`WRONGPASS`/`NOPERM` reply |
|
|
123
|
+
| `ServerError` | Server answered `Err` — raw `message` + optional bracket `code` |
|
|
124
|
+
| `ConnectionError` | Dial/write failure, dead connection, invalid endpoint |
|
|
125
|
+
| `TimeoutError` | Connect or per-call timeout elapsed |
|
|
126
|
+
| `FrameTooLargeError` | Frame over the config's cap (checked before allocation) |
|
|
127
|
+
| `DecodeError` | Malformed frame, or push frame while push is `RESERVED` |
|
|
128
|
+
|
|
129
|
+
## Wire layer
|
|
130
|
+
|
|
131
|
+
`thunder_rpc.wire` is pure (no sockets): `encode_frame`, `decode_request`,
|
|
132
|
+
`decode_response` over the 8-variant `Value` model
|
|
133
|
+
(`Null | Bool | Int | Float | Bytes | Str | Array | Map`). Canonical bytes are pinned by
|
|
134
|
+
the corpus: `Bytes` emits as MessagePack bin, floats always pack as f64 bit-exact,
|
|
135
|
+
structs are array-encoded; legacy int-array `Bytes` and map-shaped `Request` decode but
|
|
136
|
+
are never re-emitted.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
pip install -e .[dev]
|
|
142
|
+
python -m pytest # includes the conformance corpus — never skipped
|
|
143
|
+
ruff check .
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Specs
|
|
147
|
+
|
|
148
|
+
- [SPEC-001 Wire format](../docs/specs/SPEC-001-wire-format.md)
|
|
149
|
+
- [SPEC-002 Protocol configuration](../docs/specs/SPEC-002-configuration.md)
|
|
150
|
+
- [SPEC-003 Client contract](../docs/specs/SPEC-003-client.md)
|
|
151
|
+
- [SPEC-005 Conformance](../docs/specs/SPEC-005-conformance.md)
|
|
152
|
+
- [SPEC-006 Packaging](../docs/specs/SPEC-006-packaging-release.md)
|
|
153
|
+
|
|
154
|
+
Apache-2.0 — part of the [Thunder](../README.md) monorepo release train.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# hivellm-thunder
|
|
2
|
+
|
|
3
|
+
**⚡ The HiveLLM binary RPC protocol for Python — wire v1 (frozen), one configurable standard, sync + async multiplexed clients**
|
|
4
|
+
|
|
5
|
+
Thunder is the shared home of the HiveLLM binary RPC standard: a length-prefixed
|
|
6
|
+
MessagePack protocol (`u32 LE length` + body) multiplexing concurrent requests over one
|
|
7
|
+
persistent TCP connection. This package is the Python implementation — import name
|
|
8
|
+
`thunder_rpc`, one runtime dependency (`msgpack`), conformance-tested against the
|
|
9
|
+
language-neutral golden-vector corpus in [`conformance/`](../conformance/).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install hivellm-thunder
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart — sync
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from thunder_rpc import Client, ClientConfig, Config, Credentials, Value
|
|
21
|
+
|
|
22
|
+
# Your application's identity on top of the family standard.
|
|
23
|
+
config = Config.standard().with_scheme("myapp").with_port(9000)
|
|
24
|
+
|
|
25
|
+
client_config = ClientConfig(credentials=Credentials.api_key("secret-key"))
|
|
26
|
+
with Client.connect("myapp://localhost", config, client_config) as client:
|
|
27
|
+
pong = client.call("PING")
|
|
28
|
+
assert pong.as_str() == "PONG"
|
|
29
|
+
|
|
30
|
+
hits = client.call("SEARCH", [Value.str("docs"), Value.bytes(embedding)], timeout=5.0)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart — async
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from thunder_rpc import AsyncClient, Config
|
|
37
|
+
|
|
38
|
+
config = Config.standard().with_scheme("myapp").with_port(9000)
|
|
39
|
+
|
|
40
|
+
async with await AsyncClient.connect("myapp://localhost", config) as client:
|
|
41
|
+
pong = await client.call("PING")
|
|
42
|
+
assert pong.as_str() == "PONG"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Both clients implement the identical SPEC-003 contract: pipelined out-of-order
|
|
46
|
+
completion, monotonic u32 ids (skipping `PUSH_ID`), serialized writes, `max_in_flight`
|
|
47
|
+
backpressure, per-call timeouts (default 30 s), lazy 2-attempt reconnect with
|
|
48
|
+
re-handshake, typed errors, and push-frame routing. The asyncio client additionally
|
|
49
|
+
honors task cancellation by removing the pending entry (CLT-021).
|
|
50
|
+
|
|
51
|
+
## One standard, zero product knowledge
|
|
52
|
+
|
|
53
|
+
Thunder is a protocol library, not a product catalogue: there is no registry of named
|
|
54
|
+
configurations, because a library that must serve implementations which do not exist yet
|
|
55
|
+
cannot ship a hardcoded list of the ones that did. Instead `Config.standard()` is **the**
|
|
56
|
+
family standard (data, not behavior — SPEC-002), pinned to
|
|
57
|
+
[`conformance/standard.yaml`](../conformance/standard.yaml) by the test suite so all four
|
|
58
|
+
language implementations agree on what "standard" means:
|
|
59
|
+
|
|
60
|
+
| Dimension | Standard | Why |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `handshake` | `HELLO_MANDATORY` | the only shape that negotiates `proto` and advertises capabilities |
|
|
63
|
+
| `hello_style` | `MAP_PAYLOAD` | `{version, token \| api_key, client_name}`; reply carries proto + capabilities |
|
|
64
|
+
| `push` | `RESERVED` | emitting push is a capability an application opts into |
|
|
65
|
+
| `max_frame_bytes` | 64 MiB | checked before allocation (WIRE-020) |
|
|
66
|
+
| `max_in_flight` | 256 | per-connection request bound |
|
|
67
|
+
| `error_codes` | `BOTH` | `"[code] message"` superset recognizing the RESP3 auth tokens — needs no negotiation |
|
|
68
|
+
| `tls` | `OFF` | additive capability a deployment turns on, never a dialect |
|
|
69
|
+
|
|
70
|
+
`scheme` and `default_port` are deliberately **not** part of the standard: identity is
|
|
71
|
+
your application's, and Thunder has no opinion about it. An application that matches the
|
|
72
|
+
standard writes its identity and nothing else:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
config = Config.standard().with_scheme("myapp").with_port(9000)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
An application that still diverges says so in its own repository, where that knowledge
|
|
79
|
+
belongs — every dimension is a `with_*` override returning a new frozen `Config`
|
|
80
|
+
(plain `Config(...)` construction works too):
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
config = (
|
|
84
|
+
Config.standard()
|
|
85
|
+
.with_scheme("legacy")
|
|
86
|
+
.with_port(15501)
|
|
87
|
+
.with_handshake(Handshake.AUTH_COMMAND) # AUTH-command auth, no HELLO handler
|
|
88
|
+
.with_hello_style(HelloStyle.NOT_USED)
|
|
89
|
+
.with_push(PushPolicy.ENABLED) # ships a push-producing command
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Convergence is then visible and per-application: delete overrides until only identity
|
|
94
|
+
remains. Nobody waits on a Thunder release for a row in a registry (PRO-020).
|
|
95
|
+
|
|
96
|
+
Endpoints accept `scheme://host[:port]` — where the scheme is **your** `config.scheme` —
|
|
97
|
+
or bare `host:port`; `http(s)://` is rejected (Thunder is RPC-only).
|
|
98
|
+
|
|
99
|
+
## Error classes
|
|
100
|
+
|
|
101
|
+
All errors derive from `thunder_rpc.ThunderError`; branch on the class and `code`, never
|
|
102
|
+
on message text (CLT-052):
|
|
103
|
+
|
|
104
|
+
| Class | Meaning |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `AuthError` | Handshake rejected, or `NOAUTH`/`WRONGPASS`/`NOPERM` reply |
|
|
107
|
+
| `ServerError` | Server answered `Err` — raw `message` + optional bracket `code` |
|
|
108
|
+
| `ConnectionError` | Dial/write failure, dead connection, invalid endpoint |
|
|
109
|
+
| `TimeoutError` | Connect or per-call timeout elapsed |
|
|
110
|
+
| `FrameTooLargeError` | Frame over the config's cap (checked before allocation) |
|
|
111
|
+
| `DecodeError` | Malformed frame, or push frame while push is `RESERVED` |
|
|
112
|
+
|
|
113
|
+
## Wire layer
|
|
114
|
+
|
|
115
|
+
`thunder_rpc.wire` is pure (no sockets): `encode_frame`, `decode_request`,
|
|
116
|
+
`decode_response` over the 8-variant `Value` model
|
|
117
|
+
(`Null | Bool | Int | Float | Bytes | Str | Array | Map`). Canonical bytes are pinned by
|
|
118
|
+
the corpus: `Bytes` emits as MessagePack bin, floats always pack as f64 bit-exact,
|
|
119
|
+
structs are array-encoded; legacy int-array `Bytes` and map-shaped `Request` decode but
|
|
120
|
+
are never re-emitted.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install -e .[dev]
|
|
126
|
+
python -m pytest # includes the conformance corpus — never skipped
|
|
127
|
+
ruff check .
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Specs
|
|
131
|
+
|
|
132
|
+
- [SPEC-001 Wire format](../docs/specs/SPEC-001-wire-format.md)
|
|
133
|
+
- [SPEC-002 Protocol configuration](../docs/specs/SPEC-002-configuration.md)
|
|
134
|
+
- [SPEC-003 Client contract](../docs/specs/SPEC-003-client.md)
|
|
135
|
+
- [SPEC-005 Conformance](../docs/specs/SPEC-005-conformance.md)
|
|
136
|
+
- [SPEC-006 Packaging](../docs/specs/SPEC-006-packaging-release.md)
|
|
137
|
+
|
|
138
|
+
Apache-2.0 — part of the [Thunder](../README.md) monorepo release train.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hivellm-thunder"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HiveLLM binary RPC (Thunder) - wire v1 codec, family profiles, sync/async multiplexed clients"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["msgpack>=1.1"]
|
|
13
|
+
keywords = ["rpc", "messagepack", "msgpack", "hivellm", "thunder"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8",
|
|
18
|
+
"pytest-asyncio>=0.24",
|
|
19
|
+
"PyYAML>=6",
|
|
20
|
+
"ruff>=0.6",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/hivellm/thunder"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["thunder_rpc"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 100
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Test-suite plumbing: guarantees the tests directory is importable
|
|
2
|
+
(``mockserver``) and the package resolves from a source checkout."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_HERE = Path(__file__).resolve().parent
|
|
10
|
+
for path in (str(_HERE), str(_HERE.parent)):
|
|
11
|
+
if path not in sys.path:
|
|
12
|
+
sys.path.insert(0, path)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Scripted loopback TCP responders for the behavioral floor tests
|
|
2
|
+
(SPEC-003 / CLT-090) — the Python mirror of the tokio responders in
|
|
3
|
+
``rust/thunder-client/tests/behavior.rs``. Built on the thunder_rpc wire
|
|
4
|
+
codec; serves both the sync and the asyncio client (the server side is
|
|
5
|
+
always a plain thread)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from thunder_rpc import PUSH_ID, Request, Response, Value, wire
|
|
14
|
+
|
|
15
|
+
#: Frame cap the loopback responders read with.
|
|
16
|
+
SRV_CAP = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
#: Generous safety timeout so a broken test fails instead of hanging.
|
|
19
|
+
IO_TIMEOUT = 10.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PeerClosed(Exception):
|
|
23
|
+
"""The client closed the connection while the script was reading."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ServerConn:
|
|
27
|
+
"""One accepted connection, with frame-level helpers."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
30
|
+
self.sock = sock
|
|
31
|
+
sock.settimeout(IO_TIMEOUT)
|
|
32
|
+
|
|
33
|
+
def read_request(self) -> Request:
|
|
34
|
+
header = self._read_exact(4)
|
|
35
|
+
length = int.from_bytes(header, "little")
|
|
36
|
+
assert length <= SRV_CAP, f"client sent an over-cap frame ({length} bytes)"
|
|
37
|
+
body = self._read_exact(length)
|
|
38
|
+
return wire.decode_request_body(body)
|
|
39
|
+
|
|
40
|
+
def send_ok(self, frame_id: int, value: Value) -> None:
|
|
41
|
+
self.send_raw(wire.encode_frame(Response(id=frame_id, ok=value)))
|
|
42
|
+
|
|
43
|
+
def send_err(self, frame_id: int, message: str) -> None:
|
|
44
|
+
self.send_raw(wire.encode_frame(Response(id=frame_id, err=message)))
|
|
45
|
+
|
|
46
|
+
def send_push(self, value: Value) -> None:
|
|
47
|
+
self.send_raw(wire.encode_frame(Response(id=PUSH_ID, ok=value)))
|
|
48
|
+
|
|
49
|
+
def send_raw(self, data: bytes) -> None:
|
|
50
|
+
self.sock.sendall(data)
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
try:
|
|
54
|
+
self.sock.shutdown(socket.SHUT_RDWR)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
try:
|
|
58
|
+
self.sock.close()
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _read_exact(self, size: int) -> bytes:
|
|
63
|
+
buf = bytearray(size)
|
|
64
|
+
view = memoryview(buf)
|
|
65
|
+
got = 0
|
|
66
|
+
while got < size:
|
|
67
|
+
read = self.sock.recv_into(view[got:], size - got)
|
|
68
|
+
if read == 0:
|
|
69
|
+
raise PeerClosed()
|
|
70
|
+
got += read
|
|
71
|
+
return bytes(buf)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MockServer:
|
|
75
|
+
"""Runs ``script(server)`` on a background thread; the script drives
|
|
76
|
+
``accept()`` / frame helpers. Use as a context manager — exit joins the
|
|
77
|
+
script and re-raises anything it tripped on."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, script: Callable[["MockServer"], None]) -> None:
|
|
80
|
+
self._script = script
|
|
81
|
+
self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
82
|
+
self._listener.bind(("127.0.0.1", 0))
|
|
83
|
+
self._listener.listen(8)
|
|
84
|
+
self._listener.settimeout(IO_TIMEOUT)
|
|
85
|
+
self.port = self._listener.getsockname()[1]
|
|
86
|
+
#: Endpoint string the clients dial (bare host:port, CLT-070).
|
|
87
|
+
self.address = f"127.0.0.1:{self.port}"
|
|
88
|
+
#: How many connections the script accepted (reconnect assertions).
|
|
89
|
+
self.accepts = 0
|
|
90
|
+
self._conns: list[ServerConn] = []
|
|
91
|
+
self._error: BaseException | None = None
|
|
92
|
+
self._thread = threading.Thread(
|
|
93
|
+
target=self._run, name="mock-server", daemon=True
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def accept(self) -> ServerConn:
|
|
97
|
+
sock, _ = self._listener.accept()
|
|
98
|
+
self.accepts += 1
|
|
99
|
+
conn = ServerConn(sock)
|
|
100
|
+
self._conns.append(conn)
|
|
101
|
+
return conn
|
|
102
|
+
|
|
103
|
+
def _run(self) -> None:
|
|
104
|
+
try:
|
|
105
|
+
self._script(self)
|
|
106
|
+
except BaseException as exc: # surface script failures on __exit__
|
|
107
|
+
self._error = exc
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "MockServer":
|
|
110
|
+
self._thread.start()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, exc_type: object, *_exc: object) -> None:
|
|
114
|
+
self._thread.join(timeout=IO_TIMEOUT + 5)
|
|
115
|
+
for conn in self._conns:
|
|
116
|
+
conn.close()
|
|
117
|
+
self._listener.close()
|
|
118
|
+
if exc_type is None:
|
|
119
|
+
assert not self._thread.is_alive(), "mock server script did not finish"
|
|
120
|
+
if self._error is not None:
|
|
121
|
+
raise self._error
|