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,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .classification import (
|
|
4
|
+
BindingClassification,
|
|
5
|
+
FamilyCapability,
|
|
6
|
+
classify_binding,
|
|
7
|
+
family_capability,
|
|
8
|
+
runtime_interface_available,
|
|
9
|
+
validate_binding_legality,
|
|
10
|
+
)
|
|
11
|
+
from .compat import (
|
|
12
|
+
CompatibilityParityRow,
|
|
13
|
+
HTTPFeatureContractMap,
|
|
14
|
+
alt_svc_contract_map,
|
|
15
|
+
asgi3_compat_scope,
|
|
16
|
+
asgi_extension_bridge,
|
|
17
|
+
compatibility_feature_parity,
|
|
18
|
+
content_coding_contract_map,
|
|
19
|
+
early_hints_contract_map,
|
|
20
|
+
http_feature_contract_map,
|
|
21
|
+
observability_contract_metadata,
|
|
22
|
+
proxy_normalization_contract_map,
|
|
23
|
+
static_delivery_contract_map,
|
|
24
|
+
trailers_contract_map,
|
|
25
|
+
)
|
|
26
|
+
from .events import (
|
|
27
|
+
CompletionLevel,
|
|
28
|
+
CompletionStatus,
|
|
29
|
+
datagram_receive,
|
|
30
|
+
datagram_send,
|
|
31
|
+
emit_complete,
|
|
32
|
+
http_disconnect,
|
|
33
|
+
http_request,
|
|
34
|
+
http_response_body,
|
|
35
|
+
http_response_pathsend,
|
|
36
|
+
http_response_start,
|
|
37
|
+
lifespan_shutdown,
|
|
38
|
+
lifespan_shutdown_complete,
|
|
39
|
+
lifespan_shutdown_failed,
|
|
40
|
+
lifespan_startup,
|
|
41
|
+
lifespan_startup_complete,
|
|
42
|
+
lifespan_startup_failed,
|
|
43
|
+
map_contract_event,
|
|
44
|
+
stream_receive,
|
|
45
|
+
stream_send,
|
|
46
|
+
validate_event_order,
|
|
47
|
+
websocket_accept,
|
|
48
|
+
websocket_close,
|
|
49
|
+
websocket_connect,
|
|
50
|
+
websocket_disconnect,
|
|
51
|
+
websocket_receive,
|
|
52
|
+
websocket_send,
|
|
53
|
+
webtransport_accept,
|
|
54
|
+
webtransport_close,
|
|
55
|
+
webtransport_connect,
|
|
56
|
+
webtransport_datagram_receive,
|
|
57
|
+
webtransport_datagram_send,
|
|
58
|
+
webtransport_disconnect,
|
|
59
|
+
webtransport_stream_receive,
|
|
60
|
+
webtransport_stream_send,
|
|
61
|
+
)
|
|
62
|
+
from .metadata import (
|
|
63
|
+
ConnectionIdentity,
|
|
64
|
+
EndpointMetadata,
|
|
65
|
+
SecurityMetadata,
|
|
66
|
+
StreamIdentity,
|
|
67
|
+
UnitIdentity,
|
|
68
|
+
asgi3_extensions,
|
|
69
|
+
datagram_identity,
|
|
70
|
+
endpoint_metadata,
|
|
71
|
+
require_lossless_metadata,
|
|
72
|
+
security_metadata,
|
|
73
|
+
stream_identity,
|
|
74
|
+
transport_identity,
|
|
75
|
+
unit_identity,
|
|
76
|
+
validate_endpoint_metadata,
|
|
77
|
+
)
|
|
78
|
+
from .scopes import SUPPORTED_SCOPE_TYPES, contract_scope, validate_scope
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"BindingClassification",
|
|
82
|
+
"CompletionLevel",
|
|
83
|
+
"CompletionStatus",
|
|
84
|
+
"ConnectionIdentity",
|
|
85
|
+
"EndpointMetadata",
|
|
86
|
+
"FamilyCapability",
|
|
87
|
+
"CompatibilityParityRow",
|
|
88
|
+
"HTTPFeatureContractMap",
|
|
89
|
+
"SUPPORTED_SCOPE_TYPES",
|
|
90
|
+
"SecurityMetadata",
|
|
91
|
+
"StreamIdentity",
|
|
92
|
+
"UnitIdentity",
|
|
93
|
+
"alt_svc_contract_map",
|
|
94
|
+
"asgi3_extensions",
|
|
95
|
+
"asgi3_compat_scope",
|
|
96
|
+
"asgi_extension_bridge",
|
|
97
|
+
"classify_binding",
|
|
98
|
+
"compatibility_feature_parity",
|
|
99
|
+
"contract_scope",
|
|
100
|
+
"content_coding_contract_map",
|
|
101
|
+
"datagram_identity",
|
|
102
|
+
"datagram_receive",
|
|
103
|
+
"datagram_send",
|
|
104
|
+
"emit_complete",
|
|
105
|
+
"endpoint_metadata",
|
|
106
|
+
"early_hints_contract_map",
|
|
107
|
+
"family_capability",
|
|
108
|
+
"http_disconnect",
|
|
109
|
+
"http_feature_contract_map",
|
|
110
|
+
"http_request",
|
|
111
|
+
"http_response_body",
|
|
112
|
+
"http_response_pathsend",
|
|
113
|
+
"http_response_start",
|
|
114
|
+
"lifespan_shutdown",
|
|
115
|
+
"lifespan_shutdown_complete",
|
|
116
|
+
"lifespan_shutdown_failed",
|
|
117
|
+
"lifespan_startup",
|
|
118
|
+
"lifespan_startup_complete",
|
|
119
|
+
"lifespan_startup_failed",
|
|
120
|
+
"map_contract_event",
|
|
121
|
+
"observability_contract_metadata",
|
|
122
|
+
"proxy_normalization_contract_map",
|
|
123
|
+
"require_lossless_metadata",
|
|
124
|
+
"runtime_interface_available",
|
|
125
|
+
"security_metadata",
|
|
126
|
+
"static_delivery_contract_map",
|
|
127
|
+
"stream_identity",
|
|
128
|
+
"stream_receive",
|
|
129
|
+
"stream_send",
|
|
130
|
+
"transport_identity",
|
|
131
|
+
"trailers_contract_map",
|
|
132
|
+
"unit_identity",
|
|
133
|
+
"validate_binding_legality",
|
|
134
|
+
"validate_endpoint_metadata",
|
|
135
|
+
"validate_event_order",
|
|
136
|
+
"validate_scope",
|
|
137
|
+
"websocket_accept",
|
|
138
|
+
"websocket_close",
|
|
139
|
+
"websocket_connect",
|
|
140
|
+
"websocket_disconnect",
|
|
141
|
+
"websocket_receive",
|
|
142
|
+
"websocket_send",
|
|
143
|
+
"webtransport_accept",
|
|
144
|
+
"webtransport_close",
|
|
145
|
+
"webtransport_connect",
|
|
146
|
+
"webtransport_datagram_receive",
|
|
147
|
+
"webtransport_datagram_send",
|
|
148
|
+
"webtransport_disconnect",
|
|
149
|
+
"webtransport_stream_receive",
|
|
150
|
+
"webtransport_stream_send",
|
|
151
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from tigrcorn_core.errors import ConfigError
|
|
7
|
+
|
|
8
|
+
BindingKind = Literal["http", "websocket", "lifespan", "webtransport", "stream", "datagram", "rest", "jsonrpc", "sse"]
|
|
9
|
+
|
|
10
|
+
_SERVER_OWNED_RUNTIMES = {"http", "websocket", "lifespan", "webtransport", "stream", "datagram"}
|
|
11
|
+
_CLASSIFICATION_ONLY = {"rest", "jsonrpc", "sse"}
|
|
12
|
+
_SUPPORTED_APP_INTERFACES = {"auto", "tigr-asgi-contract", "asgi3"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class BindingClassification:
|
|
17
|
+
kind: BindingKind
|
|
18
|
+
runtime_owned: bool
|
|
19
|
+
classification_only: bool
|
|
20
|
+
dispatch_runtime: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class FamilyCapability:
|
|
25
|
+
family: str
|
|
26
|
+
bindings: tuple[str, ...]
|
|
27
|
+
subevents: tuple[str, ...]
|
|
28
|
+
exchanges: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_FAMILY_CAPABILITIES = {
|
|
32
|
+
"request": FamilyCapability(
|
|
33
|
+
family="request",
|
|
34
|
+
bindings=("http", "http.stream", "rest", "jsonrpc"),
|
|
35
|
+
subevents=("request.open", "request.body_in", "request.chunk_in", "request.close", "request.disconnect"),
|
|
36
|
+
exchanges=("unary", "server_stream"),
|
|
37
|
+
),
|
|
38
|
+
"session": FamilyCapability(
|
|
39
|
+
family="session",
|
|
40
|
+
bindings=("websocket", "webtransport", "lifespan"),
|
|
41
|
+
subevents=("session.open", "session.accept", "session.ready", "session.heartbeat", "session.close", "session.disconnect"),
|
|
42
|
+
exchanges=("duplex",),
|
|
43
|
+
),
|
|
44
|
+
"message": FamilyCapability(
|
|
45
|
+
family="message",
|
|
46
|
+
bindings=("websocket", "sse", "jsonrpc"),
|
|
47
|
+
subevents=("message.in", "message.decode", "message.handle", "message.out", "message.ack", "message.nack"),
|
|
48
|
+
exchanges=("unary", "server_stream", "duplex"),
|
|
49
|
+
),
|
|
50
|
+
"stream": FamilyCapability(
|
|
51
|
+
family="stream",
|
|
52
|
+
bindings=("http.stream", "webtransport", "stream"),
|
|
53
|
+
subevents=("stream.open", "stream.chunk_in", "stream.chunk_out", "stream.flush", "stream.finalize", "stream.abort", "stream.close"),
|
|
54
|
+
exchanges=("server_stream", "duplex"),
|
|
55
|
+
),
|
|
56
|
+
"datagram": FamilyCapability(
|
|
57
|
+
family="datagram",
|
|
58
|
+
bindings=("webtransport", "datagram"),
|
|
59
|
+
subevents=("datagram.in", "datagram.handle", "datagram.out", "datagram.ack", "datagram.close"),
|
|
60
|
+
exchanges=("duplex",),
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def classify_binding(kind: str) -> BindingClassification:
|
|
66
|
+
normalized = kind.strip().lower().replace("_", "-")
|
|
67
|
+
if normalized == "json-rpc":
|
|
68
|
+
normalized = "jsonrpc"
|
|
69
|
+
if normalized not in _SERVER_OWNED_RUNTIMES | _CLASSIFICATION_ONLY:
|
|
70
|
+
raise ConfigError(f"unsupported binding classification: {kind!r}")
|
|
71
|
+
return BindingClassification(
|
|
72
|
+
kind=normalized, # type: ignore[arg-type]
|
|
73
|
+
runtime_owned=normalized in _SERVER_OWNED_RUNTIMES,
|
|
74
|
+
classification_only=normalized in _CLASSIFICATION_ONLY,
|
|
75
|
+
dispatch_runtime="application" if normalized in _CLASSIFICATION_ONLY else "tigrcorn",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def runtime_interface_available(interface: str) -> bool:
|
|
80
|
+
normalized = interface.strip().lower().replace("_", "-")
|
|
81
|
+
if normalized == "jsonrpc":
|
|
82
|
+
normalized = "json-rpc"
|
|
83
|
+
return normalized in _SUPPORTED_APP_INTERFACES
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def family_capability(family: str) -> FamilyCapability:
|
|
87
|
+
normalized = family.strip().lower()
|
|
88
|
+
try:
|
|
89
|
+
return _FAMILY_CAPABILITIES[normalized]
|
|
90
|
+
except KeyError as exc:
|
|
91
|
+
raise ConfigError(f"unsupported contract family: {family!r}") from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_binding_legality(*, binding: str, family: str, subevent: str | None = None, exchange: str | None = None) -> None:
|
|
95
|
+
normalized_binding = binding.strip().lower()
|
|
96
|
+
if normalized_binding == "json-rpc":
|
|
97
|
+
normalized_binding = "jsonrpc"
|
|
98
|
+
capability = family_capability(family)
|
|
99
|
+
if normalized_binding not in capability.bindings:
|
|
100
|
+
raise ConfigError(f"binding {binding!r} is illegal for family {family!r}")
|
|
101
|
+
if subevent is not None and subevent not in capability.subevents and not subevent.endswith(".emit_complete"):
|
|
102
|
+
raise ConfigError(f"subevent {subevent!r} is illegal for family {family!r}")
|
|
103
|
+
if exchange is not None and exchange not in capability.exchanges:
|
|
104
|
+
raise ConfigError(f"exchange {exchange!r} is illegal for family {family!r}")
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
from .metadata import UnitIdentity, asgi3_extensions
|
|
9
|
+
from .scopes import validate_scope
|
|
10
|
+
|
|
11
|
+
HTTPFeatureKind = Literal[
|
|
12
|
+
"alt-svc",
|
|
13
|
+
"content-coding",
|
|
14
|
+
"early-hints",
|
|
15
|
+
"proxy-normalization",
|
|
16
|
+
"static-delivery",
|
|
17
|
+
"trailers",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_HTTP_FEATURE_EVENTS = {
|
|
21
|
+
"alt-svc": ("http.response.start",),
|
|
22
|
+
"content-coding": ("http.response.start", "http.response.body"),
|
|
23
|
+
"early-hints": ("http.response.start",),
|
|
24
|
+
"proxy-normalization": ("http.request",),
|
|
25
|
+
"static-delivery": ("http.response.pathsend", "http.response.body"),
|
|
26
|
+
"trailers": ("http.request.trailers", "http.response.trailers"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class CompatibilityParityRow:
|
|
32
|
+
feature_id: str
|
|
33
|
+
native_contract: bool
|
|
34
|
+
asgi3_compat: bool
|
|
35
|
+
notes: str = ""
|
|
36
|
+
|
|
37
|
+
def as_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"feature_id": self.feature_id,
|
|
40
|
+
"native_contract": self.native_contract,
|
|
41
|
+
"asgi3_compat": self.asgi3_compat,
|
|
42
|
+
"notes": self.notes,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class HTTPFeatureContractMap:
|
|
48
|
+
feature: HTTPFeatureKind
|
|
49
|
+
contract_events: tuple[str, ...]
|
|
50
|
+
asgi_extensions: tuple[str, ...] = ()
|
|
51
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"feature": self.feature,
|
|
56
|
+
"contract_events": list(self.contract_events),
|
|
57
|
+
"asgi_extensions": list(self.asgi_extensions),
|
|
58
|
+
"metadata": dict(self.metadata),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def asgi3_compat_scope(scope: dict[str, Any], *, extensions: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
63
|
+
validate_scope(scope)
|
|
64
|
+
compat_scope = dict(scope)
|
|
65
|
+
merged_extensions = dict(scope.get("extensions") or {})
|
|
66
|
+
merged_extensions.update(extensions or {})
|
|
67
|
+
merged_extensions.setdefault("tigrcorn.compat", {"interface": "asgi3", "native_contract": False})
|
|
68
|
+
compat_scope["extensions"] = merged_extensions
|
|
69
|
+
return compat_scope
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def asgi_extension_bridge(
|
|
73
|
+
*,
|
|
74
|
+
unit: UnitIdentity | None = None,
|
|
75
|
+
capabilities: dict[str, Any] | None = None,
|
|
76
|
+
feature_maps: list[HTTPFeatureContractMap] | None = None,
|
|
77
|
+
**extension_parts: Any,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
bridge = asgi3_extensions(unit=unit, **extension_parts)
|
|
80
|
+
bridge["tigrcorn.capabilities"] = dict(capabilities or {})
|
|
81
|
+
bridge["tigrcorn.http_features"] = [item.as_dict() for item in feature_maps or []]
|
|
82
|
+
return bridge
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compatibility_feature_parity(feature_id: str, *, native_contract: bool, asgi3_compat: bool, notes: str = "") -> CompatibilityParityRow:
|
|
86
|
+
if not feature_id.startswith("feat:"):
|
|
87
|
+
raise ProtocolError("compatibility parity rows require a feature id")
|
|
88
|
+
return CompatibilityParityRow(
|
|
89
|
+
feature_id=feature_id,
|
|
90
|
+
native_contract=native_contract,
|
|
91
|
+
asgi3_compat=asgi3_compat,
|
|
92
|
+
notes=notes,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def http_feature_contract_map(
|
|
97
|
+
feature: str,
|
|
98
|
+
*,
|
|
99
|
+
asgi_extensions: tuple[str, ...] = (),
|
|
100
|
+
metadata: dict[str, Any] | None = None,
|
|
101
|
+
) -> HTTPFeatureContractMap:
|
|
102
|
+
normalized = feature.strip().lower().replace("_", "-")
|
|
103
|
+
if normalized not in _HTTP_FEATURE_EVENTS:
|
|
104
|
+
raise ProtocolError(f"unsupported HTTP contract feature map: {feature!r}")
|
|
105
|
+
return HTTPFeatureContractMap(
|
|
106
|
+
feature=normalized, # type: ignore[arg-type]
|
|
107
|
+
contract_events=_HTTP_FEATURE_EVENTS[normalized],
|
|
108
|
+
asgi_extensions=asgi_extensions,
|
|
109
|
+
metadata=dict(metadata or {}),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def alt_svc_contract_map(value: str, *, max_age: int | None = None, persist: bool = False) -> HTTPFeatureContractMap:
|
|
114
|
+
if not value:
|
|
115
|
+
raise ProtocolError("Alt-Svc contract map requires a header value")
|
|
116
|
+
metadata: dict[str, Any] = {"header": value, "persist": persist}
|
|
117
|
+
if max_age is not None:
|
|
118
|
+
metadata["max_age"] = max_age
|
|
119
|
+
return http_feature_contract_map("alt-svc", metadata=metadata)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def content_coding_contract_map(codings: tuple[str, ...]) -> HTTPFeatureContractMap:
|
|
123
|
+
if not codings:
|
|
124
|
+
raise ProtocolError("content-coding contract map requires at least one coding")
|
|
125
|
+
return http_feature_contract_map("content-coding", metadata={"codings": list(codings)})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def early_hints_contract_map(headers: list[tuple[bytes, bytes]]) -> HTTPFeatureContractMap:
|
|
129
|
+
if not headers:
|
|
130
|
+
raise ProtocolError("early-hints contract map requires headers")
|
|
131
|
+
return http_feature_contract_map("early-hints", metadata={"status": 103, "headers": headers})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def proxy_normalization_contract_map(*, trusted: bool, forwarded_for: str | None = None, scheme: str | None = None) -> HTTPFeatureContractMap:
|
|
135
|
+
metadata = {"trusted": trusted}
|
|
136
|
+
if forwarded_for is not None:
|
|
137
|
+
metadata["forwarded_for"] = forwarded_for
|
|
138
|
+
if scheme is not None:
|
|
139
|
+
metadata["scheme"] = scheme
|
|
140
|
+
return http_feature_contract_map("proxy-normalization", metadata=metadata)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def static_delivery_contract_map(path: str, *, pathsend: bool = True, range_request: bool = False, etag: str | None = None) -> HTTPFeatureContractMap:
|
|
144
|
+
if not path.startswith("/"):
|
|
145
|
+
raise ProtocolError("static delivery contract path must be absolute")
|
|
146
|
+
metadata: dict[str, Any] = {"path": path, "pathsend": pathsend, "range_request": range_request}
|
|
147
|
+
if etag is not None:
|
|
148
|
+
metadata["etag"] = etag
|
|
149
|
+
return http_feature_contract_map(
|
|
150
|
+
"static-delivery",
|
|
151
|
+
asgi_extensions=("http.response.pathsend",) if pathsend else (),
|
|
152
|
+
metadata=metadata,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def trailers_contract_map(*, request: bool = False, response: bool = False) -> HTTPFeatureContractMap:
|
|
157
|
+
if not request and not response:
|
|
158
|
+
raise ProtocolError("trailers contract map requires request or response trailers")
|
|
159
|
+
extensions = []
|
|
160
|
+
if request:
|
|
161
|
+
extensions.append("tigrcorn.http.request_trailers")
|
|
162
|
+
if response:
|
|
163
|
+
extensions.append("http.response.trailers")
|
|
164
|
+
return http_feature_contract_map("trailers", asgi_extensions=tuple(extensions), metadata={"request": request, "response": response})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def observability_contract_metadata(
|
|
168
|
+
*,
|
|
169
|
+
unit_id: str,
|
|
170
|
+
feature_id: str,
|
|
171
|
+
boundary_id: str,
|
|
172
|
+
attributes: dict[str, Any] | None = None,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
if not unit_id or not feature_id.startswith("feat:") or not boundary_id.startswith("bnd:"):
|
|
175
|
+
raise ProtocolError("observability contract metadata requires unit, feature, and boundary ids")
|
|
176
|
+
return {
|
|
177
|
+
"tigrcorn.observability": {
|
|
178
|
+
"unit_id": unit_id,
|
|
179
|
+
"feature_id": feature_id,
|
|
180
|
+
"boundary_id": boundary_id,
|
|
181
|
+
"attributes": dict(attributes or {}),
|
|
182
|
+
}
|
|
183
|
+
}
|