tigrbl 0.0.1.dev1__py3-none-any.whl → 0.3.0.dev3__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 (252) 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 +72 -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 +286 -0
  9. tigrbl/app/__init__.py +0 -0
  10. tigrbl/app/_app.py +61 -0
  11. tigrbl/app/app_spec.py +42 -0
  12. tigrbl/app/mro_collect.py +67 -0
  13. tigrbl/app/shortcuts.py +65 -0
  14. tigrbl/app/tigrbl_app.py +314 -0
  15. tigrbl/bindings/__init__.py +73 -0
  16. tigrbl/bindings/api/__init__.py +12 -0
  17. tigrbl/bindings/api/common.py +109 -0
  18. tigrbl/bindings/api/include.py +256 -0
  19. tigrbl/bindings/api/resource_proxy.py +149 -0
  20. tigrbl/bindings/api/rpc.py +111 -0
  21. tigrbl/bindings/columns.py +49 -0
  22. tigrbl/bindings/handlers/__init__.py +11 -0
  23. tigrbl/bindings/handlers/builder.py +119 -0
  24. tigrbl/bindings/handlers/ctx.py +74 -0
  25. tigrbl/bindings/handlers/identifiers.py +228 -0
  26. tigrbl/bindings/handlers/namespaces.py +51 -0
  27. tigrbl/bindings/handlers/steps.py +276 -0
  28. tigrbl/bindings/hooks.py +311 -0
  29. tigrbl/bindings/model.py +194 -0
  30. tigrbl/bindings/model_helpers.py +139 -0
  31. tigrbl/bindings/model_registry.py +77 -0
  32. tigrbl/bindings/rest/__init__.py +7 -0
  33. tigrbl/bindings/rest/attach.py +34 -0
  34. tigrbl/bindings/rest/collection.py +265 -0
  35. tigrbl/bindings/rest/common.py +116 -0
  36. tigrbl/bindings/rest/fastapi.py +76 -0
  37. tigrbl/bindings/rest/helpers.py +119 -0
  38. tigrbl/bindings/rest/io.py +317 -0
  39. tigrbl/bindings/rest/member.py +367 -0
  40. tigrbl/bindings/rest/router.py +292 -0
  41. tigrbl/bindings/rest/routing.py +133 -0
  42. tigrbl/bindings/rpc.py +364 -0
  43. tigrbl/bindings/schemas/__init__.py +11 -0
  44. tigrbl/bindings/schemas/builder.py +348 -0
  45. tigrbl/bindings/schemas/defaults.py +260 -0
  46. tigrbl/bindings/schemas/utils.py +193 -0
  47. tigrbl/column/README.md +62 -0
  48. tigrbl/column/__init__.py +72 -0
  49. tigrbl/column/_column.py +96 -0
  50. tigrbl/column/column_spec.py +40 -0
  51. tigrbl/column/field_spec.py +31 -0
  52. tigrbl/column/infer/__init__.py +25 -0
  53. tigrbl/column/infer/core.py +92 -0
  54. tigrbl/column/infer/jsonhints.py +44 -0
  55. tigrbl/column/infer/planning.py +133 -0
  56. tigrbl/column/infer/types.py +102 -0
  57. tigrbl/column/infer/utils.py +59 -0
  58. tigrbl/column/io_spec.py +133 -0
  59. tigrbl/column/mro_collect.py +59 -0
  60. tigrbl/column/shortcuts.py +89 -0
  61. tigrbl/column/storage_spec.py +65 -0
  62. tigrbl/config/__init__.py +19 -0
  63. tigrbl/config/constants.py +224 -0
  64. tigrbl/config/defaults.py +29 -0
  65. tigrbl/config/resolver.py +295 -0
  66. tigrbl/core/__init__.py +47 -0
  67. tigrbl/core/crud/__init__.py +36 -0
  68. tigrbl/core/crud/bulk.py +168 -0
  69. tigrbl/core/crud/helpers/__init__.py +76 -0
  70. tigrbl/core/crud/helpers/db.py +92 -0
  71. tigrbl/core/crud/helpers/enum.py +86 -0
  72. tigrbl/core/crud/helpers/filters.py +162 -0
  73. tigrbl/core/crud/helpers/model.py +123 -0
  74. tigrbl/core/crud/helpers/normalize.py +99 -0
  75. tigrbl/core/crud/ops.py +235 -0
  76. tigrbl/ddl/__init__.py +344 -0
  77. tigrbl/decorators.py +17 -0
  78. tigrbl/deps/__init__.py +20 -0
  79. tigrbl/deps/fastapi.py +45 -0
  80. tigrbl/deps/favicon.svg +4 -0
  81. tigrbl/deps/jinja.py +27 -0
  82. tigrbl/deps/pydantic.py +10 -0
  83. tigrbl/deps/sqlalchemy.py +94 -0
  84. tigrbl/deps/starlette.py +36 -0
  85. tigrbl/engine/__init__.py +26 -0
  86. tigrbl/engine/_engine.py +130 -0
  87. tigrbl/engine/bind.py +33 -0
  88. tigrbl/engine/builders.py +236 -0
  89. tigrbl/engine/collect.py +111 -0
  90. tigrbl/engine/decorators.py +108 -0
  91. tigrbl/engine/engine_spec.py +261 -0
  92. tigrbl/engine/resolver.py +224 -0
  93. tigrbl/engine/shortcuts.py +216 -0
  94. tigrbl/hook/__init__.py +21 -0
  95. tigrbl/hook/_hook.py +22 -0
  96. tigrbl/hook/decorators.py +28 -0
  97. tigrbl/hook/hook_spec.py +24 -0
  98. tigrbl/hook/mro_collect.py +98 -0
  99. tigrbl/hook/shortcuts.py +44 -0
  100. tigrbl/hook/types.py +76 -0
  101. tigrbl/op/__init__.py +50 -0
  102. tigrbl/op/_op.py +31 -0
  103. tigrbl/op/canonical.py +31 -0
  104. tigrbl/op/collect.py +11 -0
  105. tigrbl/op/decorators.py +238 -0
  106. tigrbl/op/model_registry.py +301 -0
  107. tigrbl/op/mro_collect.py +99 -0
  108. tigrbl/op/resolver.py +216 -0
  109. tigrbl/op/types.py +136 -0
  110. tigrbl/orm/__init__.py +1 -0
  111. tigrbl/orm/mixins/_RowBound.py +83 -0
  112. tigrbl/orm/mixins/__init__.py +95 -0
  113. tigrbl/orm/mixins/bootstrappable.py +113 -0
  114. tigrbl/orm/mixins/bound.py +47 -0
  115. tigrbl/orm/mixins/edges.py +40 -0
  116. tigrbl/orm/mixins/fields.py +165 -0
  117. tigrbl/orm/mixins/hierarchy.py +54 -0
  118. tigrbl/orm/mixins/key_digest.py +44 -0
  119. tigrbl/orm/mixins/lifecycle.py +115 -0
  120. tigrbl/orm/mixins/locks.py +51 -0
  121. tigrbl/orm/mixins/markers.py +16 -0
  122. tigrbl/orm/mixins/operations.py +57 -0
  123. tigrbl/orm/mixins/ownable.py +337 -0
  124. tigrbl/orm/mixins/principals.py +98 -0
  125. tigrbl/orm/mixins/tenant_bound.py +301 -0
  126. tigrbl/orm/mixins/upsertable.py +111 -0
  127. tigrbl/orm/mixins/utils.py +49 -0
  128. tigrbl/orm/tables/__init__.py +72 -0
  129. tigrbl/orm/tables/_base.py +8 -0
  130. tigrbl/orm/tables/audit.py +56 -0
  131. tigrbl/orm/tables/client.py +25 -0
  132. tigrbl/orm/tables/group.py +29 -0
  133. tigrbl/orm/tables/org.py +30 -0
  134. tigrbl/orm/tables/rbac.py +76 -0
  135. tigrbl/orm/tables/status.py +106 -0
  136. tigrbl/orm/tables/tenant.py +22 -0
  137. tigrbl/orm/tables/user.py +39 -0
  138. tigrbl/response/README.md +34 -0
  139. tigrbl/response/__init__.py +33 -0
  140. tigrbl/response/bind.py +12 -0
  141. tigrbl/response/decorators.py +37 -0
  142. tigrbl/response/resolver.py +83 -0
  143. tigrbl/response/shortcuts.py +144 -0
  144. tigrbl/response/types.py +49 -0
  145. tigrbl/rest/__init__.py +27 -0
  146. tigrbl/runtime/README.md +129 -0
  147. tigrbl/runtime/__init__.py +20 -0
  148. tigrbl/runtime/atoms/__init__.py +102 -0
  149. tigrbl/runtime/atoms/emit/__init__.py +42 -0
  150. tigrbl/runtime/atoms/emit/paired_post.py +158 -0
  151. tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
  152. tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
  153. tigrbl/runtime/atoms/out/__init__.py +38 -0
  154. tigrbl/runtime/atoms/out/masking.py +135 -0
  155. tigrbl/runtime/atoms/refresh/__init__.py +38 -0
  156. tigrbl/runtime/atoms/refresh/demand.py +130 -0
  157. tigrbl/runtime/atoms/resolve/__init__.py +40 -0
  158. tigrbl/runtime/atoms/resolve/assemble.py +167 -0
  159. tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
  160. tigrbl/runtime/atoms/response/__init__.py +17 -0
  161. tigrbl/runtime/atoms/response/negotiate.py +30 -0
  162. tigrbl/runtime/atoms/response/negotiation.py +43 -0
  163. tigrbl/runtime/atoms/response/render.py +36 -0
  164. tigrbl/runtime/atoms/response/renderer.py +116 -0
  165. tigrbl/runtime/atoms/response/template.py +44 -0
  166. tigrbl/runtime/atoms/response/templates.py +88 -0
  167. tigrbl/runtime/atoms/schema/__init__.py +40 -0
  168. tigrbl/runtime/atoms/schema/collect_in.py +21 -0
  169. tigrbl/runtime/atoms/schema/collect_out.py +21 -0
  170. tigrbl/runtime/atoms/storage/__init__.py +38 -0
  171. tigrbl/runtime/atoms/storage/to_stored.py +167 -0
  172. tigrbl/runtime/atoms/wire/__init__.py +45 -0
  173. tigrbl/runtime/atoms/wire/build_in.py +166 -0
  174. tigrbl/runtime/atoms/wire/build_out.py +87 -0
  175. tigrbl/runtime/atoms/wire/dump.py +206 -0
  176. tigrbl/runtime/atoms/wire/validate_in.py +227 -0
  177. tigrbl/runtime/context.py +206 -0
  178. tigrbl/runtime/errors/__init__.py +61 -0
  179. tigrbl/runtime/errors/converters.py +214 -0
  180. tigrbl/runtime/errors/exceptions.py +124 -0
  181. tigrbl/runtime/errors/mappings.py +71 -0
  182. tigrbl/runtime/errors/utils.py +150 -0
  183. tigrbl/runtime/events.py +209 -0
  184. tigrbl/runtime/executor/__init__.py +6 -0
  185. tigrbl/runtime/executor/guards.py +132 -0
  186. tigrbl/runtime/executor/helpers.py +88 -0
  187. tigrbl/runtime/executor/invoke.py +150 -0
  188. tigrbl/runtime/executor/types.py +84 -0
  189. tigrbl/runtime/kernel.py +628 -0
  190. tigrbl/runtime/labels.py +353 -0
  191. tigrbl/runtime/opview.py +87 -0
  192. tigrbl/runtime/ordering.py +256 -0
  193. tigrbl/runtime/system.py +279 -0
  194. tigrbl/runtime/trace.py +330 -0
  195. tigrbl/schema/__init__.py +38 -0
  196. tigrbl/schema/_schema.py +27 -0
  197. tigrbl/schema/builder/__init__.py +17 -0
  198. tigrbl/schema/builder/build_schema.py +209 -0
  199. tigrbl/schema/builder/cache.py +24 -0
  200. tigrbl/schema/builder/compat.py +16 -0
  201. tigrbl/schema/builder/extras.py +85 -0
  202. tigrbl/schema/builder/helpers.py +51 -0
  203. tigrbl/schema/builder/list_params.py +117 -0
  204. tigrbl/schema/builder/strip_parent_fields.py +70 -0
  205. tigrbl/schema/collect.py +55 -0
  206. tigrbl/schema/decorators.py +68 -0
  207. tigrbl/schema/get_schema.py +86 -0
  208. tigrbl/schema/schema_spec.py +20 -0
  209. tigrbl/schema/shortcuts.py +42 -0
  210. tigrbl/schema/types.py +34 -0
  211. tigrbl/schema/utils.py +143 -0
  212. tigrbl/shortcuts.py +22 -0
  213. tigrbl/specs.py +44 -0
  214. tigrbl/system/__init__.py +12 -0
  215. tigrbl/system/diagnostics/__init__.py +24 -0
  216. tigrbl/system/diagnostics/compat.py +31 -0
  217. tigrbl/system/diagnostics/healthz.py +41 -0
  218. tigrbl/system/diagnostics/hookz.py +51 -0
  219. tigrbl/system/diagnostics/kernelz.py +20 -0
  220. tigrbl/system/diagnostics/methodz.py +43 -0
  221. tigrbl/system/diagnostics/router.py +73 -0
  222. tigrbl/system/diagnostics/utils.py +43 -0
  223. tigrbl/table/__init__.py +9 -0
  224. tigrbl/table/_base.py +237 -0
  225. tigrbl/table/_table.py +54 -0
  226. tigrbl/table/mro_collect.py +69 -0
  227. tigrbl/table/shortcuts.py +57 -0
  228. tigrbl/table/table_spec.py +28 -0
  229. tigrbl/transport/__init__.py +74 -0
  230. tigrbl/transport/jsonrpc/__init__.py +19 -0
  231. tigrbl/transport/jsonrpc/dispatcher.py +352 -0
  232. tigrbl/transport/jsonrpc/helpers.py +115 -0
  233. tigrbl/transport/jsonrpc/models.py +41 -0
  234. tigrbl/transport/rest/__init__.py +25 -0
  235. tigrbl/transport/rest/aggregator.py +132 -0
  236. tigrbl/types/__init__.py +174 -0
  237. tigrbl/types/allow_anon_provider.py +19 -0
  238. tigrbl/types/authn_abc.py +30 -0
  239. tigrbl/types/nested_path_provider.py +22 -0
  240. tigrbl/types/op.py +35 -0
  241. tigrbl/types/op_config_provider.py +17 -0
  242. tigrbl/types/op_verb_alias_provider.py +33 -0
  243. tigrbl/types/request_extras_provider.py +22 -0
  244. tigrbl/types/response_extras_provider.py +22 -0
  245. tigrbl/types/table_config_provider.py +13 -0
  246. tigrbl-0.3.0.dev3.dist-info/LICENSE +201 -0
  247. tigrbl-0.3.0.dev3.dist-info/METADATA +501 -0
  248. tigrbl-0.3.0.dev3.dist-info/RECORD +249 -0
  249. tigrbl/ExampleAgent.py +0 -1
  250. tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
  251. tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
  252. {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dev3.dist-info}/WHEEL +0 -0
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from ...specs import ColumnSpec, F, S, acol
4
+ from ...specs.storage_spec import ForeignKeySpec
5
+ from ...types import PgUUID, UUID, declarative_mixin, declared_attr, uuid4, Mapped
6
+
7
+ from .utils import _infer_schema, uuid_example, CRUD_IO, RO_IO
8
+
9
+
10
+ @declarative_mixin
11
+ class GUIDPk:
12
+ """Universal surrogate primary key."""
13
+
14
+ id: Mapped[UUID] = acol(
15
+ spec=ColumnSpec(
16
+ storage=S(
17
+ type_=PgUUID(as_uuid=True),
18
+ primary_key=True,
19
+ default=uuid4,
20
+ ),
21
+ field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
22
+ io=RO_IO,
23
+ )
24
+ )
25
+
26
+
27
+ @declarative_mixin
28
+ class TenantColumn:
29
+ """Adds ``tenant_id`` with a schema-qualified FK to ``<schema>.tenants.id``."""
30
+
31
+ @declared_attr
32
+ def tenant_id(cls) -> Mapped[UUID]:
33
+ schema = getattr(cls, "__tenant_table_schema__", None) or _infer_schema(cls)
34
+ spec = ColumnSpec(
35
+ storage=S(
36
+ type_=PgUUID(as_uuid=True),
37
+ fk=ForeignKeySpec(target=f"{schema}.tenants.id"),
38
+ nullable=False,
39
+ index=True,
40
+ ),
41
+ field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
42
+ io=CRUD_IO,
43
+ )
44
+ return acol(spec=spec)
45
+
46
+
47
+ @declarative_mixin
48
+ class UserColumn:
49
+ """Adds ``user_id`` with a schema-qualified FK to ``<schema>.users.id``."""
50
+
51
+ @declared_attr
52
+ def user_id(cls) -> Mapped[UUID]:
53
+ schema = getattr(cls, "__user_table_schema__", None) or _infer_schema(cls)
54
+ spec = ColumnSpec(
55
+ storage=S(
56
+ type_=PgUUID(as_uuid=True),
57
+ fk=ForeignKeySpec(target=f"{schema}.users.id"),
58
+ nullable=False,
59
+ index=True,
60
+ ),
61
+ field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
62
+ io=CRUD_IO,
63
+ )
64
+ return acol(spec=spec)
65
+
66
+
67
+ @declarative_mixin
68
+ class OrgColumn:
69
+ """Adds ``org_id`` with a schema-qualified FK to ``<schema>.orgs.id``."""
70
+
71
+ @declared_attr
72
+ def org_id(cls) -> Mapped[UUID]:
73
+ schema = getattr(cls, "__org_table_schema__", None) or _infer_schema(cls)
74
+ spec = ColumnSpec(
75
+ storage=S(
76
+ type_=PgUUID(as_uuid=True),
77
+ fk=ForeignKeySpec(target=f"{schema}.orgs.id"),
78
+ nullable=False,
79
+ index=True,
80
+ ),
81
+ field=F(py_type=UUID, constraints={"examples": [uuid_example]}),
82
+ io=CRUD_IO,
83
+ )
84
+ return acol(spec=spec)
85
+
86
+
87
+ @declarative_mixin
88
+ class Principal:
89
+ __abstract__ = True
90
+
91
+
92
+ __all__ = [
93
+ "GUIDPk",
94
+ "TenantColumn",
95
+ "UserColumn",
96
+ "OrgColumn",
97
+ "Principal",
98
+ ]
@@ -0,0 +1,301 @@
1
+ """Tenant scoping mixin for Tigrbl v3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from enum import Enum
7
+ from typing import Any, Mapping
8
+ from uuid import UUID
9
+
10
+ from ._RowBound import _RowBound
11
+ from ...specs import acol
12
+ from ...config.constants import (
13
+ TIGRBL_TENANT_POLICY_ATTR,
14
+ CTX_AUTH_KEY,
15
+ CTX_TENANT_ID_KEY,
16
+ )
17
+ from ...runtime.errors import create_standardized_error
18
+ from ...specs import ColumnSpec, F, IO, S
19
+ from ...specs.storage_spec import ForeignKeySpec
20
+ from ...types import Mapped, PgUUID, declared_attr
21
+
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class TenantPolicy(str, Enum):
27
+ CLIENT_SET = "client" # client may supply tenant_id on create/update
28
+ DEFAULT_TO_CTX = "default" # server fills tenant_id on create; immutable
29
+ STRICT_SERVER = "strict" # server forces tenant_id and forbids changes
30
+
31
+
32
+ def _infer_schema(cls, default: str = "public") -> str:
33
+ args = getattr(cls, "__table_args__", None)
34
+ if not args:
35
+ return default
36
+ if isinstance(args, dict):
37
+ return args.get("schema", default)
38
+ if isinstance(args, (tuple, list)):
39
+ for elem in args:
40
+ if isinstance(elem, dict) and "schema" in elem:
41
+ return elem["schema"]
42
+ return default
43
+
44
+
45
+ def _is_missing(value) -> bool:
46
+ """Treat None or empty strings as 'not provided'."""
47
+ return value is None or (isinstance(value, str) and not value.strip())
48
+
49
+
50
+ def _normalize_uuid(val):
51
+ if isinstance(val, UUID):
52
+ return val
53
+ if isinstance(val, str):
54
+ try:
55
+ return UUID(val)
56
+ except ValueError:
57
+ return val # let model validation surface the error
58
+ return val
59
+
60
+
61
+ class TenantBound(_RowBound):
62
+ """
63
+ Plug-and-play tenant isolation.
64
+
65
+ • tenant_id column is defined per subclass (declared_attr) so the schema
66
+ builder sees the right flags before caching.
67
+ • _RowBound’s read/list filters work because we implement `is_visible`.
68
+ """
69
+
70
+ __tigrbl_tenant_policy__: TenantPolicy = TenantPolicy.CLIENT_SET
71
+
72
+ # ────────────────────────────────────────────────────────────────────
73
+ # tenant_id column (Schema-Aware; PgUUID(as_uuid=True))
74
+ # -------------------------------------------------------------------
75
+ @declared_attr
76
+ def tenant_id(cls) -> Mapped[UUID]:
77
+ pol = getattr(cls, TIGRBL_TENANT_POLICY_ATTR, TenantPolicy.CLIENT_SET)
78
+ schema = _infer_schema(cls, default="public")
79
+
80
+ in_verbs = (
81
+ ("create", "update", "replace")
82
+ if pol == TenantPolicy.CLIENT_SET
83
+ else ("create",)
84
+ )
85
+ io = IO(
86
+ in_verbs=in_verbs,
87
+ out_verbs=("read", "list"),
88
+ mutable_verbs=in_verbs,
89
+ )
90
+
91
+ spec = ColumnSpec(
92
+ storage=S(
93
+ type_=PgUUID(as_uuid=True),
94
+ fk=ForeignKeySpec(target=f"{schema}.tenants.id"),
95
+ nullable=False,
96
+ index=True,
97
+ ),
98
+ field=F(py_type=UUID),
99
+ io=io,
100
+ )
101
+ return acol(spec=spec)
102
+
103
+ @declared_attr
104
+ def __tablename__(cls):
105
+ return cls.__name__.lower()
106
+
107
+ # -------------------------------------------------------------------
108
+ # Row-level visibility for _RowBound
109
+ # -------------------------------------------------------------------
110
+ @staticmethod
111
+ def is_visible(obj, ctx) -> bool:
112
+ return getattr(obj, "tenant_id", None) == _ctx_tenant_id(ctx)
113
+
114
+ # -------------------------------------------------------------------
115
+ # Runtime hooks
116
+ # -------------------------------------------------------------------
117
+ def __init_subclass__(cls, **kw):
118
+ super().__init_subclass__(**kw)
119
+ cls._install_tenant_bound_hooks()
120
+
121
+ @classmethod
122
+ def _install_tenant_bound_hooks(cls) -> None:
123
+ pol = getattr(cls, "__tigrbl_tenant_policy__", TenantPolicy.CLIENT_SET)
124
+
125
+ def _err(code: int, msg: str) -> None:
126
+ http_exc, _, _ = create_standardized_error(code, message=msg)
127
+ raise http_exc
128
+
129
+ def _before_create(ctx: dict[str, Any]) -> None:
130
+ env = (
131
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
132
+ ) or {}
133
+ params = (
134
+ (
135
+ env.get("params")
136
+ if isinstance(env, dict)
137
+ else getattr(env, "params", None)
138
+ )
139
+ or (
140
+ ctx.get("payload")
141
+ if isinstance(ctx, dict)
142
+ else getattr(ctx, "payload", None)
143
+ )
144
+ or {}
145
+ )
146
+ if hasattr(params, "model_dump"):
147
+ params = params.model_dump()
148
+
149
+ tenant_id = _ctx_tenant_id(ctx)
150
+ provided = params.get("tenant_id")
151
+ missing = _is_missing(provided)
152
+
153
+ if pol == TenantPolicy.STRICT_SERVER:
154
+ if tenant_id is None:
155
+ _err(400, "tenant_id is required.")
156
+ if not missing:
157
+ if _normalize_uuid(provided) != _normalize_uuid(tenant_id):
158
+ _err(400, "tenant_id mismatch.")
159
+ _err(400, "tenant_id is server-assigned.")
160
+ params["tenant_id"] = tenant_id
161
+ elif pol == TenantPolicy.DEFAULT_TO_CTX:
162
+ if missing and tenant_id is not None:
163
+ params["tenant_id"] = tenant_id
164
+ elif not missing:
165
+ params["tenant_id"] = _normalize_uuid(provided)
166
+ else: # CLIENT_SET
167
+ if not missing:
168
+ params["tenant_id"] = _normalize_uuid(provided)
169
+
170
+ env_attr = (
171
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
172
+ )
173
+ if env_attr is not None:
174
+ if isinstance(env_attr, dict):
175
+ env_attr["params"] = params
176
+ else:
177
+ env_attr.params = params
178
+ if isinstance(ctx, dict):
179
+ ctx["payload"] = params
180
+ else:
181
+ setattr(ctx, "payload", params)
182
+
183
+ def _before_update(ctx: dict[str, Any]) -> None:
184
+ env = (
185
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
186
+ ) or {}
187
+ params = (
188
+ (
189
+ env.get("params")
190
+ if isinstance(env, dict)
191
+ else getattr(env, "params", None)
192
+ )
193
+ or (
194
+ ctx.get("payload")
195
+ if isinstance(ctx, dict)
196
+ else getattr(ctx, "payload", None)
197
+ )
198
+ or {}
199
+ )
200
+ if hasattr(params, "model_dump"):
201
+ params = params.model_dump()
202
+
203
+ if "tenant_id" not in params:
204
+ return
205
+
206
+ if _is_missing(params.get("tenant_id")):
207
+ params.pop("tenant_id", None)
208
+ env_attr = (
209
+ ctx.get("env")
210
+ if isinstance(ctx, dict)
211
+ else getattr(ctx, "env", None)
212
+ )
213
+ if env_attr is not None:
214
+ if isinstance(env_attr, dict):
215
+ env_attr["params"] = params
216
+ else:
217
+ env_attr.params = params
218
+ if isinstance(ctx, dict):
219
+ ctx["payload"] = params
220
+ else:
221
+ setattr(ctx, "payload", params)
222
+ return
223
+
224
+ if pol != TenantPolicy.CLIENT_SET:
225
+ _err(400, "tenant_id is immutable.")
226
+
227
+ new_val = _normalize_uuid(params["tenant_id"])
228
+ tenant_id = _ctx_tenant_id(ctx)
229
+ is_admin = bool(ctx.get("is_admin"))
230
+
231
+ if not is_admin and tenant_id is not None and new_val != tenant_id:
232
+ _err(403, "Cannot switch tenant context.")
233
+
234
+ params["tenant_id"] = new_val
235
+ env_attr = (
236
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
237
+ )
238
+ if env_attr is not None:
239
+ if isinstance(env_attr, dict):
240
+ env_attr["params"] = params
241
+ else:
242
+ env_attr.params = params
243
+ if isinstance(ctx, dict):
244
+ ctx["payload"] = params
245
+ else:
246
+ setattr(ctx, "payload", params)
247
+
248
+ hooks = {**getattr(cls, "__tigrbl_hooks__", {})}
249
+
250
+ def _append(alias: str, phase: str, fn) -> None:
251
+ phase_map = hooks.get(alias) or {}
252
+ lst = list(phase_map.get(phase) or [])
253
+ if fn not in lst:
254
+ lst.append(fn)
255
+ phase_map[phase] = tuple(lst)
256
+ hooks[alias] = phase_map
257
+
258
+ _append("create", "PRE_TX_BEGIN", _before_create)
259
+ _append("update", "PRE_TX_BEGIN", _before_update)
260
+
261
+ setattr(cls, "__tigrbl_hooks__", hooks)
262
+
263
+
264
+ def _ctx_tenant_id(ctx: Mapping[str, Any]) -> Any | None:
265
+ """Best-effort extraction of tenant_id from ctx."""
266
+ t = (
267
+ ctx.get(CTX_TENANT_ID_KEY)
268
+ if isinstance(ctx, dict)
269
+ else getattr(ctx, CTX_TENANT_ID_KEY, None)
270
+ )
271
+ if t:
272
+ return _normalize_uuid(t)
273
+
274
+ auth = (
275
+ ctx.get(CTX_AUTH_KEY)
276
+ if isinstance(ctx, dict)
277
+ else getattr(ctx, CTX_AUTH_KEY, None)
278
+ ) or {}
279
+ t = auth.get(CTX_TENANT_ID_KEY)
280
+ if t:
281
+ return _normalize_uuid(t)
282
+
283
+ inj = (
284
+ ctx.get("injected_fields")
285
+ if isinstance(ctx, dict)
286
+ else getattr(ctx, "injected_fields", None)
287
+ ) or {}
288
+ t = inj.get(CTX_TENANT_ID_KEY)
289
+ if t:
290
+ return _normalize_uuid(t)
291
+
292
+ ac = (
293
+ ctx.get("auth_context")
294
+ if isinstance(ctx, dict)
295
+ else getattr(ctx, "auth_context", None)
296
+ ) or {}
297
+ t = ac.get(CTX_TENANT_ID_KEY)
298
+ if t:
299
+ return _normalize_uuid(t)
300
+
301
+ return None
@@ -0,0 +1,111 @@
1
+ # tigrbl/v3/mixins/upsertable.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Mapping, Sequence, Optional, Tuple
5
+ from sqlalchemy import and_, inspect as sa_inspect
6
+ from tigrbl.types import Session
7
+
8
+
9
+ class Upsertable:
10
+ """
11
+ Hybrid upsert:
12
+ • If __upsert_keys__ is set and fully present -> decide by those keys
13
+ • Else if all PK parts are present -> decide by PK
14
+ • Else -> no rewrite
15
+ """
16
+
17
+ __upsert_keys__: Sequence[str] | None = None # optional natural key list
18
+
19
+ def __init_subclass__(cls, **kw):
20
+ super().__init_subclass__(**kw)
21
+ cls._install_upsertable_hooks()
22
+
23
+ @classmethod
24
+ def _install_upsertable_hooks(cls) -> None:
25
+ hooks = {**getattr(cls, "__tigrbl_hooks__", {})}
26
+
27
+ def _append(alias: str, phase: str, fn) -> None:
28
+ phase_map = hooks.get(alias) or {}
29
+ lst = list(phase_map.get(phase) or [])
30
+ if fn not in lst:
31
+ lst.append(fn)
32
+ phase_map[phase] = tuple(lst)
33
+ hooks[alias] = phase_map
34
+
35
+ for op in ("create", "update", "replace"):
36
+ _append(op, "PRE_TX_BEGIN", cls._make_upsert_rewrite_hook(op))
37
+
38
+ setattr(cls, "__tigrbl_hooks__", hooks)
39
+
40
+ @classmethod
41
+ def _make_upsert_rewrite_hook(cls, verb: str):
42
+ tab = "".join(w.title() for w in cls.__tablename__.split("_"))
43
+
44
+ async def _rewrite(ctx: Mapping[str, Any]) -> None:
45
+ params = ctx["env"].params if ctx.get("env") else {}
46
+ if hasattr(params, "model_dump"):
47
+ params = params.model_dump()
48
+ if not isinstance(params, Mapping):
49
+ return
50
+
51
+ db: Session = ctx["db"]
52
+ mapper = sa_inspect(cls)
53
+
54
+ # 1) Try natural keys if declared
55
+ key_names: Sequence[str] | None = cls.__upsert_keys__
56
+ if key_names:
57
+ kv = _extract_values(params, key_names)
58
+ if kv is not None:
59
+ exists = _exists_by_names(cls, db, key_names, kv)
60
+ _rewrite_by_existence(ctx, tab, verb, exists)
61
+ return # done
62
+
63
+ # 2) Fall back to PKs if fully present
64
+ pk_cols = tuple(mapper.primary_key)
65
+ pk_names = tuple(c.key for c in pk_cols)
66
+ kv = _extract_values(params, pk_names)
67
+ if kv is None:
68
+ return # not enough info → no rewrite
69
+
70
+ exists = _exists_by_pk(cls, db, pk_cols, kv)
71
+ _rewrite_by_existence(ctx, tab, verb, exists)
72
+
73
+ return _rewrite
74
+
75
+
76
+ def _extract_values(
77
+ p: Mapping[str, Any], names: Sequence[str]
78
+ ) -> Optional[Tuple[Any, ...]]:
79
+ vals = []
80
+ for n in names:
81
+ v = p.get(n)
82
+ if v is None:
83
+ return None
84
+ vals.append(v)
85
+ return tuple(vals)
86
+
87
+
88
+ def _exists_by_names(
89
+ model, db: Session, names: Sequence[str], vals: Tuple[Any, ...]
90
+ ) -> bool:
91
+ q = db.query(model)
92
+ for n, v in zip(names, vals):
93
+ q = q.filter(getattr(model, n) == v)
94
+ return db.query(q.exists()).scalar() is True
95
+
96
+
97
+ def _exists_by_pk(model, db: Session, pk_cols, pk_vals: Tuple[Any, ...]) -> bool:
98
+ if len(pk_cols) == 1:
99
+ # fast path
100
+ return db.get(model, pk_vals[0]) is not None
101
+ conds = [getattr(model, c.key) == v for c, v in zip(pk_cols, pk_vals)]
102
+ return db.query(db.query(model).filter(and_(*conds)).exists()).scalar() is True
103
+
104
+
105
+ def _rewrite_by_existence(ctx, tab: str, verb: str, exists: bool) -> None:
106
+ if verb == "create" and exists:
107
+ ctx["env"].method = f"{tab}.update"
108
+ elif verb == "update" and not exists:
109
+ ctx["env"].method = f"{tab}.create"
110
+ elif verb == "replace":
111
+ ctx["env"].method = f"{tab}.update" if exists else f"{tab}.create"
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+
5
+ from ...specs import IO
6
+ from ...types import UUID
7
+
8
+
9
+ def tzutcnow() -> dt.datetime:
10
+ """Return an aware UTC ``datetime``."""
11
+ return dt.datetime.now(dt.timezone.utc)
12
+
13
+
14
+ def tzutcnow_plus_day() -> dt.datetime:
15
+ """Return an aware UTC ``datetime`` one day in the future."""
16
+ return tzutcnow() + dt.timedelta(days=1)
17
+
18
+
19
+ def _infer_schema(cls, default: str = "public") -> str:
20
+ """Extract schema from ``__table_args__`` in dict or tuple/list form."""
21
+ args = getattr(cls, "__table_args__", None)
22
+ if not args:
23
+ return default
24
+ if isinstance(args, dict):
25
+ return args.get("schema", default)
26
+ if isinstance(args, (tuple, list)):
27
+ for elem in args:
28
+ if isinstance(elem, dict) and "schema" in elem:
29
+ return elem["schema"]
30
+ return default
31
+
32
+
33
+ uuid_example = UUID("00000000-dead-beef-cafe-000000000000")
34
+
35
+ CRUD_IN = ("create", "update", "replace")
36
+ CRUD_OUT = ("read", "list")
37
+ CRUD_IO = IO(in_verbs=CRUD_IN, out_verbs=CRUD_OUT, mutable_verbs=CRUD_IN)
38
+ RO_IO = IO(out_verbs=CRUD_OUT)
39
+
40
+ __all__ = [
41
+ "tzutcnow",
42
+ "tzutcnow_plus_day",
43
+ "_infer_schema",
44
+ "uuid_example",
45
+ "CRUD_IN",
46
+ "CRUD_OUT",
47
+ "CRUD_IO",
48
+ "RO_IO",
49
+ ]
@@ -0,0 +1,72 @@
1
+ """Public façade for all table classes.
2
+
3
+ Usage
4
+ -----
5
+ from tigrbl.orm.tables import (
6
+ Tenant,
7
+ User,
8
+ Group,
9
+ Role,
10
+ )
11
+ """
12
+
13
+ import importlib
14
+ from typing import TYPE_CHECKING, Any
15
+ from ._base import Base
16
+
17
+ __all__ = [
18
+ "Tenant",
19
+ "Client",
20
+ "User",
21
+ "Group",
22
+ "Org",
23
+ "Role",
24
+ "RolePerm",
25
+ "RoleGrant",
26
+ "Status",
27
+ "StatusEnum",
28
+ "Change",
29
+ "Base",
30
+ ]
31
+
32
+ # ------------------------------------------------------------------ #
33
+ # Lazy attribute loader (PEP 562). Keeps import graphs light-weight.
34
+ # ------------------------------------------------------------------ #
35
+ _module_map = {
36
+ "Tenant": f"{__name__}.tenant",
37
+ "Client": f"{__name__}.client",
38
+ "User": f"{__name__}.user",
39
+ "Group": f"{__name__}.group",
40
+ "Org": f"{__name__}.org",
41
+ "Role": f"{__name__}.rbac",
42
+ "RolePerm": f"{__name__}.rbac",
43
+ "RoleGrant": f"{__name__}.rbac",
44
+ "Status": f"{__name__}.status",
45
+ "StatusEnum": f"{__name__}.status",
46
+ "Change": f"{__name__}.audit",
47
+ }
48
+
49
+
50
+ def __getattr__(name: str) -> Any: # noqa: D401
51
+ """Dynamically import `tenant`, `user`, or `group` on first use."""
52
+ if name not in _module_map:
53
+ raise AttributeError(name)
54
+ module = importlib.import_module(_module_map[name])
55
+ obj = getattr(module, name)
56
+ globals()[name] = obj # cache for future look-ups
57
+ return obj
58
+
59
+
60
+ # ------------------------------------------------------------------ #
61
+ # Static typing support – imported eagerly only during type checking.
62
+ # ------------------------------------------------------------------ #
63
+ if TYPE_CHECKING: # pragma: no cover
64
+ from ._base import Base
65
+ from .tenant import Tenant
66
+ from .client import Client
67
+ from .user import User
68
+ from .group import Group
69
+ from .org import Org
70
+ from .rbac import Role, RoleGrant, RolePerm
71
+ from .status import Status, StatusEnum
72
+ from .audit import Change
@@ -0,0 +1,8 @@
1
+ """Compatibility layer for Base moved to tigrbl.table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...table import Base
6
+ from ...table._base import _materialize_colspecs_to_sqla
7
+
8
+ __all__ = ["Base", "_materialize_colspecs_to_sqla"]
@@ -0,0 +1,56 @@
1
+ # tigrbl/tables/audit.py
2
+ import datetime as dt
3
+ from uuid import UUID
4
+
5
+ from . import Base
6
+ from ..mixins import GUIDPk, Timestamped
7
+ from ...specs import IO, F, acol, S
8
+ from ...types import DateTime, Integer, String, PgUUID, Mapped
9
+
10
+
11
+ class Change(Base, GUIDPk, Timestamped):
12
+ __tablename__ = "changes"
13
+
14
+ seq: Mapped[int] = acol(
15
+ storage=S(Integer, primary_key=True),
16
+ field=F(),
17
+ io=IO(out_verbs=("read", "list")),
18
+ )
19
+ at: Mapped[dt.datetime] = acol(
20
+ storage=S(DateTime, default=dt.datetime.utcnow),
21
+ field=F(),
22
+ io=IO(out_verbs=("read", "list")),
23
+ )
24
+ actor_id: Mapped[UUID | None] = acol(
25
+ storage=S(PgUUID, nullable=True),
26
+ field=F(),
27
+ io=IO(out_verbs=("read", "list")),
28
+ )
29
+ table_name: Mapped[str] = acol(
30
+ storage=S(String),
31
+ field=F(),
32
+ io=IO(out_verbs=("read", "list")),
33
+ )
34
+ row_id: Mapped[UUID | None] = acol(
35
+ storage=S(PgUUID, nullable=True),
36
+ field=F(),
37
+ io=IO(out_verbs=("read", "list")),
38
+ )
39
+ action: Mapped[str] = acol(
40
+ storage=S(String),
41
+ field=F(),
42
+ io=IO(out_verbs=("read", "list")),
43
+ )
44
+
45
+
46
+ __all__ = ["Change"]
47
+
48
+ for _name in list(globals()):
49
+ if _name not in __all__ and not _name.startswith("__"):
50
+ del globals()[_name]
51
+
52
+
53
+ def __dir__():
54
+ """Tighten ``dir()`` output for interactive sessions."""
55
+
56
+ return sorted(__all__)