tigrbl 0.0.1.dev1__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. tigrbl/README.md +94 -0
  2. tigrbl/__init__.py +139 -14
  3. tigrbl/api/__init__.py +6 -0
  4. tigrbl/api/_api.py +97 -0
  5. tigrbl/api/api_spec.py +30 -0
  6. tigrbl/api/mro_collect.py +43 -0
  7. tigrbl/api/shortcuts.py +56 -0
  8. tigrbl/api/tigrbl_api.py +291 -0
  9. tigrbl/app/__init__.py +0 -0
  10. tigrbl/app/_app.py +86 -0
  11. tigrbl/app/_model_registry.py +41 -0
  12. tigrbl/app/app_spec.py +42 -0
  13. tigrbl/app/mro_collect.py +67 -0
  14. tigrbl/app/shortcuts.py +65 -0
  15. tigrbl/app/tigrbl_app.py +319 -0
  16. tigrbl/bindings/__init__.py +73 -0
  17. tigrbl/bindings/api/__init__.py +12 -0
  18. tigrbl/bindings/api/common.py +109 -0
  19. tigrbl/bindings/api/include.py +256 -0
  20. tigrbl/bindings/api/resource_proxy.py +149 -0
  21. tigrbl/bindings/api/rpc.py +111 -0
  22. tigrbl/bindings/columns.py +49 -0
  23. tigrbl/bindings/handlers/__init__.py +11 -0
  24. tigrbl/bindings/handlers/builder.py +119 -0
  25. tigrbl/bindings/handlers/ctx.py +74 -0
  26. tigrbl/bindings/handlers/identifiers.py +228 -0
  27. tigrbl/bindings/handlers/namespaces.py +51 -0
  28. tigrbl/bindings/handlers/steps.py +276 -0
  29. tigrbl/bindings/hooks.py +311 -0
  30. tigrbl/bindings/model.py +194 -0
  31. tigrbl/bindings/model_helpers.py +139 -0
  32. tigrbl/bindings/model_registry.py +77 -0
  33. tigrbl/bindings/rest/__init__.py +7 -0
  34. tigrbl/bindings/rest/attach.py +34 -0
  35. tigrbl/bindings/rest/collection.py +286 -0
  36. tigrbl/bindings/rest/common.py +120 -0
  37. tigrbl/bindings/rest/fastapi.py +76 -0
  38. tigrbl/bindings/rest/helpers.py +119 -0
  39. tigrbl/bindings/rest/io.py +317 -0
  40. tigrbl/bindings/rest/io_headers.py +49 -0
  41. tigrbl/bindings/rest/member.py +386 -0
  42. tigrbl/bindings/rest/router.py +296 -0
  43. tigrbl/bindings/rest/routing.py +153 -0
  44. tigrbl/bindings/rpc.py +364 -0
  45. tigrbl/bindings/schemas/__init__.py +11 -0
  46. tigrbl/bindings/schemas/builder.py +348 -0
  47. tigrbl/bindings/schemas/defaults.py +260 -0
  48. tigrbl/bindings/schemas/utils.py +193 -0
  49. tigrbl/column/README.md +62 -0
  50. tigrbl/column/__init__.py +72 -0
  51. tigrbl/column/_column.py +96 -0
  52. tigrbl/column/column_spec.py +40 -0
  53. tigrbl/column/field_spec.py +31 -0
  54. tigrbl/column/infer/__init__.py +25 -0
  55. tigrbl/column/infer/core.py +92 -0
  56. tigrbl/column/infer/jsonhints.py +44 -0
  57. tigrbl/column/infer/planning.py +133 -0
  58. tigrbl/column/infer/types.py +102 -0
  59. tigrbl/column/infer/utils.py +59 -0
  60. tigrbl/column/io_spec.py +136 -0
  61. tigrbl/column/mro_collect.py +59 -0
  62. tigrbl/column/shortcuts.py +89 -0
  63. tigrbl/column/storage_spec.py +65 -0
  64. tigrbl/config/__init__.py +19 -0
  65. tigrbl/config/constants.py +224 -0
  66. tigrbl/config/defaults.py +29 -0
  67. tigrbl/config/resolver.py +295 -0
  68. tigrbl/core/__init__.py +47 -0
  69. tigrbl/core/crud/__init__.py +36 -0
  70. tigrbl/core/crud/bulk.py +168 -0
  71. tigrbl/core/crud/helpers/__init__.py +76 -0
  72. tigrbl/core/crud/helpers/db.py +92 -0
  73. tigrbl/core/crud/helpers/enum.py +86 -0
  74. tigrbl/core/crud/helpers/filters.py +162 -0
  75. tigrbl/core/crud/helpers/model.py +123 -0
  76. tigrbl/core/crud/helpers/normalize.py +99 -0
  77. tigrbl/core/crud/ops.py +235 -0
  78. tigrbl/ddl/__init__.py +344 -0
  79. tigrbl/decorators.py +17 -0
  80. tigrbl/deps/__init__.py +20 -0
  81. tigrbl/deps/fastapi.py +45 -0
  82. tigrbl/deps/favicon.svg +4 -0
  83. tigrbl/deps/jinja.py +27 -0
  84. tigrbl/deps/pydantic.py +10 -0
  85. tigrbl/deps/sqlalchemy.py +94 -0
  86. tigrbl/deps/starlette.py +36 -0
  87. tigrbl/engine/__init__.py +45 -0
  88. tigrbl/engine/_engine.py +144 -0
  89. tigrbl/engine/bind.py +33 -0
  90. tigrbl/engine/builders.py +236 -0
  91. tigrbl/engine/capabilities.py +29 -0
  92. tigrbl/engine/collect.py +111 -0
  93. tigrbl/engine/decorators.py +110 -0
  94. tigrbl/engine/docs/PLUGINS.md +49 -0
  95. tigrbl/engine/engine_spec.py +355 -0
  96. tigrbl/engine/plugins.py +52 -0
  97. tigrbl/engine/registry.py +36 -0
  98. tigrbl/engine/resolver.py +224 -0
  99. tigrbl/engine/shortcuts.py +216 -0
  100. tigrbl/hook/__init__.py +21 -0
  101. tigrbl/hook/_hook.py +22 -0
  102. tigrbl/hook/decorators.py +28 -0
  103. tigrbl/hook/hook_spec.py +24 -0
  104. tigrbl/hook/mro_collect.py +98 -0
  105. tigrbl/hook/shortcuts.py +44 -0
  106. tigrbl/hook/types.py +76 -0
  107. tigrbl/op/__init__.py +50 -0
  108. tigrbl/op/_op.py +31 -0
  109. tigrbl/op/canonical.py +31 -0
  110. tigrbl/op/collect.py +11 -0
  111. tigrbl/op/decorators.py +238 -0
  112. tigrbl/op/model_registry.py +301 -0
  113. tigrbl/op/mro_collect.py +99 -0
  114. tigrbl/op/resolver.py +216 -0
  115. tigrbl/op/types.py +136 -0
  116. tigrbl/orm/__init__.py +1 -0
  117. tigrbl/orm/mixins/_RowBound.py +83 -0
  118. tigrbl/orm/mixins/__init__.py +95 -0
  119. tigrbl/orm/mixins/bootstrappable.py +113 -0
  120. tigrbl/orm/mixins/bound.py +47 -0
  121. tigrbl/orm/mixins/edges.py +40 -0
  122. tigrbl/orm/mixins/fields.py +165 -0
  123. tigrbl/orm/mixins/hierarchy.py +54 -0
  124. tigrbl/orm/mixins/key_digest.py +44 -0
  125. tigrbl/orm/mixins/lifecycle.py +115 -0
  126. tigrbl/orm/mixins/locks.py +51 -0
  127. tigrbl/orm/mixins/markers.py +16 -0
  128. tigrbl/orm/mixins/operations.py +57 -0
  129. tigrbl/orm/mixins/ownable.py +337 -0
  130. tigrbl/orm/mixins/principals.py +98 -0
  131. tigrbl/orm/mixins/tenant_bound.py +301 -0
  132. tigrbl/orm/mixins/upsertable.py +118 -0
  133. tigrbl/orm/mixins/utils.py +49 -0
  134. tigrbl/orm/tables/__init__.py +72 -0
  135. tigrbl/orm/tables/_base.py +8 -0
  136. tigrbl/orm/tables/audit.py +56 -0
  137. tigrbl/orm/tables/client.py +25 -0
  138. tigrbl/orm/tables/group.py +29 -0
  139. tigrbl/orm/tables/org.py +30 -0
  140. tigrbl/orm/tables/rbac.py +76 -0
  141. tigrbl/orm/tables/status.py +106 -0
  142. tigrbl/orm/tables/tenant.py +22 -0
  143. tigrbl/orm/tables/user.py +39 -0
  144. tigrbl/response/README.md +34 -0
  145. tigrbl/response/__init__.py +33 -0
  146. tigrbl/response/bind.py +12 -0
  147. tigrbl/response/decorators.py +37 -0
  148. tigrbl/response/resolver.py +83 -0
  149. tigrbl/response/shortcuts.py +171 -0
  150. tigrbl/response/types.py +49 -0
  151. tigrbl/rest/__init__.py +27 -0
  152. tigrbl/runtime/README.md +129 -0
  153. tigrbl/runtime/__init__.py +20 -0
  154. tigrbl/runtime/atoms/__init__.py +102 -0
  155. tigrbl/runtime/atoms/emit/__init__.py +42 -0
  156. tigrbl/runtime/atoms/emit/paired_post.py +158 -0
  157. tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
  158. tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
  159. tigrbl/runtime/atoms/out/__init__.py +38 -0
  160. tigrbl/runtime/atoms/out/masking.py +135 -0
  161. tigrbl/runtime/atoms/refresh/__init__.py +38 -0
  162. tigrbl/runtime/atoms/refresh/demand.py +130 -0
  163. tigrbl/runtime/atoms/resolve/__init__.py +40 -0
  164. tigrbl/runtime/atoms/resolve/assemble.py +167 -0
  165. tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
  166. tigrbl/runtime/atoms/response/__init__.py +19 -0
  167. tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
  168. tigrbl/runtime/atoms/response/negotiate.py +30 -0
  169. tigrbl/runtime/atoms/response/negotiation.py +43 -0
  170. tigrbl/runtime/atoms/response/render.py +36 -0
  171. tigrbl/runtime/atoms/response/renderer.py +116 -0
  172. tigrbl/runtime/atoms/response/template.py +44 -0
  173. tigrbl/runtime/atoms/response/templates.py +88 -0
  174. tigrbl/runtime/atoms/schema/__init__.py +40 -0
  175. tigrbl/runtime/atoms/schema/collect_in.py +21 -0
  176. tigrbl/runtime/atoms/schema/collect_out.py +21 -0
  177. tigrbl/runtime/atoms/storage/__init__.py +38 -0
  178. tigrbl/runtime/atoms/storage/to_stored.py +167 -0
  179. tigrbl/runtime/atoms/wire/__init__.py +45 -0
  180. tigrbl/runtime/atoms/wire/build_in.py +166 -0
  181. tigrbl/runtime/atoms/wire/build_out.py +87 -0
  182. tigrbl/runtime/atoms/wire/dump.py +206 -0
  183. tigrbl/runtime/atoms/wire/validate_in.py +227 -0
  184. tigrbl/runtime/context.py +206 -0
  185. tigrbl/runtime/errors/__init__.py +61 -0
  186. tigrbl/runtime/errors/converters.py +214 -0
  187. tigrbl/runtime/errors/exceptions.py +124 -0
  188. tigrbl/runtime/errors/mappings.py +71 -0
  189. tigrbl/runtime/errors/utils.py +150 -0
  190. tigrbl/runtime/events.py +209 -0
  191. tigrbl/runtime/executor/__init__.py +6 -0
  192. tigrbl/runtime/executor/guards.py +132 -0
  193. tigrbl/runtime/executor/helpers.py +88 -0
  194. tigrbl/runtime/executor/invoke.py +150 -0
  195. tigrbl/runtime/executor/types.py +84 -0
  196. tigrbl/runtime/kernel.py +644 -0
  197. tigrbl/runtime/labels.py +353 -0
  198. tigrbl/runtime/opview.py +89 -0
  199. tigrbl/runtime/ordering.py +256 -0
  200. tigrbl/runtime/system.py +279 -0
  201. tigrbl/runtime/trace.py +330 -0
  202. tigrbl/schema/__init__.py +38 -0
  203. tigrbl/schema/_schema.py +27 -0
  204. tigrbl/schema/builder/__init__.py +17 -0
  205. tigrbl/schema/builder/build_schema.py +209 -0
  206. tigrbl/schema/builder/cache.py +24 -0
  207. tigrbl/schema/builder/compat.py +16 -0
  208. tigrbl/schema/builder/extras.py +85 -0
  209. tigrbl/schema/builder/helpers.py +51 -0
  210. tigrbl/schema/builder/list_params.py +117 -0
  211. tigrbl/schema/builder/strip_parent_fields.py +70 -0
  212. tigrbl/schema/collect.py +79 -0
  213. tigrbl/schema/decorators.py +68 -0
  214. tigrbl/schema/get_schema.py +86 -0
  215. tigrbl/schema/schema_spec.py +20 -0
  216. tigrbl/schema/shortcuts.py +42 -0
  217. tigrbl/schema/types.py +34 -0
  218. tigrbl/schema/utils.py +143 -0
  219. tigrbl/session/README.md +14 -0
  220. tigrbl/session/__init__.py +28 -0
  221. tigrbl/session/abc.py +76 -0
  222. tigrbl/session/base.py +151 -0
  223. tigrbl/session/decorators.py +43 -0
  224. tigrbl/session/default.py +118 -0
  225. tigrbl/session/shortcuts.py +50 -0
  226. tigrbl/session/spec.py +112 -0
  227. tigrbl/shortcuts.py +22 -0
  228. tigrbl/specs.py +44 -0
  229. tigrbl/system/__init__.py +13 -0
  230. tigrbl/system/diagnostics/__init__.py +24 -0
  231. tigrbl/system/diagnostics/compat.py +31 -0
  232. tigrbl/system/diagnostics/healthz.py +41 -0
  233. tigrbl/system/diagnostics/hookz.py +51 -0
  234. tigrbl/system/diagnostics/kernelz.py +20 -0
  235. tigrbl/system/diagnostics/methodz.py +43 -0
  236. tigrbl/system/diagnostics/router.py +73 -0
  237. tigrbl/system/diagnostics/utils.py +43 -0
  238. tigrbl/system/uvicorn.py +60 -0
  239. tigrbl/table/__init__.py +9 -0
  240. tigrbl/table/_base.py +260 -0
  241. tigrbl/table/_table.py +54 -0
  242. tigrbl/table/mro_collect.py +69 -0
  243. tigrbl/table/shortcuts.py +57 -0
  244. tigrbl/table/table_spec.py +28 -0
  245. tigrbl/transport/__init__.py +74 -0
  246. tigrbl/transport/jsonrpc/__init__.py +19 -0
  247. tigrbl/transport/jsonrpc/dispatcher.py +352 -0
  248. tigrbl/transport/jsonrpc/helpers.py +115 -0
  249. tigrbl/transport/jsonrpc/models.py +41 -0
  250. tigrbl/transport/rest/__init__.py +25 -0
  251. tigrbl/transport/rest/aggregator.py +132 -0
  252. tigrbl/types/__init__.py +170 -0
  253. tigrbl/types/allow_anon_provider.py +19 -0
  254. tigrbl/types/authn_abc.py +30 -0
  255. tigrbl/types/nested_path_provider.py +22 -0
  256. tigrbl/types/op.py +35 -0
  257. tigrbl/types/op_config_provider.py +17 -0
  258. tigrbl/types/op_verb_alias_provider.py +33 -0
  259. tigrbl/types/request_extras_provider.py +22 -0
  260. tigrbl/types/response_extras_provider.py +22 -0
  261. tigrbl/types/table_config_provider.py +13 -0
  262. tigrbl/types/uuid.py +55 -0
  263. tigrbl-0.3.0.dist-info/METADATA +516 -0
  264. tigrbl-0.3.0.dist-info/RECORD +266 -0
  265. {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dist-info}/WHEEL +1 -1
  266. tigrbl-0.3.0.dist-info/licenses/LICENSE +201 -0
  267. tigrbl/ExampleAgent.py +0 -1
  268. tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
  269. tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+ import logging
3
+
4
+ import inspect
5
+ from types import SimpleNamespace
6
+ from typing import (
7
+ Annotated,
8
+ Any,
9
+ Awaitable,
10
+ Callable,
11
+ Dict,
12
+ Mapping,
13
+ Optional,
14
+ Sequence,
15
+ )
16
+
17
+ from .common import (
18
+ TIGRBL_AUTH_CONTEXT_ATTR,
19
+ Body,
20
+ Depends,
21
+ HTTPException,
22
+ Response,
23
+ Path,
24
+ Request,
25
+ OpSpec,
26
+ _coerce_parent_kw,
27
+ _get_phase_chains,
28
+ _pk_name,
29
+ _pk_names,
30
+ _request_model_for,
31
+ _serialize_output,
32
+ _validate_body,
33
+ _executor,
34
+ _status,
35
+ _status_for,
36
+ )
37
+
38
+ from .io_headers import _make_header_dep
39
+
40
+ from ...runtime.executor.types import _Ctx
41
+
42
+
43
+ logger = logging.getLogger("uvicorn")
44
+ logger.debug("Loaded module v3/bindings/rest/member")
45
+
46
+
47
+ def _make_member_endpoint(
48
+ model: type,
49
+ sp: OpSpec,
50
+ *,
51
+ resource: str,
52
+ db_dep: Callable[..., Any],
53
+ pk_param: str = "item_id",
54
+ nested_vars: Sequence[str] | None = None,
55
+ api: Any | None = None,
56
+ ) -> Callable[..., Awaitable[Any]]:
57
+ alias = sp.alias
58
+ target = sp.target
59
+ status_code = _status_for(sp)
60
+ real_pk = _pk_name(model)
61
+ pk_names = _pk_names(model)
62
+ nested_vars = list(nested_vars or [])
63
+ hdr_dep = _make_header_dep(model, alias)
64
+
65
+ # --- No body on GET read / DELETE delete ---
66
+ if target in {"read", "delete"}:
67
+
68
+ async def _endpoint(
69
+ item_id: Any,
70
+ request: Request,
71
+ db: Any = Depends(db_dep),
72
+ h: Mapping[str, Any] = Depends(hdr_dep),
73
+ **kw: Any,
74
+ ):
75
+ parent_kw = {k: kw[k] for k in nested_vars if k in kw}
76
+ _coerce_parent_kw(model, parent_kw)
77
+ payload: Mapping[str, Any] = dict(parent_kw)
78
+ if isinstance(h, Mapping):
79
+ payload = {**payload, **dict(h)}
80
+ path_params = {real_pk: item_id, pk_param: item_id, **parent_kw}
81
+ ctx: Dict[str, Any] = {
82
+ "request": request,
83
+ "db": db,
84
+ "payload": payload,
85
+ "path_params": path_params,
86
+ # expose contextual metadata for downstream atoms
87
+ "api": api if api is not None else getattr(request, "app", None),
88
+ "app": getattr(request, "app", None),
89
+ "model": model,
90
+ "op": alias,
91
+ "method": alias,
92
+ "target": target,
93
+ "env": SimpleNamespace(
94
+ method=alias, params=payload, target=target, model=model
95
+ ),
96
+ }
97
+ ac = getattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, None)
98
+ if ac is not None:
99
+ ctx["auth_context"] = ac
100
+ ctx = _Ctx(ctx)
101
+
102
+ def _serializer(r, _ctx=ctx):
103
+ out = _serialize_output(model, alias, target, sp, r)
104
+ temp = getattr(_ctx, "temp", {}) if isinstance(_ctx, Mapping) else {}
105
+ extras = (
106
+ temp.get("response_extras", {}) if isinstance(temp, Mapping) else {}
107
+ )
108
+ if isinstance(out, dict) and isinstance(extras, dict):
109
+ out.update(extras)
110
+ return out
111
+
112
+ ctx["response_serializer"] = _serializer
113
+ phases = _get_phase_chains(model, alias)
114
+ result = await _executor._invoke(
115
+ request=request,
116
+ db=db,
117
+ phases=phases,
118
+ ctx=ctx,
119
+ )
120
+ if isinstance(result, Response):
121
+ if sp.status_code is not None or result.status_code == 200:
122
+ result.status_code = status_code
123
+ return result
124
+ return result
125
+
126
+ params = [
127
+ inspect.Parameter(
128
+ nv,
129
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
130
+ annotation=Annotated[str, Path(...)],
131
+ )
132
+ for nv in nested_vars
133
+ ]
134
+ params.extend(
135
+ [
136
+ inspect.Parameter(
137
+ "item_id",
138
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
139
+ annotation=Annotated[Any, Path(...)],
140
+ ),
141
+ inspect.Parameter(
142
+ "request",
143
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
144
+ annotation=Request,
145
+ ),
146
+ inspect.Parameter(
147
+ "db",
148
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
149
+ annotation=Annotated[Any, Depends(db_dep)],
150
+ ),
151
+ inspect.Parameter(
152
+ "h",
153
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
154
+ annotation=Annotated[Mapping[str, Any], Depends(hdr_dep)],
155
+ ),
156
+ ]
157
+ )
158
+ _endpoint.__signature__ = inspect.Signature(params)
159
+
160
+ _endpoint.__name__ = f"rest_{model.__name__}_{alias}_member"
161
+ _endpoint.__qualname__ = _endpoint.__name__
162
+ _endpoint.__doc__ = (
163
+ f"REST member endpoint for {model.__name__}.{alias} ({target})"
164
+ )
165
+ # NOTE: do NOT set body annotation for no-body endpoints
166
+ return _endpoint
167
+
168
+ body_model = _request_model_for(sp, model)
169
+ if body_model is None and sp.request_model is None and target == "custom":
170
+
171
+ async def _endpoint(
172
+ item_id: Any,
173
+ request: Request,
174
+ db: Any = Depends(db_dep),
175
+ **kw: Any,
176
+ ):
177
+ parent_kw = {k: kw[k] for k in nested_vars if k in kw}
178
+ _coerce_parent_kw(model, parent_kw)
179
+ payload: Mapping[str, Any] = dict(parent_kw)
180
+ path_params = {real_pk: item_id, pk_param: item_id, **parent_kw}
181
+ ctx: Dict[str, Any] = {
182
+ "request": request,
183
+ "db": db,
184
+ "payload": payload,
185
+ "path_params": path_params,
186
+ # expose contextual metadata for downstream atoms
187
+ "api": api if api is not None else getattr(request, "app", None),
188
+ "app": getattr(request, "app", None),
189
+ "model": model,
190
+ "op": alias,
191
+ "method": alias,
192
+ "target": target,
193
+ "env": SimpleNamespace(
194
+ method=alias, params=payload, target=target, model=model
195
+ ),
196
+ }
197
+ ac = getattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, None)
198
+ if ac is not None:
199
+ ctx["auth_context"] = ac
200
+ ctx = _Ctx(ctx)
201
+
202
+ def _serializer(r, _ctx=ctx):
203
+ out = _serialize_output(model, alias, target, sp, r)
204
+ temp = getattr(_ctx, "temp", {}) if isinstance(_ctx, Mapping) else {}
205
+ extras = (
206
+ temp.get("response_extras", {}) if isinstance(temp, Mapping) else {}
207
+ )
208
+ if isinstance(out, dict) and isinstance(extras, dict):
209
+ out.update(extras)
210
+ return out
211
+
212
+ ctx["response_serializer"] = _serializer
213
+ phases = _get_phase_chains(model, alias)
214
+ result = await _executor._invoke(
215
+ request=request,
216
+ db=db,
217
+ phases=phases,
218
+ ctx=ctx,
219
+ )
220
+ return result
221
+
222
+ params = [
223
+ inspect.Parameter(
224
+ nv,
225
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
226
+ annotation=Annotated[str, Path(...)],
227
+ )
228
+ for nv in nested_vars
229
+ ]
230
+ params.extend(
231
+ [
232
+ inspect.Parameter(
233
+ "item_id",
234
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
235
+ annotation=Annotated[Any, Path(...)],
236
+ ),
237
+ inspect.Parameter(
238
+ "request",
239
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
240
+ annotation=Request,
241
+ ),
242
+ inspect.Parameter(
243
+ "db",
244
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
245
+ annotation=Annotated[Any, Depends(db_dep)],
246
+ ),
247
+ ]
248
+ )
249
+ _endpoint.__signature__ = inspect.Signature(params)
250
+
251
+ _endpoint.__name__ = f"rest_{model.__name__}_{alias}_member"
252
+ _endpoint.__qualname__ = _endpoint.__name__
253
+ _endpoint.__doc__ = (
254
+ f"REST member endpoint for {model.__name__}.{alias} ({target})"
255
+ )
256
+ return _endpoint
257
+
258
+ # --- Body-based member endpoints: PATCH update / PUT replace (and custom member) ---
259
+
260
+ if body_model is None:
261
+ body_annotation = Optional[Mapping[str, Any]]
262
+ body_default = Body(None)
263
+ else:
264
+ body_annotation = body_model
265
+ body_default = Body(...)
266
+
267
+ async def _endpoint(
268
+ item_id: Any,
269
+ request: Request,
270
+ db: Any = Depends(db_dep),
271
+ h: Mapping[str, Any] = Depends(hdr_dep),
272
+ body=body_default,
273
+ **kw: Any,
274
+ ):
275
+ parent_kw = {k: kw[k] for k in nested_vars if k in kw}
276
+ _coerce_parent_kw(model, parent_kw)
277
+ payload = _validate_body(model, alias, target, body)
278
+ if isinstance(h, Mapping):
279
+ payload = {**payload, **dict(h)}
280
+
281
+ # Enforce path-PK canonicality. If body echoes PK: drop if equal, 409 if mismatch.
282
+ for k in pk_names:
283
+ if k in payload:
284
+ if str(payload[k]) != str(item_id) and len(pk_names) == 1:
285
+ raise HTTPException(
286
+ status_code=_status.HTTP_409_CONFLICT,
287
+ detail=f"Identifier mismatch for '{k}': path={item_id}, body={payload[k]}",
288
+ )
289
+ payload.pop(k, None)
290
+ payload.pop(pk_param, None)
291
+ if parent_kw:
292
+ payload.update(parent_kw)
293
+
294
+ path_params = {real_pk: item_id, pk_param: item_id, **parent_kw}
295
+
296
+ ctx: Dict[str, Any] = {
297
+ "request": request,
298
+ "db": db,
299
+ "payload": payload,
300
+ "path_params": path_params,
301
+ # expose contextual metadata for downstream atoms
302
+ "app": getattr(request, "app", None),
303
+ "api": getattr(request, "app", None),
304
+ "model": model,
305
+ "op": alias,
306
+ "method": alias,
307
+ "target": target,
308
+ "env": SimpleNamespace(
309
+ method=alias, params=payload, target=target, model=model
310
+ ),
311
+ }
312
+ ac = getattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, None)
313
+ if ac is not None:
314
+ ctx["auth_context"] = ac
315
+ ctx = _Ctx(ctx)
316
+
317
+ def _serializer(r, _ctx=ctx):
318
+ out = _serialize_output(model, alias, target, sp, r)
319
+ temp = getattr(_ctx, "temp", {}) if isinstance(_ctx, Mapping) else {}
320
+ extras = (
321
+ temp.get("response_extras", {}) if isinstance(temp, Mapping) else {}
322
+ )
323
+ if isinstance(out, dict) and isinstance(extras, dict):
324
+ out.update(extras)
325
+ return out
326
+
327
+ ctx["response_serializer"] = _serializer
328
+ phases = _get_phase_chains(model, alias)
329
+ result = await _executor._invoke(
330
+ request=request,
331
+ db=db,
332
+ phases=phases,
333
+ ctx=ctx,
334
+ )
335
+
336
+ if isinstance(result, Response):
337
+ if sp.status_code is not None or result.status_code == 200:
338
+ result.status_code = status_code
339
+ return result
340
+ return result
341
+
342
+ params = [
343
+ inspect.Parameter(
344
+ nv,
345
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
346
+ annotation=Annotated[str, Path(...)],
347
+ )
348
+ for nv in nested_vars
349
+ ]
350
+ params.extend(
351
+ [
352
+ inspect.Parameter(
353
+ "item_id",
354
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
355
+ annotation=Annotated[Any, Path(...)],
356
+ ),
357
+ inspect.Parameter(
358
+ "request",
359
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
360
+ annotation=Request,
361
+ ),
362
+ inspect.Parameter(
363
+ "db",
364
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
365
+ annotation=Annotated[Any, Depends(db_dep)],
366
+ ),
367
+ inspect.Parameter(
368
+ "h",
369
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
370
+ annotation=Annotated[Mapping[str, Any], Depends(hdr_dep)],
371
+ ),
372
+ inspect.Parameter(
373
+ "body",
374
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
375
+ annotation=body_annotation,
376
+ default=body_default,
377
+ ),
378
+ ]
379
+ )
380
+ _endpoint.__signature__ = inspect.Signature(params)
381
+
382
+ _endpoint.__name__ = f"rest_{model.__name__}_{alias}_member"
383
+ _endpoint.__qualname__ = _endpoint.__name__
384
+ _endpoint.__doc__ = f"REST member endpoint for {model.__name__}.{alias} ({target})"
385
+ _endpoint.__annotations__["body"] = body_annotation
386
+ return _endpoint
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+ import logging
3
+
4
+ import inspect
5
+ import re
6
+ from uuid import uuid4
7
+ from typing import Any, Sequence
8
+
9
+ from .collection import _make_collection_endpoint
10
+ from .member import _make_member_endpoint
11
+ from .common import (
12
+ TIGRBL_ALLOW_ANON_ATTR,
13
+ TIGRBL_AUTH_DEP_ATTR,
14
+ TIGRBL_GET_DB_ATTR,
15
+ TIGRBL_REST_DEPENDENCIES_ATTR,
16
+ BaseModel,
17
+ CANON,
18
+ OpSpec,
19
+ Response,
20
+ Router,
21
+ _DEFAULT_METHODS,
22
+ _default_path_suffix,
23
+ _nested_prefix,
24
+ _normalize_deps,
25
+ _normalize_secdeps,
26
+ _optionalize_list_in_model,
27
+ _path_for_spec,
28
+ _require_auth_header,
29
+ _requires_auth_header,
30
+ _req_state_db,
31
+ _resource_name,
32
+ _status,
33
+ _status_for,
34
+ _strip_parent_fields,
35
+ _RESPONSES_META,
36
+ )
37
+ from ...schema import _make_bulk_rows_model
38
+ import typing as _typing
39
+ from typing import get_args as _get_args, get_origin as _get_origin
40
+
41
+ logger = logging.getLogger("uvicorn")
42
+ logger.debug("Loaded module v3/bindings/rest/router")
43
+
44
+
45
+ def _build_router(
46
+ model: type, specs: Sequence[OpSpec], *, api: Any | None = None
47
+ ) -> Router:
48
+ resource = _resource_name(model)
49
+
50
+ # Router-level deps: extra deps only (transport-level; never part of kernel plan)
51
+ extra_router_deps = _normalize_deps(
52
+ getattr(model, TIGRBL_REST_DEPENDENCIES_ATTR, None)
53
+ )
54
+ auth_dep = getattr(model, TIGRBL_AUTH_DEP_ATTR, None)
55
+
56
+ # Verbs explicitly allowed without auth
57
+ allow_anon_attr = getattr(model, TIGRBL_ALLOW_ANON_ATTR, None)
58
+ allow_anon = set(
59
+ allow_anon_attr() if callable(allow_anon_attr) else allow_anon_attr or []
60
+ )
61
+
62
+ router = Router(dependencies=extra_router_deps or None)
63
+
64
+ pk_param = "item_id"
65
+ db_dep = getattr(model, TIGRBL_GET_DB_ATTR, None) or _req_state_db
66
+
67
+ raw_nested = _nested_prefix(model) or ""
68
+ nested_pref = re.sub(r"/{2,}", "/", raw_nested).rstrip("/") or ""
69
+ nested_vars = re.findall(r"{(\w+)}", raw_nested)
70
+
71
+ # When models are mounted on nested paths, parent identifiers should not
72
+ # appear in request schemas. Capture the original spec sequence so we can
73
+ # prune request models even if some specs (e.g. ``create`` when
74
+ # ``bulk_create`` is present) are later dropped from the router.
75
+ all_specs = list(specs)
76
+
77
+ if nested_vars:
78
+ schemas_root = getattr(model, "schemas", None)
79
+ if schemas_root:
80
+ for sp in all_specs:
81
+ alias_ns = getattr(schemas_root, sp.alias, None)
82
+ if not alias_ns:
83
+ continue
84
+ in_model = getattr(alias_ns, "in_", None)
85
+ if (
86
+ in_model
87
+ and inspect.isclass(in_model)
88
+ and issubclass(in_model, BaseModel)
89
+ ):
90
+ root_field = getattr(in_model, "model_fields", {}).get("root")
91
+ if root_field is not None:
92
+ ann = root_field.annotation
93
+ inner = None
94
+ for t in _get_args(ann) or (ann,):
95
+ origin = _get_origin(t)
96
+ if origin in {list, _typing.List}:
97
+ t_args = _get_args(t)
98
+ if t_args:
99
+ t = t_args[0]
100
+ origin = _get_origin(t)
101
+ if inspect.isclass(t) and issubclass(t, BaseModel):
102
+ inner = t
103
+ break
104
+ if inner is not None:
105
+ pruned = _strip_parent_fields(inner, drop=set(nested_vars))
106
+ setattr(alias_ns, "in_item", pruned)
107
+ setattr(
108
+ alias_ns,
109
+ "in_",
110
+ _make_bulk_rows_model(model, sp.target, pruned),
111
+ )
112
+ continue
113
+ pruned = _strip_parent_fields(in_model, drop=set(nested_vars))
114
+ setattr(alias_ns, "in_", pruned)
115
+
116
+ # If bulk_delete is present, drop clear to avoid route conflicts
117
+ if any(sp.target == "bulk_delete" for sp in specs):
118
+ specs = [sp for sp in specs if sp.target != "clear"]
119
+
120
+ # When both ``create`` and ``bulk_create`` handlers are available,
121
+ # prefer ``bulk_create`` for the REST route to avoid conflicting POST
122
+ # registrations at the collection path. Both operations remain bound
123
+ # for schema generation, but only ``bulk_create`` should surface as a
124
+ # REST endpoint and in the OpenAPI spec.
125
+ if any(sp.target == "bulk_create" for sp in specs) and any(
126
+ sp.target == "create" for sp in specs
127
+ ):
128
+ specs = [sp for sp in specs if sp.target != "create"]
129
+
130
+ # Register collection-level bulk routes before member routes so static paths
131
+ # like "/resource/bulk" aren't captured by dynamic member routes such as
132
+ # "/resource/{item_id}". FastAPI matches routes in the order they are
133
+ # added, so sorting here prevents "bulk" from being treated as an
134
+ # identifier.
135
+ specs = sorted(
136
+ specs,
137
+ key=lambda sp: (
138
+ -1
139
+ if sp.target == "clear"
140
+ else 0
141
+ if sp.target in {"bulk_update", "bulk_replace", "bulk_delete", "bulk_merge"}
142
+ else 1
143
+ if sp.target in {"create", "merge"}
144
+ else 2
145
+ if sp.target in {"bulk_create"}
146
+ else 3
147
+ ),
148
+ )
149
+
150
+ for sp in specs:
151
+ if not sp.expose_routes:
152
+ continue
153
+
154
+ # Determine path and membership
155
+ if nested_pref:
156
+ if sp.path_suffix is None:
157
+ suffix = _default_path_suffix(sp) or ""
158
+ else:
159
+ suffix = sp.path_suffix or ""
160
+ if suffix and not suffix.startswith("/"):
161
+ suffix = "/" + suffix
162
+ base = nested_pref.rstrip("/")
163
+ if not base.endswith(f"/{resource}"):
164
+ base = f"{base}/{resource}"
165
+ if sp.arity == "member" or sp.target in {
166
+ "read",
167
+ "update",
168
+ "replace",
169
+ "merge",
170
+ "delete",
171
+ }:
172
+ path = f"{base}/{{{pk_param}}}{suffix}"
173
+ is_member = True
174
+ else:
175
+ path = f"{base}{suffix}"
176
+ is_member = False
177
+ else:
178
+ path, is_member = _path_for_spec(
179
+ model, sp, resource=resource, pk_param=pk_param
180
+ )
181
+
182
+ # HARDEN list.in_ at runtime to avoid bogus defaults blowing up empty GETs
183
+ if sp.target == "list":
184
+ schemas_root = getattr(model, "schemas", None)
185
+ if schemas_root:
186
+ alias_ns = getattr(schemas_root, sp.alias, None)
187
+ if alias_ns:
188
+ in_model = getattr(alias_ns, "in_", None)
189
+ if (
190
+ in_model
191
+ and inspect.isclass(in_model)
192
+ and issubclass(in_model, BaseModel)
193
+ and not getattr(in_model, "__tigrbl_optionalized__", False)
194
+ ):
195
+ safe = _optionalize_list_in_model(in_model)
196
+ setattr(alias_ns, "in_", safe)
197
+
198
+ # HTTP methods
199
+ methods = list(sp.http_methods or _DEFAULT_METHODS.get(sp.target, ("POST",)))
200
+ response_model = None # Allow hooks to mutate response freely
201
+
202
+ # Build endpoint (split by body/no-body)
203
+ if is_member:
204
+ endpoint = _make_member_endpoint(
205
+ model,
206
+ sp,
207
+ resource=resource,
208
+ db_dep=db_dep,
209
+ pk_param=pk_param,
210
+ nested_vars=nested_vars,
211
+ api=api,
212
+ )
213
+ else:
214
+ endpoint = _make_collection_endpoint(
215
+ model,
216
+ sp,
217
+ resource=resource,
218
+ db_dep=db_dep,
219
+ nested_vars=nested_vars,
220
+ api=api,
221
+ )
222
+
223
+ # Status codes
224
+ status_code = _status_for(sp)
225
+
226
+ # Capture OUT schema for OpenAPI without enforcing runtime validation
227
+ alias_ns = getattr(getattr(model, "schemas", None), sp.alias, None)
228
+ out_model = getattr(alias_ns, "out", None) if alias_ns else None
229
+
230
+ responses_meta = dict(_RESPONSES_META)
231
+ if out_model is not None and status_code != _status.HTTP_204_NO_CONTENT:
232
+ responses_meta[status_code] = {"model": out_model}
233
+ response_class = None
234
+ else:
235
+ responses_meta[status_code] = {"description": "Successful Response"}
236
+ response_class = Response
237
+
238
+ # Attach route
239
+ label = f"{model.__name__} - {sp.alias}"
240
+ route_deps = None
241
+ if auth_dep and sp.alias not in allow_anon and sp.target not in allow_anon:
242
+ route_deps = _normalize_deps([auth_dep])
243
+
244
+ unique_id = f"{endpoint.__name__}_{uuid4().hex}"
245
+ include_in_schema = bool(
246
+ getattr(sp, "extra", {}).get("include_in_schema", True)
247
+ )
248
+ route_kwargs = dict(
249
+ path=path,
250
+ endpoint=endpoint,
251
+ methods=methods,
252
+ name=f"{model.__name__}.{sp.alias}",
253
+ operation_id=unique_id,
254
+ summary=label,
255
+ description=label,
256
+ response_model=response_model,
257
+ status_code=status_code,
258
+ # IMPORTANT: only class name here; never table name
259
+ tags=list(sp.tags or (model.__name__,)),
260
+ responses=responses_meta,
261
+ include_in_schema=include_in_schema,
262
+ )
263
+ if route_deps:
264
+ route_kwargs["dependencies"] = route_deps
265
+ if response_class is not None:
266
+ route_kwargs["response_class"] = response_class
267
+
268
+ secdeps: list[Any] = []
269
+ if auth_dep and sp.alias not in allow_anon and sp.target not in allow_anon:
270
+ if _requires_auth_header(auth_dep):
271
+ secdeps.append(_require_auth_header)
272
+ secdeps.append(auth_dep)
273
+ secdeps.extend(getattr(sp, "secdeps", ()))
274
+ route_secdeps = _normalize_secdeps(secdeps)
275
+ if route_secdeps:
276
+ route_kwargs["dependencies"] = route_secdeps
277
+
278
+ if (
279
+ sp.alias != sp.target
280
+ and sp.target in CANON
281
+ and sp.alias != getattr(sp.handler, "__name__", sp.alias)
282
+ ):
283
+ route_kwargs["include_in_schema"] = False
284
+
285
+ router.add_api_route(**route_kwargs)
286
+
287
+ logger.debug(
288
+ "rest: registered %s %s -> %s.%s (response_model=%s)",
289
+ methods,
290
+ path,
291
+ model.__name__,
292
+ sp.alias,
293
+ getattr(response_model, "__name__", None) if response_model else None,
294
+ )
295
+
296
+ return router