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