kollabor-rpc 0.5.5__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.
- kollabor_rpc-0.5.5/.gitignore +231 -0
- kollabor_rpc-0.5.5/PKG-INFO +6 -0
- kollabor_rpc-0.5.5/README.md +22 -0
- kollabor_rpc-0.5.5/pyproject.toml +14 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/__init__.py +24 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/client.py +178 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/errors.py +35 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/models.py +96 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/py.typed +0 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/server.py +127 -0
- kollabor_rpc-0.5.5/src/kollabor_rpc/transport.py +43 -0
- kollabor_rpc-0.5.5/tests/__init__.py +0 -0
- kollabor_rpc-0.5.5/tests/test_client.py +304 -0
- kollabor_rpc-0.5.5/tests/test_loopback_integration.py +319 -0
- kollabor_rpc-0.5.5/tests/test_models.py +111 -0
- kollabor_rpc-0.5.5/tests/test_server.py +233 -0
- kollabor_rpc-0.5.5/tests/test_transport.py +74 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
110
|
+
.pdm.toml
|
|
111
|
+
|
|
112
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
113
|
+
__pypackages__/
|
|
114
|
+
|
|
115
|
+
# Celery stuff
|
|
116
|
+
celerybeat-schedule
|
|
117
|
+
celerybeat.pid
|
|
118
|
+
|
|
119
|
+
# SageMath parsed files
|
|
120
|
+
*.sage.py
|
|
121
|
+
|
|
122
|
+
# Environments
|
|
123
|
+
.env
|
|
124
|
+
.env.*
|
|
125
|
+
!.env.example
|
|
126
|
+
.venv
|
|
127
|
+
env/
|
|
128
|
+
venv/
|
|
129
|
+
ENV/
|
|
130
|
+
env.bak/
|
|
131
|
+
venv.bak/
|
|
132
|
+
|
|
133
|
+
# Spyder project settings
|
|
134
|
+
.spyderproject
|
|
135
|
+
.spyproject
|
|
136
|
+
|
|
137
|
+
# Rope project settings
|
|
138
|
+
.ropeproject
|
|
139
|
+
|
|
140
|
+
# mkdocs documentation
|
|
141
|
+
/site
|
|
142
|
+
|
|
143
|
+
# mypy
|
|
144
|
+
.mypy_cache/
|
|
145
|
+
.dmypy.json
|
|
146
|
+
dmypy.json
|
|
147
|
+
|
|
148
|
+
# Pyre type checker
|
|
149
|
+
.pyre/
|
|
150
|
+
|
|
151
|
+
# pytype static type analyzer
|
|
152
|
+
.pytype/
|
|
153
|
+
|
|
154
|
+
# Cython debug symbols
|
|
155
|
+
cython_debug/
|
|
156
|
+
|
|
157
|
+
# PyCharm
|
|
158
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
159
|
+
# be added to the global gitignore or merged into this project gitignore. For PyCharm
|
|
160
|
+
# Community Edition, use 'pycharm-ce', for PyCharm Professional Edition, use 'pycharm'.
|
|
161
|
+
.idea/
|
|
162
|
+
|
|
163
|
+
# VS Code
|
|
164
|
+
.vscode/
|
|
165
|
+
|
|
166
|
+
# Kollabor specific files
|
|
167
|
+
.kollabor/
|
|
168
|
+
kollabor.log
|
|
169
|
+
*.log
|
|
170
|
+
*.bak
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Demo and test files
|
|
174
|
+
demo_thinking_effects.py
|
|
175
|
+
|
|
176
|
+
# Temporary files
|
|
177
|
+
*.tmp
|
|
178
|
+
*.temp
|
|
179
|
+
*~
|
|
180
|
+
|
|
181
|
+
# OS generated files
|
|
182
|
+
.DS_Store
|
|
183
|
+
.DS_Store?
|
|
184
|
+
._*
|
|
185
|
+
.Spotlight-V100
|
|
186
|
+
.Trashes
|
|
187
|
+
ehthumbs.db
|
|
188
|
+
Thumbs.db
|
|
189
|
+
|
|
190
|
+
# Project trash folder
|
|
191
|
+
.trash/
|
|
192
|
+
|
|
193
|
+
# Crush tool directory
|
|
194
|
+
.crush/
|
|
195
|
+
|
|
196
|
+
# Kollabor tool directory
|
|
197
|
+
.kollabor/
|
|
198
|
+
.kollabor-cli/
|
|
199
|
+
*.bak
|
|
200
|
+
*.deleted
|
|
201
|
+
|
|
202
|
+
# Conversation logs
|
|
203
|
+
convp/
|
|
204
|
+
|
|
205
|
+
# Personal and development directories
|
|
206
|
+
.marco/
|
|
207
|
+
.marcoai/
|
|
208
|
+
.claude/activity.log
|
|
209
|
+
.claude/bash-commands.log
|
|
210
|
+
.claude/debug-*.log
|
|
211
|
+
.claude/user-*.log
|
|
212
|
+
backups/
|
|
213
|
+
code_audit/
|
|
214
|
+
codemon/
|
|
215
|
+
tools/
|
|
216
|
+
docs/project-management/
|
|
217
|
+
docs/archive/
|
|
218
|
+
.marco
|
|
219
|
+
.archive
|
|
220
|
+
.dead-code-analysis
|
|
221
|
+
.zkollabor-cli/
|
|
222
|
+
|
|
223
|
+
# Dolt database files (added by bd init)
|
|
224
|
+
.dolt/
|
|
225
|
+
*.db
|
|
226
|
+
.beads/.beads-credential-key
|
|
227
|
+
|
|
228
|
+
# Claude Code session artifacts
|
|
229
|
+
/2026-*-deploy-handoff-*.txt
|
|
230
|
+
/2027-*-deploy-handoff-*.txt
|
|
231
|
+
.claude/scheduled_tasks.lock
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# kollabor-rpc
|
|
2
|
+
|
|
3
|
+
RPC transport layer for the StateService abstraction used by Kollabor's daemon
|
|
4
|
+
and attach mode. Provides the request/response models, error types, server
|
|
5
|
+
dispatcher, and client transport that let a detached Kollab process communicate
|
|
6
|
+
with an attached TUI client.
|
|
7
|
+
|
|
8
|
+
See the refactor plan at `~/.claude/plans/golden-doodling-kahan.md` (section 1)
|
|
9
|
+
for design context and phase breakdown.
|
|
10
|
+
|
|
11
|
+
## status
|
|
12
|
+
|
|
13
|
+
Phase 1.1 skeleton - modules are stubs. Real implementations land in
|
|
14
|
+
phases 1.2 (models + errors), 1.3 (server), and 1.4 (client).
|
|
15
|
+
|
|
16
|
+
## dependencies
|
|
17
|
+
|
|
18
|
+
None - stdlib only.
|
|
19
|
+
|
|
20
|
+
## license
|
|
21
|
+
|
|
22
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kollabor-rpc"
|
|
7
|
+
version = "0.5.5"
|
|
8
|
+
description = "RPC transport for Kollabor (StateService foundation)"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["src/kollabor_rpc"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""kollabor-rpc: RPC transport for StateService abstraction."""
|
|
2
|
+
|
|
3
|
+
from .client import RpcClient
|
|
4
|
+
from .errors import RpcError, RpcHandlerError, RpcMethodNotFound, RpcTimeoutError
|
|
5
|
+
from .models import RpcRequest, RpcResponse, new_request_id
|
|
6
|
+
from .server import RpcServer
|
|
7
|
+
from .transport import (
|
|
8
|
+
LARGE_BUFFER_LIMIT,
|
|
9
|
+
open_unix_connection_with_large_buffer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"RpcRequest",
|
|
14
|
+
"RpcResponse",
|
|
15
|
+
"new_request_id",
|
|
16
|
+
"RpcError",
|
|
17
|
+
"RpcTimeoutError",
|
|
18
|
+
"RpcMethodNotFound",
|
|
19
|
+
"RpcHandlerError",
|
|
20
|
+
"RpcServer",
|
|
21
|
+
"RpcClient",
|
|
22
|
+
"LARGE_BUFFER_LIMIT",
|
|
23
|
+
"open_unix_connection_with_large_buffer",
|
|
24
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Async RPC client that sends requests over an asyncio StreamWriter and resolves
|
|
2
|
+
responses via ``on_reply``.
|
|
3
|
+
|
|
4
|
+
The caller is responsible for wiring ``on_reply`` into whatever reads the
|
|
5
|
+
underlying transport (e.g. the attach client's read loop). Multiple in-flight
|
|
6
|
+
calls are supported -- each has its own future keyed by ``request_id``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .errors import RpcError, RpcHandlerError, RpcMethodNotFound, RpcTimeoutError
|
|
17
|
+
from .models import RpcRequest, new_request_id
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RpcClient:
|
|
23
|
+
"""Async RPC client multiplexed over a single asyncio StreamWriter.
|
|
24
|
+
|
|
25
|
+
Usage pattern: the caller constructs the client with an already-connected
|
|
26
|
+
writer, then arranges for incoming lines from the matching reader to be
|
|
27
|
+
decoded and passed to :meth:`on_reply`. Each :meth:`call` returns when the
|
|
28
|
+
matching reply arrives or raises on timeout/error.
|
|
29
|
+
|
|
30
|
+
IMPORTANT: The underlying StreamReader/Writer pair MUST be created
|
|
31
|
+
with a buffer large enough for the largest expected response. The
|
|
32
|
+
default asyncio.open_unix_connection() uses a 64KB buffer which is
|
|
33
|
+
too small for real state payloads. Use kollabor_rpc's
|
|
34
|
+
open_unix_connection_with_large_buffer() helper to get a 16MB limit.
|
|
35
|
+
|
|
36
|
+
The caller (not RpcClient) is responsible for creating the reader
|
|
37
|
+
and writer -- RpcClient only holds a writer reference for sending
|
|
38
|
+
requests and receives replies via on_reply() which is called by
|
|
39
|
+
the caller's read loop.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
writer: asyncio.StreamWriter,
|
|
45
|
+
*,
|
|
46
|
+
default_timeout: float = 30.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize the client.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
writer: An already-connected asyncio StreamWriter. The client does
|
|
52
|
+
not own the writer and will not close it on :meth:`close`.
|
|
53
|
+
default_timeout: Fallback timeout (seconds) used when
|
|
54
|
+
:meth:`call` is invoked without an explicit ``timeout``.
|
|
55
|
+
"""
|
|
56
|
+
self._writer: asyncio.StreamWriter = writer
|
|
57
|
+
self._default_timeout: float = default_timeout
|
|
58
|
+
self._pending: dict[str, asyncio.Future[Any]] = {}
|
|
59
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
60
|
+
self._closed: bool = False
|
|
61
|
+
|
|
62
|
+
async def call(
|
|
63
|
+
self,
|
|
64
|
+
method: str,
|
|
65
|
+
params: dict[str, Any] | None = None,
|
|
66
|
+
*,
|
|
67
|
+
timeout: float | None = None,
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Send an RPC request and wait for the response.
|
|
70
|
+
|
|
71
|
+
Concurrent calls are supported -- each has its own future keyed by the
|
|
72
|
+
generated ``request_id``. The writer is guarded by an ``asyncio.Lock``
|
|
73
|
+
so that interleaved bytes from concurrent writes cannot corrupt the
|
|
74
|
+
NDJSON stream.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
method: Remote method name.
|
|
78
|
+
params: JSON-serializable parameter dict. Defaults to an empty dict.
|
|
79
|
+
timeout: Per-call timeout (seconds). When ``None`` the client's
|
|
80
|
+
``default_timeout`` is used.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The decoded ``result`` field from the matching RPC reply.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
RpcError: The client has been closed.
|
|
87
|
+
RpcTimeoutError: No reply arrived before the effective timeout.
|
|
88
|
+
RpcMethodNotFound: The server reported ``error_kind == "not_found"``.
|
|
89
|
+
RpcHandlerError: The server reported ``error_kind in {"handler",
|
|
90
|
+
"serialization"}``.
|
|
91
|
+
"""
|
|
92
|
+
if self._closed:
|
|
93
|
+
raise RpcError("client closed")
|
|
94
|
+
|
|
95
|
+
params = params or {}
|
|
96
|
+
effective_timeout = timeout if timeout is not None else self._default_timeout
|
|
97
|
+
request_id = new_request_id()
|
|
98
|
+
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
fut: asyncio.Future[Any] = loop.create_future()
|
|
101
|
+
self._pending[request_id] = fut
|
|
102
|
+
|
|
103
|
+
req = RpcRequest(
|
|
104
|
+
request_id=request_id,
|
|
105
|
+
method=method,
|
|
106
|
+
params=params,
|
|
107
|
+
timeout=effective_timeout,
|
|
108
|
+
)
|
|
109
|
+
line = json.dumps(req.to_wire(), default=str) + "\n"
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
async with self._lock:
|
|
113
|
+
self._writer.write(line.encode("utf-8"))
|
|
114
|
+
await self._writer.drain()
|
|
115
|
+
try:
|
|
116
|
+
return await asyncio.wait_for(fut, effective_timeout)
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
raise RpcTimeoutError(
|
|
119
|
+
f"rpc call {method!r} timed out after {effective_timeout}s"
|
|
120
|
+
)
|
|
121
|
+
finally:
|
|
122
|
+
self._pending.pop(request_id, None)
|
|
123
|
+
|
|
124
|
+
def on_reply(self, msg_data: dict[str, Any]) -> None:
|
|
125
|
+
"""Route an incoming ``rpc_reply`` dict to the waiting future.
|
|
126
|
+
|
|
127
|
+
Called by the attach client's read loop. Unknown ``request_id``s are
|
|
128
|
+
logged and discarded (likely stale after timeout). Synchronous;
|
|
129
|
+
returns immediately after scheduling the future resolution.
|
|
130
|
+
"""
|
|
131
|
+
request_id = msg_data.get("request_id")
|
|
132
|
+
if not request_id:
|
|
133
|
+
logger.warning("rpc reply missing request_id: %r", msg_data)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
fut = self._pending.get(request_id)
|
|
137
|
+
if fut is None or fut.done():
|
|
138
|
+
logger.debug("stale rpc reply for request_id=%s", request_id)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
error = msg_data.get("error")
|
|
142
|
+
error_kind = msg_data.get("error_kind")
|
|
143
|
+
|
|
144
|
+
if error_kind == "not_found":
|
|
145
|
+
fut.set_exception(RpcMethodNotFound(error or "method not found"))
|
|
146
|
+
elif error_kind == "handler":
|
|
147
|
+
fut.set_exception(
|
|
148
|
+
RpcHandlerError(
|
|
149
|
+
error or "remote handler raised",
|
|
150
|
+
remote_message=error,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
elif error_kind == "serialization":
|
|
154
|
+
fut.set_exception(
|
|
155
|
+
RpcHandlerError(
|
|
156
|
+
error or "remote result not serializable",
|
|
157
|
+
remote_message=error,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
elif error:
|
|
161
|
+
fut.set_exception(RpcError(error))
|
|
162
|
+
else:
|
|
163
|
+
fut.set_result(msg_data.get("result"))
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
"""Cancel all pending calls with ``RpcError('rpc client closed')``.
|
|
167
|
+
|
|
168
|
+
Safe to call multiple times. Does NOT close the underlying writer --
|
|
169
|
+
the caller owns that. After ``close()`` any subsequent :meth:`call`
|
|
170
|
+
raises ``RpcError`` immediately without touching the writer.
|
|
171
|
+
"""
|
|
172
|
+
if self._closed:
|
|
173
|
+
return
|
|
174
|
+
self._closed = True
|
|
175
|
+
for _request_id, fut in list(self._pending.items()):
|
|
176
|
+
if not fut.done():
|
|
177
|
+
fut.set_exception(RpcError("rpc client closed"))
|
|
178
|
+
self._pending.clear()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""RPC exception hierarchy. Raised by RpcClient when calls fail."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"RpcError",
|
|
7
|
+
"RpcTimeoutError",
|
|
8
|
+
"RpcMethodNotFound",
|
|
9
|
+
"RpcHandlerError",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RpcError(Exception):
|
|
14
|
+
"""Base class for all RPC failures."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RpcTimeoutError(RpcError):
|
|
18
|
+
"""Raised when no response arrives before the timeout."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RpcMethodNotFound(RpcError):
|
|
22
|
+
"""Raised when the server has no handler registered for the requested method."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RpcHandlerError(RpcError):
|
|
26
|
+
"""Raised when a remote handler raised an exception or returned non-serializable data.
|
|
27
|
+
|
|
28
|
+
``remote_message`` carries the server-side error string when available,
|
|
29
|
+
so callers can surface the original failure reason without losing the
|
|
30
|
+
client-side context in ``args[0]``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, remote_message: str | None = None) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.remote_message = remote_message
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Wire-safe RPC request/response models.
|
|
2
|
+
|
|
3
|
+
All types round-trip through JSON via to_wire/from_wire. These are plain
|
|
4
|
+
dataclasses with no external serialization library dependency.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_request_id() -> str:
|
|
15
|
+
"""Generate a new RPC request id.
|
|
16
|
+
|
|
17
|
+
Returns a stateless 32-character hex string derived from uuid4. Collision
|
|
18
|
+
probability is negligible in practice, so callers can treat each id as
|
|
19
|
+
globally unique without coordination.
|
|
20
|
+
"""
|
|
21
|
+
return uuid.uuid4().hex
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class RpcRequest:
|
|
26
|
+
"""A single RPC call from client to server."""
|
|
27
|
+
|
|
28
|
+
request_id: str
|
|
29
|
+
method: str
|
|
30
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
timeout: float = 30.0
|
|
32
|
+
|
|
33
|
+
def to_wire(self) -> dict[str, Any]:
|
|
34
|
+
"""Serialize to the wire dict carried over the hub socket."""
|
|
35
|
+
return {
|
|
36
|
+
"action": "rpc_request",
|
|
37
|
+
"request_id": self.request_id,
|
|
38
|
+
"method": self.method,
|
|
39
|
+
"params": self.params,
|
|
40
|
+
"timeout": self.timeout,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_wire(cls, data: dict[str, Any]) -> RpcRequest:
|
|
45
|
+
"""Reconstruct from a wire dict.
|
|
46
|
+
|
|
47
|
+
Tolerant of missing ``action`` field (the dispatcher usually strips
|
|
48
|
+
it before calling this). ``params`` and ``timeout`` fall back to
|
|
49
|
+
empty dict and 30.0 respectively.
|
|
50
|
+
"""
|
|
51
|
+
return cls(
|
|
52
|
+
request_id=data["request_id"],
|
|
53
|
+
method=data["method"],
|
|
54
|
+
params=data.get("params") or {},
|
|
55
|
+
timeout=float(data.get("timeout", 30.0)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class RpcResponse:
|
|
61
|
+
"""A single RPC reply from server to client.
|
|
62
|
+
|
|
63
|
+
``error_kind`` is one of the literal strings "not_found", "handler",
|
|
64
|
+
or "serialization", or None for successful replies. Kept as plain str
|
|
65
|
+
(not typing.Literal) to avoid python-version-specific type hint issues.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
request_id: str
|
|
69
|
+
result: Any | None = None
|
|
70
|
+
error: str | None = None
|
|
71
|
+
error_kind: str | None = None
|
|
72
|
+
|
|
73
|
+
def to_wire(self) -> dict[str, Any]:
|
|
74
|
+
"""Serialize to the wire dict carried over the hub socket."""
|
|
75
|
+
return {
|
|
76
|
+
"action": "rpc_reply",
|
|
77
|
+
"request_id": self.request_id,
|
|
78
|
+
"result": self.result,
|
|
79
|
+
"error": self.error,
|
|
80
|
+
"error_kind": self.error_kind,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_wire(cls, data: dict[str, Any]) -> RpcResponse:
|
|
85
|
+
"""Reconstruct from a wire dict. Only ``request_id`` is required."""
|
|
86
|
+
return cls(
|
|
87
|
+
request_id=data["request_id"],
|
|
88
|
+
result=data.get("result"),
|
|
89
|
+
error=data.get("error"),
|
|
90
|
+
error_kind=data.get("error_kind"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_success(self) -> bool:
|
|
95
|
+
"""True when both ``error`` and ``error_kind`` are None."""
|
|
96
|
+
return self.error is None and self.error_kind is None
|
|
File without changes
|