scxp 0.1.0__py3-none-any.whl

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.
scxp/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """SCXP SDK for Python — Symmetric Context Exchange Protocol.
2
+
3
+ Public surface for both sides of the protocol:
4
+
5
+ from scxp import Consumer, ConsumerConfig, Provider, ProviderCatalog, tool, Info
6
+
7
+ The transport is yours: implement ``Channel`` (WebSocket, TCP, stdio, ...) and
8
+ pass it to ``Consumer.create`` / ``Provider.create``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from .channel import Channel
16
+ from .connection import Connection
17
+ from .consumer import Consumer, ConsumerConfig
18
+ from .contract_builder import build_tool_contract
19
+ from .errors import (
20
+ ConnectionClosedError,
21
+ HandshakeError,
22
+ ProtocolErrors,
23
+ ScxpError,
24
+ ScxpProtocolError,
25
+ )
26
+ from .handshake import HandshakeMode, HandshakeResult
27
+ from .protocol_version import CURRENT, SUPPORTED
28
+ from .provider import Provider
29
+ from .provider_catalog import ProviderCatalog
30
+ from .tool import ToolMetadata, tool
31
+ from .tool_schema import ToolSchemaGenerationError, generate_input_schema
32
+ from .wire import (
33
+ Call,
34
+ CallToolParams,
35
+ CallToolResult,
36
+ Capabilities,
37
+ ContentBlock,
38
+ Domain,
39
+ Execution,
40
+ Info,
41
+ ListToolsResult,
42
+ ResourceLinkContent,
43
+ Role,
44
+ TextContent,
45
+ Tool,
46
+ ToolResult,
47
+ ToolsCapability,
48
+ )
49
+
50
+ __all__ = [
51
+ "__version__",
52
+ # surface
53
+ "Consumer",
54
+ "ConsumerConfig",
55
+ "Provider",
56
+ "ProviderCatalog",
57
+ "Channel",
58
+ "Connection",
59
+ "HandshakeMode",
60
+ "HandshakeResult",
61
+ # tools
62
+ "tool",
63
+ "ToolMetadata",
64
+ "build_tool_contract",
65
+ "generate_input_schema",
66
+ "ToolSchemaGenerationError",
67
+ # errors
68
+ "ScxpError",
69
+ "ScxpProtocolError",
70
+ "HandshakeError",
71
+ "ConnectionClosedError",
72
+ "ProtocolErrors",
73
+ # protocol version
74
+ "CURRENT",
75
+ "SUPPORTED",
76
+ # wire types
77
+ "Info",
78
+ "Capabilities",
79
+ "ToolsCapability",
80
+ "Role",
81
+ "Call",
82
+ "CallToolParams",
83
+ "CallToolResult",
84
+ "ToolResult",
85
+ "ListToolsResult",
86
+ "Domain",
87
+ "Tool",
88
+ "Execution",
89
+ "TextContent",
90
+ "ResourceLinkContent",
91
+ "ContentBlock",
92
+ ]
scxp/channel.py ADDED
@@ -0,0 +1,41 @@
1
+ """The transport seam between the user's transport and the protocol (doc 04 §2).
2
+
3
+ The SDK does NOT open connections: it receives an already-open ``Channel``. A
4
+ channel exposes exactly three capabilities: write bytes, subscribe to incoming
5
+ bytes, and learn when the channel dies.
6
+
7
+ The SDK depends on nothing but ``Channel``: it does not reference WebSocket, HTTP
8
+ or stdio (transport-agnostic). Wrapping a WebSocket, stdin/stdout, a raw TCP
9
+ socket, or a mock in an implementation of this Protocol runs the same SDK over
10
+ any transport.
11
+
12
+ Messages are the bytes of a serialized Envelope (JSON UTF-8). The SDK encodes and
13
+ decodes internally; the interface only carries opaque bytes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Awaitable, Callable, Optional, Protocol, runtime_checkable
19
+
20
+
21
+ @runtime_checkable
22
+ class Channel(Protocol):
23
+ """Transport seam: send bytes, receive bytes, observe close."""
24
+
25
+ async def send(self, message: bytes) -> None:
26
+ """Write one message (the bytes of an Envelope). Called by the SDK."""
27
+ ...
28
+
29
+ def on_message(self, handler: Callable[[bytes], Awaitable[None]]) -> None:
30
+ """Register the handler the SDK uses to receive incoming messages.
31
+
32
+ Each complete message (one Envelope) invokes the handler once.
33
+ """
34
+ ...
35
+
36
+ def on_close(self, handler: Callable[[Optional[Exception]], None]) -> None:
37
+ """Register the handler the SDK uses to learn the channel closed.
38
+
39
+ The argument is the cause (or None if the close was clean).
40
+ """
41
+ ...
scxp/codec.py ADDED
@@ -0,0 +1,54 @@
1
+ """Envelope (de)serialization — the single gate between bytes and typed models.
2
+
3
+ The Connection sends/receives opaque bytes (one Envelope each); this module
4
+ encodes a typed Envelope to JSON UTF-8 bytes and decodes incoming bytes back to
5
+ the correct typed shape (discriminated by ``kind``).
6
+
7
+ None policy on encode:
8
+ - Request / Notification: ``params`` is omitted when None (optional).
9
+ - Error: ``data`` is omitted when None (optional).
10
+ - Response: ``result`` is a required key that MAY be null, so it is ALWAYS
11
+ emitted (never dropped).
12
+
13
+ Malformed input (bad JSON, unknown/missing ``kind``, schema-invalid) raises
14
+ ValueError / json.JSONDecodeError / pydantic.ValidationError; the protocol layer
15
+ maps those to INVALID_PARAMS.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Type
22
+
23
+ from .wire import Envelope, Error, Notification, Request, Response
24
+
25
+ _DECODERS: dict[str, Type] = {
26
+ "request": Request,
27
+ "response": Response,
28
+ "error": Error,
29
+ "notification": Notification,
30
+ }
31
+
32
+
33
+ def encode(envelope: Envelope) -> bytes:
34
+ """Serialize a typed Envelope to JSON UTF-8 bytes."""
35
+ if isinstance(envelope, Response):
36
+ # ``result`` is required and may be null → never drop it.
37
+ data = envelope.model_dump(mode="json", by_alias=True)
38
+ else:
39
+ data = envelope.model_dump(mode="json", by_alias=True, exclude_none=True)
40
+ return json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
41
+
42
+
43
+ def decode(data: bytes) -> Envelope:
44
+ """Deserialize JSON UTF-8 bytes to the correct Envelope shape (by ``kind``)."""
45
+ obj = json.loads(data)
46
+ if not isinstance(obj, dict):
47
+ raise ValueError("Envelope must be a JSON object.")
48
+
49
+ kind = obj.get("kind")
50
+ model = _DECODERS.get(kind)
51
+ if model is None:
52
+ raise ValueError(f"Unknown or missing envelope kind: {kind!r}")
53
+
54
+ return model.model_validate(obj)
scxp/connection.py ADDED
@@ -0,0 +1,176 @@
1
+ """Connection — request/response correlation, dispatch, robustness (doc 04 §3).
2
+
3
+ Sits on top of a Channel. Outbound: ``send_request`` correlates a Response/Error
4
+ to its Request by ``id`` (via a Future), with optional timeout and clean pending
5
+ removal on cancellation/timeout. ``send_notification`` is fire-and-forget.
6
+
7
+ Inbound robustness:
8
+ - a handler raising ScxpProtocolError → an Error with that error,
9
+ - a handler raising anything else → INTERNAL_ERROR (-32603); the loop survives,
10
+ - a notification handler raising → swallowed,
11
+ - a malformed inbound message (can't correlate an id) → dropped,
12
+ - a Request with an id already in flight → DUPLICATE_ID (-32004).
13
+
14
+ Shutdown: ``aclose`` / ``async with`` faults pending requests (idempotent). The
15
+ channel itself is owned by the user and is not closed here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import uuid
22
+ from typing import Any, Awaitable, Callable, Optional
23
+
24
+ from .channel import Channel
25
+ from .codec import decode, encode
26
+ from .errors import ConnectionClosedError, ProtocolErrors, ScxpProtocolError
27
+ from .wire import Error, Notification, Request, Response
28
+
29
+ RequestHandler = Callable[[str, Any], Awaitable[Any]]
30
+ NotificationHandler = Callable[[str, Any], Awaitable[None]]
31
+
32
+
33
+ class Connection:
34
+ """Correlates requests to replies and dispatches inbound messages."""
35
+
36
+ def __init__(self, channel: Channel) -> None:
37
+ self._channel = channel
38
+ self._pending: dict[str, asyncio.Future[Any]] = {}
39
+ self._in_flight: set[str] = set()
40
+ self._closed = False
41
+ self.request_handler: Optional[RequestHandler] = None
42
+ self.notification_handler: Optional[NotificationHandler] = None
43
+
44
+ channel.on_message(self._handle_incoming)
45
+ channel.on_close(self._handle_close)
46
+
47
+ # --- Outbound ------------------------------------------------------------
48
+
49
+ async def send_request(
50
+ self, method: str, params: Any = None, timeout: Optional[float] = None
51
+ ) -> Any:
52
+ """Send a request and await the correlated result (raises on Error).
53
+
54
+ If ``timeout`` elapses the wait is cancelled with ``asyncio.TimeoutError``;
55
+ cancellation or timeout removes the pending entry so it never leaks.
56
+ """
57
+ if self._closed:
58
+ raise ConnectionClosedError("Connection is closed.")
59
+
60
+ request_id = str(uuid.uuid4())
61
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
62
+ self._pending[request_id] = future
63
+
64
+ try:
65
+ await self._channel.send(encode(Request(id=request_id, method=method, params=params)))
66
+ except BaseException:
67
+ self._pending.pop(request_id, None)
68
+ raise
69
+
70
+ try:
71
+ if timeout is not None:
72
+ return await asyncio.wait_for(future, timeout)
73
+ return await future
74
+ except BaseException:
75
+ # Cancellation / timeout / any error: don't leak the pending entry.
76
+ self._pending.pop(request_id, None)
77
+ raise
78
+
79
+ async def send_notification(self, method: str, params: Any = None) -> None:
80
+ """Send a one-way notification (no reply)."""
81
+ if self._closed:
82
+ raise ConnectionClosedError("Connection is closed.")
83
+ await self._channel.send(encode(Notification(method=method, params=params)))
84
+
85
+ # --- Inbound -------------------------------------------------------------
86
+
87
+ async def _handle_incoming(self, data: bytes) -> None:
88
+ try:
89
+ envelope = decode(data)
90
+ except Exception: # noqa: BLE001
91
+ # Malformed inbound message: can't correlate an id → drop (keep the loop alive).
92
+ return
93
+
94
+ if isinstance(envelope, Response):
95
+ future = self._pending.pop(envelope.id, None)
96
+ if future is not None and not future.done():
97
+ future.set_result(envelope.result)
98
+
99
+ elif isinstance(envelope, Error):
100
+ future = self._pending.pop(envelope.id, None)
101
+ if future is not None and not future.done():
102
+ future.set_exception(ScxpProtocolError(envelope.error))
103
+
104
+ elif isinstance(envelope, Request):
105
+ await self._dispatch_request(envelope)
106
+
107
+ elif isinstance(envelope, Notification):
108
+ handler = self.notification_handler
109
+ if handler is not None:
110
+ try:
111
+ await handler(envelope.method, envelope.params)
112
+ except Exception: # noqa: BLE001
113
+ # A notification handler failure must not break the receive loop.
114
+ pass
115
+
116
+ async def _dispatch_request(self, request: Request) -> None:
117
+ # Inbound DUPLICATE_ID (-32004): a request whose id is already being processed.
118
+ if request.id in self._in_flight:
119
+ await self._send(Error(id=request.id, error=ProtocolErrors.duplicate_id()))
120
+ return
121
+
122
+ self._in_flight.add(request.id)
123
+ try:
124
+ handler = self.request_handler
125
+ if handler is None:
126
+ await self._send(Error(id=request.id, error=ProtocolErrors.unknown_method()))
127
+ return
128
+
129
+ try:
130
+ result = await handler(request.method, request.params)
131
+ except ScxpProtocolError as exc:
132
+ await self._send(Error(id=request.id, error=exc.error))
133
+ return
134
+ except Exception as exc: # noqa: BLE001
135
+ # The handler threw an unexpected error → INTERNAL_ERROR; the loop survives.
136
+ await self._send(Error(id=request.id, error=ProtocolErrors.internal_error(str(exc))))
137
+ return
138
+
139
+ await self._send(Response(id=request.id, result=result))
140
+ finally:
141
+ self._in_flight.discard(request.id)
142
+
143
+ # --- Close / shutdown ----------------------------------------------------
144
+
145
+ def _handle_close(self, cause: Optional[Exception]) -> None:
146
+ self._closed = True
147
+ pending = list(self._pending.values())
148
+ self._pending.clear()
149
+
150
+ closed = ConnectionClosedError("The channel was closed.")
151
+ if cause is not None:
152
+ closed.__cause__ = cause
153
+
154
+ for future in pending:
155
+ if not future.done():
156
+ future.set_exception(closed)
157
+
158
+ async def aclose(self) -> None:
159
+ """Shut down: mark closed and fault pending requests (idempotent).
160
+
161
+ Does not close the channel — the transport is owned by the user.
162
+ """
163
+ if self._closed:
164
+ return
165
+ self._handle_close(None)
166
+
167
+ async def __aenter__(self) -> "Connection":
168
+ return self
169
+
170
+ async def __aexit__(self, exc_type, exc, tb) -> None:
171
+ await self.aclose()
172
+
173
+ # --- Helpers -------------------------------------------------------------
174
+
175
+ async def _send(self, envelope: Any) -> None:
176
+ await self._channel.send(encode(envelope))
scxp/consumer.py ADDED
@@ -0,0 +1,138 @@
1
+ """Consumer surface — ``create_consumer`` (doc 04 §3/§4).
2
+
3
+ The user doesn't build envelopes or run ``initialize`` by hand: ``Consumer.create``
4
+ runs the handshake and, once the session is negotiated, exposes the two consumer
5
+ operations — ``list_tools`` (tools/list) and ``call_tools`` (tools/call).
6
+
7
+ Role is implied by the method (creating a consumer ⇒ Role.consumer). The user
8
+ never writes ``role`` or ``protocolVersions`` — the SDK injects them. By default
9
+ the consumer INITIATEs the handshake, but it can AWAIT (a provider opened the
10
+ channel and greets — the multi-pod case).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+ from dataclasses import dataclass
17
+ from typing import Any, Callable, Optional, Type, TypeVar
18
+
19
+ from pydantic import BaseModel, ValidationError
20
+
21
+ from .channel import Channel
22
+ from .connection import Connection
23
+ from .errors import ProtocolErrors, ScxpProtocolError
24
+ from .handshake import HandshakeMode, HandshakeResponder, HandshakeResult, initiate
25
+ from .wire import (
26
+ Call,
27
+ CallToolParams,
28
+ CallToolResult,
29
+ Capabilities,
30
+ Info,
31
+ ListToolsResult,
32
+ Role,
33
+ ToolResult,
34
+ )
35
+
36
+ TOOLS_LIST_METHOD = "tools/list"
37
+ TOOLS_CALL_METHOD = "tools/call"
38
+ TOOLS_LIST_CHANGED_METHOD = "notifications/tools/list_changed"
39
+
40
+ _T = TypeVar("_T", bound=BaseModel)
41
+
42
+
43
+ @dataclass
44
+ class ConsumerConfig:
45
+ """Minimal consumer config (doc 04 §3.1): only genuine user decisions.
46
+
47
+ ``role`` / ``protocolVersions`` are not here on purpose — they are SDK facts,
48
+ injected, so they cannot be written wrong (input defense by construction).
49
+ """
50
+
51
+ info: Info
52
+ capabilities: Optional[Capabilities] = None
53
+
54
+
55
+ class Consumer:
56
+ """The consumer side: discovers and invokes tools after the handshake."""
57
+
58
+ def __init__(self, connection: Connection, session: HandshakeResult) -> None:
59
+ self._connection = connection
60
+ self.session = session
61
+ # No events in Python: a settable callable, invoked on tools/list_changed.
62
+ self.on_tools_list_changed: Optional[Callable[[], None]] = None
63
+ self._connection.notification_handler = self._handle_notification
64
+
65
+ @classmethod
66
+ async def create(
67
+ cls,
68
+ channel: Channel,
69
+ config: ConsumerConfig,
70
+ mode: HandshakeMode = HandshakeMode.INITIATE,
71
+ ) -> "Consumer":
72
+ """Create a consumer over an already-open channel and run the handshake."""
73
+ connection = Connection(channel)
74
+ caps = config.capabilities or Capabilities()
75
+
76
+ if mode is HandshakeMode.INITIATE:
77
+ session = await initiate(connection, Role.consumer, caps, config.info)
78
+ else:
79
+ responder = HandshakeResponder(Role.consumer, caps, config.info)
80
+ connection.request_handler = responder.handle_request
81
+ session = await responder.completed
82
+
83
+ return cls(connection, session)
84
+
85
+ async def list_tools(self) -> ListToolsResult:
86
+ """tools/list — fetch the provider's manifest, typed."""
87
+ raw = await self._connection.send_request(TOOLS_LIST_METHOD)
88
+ return self._parse(ListToolsResult, raw, TOOLS_LIST_METHOD)
89
+
90
+ async def call_tools(self, calls: list[Call]) -> CallToolResult:
91
+ """tools/call — invoke a batch of tools; sub-results correlate by tool_call_id.
92
+
93
+ A ScxpProtocolError here is a layer-1 failure (the whole batch). A single
94
+ tool failing does NOT raise: it travels in its ToolResult with isError.
95
+ """
96
+ params = CallToolParams(calls=calls).model_dump(
97
+ mode="json", by_alias=True, exclude_none=True
98
+ )
99
+ raw = await self._connection.send_request(TOOLS_CALL_METHOD, params)
100
+ return self._parse(CallToolResult, raw, TOOLS_CALL_METHOD)
101
+
102
+ async def call_tool(
103
+ self,
104
+ name: str,
105
+ arguments: dict[str, Any],
106
+ tool_call_id: Optional[str] = None,
107
+ context: Optional[dict[str, Any]] = None,
108
+ ) -> ToolResult:
109
+ """Convenience: invoke a single tool (a batch of one, doc 05 §8.1)."""
110
+ call = Call(
111
+ tool_call_id=tool_call_id or uuid.uuid4().hex,
112
+ name=name,
113
+ arguments=arguments,
114
+ context=context,
115
+ )
116
+ result = await self.call_tools([call])
117
+ if not result.results:
118
+ raise ScxpProtocolError(
119
+ ProtocolErrors.internal_error(
120
+ "The provider returned no sub-result for the sent call (empty result)."
121
+ )
122
+ )
123
+ return result.results[0]
124
+
125
+ async def _handle_notification(self, method: str, params: Any) -> None:
126
+ if method == TOOLS_LIST_CHANGED_METHOD and self.on_tools_list_changed is not None:
127
+ self.on_tools_list_changed()
128
+
129
+ @staticmethod
130
+ def _parse(model: Type[_T], raw: Any, method: str) -> _T:
131
+ try:
132
+ return model.model_validate(raw)
133
+ except ValidationError as exc:
134
+ raise ScxpProtocolError(
135
+ ProtocolErrors.internal_error(
136
+ f"The result of '{method}' is not a valid {model.__name__}."
137
+ )
138
+ ) from exc
@@ -0,0 +1,82 @@
1
+ """ContractBuilder — assembles the full Tool Contract from a decorated tool (doc 02).
2
+
3
+ Routes each declared param to its wire channel:
4
+ - LLM channel: description (or docstring fallback), when_to_use, when_not_to_use.
5
+ - Schema: input_schema (Gen A).
6
+ - Orchestrator channel: execution (only the flags that were set).
7
+ - Domain metadata: _meta (validated key by key).
8
+
9
+ ``domain`` is NOT part of the Contract (it is structural — the catalog uses it).
10
+ Invalid _meta keys raise ``ToolSchemaGenerationError`` (same error as Gen A).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any, Callable
17
+
18
+ from .tool import ToolMetadata, get_tool_metadata
19
+ from .tool_schema import ToolSchemaGenerationError, generate_input_schema
20
+
21
+ # _meta key: DNS-reverse prefix ending in '/', then a non-space/quote/backslash tail.
22
+ _META_KEY = re.compile("^([a-z0-9-]+\\.)+[a-z0-9-]+/[^\\s\"'\\\\]+$")
23
+
24
+ # Second labels reserved for the protocol (cases.json reserved_meta_second_labels).
25
+ _RESERVED_SECOND_LABELS = {"mcp", "modelcontextprotocol"}
26
+
27
+
28
+ def _validate_meta_key(key: str) -> None:
29
+ if not _META_KEY.match(key):
30
+ raise ToolSchemaGenerationError(
31
+ f"Invalid _meta key {key!r}: must be a DNS-reverse prefix ending in '/…'."
32
+ )
33
+ prefix = key.split("/", 1)[0]
34
+ labels = prefix.split(".")
35
+ if len(labels) >= 2 and labels[1] in _RESERVED_SECOND_LABELS:
36
+ raise ToolSchemaGenerationError(
37
+ f"_meta key {key!r} uses a reserved second label ({labels[1]!r})."
38
+ )
39
+
40
+
41
+ def _execution(metadata: ToolMetadata) -> dict[str, Any]:
42
+ execution: dict[str, Any] = {}
43
+ if metadata.requires_approval is not None:
44
+ execution["requires_approval"] = metadata.requires_approval
45
+ if metadata.idempotent is not None:
46
+ execution["idempotent"] = metadata.idempotent
47
+ if metadata.destructive is not None:
48
+ execution["destructive"] = metadata.destructive
49
+ if metadata.timeout_ms is not None:
50
+ execution["timeout_ms"] = metadata.timeout_ms
51
+ return execution
52
+
53
+
54
+ def build_tool_contract(func: Callable[..., Any]) -> dict[str, Any]:
55
+ """Build the full Tool Contract dict for a decorated tool."""
56
+ metadata = get_tool_metadata(func) or ToolMetadata()
57
+
58
+ name = metadata.name or func.__name__
59
+ description = metadata.description
60
+ if description is None and func.__doc__:
61
+ description = func.__doc__.strip()
62
+
63
+ contract: dict[str, Any] = {"name": name}
64
+ if description:
65
+ contract["description"] = description
66
+ if metadata.when_to_use:
67
+ contract["when_to_use"] = metadata.when_to_use
68
+ if metadata.when_not_to_use:
69
+ contract["when_not_to_use"] = metadata.when_not_to_use
70
+
71
+ contract["input_schema"] = generate_input_schema(func)
72
+
73
+ execution = _execution(metadata)
74
+ if execution:
75
+ contract["execution"] = execution
76
+
77
+ if metadata.meta:
78
+ for key in metadata.meta:
79
+ _validate_meta_key(key)
80
+ contract["_meta"] = dict(metadata.meta)
81
+
82
+ return contract
scxp/errors.py ADDED
@@ -0,0 +1,105 @@
1
+ """Protocol error codes, error factories, and SDK exceptions (doc 03 §6).
2
+
3
+ Codes mirror protocol.yaml. ``ProtocolErrors`` builds wire ``ProtocolError``
4
+ payloads; the exception types are what the SDK raises to the caller.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+ from .wire import ProtocolError
12
+
13
+ # --- Error codes (doc 03 §6 / protocol.yaml) ---------------------------------
14
+
15
+ VERSION_MISMATCH = -32001
16
+ ROLE_CONFLICT = -32002
17
+ PREMATURE_METHOD = -32003
18
+ DUPLICATE_ID = -32004
19
+ UNKNOWN_METHOD = -32601
20
+ INVALID_PARAMS = -32602
21
+ INTERNAL_ERROR = -32603
22
+
23
+
24
+ # --- Exceptions --------------------------------------------------------------
25
+
26
+ class ScxpError(Exception):
27
+ """Base class for every error raised by the SDK."""
28
+
29
+
30
+ class ScxpProtocolError(ScxpError):
31
+ """Carries a wire ``ProtocolError`` (an incoming Error envelope or a raised one)."""
32
+
33
+ def __init__(self, error: ProtocolError) -> None:
34
+ self.error = error
35
+ super().__init__(f"[{error.code}] {error.message}")
36
+
37
+ @property
38
+ def code(self) -> int:
39
+ return self.error.code
40
+
41
+ @property
42
+ def message(self) -> str:
43
+ return self.error.message
44
+
45
+ @property
46
+ def data(self) -> Optional[Any]:
47
+ return self.error.data
48
+
49
+
50
+ class HandshakeError(ScxpProtocolError):
51
+ """Raised when the initialize handshake fails (version/role/layer-1 error)."""
52
+
53
+
54
+ class ConnectionClosedError(ScxpError):
55
+ """Raised on in-flight requests when the underlying channel closes."""
56
+
57
+
58
+ # --- Factories ---------------------------------------------------------------
59
+
60
+ class ProtocolErrors:
61
+ """Builders for the standard wire ``ProtocolError`` payloads."""
62
+
63
+ @staticmethod
64
+ def version_mismatch(
65
+ message: str = "No common protocol version.", data: Optional[Any] = None
66
+ ) -> ProtocolError:
67
+ return ProtocolError(code=VERSION_MISMATCH, message=message, data=data)
68
+
69
+ @staticmethod
70
+ def role_conflict(
71
+ message: str = "Both sides declared the same role.", data: Optional[Any] = None
72
+ ) -> ProtocolError:
73
+ return ProtocolError(code=ROLE_CONFLICT, message=message, data=data)
74
+
75
+ @staticmethod
76
+ def premature_method(
77
+ message: str = "A method was sent before the handshake completed.",
78
+ data: Optional[Any] = None,
79
+ ) -> ProtocolError:
80
+ return ProtocolError(code=PREMATURE_METHOD, message=message, data=data)
81
+
82
+ @staticmethod
83
+ def duplicate_id(
84
+ message: str = "A request with this id is already in flight.",
85
+ data: Optional[Any] = None,
86
+ ) -> ProtocolError:
87
+ return ProtocolError(code=DUPLICATE_ID, message=message, data=data)
88
+
89
+ @staticmethod
90
+ def unknown_method(
91
+ message: str = "Unknown method.", data: Optional[Any] = None
92
+ ) -> ProtocolError:
93
+ return ProtocolError(code=UNKNOWN_METHOD, message=message, data=data)
94
+
95
+ @staticmethod
96
+ def invalid_params(
97
+ message: str = "Invalid params.", data: Optional[Any] = None
98
+ ) -> ProtocolError:
99
+ return ProtocolError(code=INVALID_PARAMS, message=message, data=data)
100
+
101
+ @staticmethod
102
+ def internal_error(
103
+ message: str = "Internal error.", data: Optional[Any] = None
104
+ ) -> ProtocolError:
105
+ return ProtocolError(code=INTERNAL_ERROR, message=message, data=data)