tigrcorn-contract 0.3.16.dev5__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.
- tigrcorn_contract/__init__.py +151 -0
- tigrcorn_contract/classification.py +104 -0
- tigrcorn_contract/compat.py +183 -0
- tigrcorn_contract/events.py +273 -0
- tigrcorn_contract/metadata.py +206 -0
- tigrcorn_contract/py.typed +1 -0
- tigrcorn_contract/scopes.py +35 -0
- tigrcorn_contract-0.3.16.dev5.dist-info/METADATA +237 -0
- tigrcorn_contract-0.3.16.dev5.dist-info/RECORD +12 -0
- tigrcorn_contract-0.3.16.dev5.dist-info/WHEEL +5 -0
- tigrcorn_contract-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
- tigrcorn_contract-0.3.16.dev5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tigrcorn_core.errors import ProtocolError
|
|
8
|
+
|
|
9
|
+
HTTP_EVENT_MAP = {
|
|
10
|
+
"request.open": "http.request",
|
|
11
|
+
"request.body_in": "http.request",
|
|
12
|
+
"request.disconnect": "http.disconnect",
|
|
13
|
+
"response.open": "http.response.start",
|
|
14
|
+
"response.body_out": "http.response.body",
|
|
15
|
+
"response.close": "http.response.body",
|
|
16
|
+
"response.emit_complete": "transport.emit.complete",
|
|
17
|
+
}
|
|
18
|
+
WEBSOCKET_EVENT_MAP = {
|
|
19
|
+
"session.open": "websocket.connect",
|
|
20
|
+
"message.in": "websocket.receive",
|
|
21
|
+
"message.out": "websocket.send",
|
|
22
|
+
"session.accept": "websocket.accept",
|
|
23
|
+
"session.close": "websocket.close",
|
|
24
|
+
"session.disconnect": "websocket.disconnect",
|
|
25
|
+
"session.emit_complete": "transport.emit.complete",
|
|
26
|
+
}
|
|
27
|
+
LIFESPAN_EVENT_MAP = {
|
|
28
|
+
"session.open": "lifespan.startup",
|
|
29
|
+
"session.ready": "lifespan.startup.complete",
|
|
30
|
+
"session.close": "lifespan.shutdown",
|
|
31
|
+
"session.disconnect": "lifespan.shutdown.complete",
|
|
32
|
+
}
|
|
33
|
+
WEBTRANSPORT_EVENT_MAP = {
|
|
34
|
+
"session.open": "webtransport.connect",
|
|
35
|
+
"session.accept": "webtransport.accept",
|
|
36
|
+
"session.close": "webtransport.close",
|
|
37
|
+
"session.disconnect": "webtransport.disconnect",
|
|
38
|
+
"stream.chunk_in": "webtransport.stream.receive",
|
|
39
|
+
"stream.chunk_out": "webtransport.stream.send",
|
|
40
|
+
"datagram.in": "webtransport.datagram.receive",
|
|
41
|
+
"datagram.out": "webtransport.datagram.send",
|
|
42
|
+
"stream.emit_complete": "transport.emit.complete",
|
|
43
|
+
"datagram.emit_complete": "transport.emit.complete",
|
|
44
|
+
"session.emit_complete": "transport.emit.complete",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CompletionLevel(StrEnum):
|
|
49
|
+
ACCEPTED_BY_RUNTIME = "accepted_by_runtime"
|
|
50
|
+
FLUSHED_TO_TRANSPORT = "flushed_to_transport"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CompletionStatus(StrEnum):
|
|
54
|
+
OK = "ok"
|
|
55
|
+
REJECTED = "rejected"
|
|
56
|
+
FAILED = "failed"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class EventOrderRule:
|
|
61
|
+
required_first: str
|
|
62
|
+
allowed_after_close: tuple[str, ...] = ()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _event(event_type: str, **payload: Any) -> dict[str, Any]:
|
|
66
|
+
return {"type": event_type, **payload}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_unit_id(unit_id: str) -> str:
|
|
70
|
+
if not unit_id:
|
|
71
|
+
raise ProtocolError("contract event requires unit_id")
|
|
72
|
+
return unit_id
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def http_request(unit_id: str, *, body: bytes = b"", more_body: bool = False) -> dict[str, Any]:
|
|
76
|
+
return _event("http.request", unit_id=_require_unit_id(unit_id), body=body, more_body=more_body)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def http_disconnect(unit_id: str) -> dict[str, Any]:
|
|
80
|
+
return _event("http.disconnect", unit_id=_require_unit_id(unit_id))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def http_response_start(unit_id: str, *, status: int, headers: list[tuple[bytes, bytes]] | None = None) -> dict[str, Any]:
|
|
84
|
+
if not 100 <= status <= 599:
|
|
85
|
+
raise ProtocolError(f"invalid HTTP response status: {status!r}")
|
|
86
|
+
return _event("http.response.start", unit_id=_require_unit_id(unit_id), status=status, headers=headers or [])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def http_response_body(unit_id: str, *, body: bytes = b"", more_body: bool = False) -> dict[str, Any]:
|
|
90
|
+
return _event("http.response.body", unit_id=_require_unit_id(unit_id), body=body, more_body=more_body)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def http_response_pathsend(
|
|
94
|
+
unit_id: str,
|
|
95
|
+
*,
|
|
96
|
+
path: str,
|
|
97
|
+
headers: list[tuple[bytes, bytes]] | None = None,
|
|
98
|
+
stat_result: dict[str, Any] | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
if not path:
|
|
101
|
+
raise ProtocolError("pathsend event requires path")
|
|
102
|
+
event = _event("http.response.pathsend", unit_id=_require_unit_id(unit_id), path=path)
|
|
103
|
+
if headers is not None:
|
|
104
|
+
event["headers"] = headers
|
|
105
|
+
if stat_result is not None:
|
|
106
|
+
event["stat_result"] = stat_result
|
|
107
|
+
return event
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def websocket_connect(unit_id: str) -> dict[str, Any]:
|
|
111
|
+
return _event("websocket.connect", unit_id=_require_unit_id(unit_id))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def websocket_receive(unit_id: str, *, text: str | None = None, bytes_: bytes | None = None) -> dict[str, Any]:
|
|
115
|
+
if text is None and bytes_ is None:
|
|
116
|
+
raise ProtocolError("websocket receive requires text or bytes")
|
|
117
|
+
return _event("websocket.receive", unit_id=_require_unit_id(unit_id), text=text, bytes=bytes_)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def websocket_send(unit_id: str, *, text: str | None = None, bytes_: bytes | None = None) -> dict[str, Any]:
|
|
121
|
+
if text is None and bytes_ is None:
|
|
122
|
+
raise ProtocolError("websocket send requires text or bytes")
|
|
123
|
+
return _event("websocket.send", unit_id=_require_unit_id(unit_id), text=text, bytes=bytes_)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def websocket_accept(unit_id: str, *, subprotocol: str | None = None) -> dict[str, Any]:
|
|
127
|
+
event = _event("websocket.accept", unit_id=_require_unit_id(unit_id))
|
|
128
|
+
if subprotocol is not None:
|
|
129
|
+
event["subprotocol"] = subprotocol
|
|
130
|
+
return event
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def websocket_disconnect(unit_id: str, *, code: int = 1005, reason: str = "") -> dict[str, Any]:
|
|
134
|
+
return _event("websocket.disconnect", unit_id=_require_unit_id(unit_id), code=code, reason=reason)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def websocket_close(unit_id: str, *, code: int = 1000, reason: str = "") -> dict[str, Any]:
|
|
138
|
+
return _event("websocket.close", unit_id=_require_unit_id(unit_id), code=code, reason=reason)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def lifespan_startup(unit_id: str) -> dict[str, Any]:
|
|
142
|
+
return _event("lifespan.startup", unit_id=_require_unit_id(unit_id))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def lifespan_startup_complete(unit_id: str) -> dict[str, Any]:
|
|
146
|
+
return _event("lifespan.startup.complete", unit_id=_require_unit_id(unit_id))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def lifespan_startup_failed(unit_id: str, *, message: str) -> dict[str, Any]:
|
|
150
|
+
return _event("lifespan.startup.failed", unit_id=_require_unit_id(unit_id), message=message)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def lifespan_shutdown(unit_id: str) -> dict[str, Any]:
|
|
154
|
+
return _event("lifespan.shutdown", unit_id=_require_unit_id(unit_id))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def lifespan_shutdown_complete(unit_id: str) -> dict[str, Any]:
|
|
158
|
+
return _event("lifespan.shutdown.complete", unit_id=_require_unit_id(unit_id))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def lifespan_shutdown_failed(unit_id: str, *, message: str) -> dict[str, Any]:
|
|
162
|
+
return _event("lifespan.shutdown.failed", unit_id=_require_unit_id(unit_id), message=message)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def map_contract_event(binding: str, subevent: str) -> str:
|
|
166
|
+
maps = {
|
|
167
|
+
"http": HTTP_EVENT_MAP,
|
|
168
|
+
"http.stream": HTTP_EVENT_MAP,
|
|
169
|
+
"websocket": WEBSOCKET_EVENT_MAP,
|
|
170
|
+
"lifespan": LIFESPAN_EVENT_MAP,
|
|
171
|
+
"webtransport": WEBTRANSPORT_EVENT_MAP,
|
|
172
|
+
}
|
|
173
|
+
event_map = maps.get(binding)
|
|
174
|
+
if event_map is None:
|
|
175
|
+
raise ProtocolError(f"unsupported contract event binding: {binding!r}")
|
|
176
|
+
try:
|
|
177
|
+
return event_map[subevent]
|
|
178
|
+
except KeyError as exc:
|
|
179
|
+
raise ProtocolError(f"illegal subevent {subevent!r} for binding {binding!r}") from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def stream_receive(stream_id: str, data: bytes, *, more: bool = False) -> dict[str, Any]:
|
|
183
|
+
return _event("transport.stream.receive", stream_id=stream_id, data=data, more=more)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def stream_send(stream_id: str, data: bytes, *, more: bool = False) -> dict[str, Any]:
|
|
187
|
+
return _event("transport.stream.send", stream_id=stream_id, data=data, more=more)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def datagram_receive(datagram_id: str, data: bytes, *, flow_controlled: bool = False) -> dict[str, Any]:
|
|
191
|
+
return _event("transport.datagram.receive", datagram_id=datagram_id, data=data, flow_controlled=flow_controlled)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def datagram_send(datagram_id: str, data: bytes, *, flow_controlled: bool = False) -> dict[str, Any]:
|
|
195
|
+
return _event("transport.datagram.send", datagram_id=datagram_id, data=data, flow_controlled=flow_controlled)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def webtransport_connect(session_id: str) -> dict[str, Any]:
|
|
199
|
+
return _event("webtransport.connect", session_id=session_id)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def webtransport_accept(session_id: str) -> dict[str, Any]:
|
|
203
|
+
return _event("webtransport.accept", session_id=session_id)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def webtransport_disconnect(session_id: str, *, code: int = 0, reason: str = "") -> dict[str, Any]:
|
|
207
|
+
return _event("webtransport.disconnect", session_id=session_id, code=code, reason=reason)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def webtransport_close(session_id: str, *, code: int = 0, reason: str = "") -> dict[str, Any]:
|
|
211
|
+
return _event("webtransport.close", session_id=session_id, code=code, reason=reason)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def webtransport_stream_receive(session_id: str, stream_id: str, data: bytes, *, more: bool = False) -> dict[str, Any]:
|
|
215
|
+
return _event("webtransport.stream.receive", session_id=session_id, stream_id=stream_id, data=data, more=more)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def webtransport_stream_send(session_id: str, stream_id: str, data: bytes, *, more: bool = False) -> dict[str, Any]:
|
|
219
|
+
return _event("webtransport.stream.send", session_id=session_id, stream_id=stream_id, data=data, more=more)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def webtransport_datagram_receive(session_id: str, datagram_id: str, data: bytes) -> dict[str, Any]:
|
|
223
|
+
return _event("webtransport.datagram.receive", session_id=session_id, datagram_id=datagram_id, data=data)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def webtransport_datagram_send(session_id: str, datagram_id: str, data: bytes) -> dict[str, Any]:
|
|
227
|
+
return _event("webtransport.datagram.send", session_id=session_id, datagram_id=datagram_id, data=data)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def emit_complete(
|
|
231
|
+
unit_id: str,
|
|
232
|
+
*,
|
|
233
|
+
level: str | CompletionLevel = CompletionLevel.FLUSHED_TO_TRANSPORT,
|
|
234
|
+
status: str | CompletionStatus = CompletionStatus.OK,
|
|
235
|
+
detail: str | None = None,
|
|
236
|
+
) -> dict[str, Any]:
|
|
237
|
+
try:
|
|
238
|
+
completion_level = CompletionLevel(_normalize_completion_level(str(level)))
|
|
239
|
+
except ValueError as exc:
|
|
240
|
+
raise ProtocolError(f"unsupported completion level: {level!r}") from exc
|
|
241
|
+
try:
|
|
242
|
+
completion_status = CompletionStatus(str(status))
|
|
243
|
+
except ValueError as exc:
|
|
244
|
+
raise ProtocolError(f"unsupported completion status: {status!r}") from exc
|
|
245
|
+
event = _event("transport.emit.complete", unit_id=unit_id, level=completion_level.value, status=completion_status.value)
|
|
246
|
+
if detail:
|
|
247
|
+
event["detail"] = detail
|
|
248
|
+
return event
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _normalize_completion_level(level: str) -> str:
|
|
252
|
+
aliases = {
|
|
253
|
+
"buffered": CompletionLevel.ACCEPTED_BY_RUNTIME.value,
|
|
254
|
+
"accepted": CompletionLevel.ACCEPTED_BY_RUNTIME.value,
|
|
255
|
+
"flushed": CompletionLevel.FLUSHED_TO_TRANSPORT.value,
|
|
256
|
+
"transport": CompletionLevel.FLUSHED_TO_TRANSPORT.value,
|
|
257
|
+
"acknowledged": CompletionLevel.FLUSHED_TO_TRANSPORT.value,
|
|
258
|
+
}
|
|
259
|
+
return aliases.get(level, level)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def validate_event_order(events: list[dict[str, Any]], *, required_first: str, terminal_prefixes: tuple[str, ...]) -> None:
|
|
263
|
+
if not events:
|
|
264
|
+
raise ProtocolError("contract event sequence is empty")
|
|
265
|
+
if events[0].get("type") != required_first:
|
|
266
|
+
raise ProtocolError(f"first contract event must be {required_first}")
|
|
267
|
+
closed = False
|
|
268
|
+
for event in events:
|
|
269
|
+
event_type = str(event.get("type", ""))
|
|
270
|
+
if closed:
|
|
271
|
+
raise ProtocolError("contract event emitted after terminal event")
|
|
272
|
+
if event_type.startswith(terminal_prefixes):
|
|
273
|
+
closed = True
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from tigrcorn_core.errors import ProtocolError
|
|
7
|
+
|
|
8
|
+
EndpointKind = Literal["tcp", "uds", "fd", "pipe", "inproc"]
|
|
9
|
+
IdentityKind = Literal["tcp", "unix", "quic", "http2", "http3", "webtransport-session", "webtransport-stream", "datagram"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class EndpointMetadata:
|
|
14
|
+
kind: EndpointKind
|
|
15
|
+
address: str | None = None
|
|
16
|
+
port: int | None = None
|
|
17
|
+
fd: int | None = None
|
|
18
|
+
pipe_name: str | None = None
|
|
19
|
+
inproc_name: str | None = None
|
|
20
|
+
|
|
21
|
+
def as_dict(self) -> dict[str, Any]:
|
|
22
|
+
return {key: value for key, value in {
|
|
23
|
+
"kind": self.kind,
|
|
24
|
+
"address": self.address,
|
|
25
|
+
"port": self.port,
|
|
26
|
+
"fd": self.fd,
|
|
27
|
+
"pipe_name": self.pipe_name,
|
|
28
|
+
"inproc_name": self.inproc_name,
|
|
29
|
+
}.items() if value is not None}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ConnectionIdentity:
|
|
34
|
+
kind: IdentityKind
|
|
35
|
+
connection_id: str
|
|
36
|
+
peer: str | None = None
|
|
37
|
+
local: str | None = None
|
|
38
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def as_dict(self) -> dict[str, Any]:
|
|
41
|
+
payload = {"kind": self.kind, "connection_id": self.connection_id, **self.metadata}
|
|
42
|
+
if self.peer is not None:
|
|
43
|
+
payload["peer"] = self.peer
|
|
44
|
+
if self.local is not None:
|
|
45
|
+
payload["local"] = self.local
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class StreamIdentity:
|
|
51
|
+
kind: IdentityKind
|
|
52
|
+
connection_id: str
|
|
53
|
+
stream_id: str
|
|
54
|
+
session_id: str | None = None
|
|
55
|
+
datagram_id: str | None = None
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> dict[str, Any]:
|
|
58
|
+
payload = {"kind": self.kind, "connection_id": self.connection_id, "stream_id": self.stream_id}
|
|
59
|
+
if self.session_id is not None:
|
|
60
|
+
payload["session_id"] = self.session_id
|
|
61
|
+
if self.datagram_id is not None:
|
|
62
|
+
payload["datagram_id"] = self.datagram_id
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class UnitIdentity:
|
|
68
|
+
unit_id: str
|
|
69
|
+
family: str
|
|
70
|
+
binding: str
|
|
71
|
+
connection_id: str | None = None
|
|
72
|
+
stream_id: str | None = None
|
|
73
|
+
session_id: str | None = None
|
|
74
|
+
datagram_id: str | None = None
|
|
75
|
+
|
|
76
|
+
def as_dict(self) -> dict[str, Any]:
|
|
77
|
+
payload = {"unit_id": self.unit_id, "family": self.family, "binding": self.binding}
|
|
78
|
+
for key in ("connection_id", "stream_id", "session_id", "datagram_id"):
|
|
79
|
+
value = getattr(self, key)
|
|
80
|
+
if value is not None:
|
|
81
|
+
payload[key] = value
|
|
82
|
+
return payload
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class SecurityMetadata:
|
|
87
|
+
tls: bool = False
|
|
88
|
+
mtls: bool = False
|
|
89
|
+
alpn: str | None = None
|
|
90
|
+
sni: str | None = None
|
|
91
|
+
peer_certificate: str | None = None
|
|
92
|
+
ocsp_status: str | None = None
|
|
93
|
+
crl_status: str | None = None
|
|
94
|
+
|
|
95
|
+
def as_dict(self) -> dict[str, Any]:
|
|
96
|
+
return {key: value for key, value in {
|
|
97
|
+
"tls": self.tls,
|
|
98
|
+
"mtls": self.mtls,
|
|
99
|
+
"alpn": self.alpn,
|
|
100
|
+
"sni": self.sni,
|
|
101
|
+
"peer_certificate": self.peer_certificate,
|
|
102
|
+
"ocsp_status": self.ocsp_status,
|
|
103
|
+
"crl_status": self.crl_status,
|
|
104
|
+
}.items() if value not in (None, False)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def endpoint_metadata(kind: str, **fields: Any) -> EndpointMetadata:
|
|
108
|
+
try:
|
|
109
|
+
endpoint_kind = kind.strip().lower() # type: ignore[assignment]
|
|
110
|
+
except AttributeError as exc:
|
|
111
|
+
raise ProtocolError("endpoint kind must be a string") from exc
|
|
112
|
+
if endpoint_kind not in {"tcp", "uds", "fd", "pipe", "inproc"}:
|
|
113
|
+
raise ProtocolError(f"unsupported endpoint kind: {kind!r}")
|
|
114
|
+
metadata = EndpointMetadata(kind=endpoint_kind, **fields) # type: ignore[arg-type]
|
|
115
|
+
validate_endpoint_metadata(metadata)
|
|
116
|
+
return metadata
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_endpoint_metadata(metadata: EndpointMetadata) -> None:
|
|
120
|
+
if metadata.kind == "tcp" and (not metadata.address or metadata.port is None):
|
|
121
|
+
raise ProtocolError("tcp endpoint metadata requires address and port")
|
|
122
|
+
if metadata.kind == "uds" and not metadata.address:
|
|
123
|
+
raise ProtocolError("uds endpoint metadata requires socket path")
|
|
124
|
+
if metadata.kind == "fd" and metadata.fd is None:
|
|
125
|
+
raise ProtocolError("fd endpoint metadata requires fd")
|
|
126
|
+
if metadata.kind == "pipe" and not metadata.pipe_name:
|
|
127
|
+
raise ProtocolError("pipe endpoint metadata requires pipe_name")
|
|
128
|
+
if metadata.kind == "inproc" and not metadata.inproc_name:
|
|
129
|
+
raise ProtocolError("inproc endpoint metadata requires inproc_name")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def transport_identity(kind: str, connection_id: str, **fields: Any) -> ConnectionIdentity:
|
|
133
|
+
normalized = kind.strip().lower()
|
|
134
|
+
if normalized not in {"tcp", "unix", "quic"}:
|
|
135
|
+
raise ProtocolError(f"unsupported connection identity kind: {kind!r}")
|
|
136
|
+
if not connection_id:
|
|
137
|
+
raise ProtocolError("connection identity requires connection_id")
|
|
138
|
+
return ConnectionIdentity(kind=normalized, connection_id=connection_id, **fields) # type: ignore[arg-type]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def stream_identity(kind: str, connection_id: str, stream_id: str, **fields: Any) -> StreamIdentity:
|
|
142
|
+
normalized = kind.strip().lower()
|
|
143
|
+
if normalized not in {"http2", "http3", "webtransport-session", "webtransport-stream"}:
|
|
144
|
+
raise ProtocolError(f"unsupported stream identity kind: {kind!r}")
|
|
145
|
+
if not connection_id or not stream_id:
|
|
146
|
+
raise ProtocolError("stream identity requires connection_id and stream_id")
|
|
147
|
+
return StreamIdentity(kind=normalized, connection_id=connection_id, stream_id=stream_id, **fields) # type: ignore[arg-type]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def datagram_identity(connection_id: str, datagram_id: str, *, session_id: str | None = None) -> StreamIdentity:
|
|
151
|
+
if not connection_id or not datagram_id:
|
|
152
|
+
raise ProtocolError("datagram identity requires connection_id and datagram_id")
|
|
153
|
+
return StreamIdentity(kind="datagram", connection_id=connection_id, stream_id=datagram_id, datagram_id=datagram_id, session_id=session_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def unit_identity(unit_id: str, *, family: str, binding: str, **fields: Any) -> UnitIdentity:
|
|
157
|
+
if not unit_id:
|
|
158
|
+
raise ProtocolError("unit identity requires unit_id")
|
|
159
|
+
normalized_family = family.strip().lower()
|
|
160
|
+
if normalized_family not in {"request", "session", "message", "stream", "datagram"}:
|
|
161
|
+
raise ProtocolError(f"unsupported unit family: {family!r}")
|
|
162
|
+
normalized_binding = binding.strip().lower()
|
|
163
|
+
if normalized_binding not in {"http", "http.stream", "websocket", "lifespan", "webtransport", "stream", "datagram"}:
|
|
164
|
+
raise ProtocolError(f"unsupported unit binding: {binding!r}")
|
|
165
|
+
return UnitIdentity(unit_id=unit_id, family=normalized_family, binding=normalized_binding, **fields)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def security_metadata(**fields: Any) -> SecurityMetadata:
|
|
169
|
+
metadata = SecurityMetadata(**fields)
|
|
170
|
+
if metadata.mtls and not metadata.tls:
|
|
171
|
+
raise ProtocolError("mTLS metadata requires TLS metadata")
|
|
172
|
+
return metadata
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def require_lossless_metadata(name: str, value: Any) -> Any:
|
|
176
|
+
if value in (None, "", (), [], {}):
|
|
177
|
+
raise ProtocolError(f"required metadata would be lossy: {name}")
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def asgi3_extensions(
|
|
182
|
+
*,
|
|
183
|
+
endpoint: EndpointMetadata | None = None,
|
|
184
|
+
transport: ConnectionIdentity | StreamIdentity | None = None,
|
|
185
|
+
security: SecurityMetadata | None = None,
|
|
186
|
+
stream: StreamIdentity | None = None,
|
|
187
|
+
datagram: StreamIdentity | None = None,
|
|
188
|
+
completion: dict[str, Any] | None = None,
|
|
189
|
+
unit: UnitIdentity | None = None,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
extensions: dict[str, Any] = {}
|
|
192
|
+
if endpoint is not None:
|
|
193
|
+
extensions["tigrcorn.endpoint"] = endpoint.as_dict()
|
|
194
|
+
if transport is not None:
|
|
195
|
+
extensions["tigrcorn.transport"] = transport.as_dict()
|
|
196
|
+
if security is not None:
|
|
197
|
+
extensions["tigrcorn.security"] = security.as_dict()
|
|
198
|
+
if stream is not None:
|
|
199
|
+
extensions["tigrcorn.stream"] = stream.as_dict()
|
|
200
|
+
if datagram is not None:
|
|
201
|
+
extensions["tigrcorn.datagram"] = datagram.as_dict()
|
|
202
|
+
if completion is not None:
|
|
203
|
+
extensions["tigrcorn.emit_completion"] = completion
|
|
204
|
+
if unit is not None:
|
|
205
|
+
extensions["tigrcorn.unit"] = unit.as_dict()
|
|
206
|
+
return extensions
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tigrcorn_asgi.scopes.custom import build_custom_scope
|
|
6
|
+
from tigrcorn_core.errors import ProtocolError
|
|
7
|
+
|
|
8
|
+
SUPPORTED_SCOPE_TYPES = ("http", "websocket", "lifespan", "webtransport", "tigrcorn.stream", "tigrcorn.datagram")
|
|
9
|
+
_PATH_SCOPE_TYPES = {"http", "websocket", "webtransport", "tigrcorn.stream"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_scope(scope: dict[str, Any]) -> None:
|
|
13
|
+
scope_type = scope.get("type")
|
|
14
|
+
if scope_type not in SUPPORTED_SCOPE_TYPES:
|
|
15
|
+
raise ProtocolError(f"unsupported contract scope type: {scope_type!r}")
|
|
16
|
+
if scope_type in _PATH_SCOPE_TYPES and not isinstance(scope.get("path", "/"), str):
|
|
17
|
+
raise ProtocolError(f"{scope_type} scope path must be a string")
|
|
18
|
+
if scope_type == "http" and scope.get("method") is not None and not isinstance(scope["method"], str):
|
|
19
|
+
raise ProtocolError("http scope method must be a string")
|
|
20
|
+
if scope_type == "websocket" and scope.get("subprotocols") is not None and not isinstance(scope["subprotocols"], list):
|
|
21
|
+
raise ProtocolError("websocket scope subprotocols must be a list")
|
|
22
|
+
if scope_type == "lifespan" and scope.get("state") is not None and not isinstance(scope["state"], dict):
|
|
23
|
+
raise ProtocolError("lifespan scope state must be a mapping")
|
|
24
|
+
if scope_type == "webtransport":
|
|
25
|
+
extensions = scope.get("extensions", {})
|
|
26
|
+
if "h3" not in extensions or "quic" not in extensions:
|
|
27
|
+
raise ProtocolError("webtransport scope requires h3 and quic extension metadata")
|
|
28
|
+
if scope_type == "tigrcorn.datagram" and "datagram_id" in scope and not scope["datagram_id"]:
|
|
29
|
+
raise ProtocolError("datagram scope datagram_id must not be empty")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def contract_scope(scope_type: str, **fields: Any) -> dict[str, Any]:
|
|
33
|
+
scope = build_custom_scope(scope_type, **fields)
|
|
34
|
+
validate_scope(scope)
|
|
35
|
+
return scope
|