tigrbl-core 0.1.12.dev1__tar.gz → 0.4.0.dev2__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.
Files changed (55) hide show
  1. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/PKG-INFO +10 -1
  2. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/README.md +4 -0
  3. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/pyproject.toml +10 -2
  4. tigrbl_core-0.4.0.dev2/tigrbl_core/_spec/binding_spec.py +269 -0
  5. tigrbl_core-0.4.0.dev2/tigrbl_core/_spec/hook_spec.py +149 -0
  6. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/hook_types.py +12 -3
  7. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/serde.py +5 -0
  8. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/session_spec.py +8 -3
  9. tigrbl_core-0.4.0.dev2/tigrbl_core/canonical_json.py +46 -0
  10. tigrbl_core-0.1.12.dev1/tigrbl_core/_spec/binding_spec.py +0 -133
  11. tigrbl_core-0.1.12.dev1/tigrbl_core/_spec/hook_spec.py +0 -48
  12. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/__init__.py +0 -0
  13. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/alias_spec.py +0 -0
  14. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/app_spec.py +0 -0
  15. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/column_spec.py +0 -0
  16. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/datatypes.py +0 -0
  17. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/engine_spec.py +0 -0
  18. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/field_spec.py +0 -0
  19. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/io_spec.py +0 -0
  20. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/middleware_spec.py +0 -0
  21. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/monotone.py +0 -0
  22. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/op_spec.py +0 -0
  23. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/op_utils.py +0 -0
  24. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/plugins.py +0 -0
  25. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/registry.py +0 -0
  26. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/request_spec.py +0 -0
  27. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/response_resolver.py +0 -0
  28. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/response_spec.py +0 -0
  29. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/response_types.py +0 -0
  30. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/router_spec.py +0 -0
  31. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/schema_spec.py +0 -0
  32. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/storage_spec.py +0 -0
  33. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/table_registry_spec.py +0 -0
  34. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/_spec/table_spec.py +0 -0
  35. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/config/__init__.py +0 -0
  36. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/config/constants.py +0 -0
  37. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/config/defaults.py +0 -0
  38. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/config/engine_traversal.py +0 -0
  39. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/config/resolver.py +0 -0
  40. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/core/__init__.py +0 -0
  41. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/op/__init__.py +0 -0
  42. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/op/canonical.py +0 -0
  43. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/op/collect.py +0 -0
  44. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/op/types.py +0 -0
  45. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/__init__.py +0 -0
  46. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/__init__.py +0 -0
  47. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/build_schema.py +0 -0
  48. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/cache.py +0 -0
  49. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/extras.py +0 -0
  50. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/helpers.py +0 -0
  51. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/list_params.py +0 -0
  52. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/builder/strip_parent_fields.py +0 -0
  53. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/get_schema.py +0 -0
  54. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/spec_json.py +0 -0
  55. {tigrbl_core-0.1.12.dev1 → tigrbl_core-0.4.0.dev2}/tigrbl_core/schema/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrbl-core
3
- Version: 0.1.12.dev1
3
+ Version: 0.4.0.dev2
4
4
  Summary: Core specifications and primitives for the Tigrbl framework.
5
5
  License-Expression: Apache-2.0
6
6
  Keywords: tigrbl,sdk,standards,framework
@@ -20,6 +20,11 @@ Requires-Dist: pyyaml
20
20
  Requires-Dist: tigrbl-typing
21
21
  Requires-Dist: tomli (>=2.0.1) ; python_version < "3.11"
22
22
  Requires-Dist: tomli-w
23
+ Project-URL: Discord, https://discord.gg/K4YTAPapjR
24
+ Project-URL: Homepage, https://github.com/tigrbl/tigrbl
25
+ Project-URL: Issues, https://github.com/tigrbl/tigrbl/issues
26
+ Project-URL: Organization, https://github.com/tigrbl
27
+ Project-URL: Repository, https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_core
23
28
  Description-Content-Type: text/markdown
24
29
 
25
30
  # tigrbl_core
@@ -40,6 +45,10 @@ It is not the authoritative location for repository governance, current target s
40
45
 
41
46
  ## Package identity
42
47
 
48
+ - canonical repository: `https://github.com/tigrbl/tigrbl`
49
+ - organization: `https://github.com/tigrbl`
50
+ - social: `https://discord.gg/K4YTAPapjR`
51
+ - package path: `https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_core`
43
52
  - workspace path: `pkgs/core/tigrbl_core`
44
53
  - workspace class: core Python package
45
54
  - implementation layout: `tigrbl_core/`
@@ -16,6 +16,10 @@ It is not the authoritative location for repository governance, current target s
16
16
 
17
17
  ## Package identity
18
18
 
19
+ - canonical repository: `https://github.com/tigrbl/tigrbl`
20
+ - organization: `https://github.com/tigrbl`
21
+ - social: `https://discord.gg/K4YTAPapjR`
22
+ - package path: `https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_core`
19
23
  - workspace path: `pkgs/core/tigrbl_core`
20
24
  - workspace class: core Python package
21
25
  - implementation layout: `tigrbl_core/`
@@ -1,10 +1,9 @@
1
1
  [project]
2
2
  name = "tigrbl-core"
3
- version = "0.1.12.dev1"
3
+ version = "0.4.0.dev2"
4
4
  description = "Core specifications and primitives for the Tigrbl framework."
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
7
- repository = "http://github.com/swarmauri/swarmauri-sdk"
8
7
  requires-python = ">=3.10,<3.14"
9
8
  classifiers = [
10
9
  "Development Status :: 1 - Planning",
@@ -17,6 +16,7 @@ classifiers = [
17
16
  "Programming Language :: Python :: 3 :: Only",
18
17
  ]
19
18
  authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
19
+
20
20
  dependencies = [
21
21
  "tigrbl-typing",
22
22
  "pydantic>=2.10,<3",
@@ -26,6 +26,14 @@ dependencies = [
26
26
  ]
27
27
  keywords = ["tigrbl", "sdk", "standards", "framework"]
28
28
 
29
+
30
+ [project.urls]
31
+ Organization = "https://github.com/tigrbl"
32
+ Discord = "https://discord.gg/K4YTAPapjR"
33
+ Homepage = "https://github.com/tigrbl/tigrbl"
34
+ Repository = "https://github.com/tigrbl/tigrbl/tree/master/pkgs/core/tigrbl_core"
35
+ Issues = "https://github.com/tigrbl/tigrbl/issues"
36
+
29
37
  [tool.uv.sources]
30
38
  "tigrbl-typing" = { workspace = true }
31
39
 
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, Optional, Type, Union
5
+
6
+ from ..config.constants import (
7
+ TIGRBL_NESTED_PATHS_ATTR,
8
+ __JSONRPC_DEFAULT_ENDPOINT__,
9
+ )
10
+ from .serde import SerdeMixin
11
+
12
+ Exchange = Literal[
13
+ "request_response",
14
+ "server_stream",
15
+ "event_stream",
16
+ "client_stream",
17
+ "bidirectional",
18
+ "bidirectional_stream",
19
+ "fire_and_forget",
20
+ ]
21
+ Framing = Literal["json", "jsonrpc", "sse", "stream", "text", "bytes", "webtransport"]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class HttpRestBindingSpec(SerdeMixin):
26
+ proto: Literal["http.rest", "https.rest"]
27
+ methods: tuple[str, ...]
28
+ path: str
29
+ exchange: Exchange = "request_response"
30
+ framing: Framing = "json"
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class HttpJsonRpcBindingSpec(SerdeMixin):
35
+ proto: Literal["http.jsonrpc", "https.jsonrpc"]
36
+ rpc_method: str
37
+ endpoint: str = __JSONRPC_DEFAULT_ENDPOINT__
38
+ exchange: Exchange = "request_response"
39
+ framing: Framing = "jsonrpc"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class HttpStreamBindingSpec(SerdeMixin):
44
+ proto: Literal["http.stream", "https.stream"]
45
+ path: str
46
+ methods: tuple[str, ...] = ("GET",)
47
+ exchange: Exchange = "server_stream"
48
+ framing: Framing = "stream"
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class SseBindingSpec(SerdeMixin):
53
+ proto: Literal["http.sse", "https.sse"] = "http.sse"
54
+ path: str = "/"
55
+ methods: tuple[str, ...] = ("GET",)
56
+ exchange: Exchange = "server_stream"
57
+ framing: Framing = "sse"
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class WsBindingSpec(SerdeMixin):
62
+ proto: Literal["ws", "wss"]
63
+ path: str
64
+ subprotocols: tuple[str, ...] = ()
65
+ exchange: Exchange = "bidirectional_stream"
66
+ framing: Framing = "text"
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class WebTransportBindingSpec(SerdeMixin):
71
+ proto: Literal["webtransport"] = "webtransport"
72
+ path: str = "/"
73
+ exchange: Exchange = "bidirectional_stream"
74
+ framing: Framing = "webtransport"
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class MessageBindingSpec(SerdeMixin):
79
+ proto: str
80
+ topic: str
81
+ exchange: Exchange = "fire_and_forget"
82
+ framing: Framing = "bytes"
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class DatagramBindingSpec(SerdeMixin):
87
+ proto: str
88
+ endpoint: str
89
+ exchange: Exchange = "fire_and_forget"
90
+ framing: Framing = "bytes"
91
+
92
+
93
+ TransportBindingSpec = Union[
94
+ HttpRestBindingSpec,
95
+ HttpJsonRpcBindingSpec,
96
+ HttpStreamBindingSpec,
97
+ SseBindingSpec,
98
+ WsBindingSpec,
99
+ WebTransportBindingSpec,
100
+ MessageBindingSpec,
101
+ DatagramBindingSpec,
102
+ ]
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class BindingSpec(SerdeMixin):
107
+ """Named binding declaration used for registry composition."""
108
+
109
+ name: str
110
+ spec: TransportBindingSpec
111
+
112
+
113
+ @dataclass(slots=True)
114
+ class BindingRegistrySpec(SerdeMixin):
115
+ """Simple in-memory registry for named transport bindings."""
116
+
117
+ _bindings: dict[str, BindingSpec] = field(default_factory=dict)
118
+
119
+ def register(self, binding: BindingSpec) -> None:
120
+ self._bindings[binding.name] = binding
121
+
122
+ def get(self, name: str) -> Optional[BindingSpec]:
123
+ return self._bindings.get(name)
124
+
125
+ def values(self) -> tuple[BindingSpec, ...]:
126
+ return tuple(self._bindings.values())
127
+
128
+
129
+ def resolve_rest_nested_prefix(model: Type) -> Optional[str]:
130
+ """Return the configured nested REST prefix for ``model`` if present."""
131
+
132
+ cb = getattr(model, TIGRBL_NESTED_PATHS_ATTR, None)
133
+ if callable(cb):
134
+ return cb()
135
+ return getattr(model, "_nested_path", None)
136
+
137
+
138
+ _EXCHANGE_ALIASES = {
139
+ "bidirectional": "bidirectional_stream",
140
+ "event_stream": "server_stream",
141
+ }
142
+ _CANONICAL_EXCHANGES = {
143
+ "request_response",
144
+ "server_stream",
145
+ "client_stream",
146
+ "bidirectional_stream",
147
+ "fire_and_forget",
148
+ }
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class BindingEventKey:
153
+ family: str
154
+ family_code: int
155
+
156
+
157
+ def normalize_exchange(exchange: str | None) -> str:
158
+ token = str(exchange or "")
159
+ normalized = _EXCHANGE_ALIASES.get(token, token)
160
+ if normalized not in _CANONICAL_EXCHANGES:
161
+ raise ValueError(f"invalid exchange token {exchange!r}; expected canonical exchange")
162
+ return normalized
163
+
164
+
165
+ def matches_exchange_selector(*, selector: str, exchange: str | None) -> bool:
166
+ return normalize_exchange(selector) == normalize_exchange(exchange)
167
+
168
+
169
+ def project_binding_runtime_metadata(binding: TransportBindingSpec) -> dict[str, object]:
170
+ proto = str(getattr(binding, "proto", ""))
171
+ exchange = normalize_exchange(getattr(binding, "exchange", None))
172
+ framing = str(getattr(binding, "framing", ""))
173
+ family = _binding_family(binding)
174
+ _validate_binding_exchange(family, exchange)
175
+ return {
176
+ "proto": proto,
177
+ "exchange": exchange,
178
+ "framing": framing,
179
+ "family": family,
180
+ "subevents": _binding_subevents(family),
181
+ }
182
+
183
+
184
+ def compile_binding_event_key(binding: TransportBindingSpec) -> BindingEventKey:
185
+ family = str(project_binding_runtime_metadata(binding)["family"])
186
+ codes = {
187
+ "request_response": 10,
188
+ "rpc": 11,
189
+ "stream": 20,
190
+ "event_stream": 21,
191
+ "socket": 30,
192
+ "transport": 31,
193
+ "message": 40,
194
+ "datagram": 41,
195
+ }
196
+ return BindingEventKey(family=family, family_code=codes[family])
197
+
198
+
199
+ def _binding_family(binding: TransportBindingSpec) -> str:
200
+ if isinstance(binding, HttpJsonRpcBindingSpec):
201
+ return "rpc"
202
+ if isinstance(binding, HttpStreamBindingSpec):
203
+ return "stream"
204
+ if isinstance(binding, SseBindingSpec):
205
+ return "event_stream"
206
+ if isinstance(binding, WsBindingSpec):
207
+ return "socket"
208
+ if isinstance(binding, WebTransportBindingSpec):
209
+ return "transport"
210
+ if isinstance(binding, MessageBindingSpec):
211
+ return "message"
212
+ if isinstance(binding, DatagramBindingSpec):
213
+ return "datagram"
214
+ return "request_response"
215
+
216
+
217
+ def _binding_subevents(family: str) -> tuple[str, ...]:
218
+ subevents = {
219
+ "request_response": ("request.received", "response.sent"),
220
+ "rpc": ("rpc.request", "rpc.response"),
221
+ "stream": ("stream.open", "stream.message", "stream.close"),
222
+ "event_stream": ("event_stream.open", "event_stream.event", "event_stream.close"),
223
+ "socket": ("socket.open", "socket.message", "socket.close"),
224
+ "transport": ("transport.open", "transport.datagram", "transport.close"),
225
+ "message": ("message.received", "message.processed"),
226
+ "datagram": ("datagram.received", "datagram.ack"),
227
+ }
228
+ return subevents[family]
229
+
230
+
231
+ def _validate_binding_exchange(family: str, exchange: str) -> None:
232
+ allowed = {
233
+ "request_response": {"request_response"},
234
+ "rpc": {"request_response"},
235
+ "stream": {"server_stream"},
236
+ "event_stream": {"server_stream"},
237
+ "socket": {"bidirectional_stream"},
238
+ "transport": {"bidirectional_stream"},
239
+ "message": {"fire_and_forget"},
240
+ "datagram": {"fire_and_forget"},
241
+ }
242
+ if exchange not in allowed[family]:
243
+ raise ValueError(
244
+ f"invalid exchange {exchange!r} for binding family {family!r}; "
245
+ "expected canonical family exchange"
246
+ )
247
+
248
+
249
+ __all__ = [
250
+ "BindingSpec",
251
+ "BindingRegistrySpec",
252
+ "BindingEventKey",
253
+ "DatagramBindingSpec",
254
+ "Exchange",
255
+ "Framing",
256
+ "HttpJsonRpcBindingSpec",
257
+ "HttpRestBindingSpec",
258
+ "HttpStreamBindingSpec",
259
+ "MessageBindingSpec",
260
+ "SseBindingSpec",
261
+ "TransportBindingSpec",
262
+ "WebTransportBindingSpec",
263
+ "WsBindingSpec",
264
+ "compile_binding_event_key",
265
+ "matches_exchange_selector",
266
+ "normalize_exchange",
267
+ "project_binding_runtime_metadata",
268
+ "resolve_rest_nested_prefix",
269
+ ]
@@ -0,0 +1,149 @@
1
+ """Hook specification for Tigrbl v3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Optional, Tuple
7
+
8
+ from .binding_spec import Exchange
9
+ from .hook_types import HookPhase, HookPredicate, StepFn
10
+
11
+ from .serde import SerdeMixin
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class HookSpec(SerdeMixin):
16
+ phase: HookPhase
17
+ fn: StepFn
18
+ ops: str | Tuple[str, ...] = "*"
19
+ bindings: Tuple[str, ...] = ()
20
+ framing: Tuple[str, ...] = ()
21
+ exchange: Optional[Exchange] = None
22
+ family: Tuple[str, ...] = ()
23
+ subevents: Tuple[str, ...] = ()
24
+ order: int = 0
25
+ when: Optional[HookPredicate] = None
26
+ name: Optional[str] = None
27
+ description: Optional[str] = None
28
+
29
+ @classmethod
30
+ def collect(cls, owner: type) -> tuple["HookSpec", ...]:
31
+ hooks: list[HookSpec] = []
32
+ for hook in getattr(owner, "HOOKS", ()) or ():
33
+ if isinstance(hook, HookSpec):
34
+ hooks.append(hook)
35
+ elif isinstance(hook, dict):
36
+ hooks.append(cls(**hook))
37
+ for _, attr in vars(owner).items():
38
+ attr = getattr(attr, "__func__", attr)
39
+ for item in tuple(getattr(attr, "__tigrbl_hook_decls__", ()) or ()):
40
+ if isinstance(item, HookSpec):
41
+ hooks.append(item)
42
+ elif isinstance(item, dict):
43
+ hooks.append(cls(**item))
44
+ declared = getattr(attr, "__tigrbl_hook_spec__", None)
45
+ if isinstance(declared, HookSpec):
46
+ hooks.append(declared)
47
+ elif isinstance(declared, dict):
48
+ hooks.append(cls(**declared))
49
+ return tuple(hooks)
50
+
51
+
52
+ _RUNTIME_OWNED_PHASES = frozenset(
53
+ {
54
+ "INGRESS_BEGIN",
55
+ "INGRESS_PARSE",
56
+ "INGRESS_ROUTE",
57
+ "INGRESS_DISPATCH",
58
+ "EGRESS_SHAPE",
59
+ "EGRESS_FINALIZE",
60
+ "EMIT",
61
+ "POST_EMIT",
62
+ }
63
+ )
64
+ _EXCHANGE_ALIASES = {
65
+ "event_stream": "server_stream",
66
+ "sse": "server_stream",
67
+ "stream": "server_stream",
68
+ }
69
+
70
+
71
+ def _as_tuple(value: Any) -> tuple[str, ...]:
72
+ if value in (None, "", ()):
73
+ return ()
74
+ if isinstance(value, str):
75
+ return (value,)
76
+ return tuple(str(item) for item in value)
77
+
78
+
79
+ def _hook_phase_value(value: Any) -> str:
80
+ return str(getattr(value, "value", value))
81
+
82
+
83
+ def _normalize_exchange(value: Any) -> str | None:
84
+ if value in (None, ""):
85
+ return None
86
+ token = str(value)
87
+ return _EXCHANGE_ALIASES.get(token, token)
88
+
89
+
90
+ def validate_hook_legality(hook: HookSpec) -> None:
91
+ phase = _hook_phase_value(getattr(hook, "phase", None))
92
+ if phase in _RUNTIME_OWNED_PHASES:
93
+ if phase == "POST_EMIT":
94
+ raise ValueError(f"{phase} is a runtime-owned completion fence, not a hook phase")
95
+ raise ValueError(f"{phase} is a runtime-owned canonical phase, not a hook phase")
96
+ try:
97
+ HookPhase(phase)
98
+ except ValueError as exc:
99
+ raise ValueError(f"{phase} is not a governed hook phase") from exc
100
+
101
+
102
+ def validate_hook_selector_legality(selector: dict[str, Any]) -> None:
103
+ phase = selector.get("phase")
104
+ if phase is not None:
105
+ validate_hook_legality(HookSpec(phase=phase, fn=lambda _ctx: None)) # type: ignore[arg-type]
106
+
107
+
108
+ def matches_hook_selector(hook: HookSpec, metadata: dict[str, Any]) -> bool:
109
+ validate_hook_legality(hook)
110
+ hook_ops = getattr(hook, "ops", "*")
111
+ if hook_ops != "*":
112
+ op = metadata.get("op") or metadata.get("alias") or metadata.get("method")
113
+ if op not in _as_tuple(hook_ops):
114
+ return False
115
+
116
+ bindings = _as_tuple(getattr(hook, "bindings", ()))
117
+ if bindings and str(metadata.get("binding")) not in bindings:
118
+ return False
119
+
120
+ hook_exchange = _normalize_exchange(getattr(hook, "exchange", None))
121
+ metadata_exchange = _normalize_exchange(metadata.get("exchange"))
122
+ if hook_exchange is not None and metadata_exchange != hook_exchange:
123
+ return False
124
+
125
+ families = _as_tuple(getattr(hook, "family", ()))
126
+ if families and str(metadata.get("family")) not in families:
127
+ return False
128
+
129
+ subevents = _as_tuple(getattr(hook, "subevents", ()))
130
+ if subevents and str(metadata.get("subevent")) not in subevents:
131
+ return False
132
+
133
+ framings = _as_tuple(getattr(hook, "framing", ()))
134
+ if framings and str(metadata.get("framing")) not in framings:
135
+ return False
136
+
137
+ return True
138
+
139
+
140
+ # Backwards compatibility alias
141
+ OpHook = HookSpec
142
+
143
+ __all__ = [
144
+ "HookSpec",
145
+ "OpHook",
146
+ "matches_hook_selector",
147
+ "validate_hook_legality",
148
+ "validate_hook_selector_legality",
149
+ ]
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  from enum import Enum
4
4
  from typing import Any, Awaitable, Callable, Tuple
5
5
 
6
+ from tigrbl_typing.phases import normalize_phase
7
+
6
8
 
7
9
  class HookPhase(str, Enum):
8
10
  PRE_TX_BEGIN = "PRE_TX_BEGIN"
@@ -11,7 +13,7 @@ class HookPhase(str, Enum):
11
13
  HANDLER = "HANDLER"
12
14
  POST_HANDLER = "POST_HANDLER"
13
15
  PRE_COMMIT = "PRE_COMMIT"
14
- END_TX = "END_TX"
16
+ TX_COMMIT = "TX_COMMIT"
15
17
  POST_COMMIT = "POST_COMMIT"
16
18
  POST_RESPONSE = "POST_RESPONSE"
17
19
  ON_ERROR = "ON_ERROR"
@@ -21,10 +23,17 @@ class HookPhase(str, Enum):
21
23
  ON_HANDLER_ERROR = "ON_HANDLER_ERROR"
22
24
  ON_POST_HANDLER_ERROR = "ON_POST_HANDLER_ERROR"
23
25
  ON_PRE_COMMIT_ERROR = "ON_PRE_COMMIT_ERROR"
24
- ON_END_TX_ERROR = "ON_END_TX_ERROR"
26
+ ON_TX_COMMIT_ERROR = "ON_TX_COMMIT_ERROR"
25
27
  ON_POST_COMMIT_ERROR = "ON_POST_COMMIT_ERROR"
26
28
  ON_POST_RESPONSE_ERROR = "ON_POST_RESPONSE_ERROR"
27
- ON_ROLLBACK = "ON_ROLLBACK"
29
+ TX_ROLLBACK = "TX_ROLLBACK"
30
+
31
+ @classmethod
32
+ def _missing_(cls, value: object):
33
+ normalized = normalize_phase(str(value)) if value is not None else None
34
+ if normalized != value:
35
+ return cls(normalized)
36
+ return None
28
37
 
29
38
 
30
39
  HookPhases: Tuple[HookPhase, ...] = tuple(HookPhase)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from base64 import b64decode, b64encode
4
5
  from dataclasses import fields, is_dataclass
5
6
  from importlib import import_module
6
7
  from typing import Any, TypeVar
@@ -25,6 +26,8 @@ def _resolve_path(path: str) -> Any:
25
26
  def _serialize_value(value: Any) -> Any:
26
27
  if value is None or isinstance(value, (bool, int, float, str)):
27
28
  return value
29
+ if isinstance(value, bytes):
30
+ return {"__bytes__": b64encode(value).decode("ascii")}
28
31
  if is_dataclass(value):
29
32
  payload = {
30
33
  f.name: _serialize_value(getattr(value, f.name)) for f in fields(value)
@@ -53,6 +56,8 @@ def _deserialize_value(value: Any) -> Any:
53
56
  if isinstance(value, dict):
54
57
  if "__tuple__" in value:
55
58
  return tuple(_deserialize_value(item) for item in value["__tuple__"])
59
+ if "__bytes__" in value:
60
+ return b64decode(value["__bytes__"].encode("ascii"))
56
61
  if "__class__" in value:
57
62
  return _resolve_path(value["__class__"])
58
63
  if "__callable__" in value:
@@ -71,7 +71,7 @@ class SessionSpec(SerdeMixin):
71
71
 
72
72
  def merge(self, higher: "SessionSpec | Mapping[str, Any] | None") -> "SessionSpec":
73
73
  """
74
- Overlay another spec on top of this one (non-None fields take precedence).
74
+ Overlay another spec on top of this one (explicit fields take precedence).
75
75
  Use to implement op > model > router > app precedence.
76
76
  """
77
77
  if higher is None:
@@ -79,13 +79,18 @@ class SessionSpec(SerdeMixin):
79
79
  h = higher if isinstance(higher, SessionSpec) else SessionSpec.from_any(higher)
80
80
  if h is None:
81
81
  return self
82
+ defaults = SessionSpec()
82
83
  vals: MutableMapping[str, Any] = {
83
84
  f.name: getattr(self, f.name) for f in fields(SessionSpec)
84
85
  }
85
86
  for f in fields(SessionSpec):
86
87
  hv = getattr(h, f.name)
87
- if hv is not None:
88
- vals[f.name] = hv
88
+ if hv is None:
89
+ continue
90
+ default = getattr(defaults, f.name)
91
+ if hv == default and vals[f.name] != default:
92
+ continue
93
+ vals[f.name] = hv
89
94
  return SessionSpec(**vals) # type: ignore[arg-type]
90
95
 
91
96
  def to_kwargs(self) -> dict[str, Any]:
@@ -0,0 +1,46 @@
1
+ """RFC 8785-style canonical JSON helpers for proof-bound payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+
11
+ def _reject_non_finite_numbers(value: Any) -> None:
12
+ if isinstance(value, float) and not math.isfinite(value):
13
+ raise ValueError("JCS canonical JSON requires finite JSON numbers")
14
+ if isinstance(value, Decimal) and not value.is_finite():
15
+ raise ValueError("JCS canonical JSON requires finite JSON numbers")
16
+ if isinstance(value, dict):
17
+ for key, item in value.items():
18
+ if not isinstance(key, str):
19
+ raise TypeError("JCS canonical JSON object keys must be strings")
20
+ _reject_non_finite_numbers(item)
21
+ return
22
+ if isinstance(value, (list, tuple)):
23
+ for item in value:
24
+ _reject_non_finite_numbers(item)
25
+
26
+
27
+ def canonicalize(payload: Any) -> str:
28
+ """Return deterministic UTF-8 JSON text with sorted object members."""
29
+
30
+ _reject_non_finite_numbers(payload)
31
+ return json.dumps(
32
+ payload,
33
+ ensure_ascii=False,
34
+ allow_nan=False,
35
+ separators=(",", ":"),
36
+ sort_keys=True,
37
+ )
38
+
39
+
40
+ def canonical_json_bytes(payload: Any) -> bytes:
41
+ """Return deterministic UTF-8 JSON bytes with JCS rejection semantics."""
42
+
43
+ return canonicalize(payload).encode("utf-8")
44
+
45
+
46
+ __all__ = ["canonical_json_bytes", "canonicalize"]
@@ -1,133 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass, field
4
- from typing import Literal, Optional, Type, Union
5
-
6
- from ..config.constants import (
7
- TIGRBL_NESTED_PATHS_ATTR,
8
- __JSONRPC_DEFAULT_ENDPOINT__,
9
- )
10
- from .serde import SerdeMixin
11
-
12
- Exchange = Literal[
13
- "request_response",
14
- "server_stream",
15
- "event_stream",
16
- "client_stream",
17
- "bidirectional",
18
- "bidirectional_stream",
19
- "fire_and_forget",
20
- ]
21
- Framing = Literal["json", "jsonrpc", "sse", "stream", "text", "bytes", "webtransport"]
22
-
23
-
24
- @dataclass(frozen=True, slots=True)
25
- class HttpRestBindingSpec(SerdeMixin):
26
- proto: Literal["http.rest", "https.rest"]
27
- methods: tuple[str, ...]
28
- path: str
29
- exchange: Exchange = "request_response"
30
- framing: Framing = "json"
31
-
32
-
33
- @dataclass(frozen=True, slots=True)
34
- class HttpJsonRpcBindingSpec(SerdeMixin):
35
- proto: Literal["http.jsonrpc", "https.jsonrpc"]
36
- rpc_method: str
37
- endpoint: str = __JSONRPC_DEFAULT_ENDPOINT__
38
- exchange: Exchange = "request_response"
39
- framing: Framing = "jsonrpc"
40
-
41
-
42
- @dataclass(frozen=True, slots=True)
43
- class HttpStreamBindingSpec(SerdeMixin):
44
- proto: Literal["http.stream", "https.stream"]
45
- path: str
46
- methods: tuple[str, ...] = ("GET",)
47
- exchange: Exchange = "server_stream"
48
- framing: Framing = "stream"
49
-
50
-
51
- @dataclass(frozen=True, slots=True)
52
- class SseBindingSpec(SerdeMixin):
53
- proto: Literal["http.sse", "https.sse"] = "http.sse"
54
- path: str = "/"
55
- methods: tuple[str, ...] = ("GET",)
56
- exchange: Exchange = "server_stream"
57
- framing: Framing = "sse"
58
-
59
-
60
- @dataclass(frozen=True, slots=True)
61
- class WsBindingSpec(SerdeMixin):
62
- proto: Literal["ws", "wss"]
63
- path: str
64
- subprotocols: tuple[str, ...] = ()
65
- exchange: Exchange = "bidirectional_stream"
66
- framing: Framing = "text"
67
-
68
-
69
- @dataclass(frozen=True, slots=True)
70
- class WebTransportBindingSpec(SerdeMixin):
71
- proto: Literal["webtransport"] = "webtransport"
72
- path: str = "/"
73
- exchange: Exchange = "bidirectional_stream"
74
- framing: Framing = "webtransport"
75
-
76
-
77
- TransportBindingSpec = Union[
78
- HttpRestBindingSpec,
79
- HttpJsonRpcBindingSpec,
80
- HttpStreamBindingSpec,
81
- SseBindingSpec,
82
- WsBindingSpec,
83
- WebTransportBindingSpec,
84
- ]
85
-
86
-
87
- @dataclass(frozen=True, slots=True)
88
- class BindingSpec(SerdeMixin):
89
- """Named binding declaration used for registry composition."""
90
-
91
- name: str
92
- spec: TransportBindingSpec
93
-
94
-
95
- @dataclass(slots=True)
96
- class BindingRegistrySpec(SerdeMixin):
97
- """Simple in-memory registry for named transport bindings."""
98
-
99
- _bindings: dict[str, BindingSpec] = field(default_factory=dict)
100
-
101
- def register(self, binding: BindingSpec) -> None:
102
- self._bindings[binding.name] = binding
103
-
104
- def get(self, name: str) -> Optional[BindingSpec]:
105
- return self._bindings.get(name)
106
-
107
- def values(self) -> tuple[BindingSpec, ...]:
108
- return tuple(self._bindings.values())
109
-
110
-
111
- def resolve_rest_nested_prefix(model: Type) -> Optional[str]:
112
- """Return the configured nested REST prefix for ``model`` if present."""
113
-
114
- cb = getattr(model, TIGRBL_NESTED_PATHS_ATTR, None)
115
- if callable(cb):
116
- return cb()
117
- return getattr(model, "_nested_path", None)
118
-
119
-
120
- __all__ = [
121
- "BindingSpec",
122
- "BindingRegistrySpec",
123
- "Exchange",
124
- "Framing",
125
- "HttpJsonRpcBindingSpec",
126
- "HttpRestBindingSpec",
127
- "HttpStreamBindingSpec",
128
- "SseBindingSpec",
129
- "TransportBindingSpec",
130
- "WebTransportBindingSpec",
131
- "WsBindingSpec",
132
- "resolve_rest_nested_prefix",
133
- ]
@@ -1,48 +0,0 @@
1
- """Hook specification for Tigrbl v3."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from typing import Optional, Tuple
7
-
8
- from .binding_spec import Exchange
9
- from .hook_types import HookPhase, HookPredicate, StepFn
10
-
11
- from .serde import SerdeMixin
12
-
13
-
14
- @dataclass(frozen=True, slots=True)
15
- class HookSpec(SerdeMixin):
16
- phase: HookPhase
17
- fn: StepFn
18
- ops: str | Tuple[str, ...] = "*"
19
- bindings: Tuple[str, ...] = ()
20
- exchange: Optional[Exchange] = None
21
- family: Tuple[str, ...] = ()
22
- subevents: Tuple[str, ...] = ()
23
- order: int = 0
24
- when: Optional[HookPredicate] = None
25
- name: Optional[str] = None
26
- description: Optional[str] = None
27
-
28
- @classmethod
29
- def collect(cls, owner: type) -> tuple["HookSpec", ...]:
30
- hooks: list[HookSpec] = []
31
- for hook in getattr(owner, "HOOKS", ()) or ():
32
- if isinstance(hook, HookSpec):
33
- hooks.append(hook)
34
- elif isinstance(hook, dict):
35
- hooks.append(cls(**hook))
36
- for _, attr in vars(owner).items():
37
- declared = getattr(attr, "__tigrbl_hook_spec__", None)
38
- if isinstance(declared, HookSpec):
39
- hooks.append(declared)
40
- elif isinstance(declared, dict):
41
- hooks.append(cls(**declared))
42
- return tuple(hooks)
43
-
44
-
45
- # Backwards compatibility alias
46
- OpHook = HookSpec
47
-
48
- __all__ = ["HookSpec", "OpHook"]