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,54 @@
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, String, declarative_mixin, declared_attr, Mapped
6
+
7
+ from .utils import CRUD_IO
8
+
9
+
10
+ @declarative_mixin
11
+ class Contained:
12
+ @declared_attr
13
+ def parent_id(cls) -> Mapped[UUID]:
14
+ if not hasattr(cls, "parent_table"):
15
+ raise AttributeError("subclass must set parent_table")
16
+ spec = ColumnSpec(
17
+ storage=S(
18
+ type_=PgUUID(as_uuid=True),
19
+ fk=ForeignKeySpec(target=f"{cls.parent_table}.id"),
20
+ nullable=False,
21
+ ),
22
+ field=F(py_type=UUID),
23
+ io=CRUD_IO,
24
+ )
25
+ return acol(spec=spec)
26
+
27
+
28
+ @declarative_mixin
29
+ class TreeNode:
30
+ """Self-nesting hierarchy."""
31
+
32
+ @declared_attr
33
+ def parent_id(cls) -> Mapped[UUID | None]:
34
+ spec = ColumnSpec(
35
+ storage=S(
36
+ type_=PgUUID(as_uuid=True),
37
+ fk=ForeignKeySpec(target=f"{cls.__tablename__}.id"),
38
+ nullable=True,
39
+ ),
40
+ field=F(py_type=UUID),
41
+ io=CRUD_IO,
42
+ )
43
+ return acol(spec=spec)
44
+
45
+ path: Mapped[str] = acol(
46
+ spec=ColumnSpec(
47
+ storage=S(type_=String),
48
+ field=F(py_type=str),
49
+ io=CRUD_IO,
50
+ )
51
+ )
52
+
53
+
54
+ __all__ = ["Contained", "TreeNode"]
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from hashlib import sha256
4
+ from secrets import token_urlsafe
5
+
6
+ from ...column.io_spec import Pair
7
+ from ...specs import F, IO, S, acol
8
+ from ...types import Mapped, String, declarative_mixin
9
+
10
+
11
+ @declarative_mixin
12
+ class KeyDigest:
13
+ """Provides hashed API key storage with helpers."""
14
+
15
+ @staticmethod
16
+ def _generate_pair(_ctx: dict | None = None) -> Pair:
17
+ """Generate a raw/stored pair for API keys."""
18
+ raw = token_urlsafe(32)
19
+ return Pair(raw=raw, stored=sha256(raw.encode()).hexdigest())
20
+
21
+ digest: Mapped[str] = acol(
22
+ storage=S(String, nullable=False, unique=True, index=True),
23
+ field=F(constraints={"max_length": 64}),
24
+ io=IO(out_verbs=("read", "list", "create"), mutable_verbs=("create",)).paired(
25
+ make=_generate_pair,
26
+ alias="api_key",
27
+ verbs=("create",),
28
+ emit="post_refresh",
29
+ alias_field=F(py_type=str), # include alias in the response schema
30
+ mask_last=None, # set an int if you want masking
31
+ ),
32
+ )
33
+
34
+ @staticmethod
35
+ def digest_of(value: str) -> str:
36
+ return sha256(value.encode()).hexdigest()
37
+
38
+ @property
39
+ def raw_key(self) -> str: # pragma: no cover - write-only
40
+ raise AttributeError("raw_key is write-only")
41
+
42
+ @raw_key.setter
43
+ def raw_key(self, value: str) -> None:
44
+ self.digest = self.digest_of(value)
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+
5
+ from ...specs import ColumnSpec, F, IO, S, acol
6
+ from ...types import (
7
+ TZDateTime,
8
+ Boolean,
9
+ Integer,
10
+ PgUUID,
11
+ UUID,
12
+ declarative_mixin,
13
+ Mapped,
14
+ )
15
+
16
+ from .utils import tzutcnow, CRUD_IO, RO_IO
17
+
18
+
19
+ @declarative_mixin
20
+ class Created:
21
+ created_at: Mapped[dt.datetime] = acol(
22
+ spec=ColumnSpec(
23
+ storage=S(type_=TZDateTime, default=tzutcnow, nullable=False),
24
+ field=F(py_type=dt.datetime),
25
+ io=RO_IO,
26
+ )
27
+ )
28
+
29
+
30
+ @declarative_mixin
31
+ class LastUsed:
32
+ last_used_at: Mapped[dt.datetime | None] = acol(
33
+ spec=ColumnSpec(
34
+ storage=S(type_=TZDateTime, nullable=True, onupdate=tzutcnow),
35
+ field=F(py_type=dt.datetime),
36
+ io=IO(out_verbs=("read", "list", "create")),
37
+ )
38
+ )
39
+
40
+ def touch(self) -> None:
41
+ """Mark the object as used now."""
42
+ self.last_used_at = tzutcnow()
43
+
44
+
45
+ @declarative_mixin
46
+ class Timestamped:
47
+ created_at: Mapped[dt.datetime] = acol(
48
+ spec=ColumnSpec(
49
+ storage=S(type_=TZDateTime, default=tzutcnow, nullable=False),
50
+ field=F(py_type=dt.datetime),
51
+ io=RO_IO,
52
+ )
53
+ )
54
+ updated_at: Mapped[dt.datetime] = acol(
55
+ spec=ColumnSpec(
56
+ storage=S(
57
+ type_=TZDateTime,
58
+ default=tzutcnow,
59
+ onupdate=tzutcnow,
60
+ nullable=False,
61
+ ),
62
+ field=F(py_type=dt.datetime),
63
+ io=RO_IO,
64
+ )
65
+ )
66
+
67
+
68
+ @declarative_mixin
69
+ class ActiveToggle:
70
+ is_active: Mapped[bool] = acol(
71
+ spec=ColumnSpec(
72
+ storage=S(type_=Boolean, default=True, nullable=False),
73
+ field=F(py_type=bool),
74
+ io=CRUD_IO,
75
+ )
76
+ )
77
+
78
+
79
+ @declarative_mixin
80
+ class SoftDelete:
81
+ deleted_at: Mapped[dt.datetime | None] = acol(
82
+ spec=ColumnSpec(
83
+ storage=S(type_=TZDateTime, nullable=True),
84
+ field=F(py_type=dt.datetime),
85
+ io=CRUD_IO,
86
+ )
87
+ )
88
+
89
+
90
+ @declarative_mixin
91
+ class Versioned:
92
+ revision: Mapped[int] = acol(
93
+ spec=ColumnSpec(
94
+ storage=S(type_=Integer, default=1, nullable=False),
95
+ field=F(py_type=int),
96
+ io=CRUD_IO,
97
+ )
98
+ )
99
+ prev_id: Mapped[UUID | None] = acol(
100
+ spec=ColumnSpec(
101
+ storage=S(type_=PgUUID(as_uuid=True), nullable=True),
102
+ field=F(py_type=UUID),
103
+ io=CRUD_IO,
104
+ )
105
+ )
106
+
107
+
108
+ __all__ = [
109
+ "Created",
110
+ "LastUsed",
111
+ "Timestamped",
112
+ "ActiveToggle",
113
+ "SoftDelete",
114
+ "Versioned",
115
+ ]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+
5
+ from ...specs import ColumnSpec, F, S, acol
6
+ from ...specs.storage_spec import ForeignKeySpec
7
+ from ...types import PgUUID, TZDateTime, UUID, declarative_mixin, Mapped
8
+
9
+ from .utils import CRUD_IO
10
+
11
+
12
+ @declarative_mixin
13
+ class RowLock:
14
+ lock_token: Mapped[UUID | None] = acol(
15
+ spec=ColumnSpec(
16
+ storage=S(type_=PgUUID(as_uuid=True), nullable=True),
17
+ field=F(py_type=UUID),
18
+ io=CRUD_IO,
19
+ )
20
+ )
21
+ locked_at: Mapped[dt.datetime | None] = acol(
22
+ spec=ColumnSpec(
23
+ storage=S(type_=TZDateTime, nullable=True),
24
+ field=F(py_type=dt.datetime),
25
+ io=CRUD_IO,
26
+ )
27
+ )
28
+
29
+
30
+ @declarative_mixin
31
+ class SoftLock:
32
+ locked_by: Mapped[UUID | None] = acol(
33
+ spec=ColumnSpec(
34
+ storage=S(
35
+ type_=PgUUID(as_uuid=True),
36
+ fk=ForeignKeySpec(target="users.id"),
37
+ ),
38
+ field=F(py_type=UUID),
39
+ io=CRUD_IO,
40
+ )
41
+ )
42
+ locked_at: Mapped[dt.datetime | None] = acol(
43
+ spec=ColumnSpec(
44
+ storage=S(type_=TZDateTime),
45
+ field=F(py_type=dt.datetime),
46
+ io=CRUD_IO,
47
+ )
48
+ )
49
+
50
+
51
+ __all__ = ["RowLock", "SoftLock"]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from ...types import declarative_mixin
4
+
5
+
6
+ @declarative_mixin
7
+ class AsyncCapable:
8
+ pass
9
+
10
+
11
+ @declarative_mixin
12
+ class Audited:
13
+ pass
14
+
15
+
16
+ __all__ = ["AsyncCapable", "Audited"]
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from ...config.constants import BULK_VERBS
4
+ from ...types import declarative_mixin
5
+
6
+
7
+ @declarative_mixin
8
+ class BulkCapable:
9
+ __tigrbl_defaults_mode__: str = "all"
10
+ __tigrbl_defaults_include__: set[str] = {
11
+ v for v in BULK_VERBS if v not in {"bulk_replace", "bulk_merge"}
12
+ }
13
+ __tigrbl_defaults_exclude__: set[str] = set()
14
+
15
+ def __init_subclass__(cls, **kw):
16
+ super().__init_subclass__(**kw)
17
+ inc = set(getattr(cls, "__tigrbl_defaults_include__", set()))
18
+ inc.update(BulkCapable.__tigrbl_defaults_include__)
19
+ cls.__tigrbl_defaults_include__ = inc
20
+
21
+ exc = set(getattr(cls, "__tigrbl_defaults_exclude__", set()))
22
+ exc.update(BulkCapable.__tigrbl_defaults_exclude__)
23
+ cls.__tigrbl_defaults_exclude__ = exc
24
+
25
+
26
+ @declarative_mixin
27
+ class Replaceable:
28
+ __tigrbl_defaults_mode__: str = "all"
29
+ __tigrbl_defaults_include__: set[str] = {"replace", "bulk_replace"}
30
+ __tigrbl_defaults_exclude__: set[str] = set()
31
+
32
+ def __init_subclass__(cls, **kw):
33
+ super().__init_subclass__(**kw)
34
+ inc = set(getattr(cls, "__tigrbl_defaults_include__", set()))
35
+ inc.update(Replaceable.__tigrbl_defaults_include__)
36
+ cls.__tigrbl_defaults_include__ = inc
37
+
38
+
39
+ @declarative_mixin
40
+ class Mergeable:
41
+ __tigrbl_defaults_mode__: str = "all"
42
+ __tigrbl_defaults_include__: set[str] = {"merge", "bulk_merge"}
43
+ __tigrbl_defaults_exclude__: set[str] = set()
44
+
45
+ def __init_subclass__(cls, **kw):
46
+ super().__init_subclass__(**kw)
47
+ inc = set(getattr(cls, "__tigrbl_defaults_include__", set()))
48
+ inc.update(Mergeable.__tigrbl_defaults_include__)
49
+ cls.__tigrbl_defaults_include__ = inc
50
+
51
+
52
+ @declarative_mixin
53
+ class Streamable:
54
+ pass
55
+
56
+
57
+ __all__ = ["BulkCapable", "Replaceable", "Mergeable", "Streamable"]
@@ -0,0 +1,337 @@
1
+ # tigrbl/v3/mixins/ownable.py
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from enum import Enum
6
+ from typing import Any, Mapping
7
+ from uuid import UUID
8
+
9
+ from ...types import PgUUID, Mapped, declared_attr
10
+
11
+ from ...specs import acol
12
+ from ...config.constants import (
13
+ TIGRBL_HOOKS_ATTR,
14
+ TIGRBL_OWNER_POLICY_ATTR,
15
+ CTX_AUTH_KEY,
16
+ CTX_USER_ID_KEY,
17
+ )
18
+ from ...runtime.errors import create_standardized_error
19
+ from ...specs import ColumnSpec, F, IO, S
20
+ from ...specs.storage_spec import ForeignKeySpec
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ class OwnerPolicy(str, Enum):
26
+ CLIENT_SET = "client" # client may set; validated against user_id if provided
27
+ DEFAULT_TO_USER = "default" # if missing, default to user_id
28
+ STRICT_SERVER = "strict" # server enforces user_id; client cannot override
29
+
30
+
31
+ def _infer_schema(cls, default: str = "public") -> str:
32
+ args = getattr(cls, "__table_args__", None)
33
+ if not args:
34
+ return default
35
+ if isinstance(args, dict):
36
+ return args.get("schema", default)
37
+ if isinstance(args, (tuple, list)):
38
+ for elem in args:
39
+ if isinstance(elem, dict) and "schema" in elem:
40
+ return elem["schema"]
41
+ return default
42
+
43
+
44
+ def _is_missing(v: Any) -> bool:
45
+ return v is None or (isinstance(v, str) and not v.strip())
46
+
47
+
48
+ def _normalize_uuid(v: Any) -> Any:
49
+ if isinstance(v, UUID):
50
+ return v
51
+ if isinstance(v, str):
52
+ try:
53
+ return UUID(v)
54
+ except ValueError:
55
+ return v
56
+ return v
57
+
58
+
59
+ def _ctx_user_id(ctx: Mapping[str, Any]) -> Any | None:
60
+ """
61
+ Best-effort extraction of the caller user_id from ctx.
62
+ Checks:
63
+ 1) ctx["user_id"] (preferred in v3)
64
+ 2) ctx["auth"]["user_id"] (v3 conventional)
65
+ 3) ctx["injected_fields"]["user_id"] (legacy)
66
+ 4) ctx["auth_context"]["user_id"] (legacy)
67
+ """
68
+ # 1) direct
69
+ u = (
70
+ ctx.get(CTX_USER_ID_KEY)
71
+ if isinstance(ctx, dict)
72
+ else getattr(ctx, CTX_USER_ID_KEY, None)
73
+ )
74
+ if u:
75
+ return _normalize_uuid(u)
76
+
77
+ # 2) auth dict
78
+ auth = (
79
+ ctx.get(CTX_AUTH_KEY)
80
+ if isinstance(ctx, dict)
81
+ else getattr(ctx, CTX_AUTH_KEY, None)
82
+ ) or {}
83
+ u = auth.get("user_id")
84
+ if u:
85
+ return _normalize_uuid(u)
86
+
87
+ # 3 & 4) legacy fallbacks
88
+ inj = (
89
+ ctx.get("injected_fields")
90
+ if isinstance(ctx, dict)
91
+ else getattr(ctx, "injected_fields", None)
92
+ ) or {}
93
+ u = inj.get("user_id")
94
+ if u:
95
+ return _normalize_uuid(u)
96
+
97
+ ac = (
98
+ ctx.get("auth_context")
99
+ if isinstance(ctx, dict)
100
+ else getattr(ctx, "auth_context", None)
101
+ ) or {}
102
+ u = ac.get("user_id")
103
+ if u:
104
+ return _normalize_uuid(u)
105
+
106
+ return None
107
+
108
+
109
+ class Ownable:
110
+ """
111
+ Mixin that adds an `owner_id` column and installs v3 hooks to enforce ownership policy.
112
+
113
+ Policy (per `__tigrbl_owner_policy__`):
114
+ • CLIENT_SET: client may provide `owner_id`; if missing, we leave it as-is.
115
+ • DEFAULT_TO_USER: if `owner_id` missing, default to ctx user; if provided, keep it.
116
+ • STRICT_SERVER: always enforce `owner_id = ctx user`; reject mismatches.
117
+
118
+ Hooks (installed at class creation via __init_subclass__):
119
+ • PRE_TX_BEGIN on "create": normalize/enforce `owner_id` in ctx.env.params & ctx.payload
120
+ • PRE_TX_BEGIN on "update": forbid changing `owner_id` unless CLIENT_SET and matches ctx user
121
+ (note: if you need to compare with the existing DB value, do that in POST_HANDLER where
122
+ your core sets ctx["result"] or fetch the row here — this version validates intent only)
123
+ """
124
+
125
+ __tigrbl_owner_policy__: OwnerPolicy = OwnerPolicy.CLIENT_SET
126
+
127
+ @declared_attr
128
+ def owner_id(cls) -> Mapped[UUID]:
129
+ pol = getattr(cls, TIGRBL_OWNER_POLICY_ATTR, OwnerPolicy.CLIENT_SET)
130
+ schema = _infer_schema(cls, default="public")
131
+
132
+ in_verbs = (
133
+ ("create", "update", "replace")
134
+ if pol == OwnerPolicy.CLIENT_SET
135
+ else ("create",)
136
+ )
137
+ io = IO(
138
+ in_verbs=in_verbs,
139
+ out_verbs=("read", "list"),
140
+ mutable_verbs=in_verbs,
141
+ )
142
+
143
+ spec = ColumnSpec(
144
+ storage=S(
145
+ type_=PgUUID(as_uuid=True),
146
+ fk=ForeignKeySpec(target=f"{schema}.users.id"),
147
+ nullable=False,
148
+ index=True,
149
+ ),
150
+ field=F(py_type=UUID),
151
+ io=io,
152
+ )
153
+ return acol(spec=spec)
154
+
155
+ # ── hook installers --------------------------------------------------------
156
+
157
+ def __init_subclass__(cls, **kwargs):
158
+ super().__init_subclass__(**kwargs)
159
+ cls._install_ownable_hooks()
160
+
161
+ @classmethod
162
+ def _install_ownable_hooks(cls) -> None:
163
+ """
164
+ Attach PRE_TX_BEGIN hooks to the class under __tigrbl_hooks__.
165
+
166
+ Structure expected by v3 binder:
167
+ {
168
+ "<alias>": {
169
+ "PRE_TX_BEGIN": [callable, ...],
170
+ ...
171
+ },
172
+ ...
173
+ }
174
+ """
175
+
176
+ def _err(status: int, msg: str):
177
+ http_exc, _, _ = create_standardized_error(status, message=msg)
178
+ raise http_exc
179
+
180
+ def _before_create(ctx: dict[str, Any]) -> None:
181
+ env = (
182
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
183
+ ) or {}
184
+ params = (
185
+ (
186
+ env.get("params")
187
+ if isinstance(env, dict)
188
+ else getattr(env, "params", None)
189
+ )
190
+ or (
191
+ ctx.get("payload")
192
+ if isinstance(ctx, dict)
193
+ else getattr(ctx, "payload", None)
194
+ )
195
+ or {}
196
+ )
197
+ if hasattr(params, "model_dump"):
198
+ params = params.model_dump()
199
+
200
+ user_id = _ctx_user_id(ctx)
201
+ provided = params.get("owner_id")
202
+ missing = _is_missing(provided)
203
+ pol = getattr(cls, TIGRBL_OWNER_POLICY_ATTR, OwnerPolicy.CLIENT_SET)
204
+
205
+ log.debug(
206
+ "Ownable PRE_TX_BEGIN(create): policy=%s params=%s user_id=%s",
207
+ pol,
208
+ params,
209
+ user_id,
210
+ )
211
+
212
+ if pol == OwnerPolicy.STRICT_SERVER:
213
+ if user_id is None:
214
+ _err(400, "owner_id is required.")
215
+ if not missing:
216
+ if _normalize_uuid(provided) != _normalize_uuid(user_id):
217
+ _err(400, "owner_id mismatch.")
218
+ _err(400, "owner_id is server-assigned.")
219
+ params["owner_id"] = user_id # always enforce server value
220
+ elif pol == OwnerPolicy.DEFAULT_TO_USER:
221
+ if missing and user_id is not None:
222
+ params["owner_id"] = user_id
223
+ elif not missing:
224
+ params["owner_id"] = _normalize_uuid(provided)
225
+ else: # CLIENT_SET
226
+ if not missing:
227
+ params["owner_id"] = _normalize_uuid(provided)
228
+ # if missing, leave as-is (schema/DB may enforce NOT NULL)
229
+
230
+ # write back into both env.params and payload so downstream sees the same view
231
+ env_attr = (
232
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
233
+ )
234
+ if env_attr is not None:
235
+ if isinstance(env_attr, dict):
236
+ env_attr["params"] = params
237
+ else:
238
+ env_attr.params = params
239
+ if isinstance(ctx, dict):
240
+ ctx["payload"] = params
241
+ else:
242
+ setattr(ctx, "payload", params)
243
+
244
+ def _before_update(ctx: dict[str, Any]) -> None:
245
+ env = (
246
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
247
+ ) or {}
248
+ params = (
249
+ (
250
+ env.get("params")
251
+ if isinstance(env, dict)
252
+ else getattr(env, "params", None)
253
+ )
254
+ or (
255
+ ctx.get("payload")
256
+ if isinstance(ctx, dict)
257
+ else getattr(ctx, "payload", None)
258
+ )
259
+ or {}
260
+ )
261
+ if hasattr(params, "model_dump"):
262
+ params = params.model_dump()
263
+
264
+ if "owner_id" not in params:
265
+ return # nothing to check
266
+
267
+ pol = getattr(cls, TIGRBL_OWNER_POLICY_ATTR, OwnerPolicy.CLIENT_SET)
268
+ if _is_missing(params.get("owner_id")):
269
+ # treat None/"" as not provided → drop it
270
+ params.pop("owner_id", None)
271
+ env_attr = (
272
+ ctx.get("env")
273
+ if isinstance(ctx, dict)
274
+ else getattr(ctx, "env", None)
275
+ )
276
+ if env_attr is not None:
277
+ if isinstance(env_attr, dict):
278
+ env_attr["params"] = params
279
+ else:
280
+ env_attr.params = params
281
+ if isinstance(ctx, dict):
282
+ ctx["payload"] = params
283
+ else:
284
+ setattr(ctx, "payload", params)
285
+ return
286
+
287
+ if pol != OwnerPolicy.CLIENT_SET:
288
+ _err(400, "owner_id is immutable.")
289
+
290
+ # CLIENT_SET: require new value == caller user_id unless an admin flag is present
291
+ new_val = _normalize_uuid(params["owner_id"])
292
+ user_id = _ctx_user_id(ctx)
293
+ is_admin = bool(ctx.get("is_admin"))
294
+
295
+ log.debug(
296
+ "Ownable PRE_TX_BEGIN(update): new=%s user_id=%s is_admin=%s",
297
+ new_val,
298
+ user_id,
299
+ is_admin,
300
+ )
301
+
302
+ if not is_admin and user_id is not None and new_val != user_id:
303
+ _err(403, "Cannot transfer ownership.")
304
+
305
+ # normalize stored value
306
+ params["owner_id"] = new_val
307
+ env_attr = (
308
+ ctx.get("env") if isinstance(ctx, dict) else getattr(ctx, "env", None)
309
+ )
310
+ if env_attr is not None:
311
+ if isinstance(env_attr, dict):
312
+ env_attr["params"] = params
313
+ else:
314
+ env_attr.params = params
315
+ if isinstance(ctx, dict):
316
+ ctx["payload"] = params
317
+ else:
318
+ setattr(ctx, "payload", params)
319
+
320
+ # Attach (merge) into __tigrbl_hooks__ without clobbering existing mappings
321
+ hooks = getattr(cls, TIGRBL_HOOKS_ATTR, None) or {}
322
+ hooks = {**hooks} # shallow copy
323
+
324
+ def _append(alias: str, phase: str, fn):
325
+ phase_map = hooks.get(alias) or {}
326
+ lst = list(phase_map.get(phase) or [])
327
+ if fn not in lst:
328
+ lst.append(fn)
329
+ phase_map[phase] = tuple(
330
+ lst
331
+ ) # tuples are safer against accidental mutation
332
+ hooks[alias] = phase_map
333
+
334
+ _append("create", "PRE_TX_BEGIN", _before_create)
335
+ _append("update", "PRE_TX_BEGIN", _before_update)
336
+
337
+ setattr(cls, TIGRBL_HOOKS_ATTR, hooks)