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,130 @@
1
+ # tigrbl/tigrbl/v3/engine/_engine.py
2
+ from __future__ import annotations
3
+
4
+ from contextlib import contextmanager, asynccontextmanager
5
+ from dataclasses import dataclass, field
6
+ import asyncio
7
+ import inspect
8
+ from typing import Any, Callable, Optional, Tuple, Union, Protocol, TYPE_CHECKING
9
+
10
+ try:
11
+ from sqlalchemy.orm import Session
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ except Exception: # pragma: no cover
14
+ Session = object # type: ignore
15
+ AsyncSession = object # type: ignore
16
+
17
+
18
+ class SessionFactory(Protocol):
19
+ def __call__(self) -> Union[Session, AsyncSession]: ...
20
+
21
+
22
+ Builder = Callable[[], Tuple[Any, SessionFactory]] # returns (engine, sessionmaker)
23
+
24
+ if TYPE_CHECKING: # pragma: no cover - for type checkers only
25
+ from .engine_spec import EngineSpec
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Provider:
30
+ """Lazily builds an engine + sessionmaker from an :class:`EngineSpec`."""
31
+
32
+ spec: "EngineSpec"
33
+ _engine: Any = None
34
+ _maker: Optional[SessionFactory] = None
35
+ get_db: Callable[..., Any] = field(init=False)
36
+
37
+ @property
38
+ def kind(self) -> str:
39
+ return "async" if self.spec.async_ else "sync"
40
+
41
+ def ensure(self) -> Tuple[Any, SessionFactory]:
42
+ if self._maker is None:
43
+ eng, mk = self.spec.build()
44
+ object.__setattr__(self, "_engine", eng)
45
+ object.__setattr__(self, "_maker", mk)
46
+ return self._engine, self._maker # type: ignore[return-value]
47
+
48
+ def session(self) -> Union[Session, AsyncSession]:
49
+ _, mk = self.ensure()
50
+ return mk() # type: ignore[misc]
51
+
52
+ def __post_init__(self) -> None:
53
+ if self.spec.async_:
54
+
55
+ async def _get_db() -> Any:
56
+ db = self.session()
57
+ try:
58
+ yield db # type: ignore[misc]
59
+ finally:
60
+ close = getattr(db, "close", None)
61
+ if callable(close):
62
+ rv = close()
63
+ if inspect.isawaitable(rv):
64
+ await rv
65
+
66
+ object.__setattr__(self, "get_db", _get_db)
67
+ else:
68
+
69
+ def _get_db() -> Any:
70
+ db = self.session()
71
+ try:
72
+ yield db # type: ignore[misc]
73
+ finally:
74
+ close = getattr(db, "close", None)
75
+ if callable(close):
76
+ rv = close()
77
+ if inspect.isawaitable(rv):
78
+ try:
79
+ loop = asyncio.get_running_loop()
80
+ except RuntimeError:
81
+ asyncio.run(rv)
82
+ else:
83
+ loop.create_task(rv)
84
+
85
+ object.__setattr__(self, "get_db", _get_db)
86
+
87
+
88
+ @dataclass
89
+ class Engine:
90
+ """Thin façade over an :class:`EngineSpec` with convenient (a)context managers."""
91
+
92
+ spec: "EngineSpec"
93
+ provider: Provider = field(init=False)
94
+
95
+ def __post_init__(self) -> None:
96
+ object.__setattr__(self, "provider", Provider(self.spec))
97
+
98
+ @property
99
+ def is_async(self) -> bool:
100
+ return self.provider.kind == "async"
101
+
102
+ def raw(self) -> Tuple[Any, SessionFactory]:
103
+ return self.provider.ensure()
104
+
105
+ @property
106
+ def get_db(self) -> Callable[..., Any]:
107
+ return self.provider.get_db
108
+
109
+ @contextmanager
110
+ def session(self) -> Session:
111
+ db = self.provider.session()
112
+ try:
113
+ yield db # type: ignore[return-value]
114
+ finally:
115
+ close = getattr(db, "close", None)
116
+ if callable(close):
117
+ close()
118
+
119
+ @asynccontextmanager
120
+ async def asession(self) -> AsyncSession:
121
+ db = self.provider.session()
122
+ try:
123
+ yield db # type: ignore[return-value]
124
+ finally:
125
+ close = getattr(db, "close", None)
126
+ if callable(close):
127
+ # AsyncSession.close() is sync; close() may exist as async in some impls
128
+ res = close()
129
+ if hasattr(res, "__await__"):
130
+ await res
tigrbl/engine/bind.py ADDED
@@ -0,0 +1,33 @@
1
+ """Bind collected engine configuration to the resolver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Iterable
6
+
7
+ from .resolver import register_api, register_op, register_table, set_default
8
+
9
+
10
+ def bind(collected: Dict[str, Any]) -> None:
11
+ """Bind a collected configuration mapping into the resolver."""
12
+ default_db = collected.get("default")
13
+ if default_db is not None:
14
+ set_default(default_db)
15
+
16
+ for api_obj, db in collected.get("api", {}).items():
17
+ register_api(api_obj, db)
18
+
19
+ for table_obj, db in collected.get("tables", {}).items():
20
+ register_table(table_obj, db)
21
+
22
+ for (model, alias), db in collected.get("ops", {}).items():
23
+ register_op(model, alias, db)
24
+
25
+
26
+ def install_from_objects(
27
+ *, app: Any | None = None, api: Any | None = None, models: Iterable[Any] = ()
28
+ ) -> None:
29
+ """Collect engine config from objects and bind them to the resolver."""
30
+ from .collect import collect_engine_config
31
+
32
+ collected = collect_engine_config(app=app, api=api, models=models)
33
+ bind(collected)
@@ -0,0 +1,236 @@
1
+ """SQLAlchemy engine and sessionmaker builders."""
2
+
3
+ import logging
4
+ import os
5
+ from sqlalchemy import create_engine, event
6
+ from sqlalchemy.exc import SQLAlchemyError
7
+ from sqlalchemy.pool import StaticPool # only for SQLite
8
+ from sqlalchemy.orm import sessionmaker
9
+
10
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
11
+
12
+ logging.getLogger("uvicorn").setLevel(logging.DEBUG)
13
+ logger = logging.getLogger("uvicorn")
14
+
15
+
16
+ # ---------------------------------------------------------------------
17
+ # 1. BLOCKING • SQLite
18
+ # ---------------------------------------------------------------------
19
+ def blocking_sqlite_engine(path: str | None = None):
20
+ """
21
+ Parameters
22
+ ----------
23
+ path : str | None
24
+ • None → single shared in-memory DB (thread-safe).
25
+ • "./db.sqlite3" etc. → file-backed database.
26
+ """
27
+ logger.debug("blocking_sqlite_engine called with path=%r", path)
28
+ if path is None:
29
+ logger.debug("blocking_sqlite_engine: creating shared in-memory SQLite engine")
30
+ url = "sqlite+pysqlite://"
31
+ kwargs = dict(
32
+ connect_args={"check_same_thread": False},
33
+ poolclass=StaticPool, # same connection everywhere
34
+ echo=False,
35
+ future=True,
36
+ )
37
+ else:
38
+ logger.debug(
39
+ "blocking_sqlite_engine: creating file-backed SQLite engine at %s",
40
+ path,
41
+ )
42
+ url = f"sqlite+pysqlite:///{path}"
43
+ kwargs = dict(echo=False, future=True)
44
+
45
+ eng = create_engine(url, **kwargs)
46
+
47
+ def _fk_pragma(dbapi_conn, _):
48
+ try:
49
+ dbapi_conn.execute("PRAGMA foreign_keys=ON")
50
+ except Exception:
51
+ pass
52
+
53
+ event.listen(eng, "connect", _fk_pragma)
54
+ logger.debug("blocking_sqlite_engine: created engine %r", eng)
55
+ return eng, sessionmaker(bind=eng, expire_on_commit=False)
56
+
57
+
58
+ # ---------------------------------------------------------------------
59
+ # 3. BLOCKING • PostgreSQL (psycopg2)
60
+ # ---------------------------------------------------------------------
61
+ def blocking_postgres_engine(
62
+ dsn: str | None = None,
63
+ user: str = "app",
64
+ pwd: str | None = None,
65
+ host: str = "localhost",
66
+ port: int = 5432,
67
+ db: str = "app_db",
68
+ pool_size: int = 10,
69
+ max_overflow: int = 20,
70
+ ):
71
+ logger.debug(
72
+ "blocking_postgres_engine called with dsn=%r user=%r host=%r port=%r db=%r",
73
+ dsn,
74
+ user,
75
+ host,
76
+ port,
77
+ db,
78
+ )
79
+ if dsn:
80
+ logger.debug("blocking_postgres_engine: using provided DSN")
81
+ url = dsn
82
+ else:
83
+ logger.debug("blocking_postgres_engine: constructing DSN from parameters")
84
+ user = os.getenv("PGUSER", user)
85
+ pwd = os.getenv("PGPASSWORD", pwd or "secret")
86
+ host = os.getenv("PGHOST", host)
87
+ port = int(os.getenv("PGPORT", port))
88
+ db = os.getenv("PGDATABASE", db)
89
+ url = f"postgresql+psycopg2://{user}:{pwd}@{host}:{port}/{db}"
90
+ eng = create_engine(
91
+ url,
92
+ pool_size=pool_size,
93
+ max_overflow=max_overflow,
94
+ pool_pre_ping=True, # drops stale connections
95
+ echo=False,
96
+ future=True,
97
+ )
98
+ logger.debug("blocking_postgres_engine: created engine %r", eng)
99
+ return eng, sessionmaker(bind=eng, expire_on_commit=False)
100
+
101
+
102
+ # ───────────────────────────────────────────────────────────────────────
103
+ # HybridSession: async under the hood, classic Session façade on top
104
+ # ───────────────────────────────────────────────────────────────────────
105
+ class HybridSession(AsyncSession):
106
+ """
107
+ An AsyncSession that ALSO behaves like a synchronous Session for the
108
+ handful of blocking helpers Tigrbl’s CRUD cores expect (`query`,
109
+ `commit`, `flush`, `refresh`, `get`, `delete`).
110
+ """
111
+
112
+ # ---- synchronous wrappers (delegate to the sync mirror) ------------
113
+ # NOTE: self.sync_session is provided by SQLAlchemy ≥1.4
114
+ def query(self, *e, **k):
115
+ logger.debug("HybridSession.query called with args=%r kwargs=%r", e, k)
116
+ return self.sync_session.query(*e, **k)
117
+
118
+ def add(self, *a, **k):
119
+ logger.debug("HybridSession.add called with args=%r kwargs=%r", a, k)
120
+ return self.sync_session.add(*a, **k)
121
+
122
+ async def get(self, *a, **k):
123
+ logger.debug("HybridSession.get called with args=%r kwargs=%r", a, k)
124
+ return await super().get(*a, **k)
125
+
126
+ async def flush(self, *a, **k):
127
+ logger.debug("HybridSession.flush called with args=%r kwargs=%r", a, k)
128
+ return await super().flush(*a, **k)
129
+
130
+ async def commit(self, *a, **k):
131
+ logger.debug("HybridSession.commit called with args=%r kwargs=%r", a, k)
132
+ return await super().commit(*a, **k)
133
+
134
+ async def refresh(self, *a, **k):
135
+ logger.debug("HybridSession.refresh called with args=%r kwargs=%r", a, k)
136
+ return await super().refresh(*a, **k)
137
+
138
+ async def delete(self, *a, **k):
139
+ logger.debug("HybridSession.delete called with args=%r kwargs=%r", a, k)
140
+ return await super().delete(*a, **k)
141
+
142
+ # ---- DDL helper used at Tigrbl bootstrap --------------------------
143
+ async def run_sync(self, fn, *a, **kw):
144
+ logger.debug(
145
+ "HybridSession.run_sync called with fn=%r args=%r kwargs=%r", fn, a, kw
146
+ )
147
+ try:
148
+ rv = await super().run_sync(fn, *a, **kw)
149
+ logger.debug("HybridSession.run_sync succeeded with result=%r", rv)
150
+ return rv
151
+ except (OSError, SQLAlchemyError) as exc:
152
+ url = getattr(self.bind, "url", "unknown")
153
+ logger.debug(
154
+ "HybridSession.run_sync failed for url %s with exc=%r", url, exc
155
+ )
156
+ await self.bind.dispose()
157
+ raise RuntimeError(
158
+ f"Failed to connect to database at '{url}'. "
159
+ "Ensure the database is reachable and credentials are correct."
160
+ ) from exc
161
+
162
+
163
+ # ----------------------------------------------------------------------
164
+ # 2. ASYNC • SQLite (aiosqlite driver)
165
+ # ----------------------------------------------------------------------
166
+ def async_sqlite_engine(path: str | None = None):
167
+ logger.debug("async_sqlite_engine called with path=%r", path)
168
+ url = "sqlite+aiosqlite://" + (f"/{path}" if path else "")
169
+ logger.debug("async_sqlite_engine: using url=%s", url)
170
+ eng = create_async_engine(
171
+ url,
172
+ connect_args={"check_same_thread": False},
173
+ poolclass=StaticPool,
174
+ echo=False,
175
+ )
176
+
177
+ def _fk_pragma(dbapi_conn, _):
178
+ try:
179
+ dbapi_conn.execute("PRAGMA foreign_keys=ON")
180
+ except Exception:
181
+ pass
182
+
183
+ event.listen(eng.sync_engine, "connect", _fk_pragma)
184
+ logger.debug("async_sqlite_engine: created engine %r", eng)
185
+ return eng, async_sessionmaker(
186
+ eng,
187
+ expire_on_commit=False,
188
+ class_=HybridSession, # CHANGED ←
189
+ )
190
+
191
+
192
+ # ----------------------------------------------------------------------
193
+ # 4. ASYNC • PostgreSQL (asyncpg)
194
+ # ----------------------------------------------------------------------
195
+ def async_postgres_engine(
196
+ dsn: str | None = None,
197
+ user: str = "app",
198
+ pwd: str | None = None,
199
+ host: str = "localhost",
200
+ port: int = 5432,
201
+ db: str = "app_db",
202
+ pool_size: int = 10,
203
+ max_size: int = 20,
204
+ ):
205
+ logger.debug(
206
+ "async_postgres_engine called with dsn=%r user=%r host=%r port=%r db=%r",
207
+ dsn,
208
+ user,
209
+ host,
210
+ port,
211
+ db,
212
+ )
213
+ if dsn:
214
+ logger.debug("async_postgres_engine: using provided DSN")
215
+ url = dsn
216
+ else:
217
+ logger.debug("async_postgres_engine: constructing DSN from parameters")
218
+ user = os.getenv("PGUSER", user)
219
+ pwd = os.getenv("PGPASSWORD", pwd or "secret")
220
+ host = os.getenv("PGHOST", host)
221
+ port = int(os.getenv("PGPORT", port))
222
+ db = os.getenv("PGDATABASE", db)
223
+ url = f"postgresql+asyncpg://{user}:{pwd}@{host}:{port}/{db}"
224
+ eng = create_async_engine(
225
+ url,
226
+ pool_size=pool_size,
227
+ max_overflow=max_size - pool_size,
228
+ pool_pre_ping=True,
229
+ echo=False,
230
+ )
231
+ logger.debug("async_postgres_engine: created engine %r", eng)
232
+ return eng, async_sessionmaker(
233
+ eng,
234
+ expire_on_commit=False,
235
+ class_=HybridSession, # CHANGED ←
236
+ )
@@ -0,0 +1,111 @@
1
+ """Functions for inspecting objects for engine configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from types import SimpleNamespace
7
+ from typing import Any, Dict, Iterable, Mapping, Tuple
8
+
9
+
10
+ logger = logging.getLogger("uvicorn")
11
+
12
+
13
+ def _read_engine_attr(obj: Any):
14
+ for k in ("engine", "db", "database", "engine_provider", "db_provider"):
15
+ if hasattr(obj, k):
16
+ return getattr(obj, k)
17
+ for k in (
18
+ "tigrbl_engine",
19
+ "tigrbl_db",
20
+ "get_engine",
21
+ ):
22
+ fn = getattr(obj, k, None)
23
+ if callable(fn):
24
+ return fn()
25
+ return None
26
+
27
+
28
+ def _iter_op_decorators(model: Any) -> Dict[Tuple[Any, str], Mapping[str, Any]]:
29
+ out: Dict[Tuple[Any, str], Mapping[str, Any]] = {}
30
+ handlers = getattr(model, "handlers", None)
31
+ if handlers:
32
+ for alias in dir(handlers):
33
+ h = getattr(handlers, alias, None)
34
+ if h is None:
35
+ continue
36
+ for slot in ("handler", "core"):
37
+ fn = getattr(h, slot, None)
38
+ if callable(fn) and (
39
+ hasattr(fn, "__tigrbl_engine_ctx__") or hasattr(fn, "__tigrbl_db__")
40
+ ):
41
+ spec = getattr(fn, "__tigrbl_engine_ctx__", None)
42
+ if spec is None:
43
+ spec = getattr(fn, "__tigrbl_db__")
44
+ out[(model, alias)] = {"engine": spec}
45
+ break
46
+ rpcns = getattr(model, "rpc", SimpleNamespace())
47
+ for alias in dir(rpcns):
48
+ if alias.startswith("_"):
49
+ continue
50
+ fn = getattr(rpcns, alias, None)
51
+ if callable(fn) and (
52
+ hasattr(fn, "__tigrbl_engine_ctx__") or hasattr(fn, "__tigrbl_db__")
53
+ ):
54
+ spec = getattr(fn, "__tigrbl_engine_ctx__", None)
55
+ if spec is None:
56
+ spec = getattr(fn, "__tigrbl_db__")
57
+ out[(model, alias)] = {"engine": spec}
58
+ return out
59
+
60
+
61
+ def _iter_declared_ops(model: Any) -> Dict[Tuple[Any, str], Mapping[str, Any]]:
62
+ out: Dict[Tuple[Any, str], Mapping[str, Any]] = {}
63
+ for spec in getattr(model, "__tigrbl_ops__", ()) or ():
64
+ eng = getattr(spec, "engine", None)
65
+ alias = getattr(spec, "alias", None)
66
+ if eng is not None and alias:
67
+ out[(model, alias)] = {"engine": eng}
68
+ return out
69
+
70
+
71
+ def collect_engine_config(
72
+ *, app: Any | None = None, api: Any | None = None, models: Iterable[Any] = ()
73
+ ) -> Dict[str, Any]:
74
+ """Collect engine configuration from objects without binding them."""
75
+ logger.info("Collecting engine configuration")
76
+ app_engine = _read_engine_attr(app) if app is not None else None
77
+ api_engine = _read_engine_attr(api) if api is not None else None
78
+
79
+ tables: Dict[Any, Any] = {}
80
+ ops: Dict[Tuple[Any, str], Any] = {}
81
+
82
+ for m in models:
83
+ cfg = getattr(m, "table_config", None)
84
+ t_engine = None
85
+ if isinstance(cfg, Mapping):
86
+ for k in ("engine", "db", "database", "engine_provider", "db_provider"):
87
+ if k in cfg:
88
+ t_engine = cfg[k]
89
+ break
90
+ if t_engine is None:
91
+ t_engine = _read_engine_attr(m)
92
+ if t_engine is not None:
93
+ tables[m] = t_engine
94
+
95
+ for (model, alias), ocfg in _iter_op_decorators(m).items():
96
+ ops[(model, alias)] = ocfg.get("engine")
97
+ for (model, alias), ocfg in _iter_declared_ops(m).items():
98
+ ops[(model, alias)] = ocfg.get("engine")
99
+
100
+ api_map = {api: api_engine} if api_engine is not None and api is not None else {}
101
+
102
+ logger.debug("Collected engine config for %d models", len(models))
103
+ return {
104
+ "default": app_engine,
105
+ "api": api_map,
106
+ "tables": tables,
107
+ "ops": ops,
108
+ }
109
+
110
+
111
+ __all__ = ["collect_engine_config"]
@@ -0,0 +1,108 @@
1
+ # tigrbl/tigrbl/v3/engine/decorators.py
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ from typing import Any, Optional
6
+
7
+ # EngineSpec provides the canonical parsing; EngineCfg is the accepted input type
8
+ # (DSN string or mapping) attached by @engine_ctx.
9
+ from .engine_spec import EngineCfg
10
+
11
+
12
+ def _normalize(ctx: Optional[EngineCfg] = None, **kw: Any) -> EngineCfg:
13
+ """
14
+ Accept either:
15
+ • ctx: a DSN string (e.g., "sqlite:///file.db", "postgresql+asyncpg://…")
16
+ • ctx: a mapping like {"kind":"sqlite","async":True,"mode":"memory"} or
17
+ {"kind":"postgres","async":True,"host":"db","db":"app_db",...}
18
+ • **kw: keyword form that will be converted to the mapping shape
19
+
20
+ Returns an EngineCfg (string or mapping) suitable for EngineSpec.from_any(...).
21
+ """
22
+ if ctx is not None:
23
+ return ctx
24
+
25
+ kind = kw.get("kind")
26
+ if not kind:
27
+ dsn = kw.get("dsn")
28
+ if not dsn:
29
+ raise ValueError(
30
+ "Provide engine_ctx=<DSN|mapping> or kind=sqlite|postgres (with fields)"
31
+ )
32
+ return str(dsn)
33
+
34
+ async_kw = kw.get("async_")
35
+ if async_kw is None:
36
+ async_kw = kw.get("async")
37
+
38
+ m: dict[str, Any] = {"kind": kind}
39
+
40
+ if kind == "sqlite":
41
+ path = kw.get("path")
42
+ mode = kw.get("mode")
43
+ memory_flag = kw.get("memory")
44
+ # memory modes: mode="memory" OR memory=True OR no path supplied
45
+ memory = (mode == "memory") or memory_flag or not path
46
+ async_default = True if async_kw is None and memory else False
47
+ m["async"] = bool(async_kw) if async_kw is not None else async_default
48
+ if memory:
49
+ m["mode"] = "memory"
50
+ else:
51
+ m["path"] = path
52
+
53
+ elif kind == "postgres":
54
+ m["async"] = bool(async_kw) if async_kw is not None else False
55
+ for k in ("user", "pwd", "host", "port", "db", "pool_size", "max"):
56
+ if k in kw:
57
+ m[k] = kw[k]
58
+ else:
59
+ raise ValueError("kind must be 'sqlite' or 'postgres'")
60
+
61
+ return m
62
+
63
+
64
+ def engine_ctx(ctx: Optional[EngineCfg] = None, **kw: Any):
65
+ """
66
+ Object-agnostic decorator to attach engine configuration to:
67
+ - App classes/instances (app-level default)
68
+ - API classes/instances (api-level default)
69
+ - ORM model classes (table-level)
70
+ - Op callables (op-level)
71
+
72
+ What it stores:
73
+ • For ops (functions/methods): sets __tigrbl_engine_ctx__ (and legacy __tigrbl_db__).
74
+ • For ORM table classes: injects mapping under model.table_config["engine"] (and legacy "db").
75
+ • For App/API classes or instances: sets attribute .engine = EngineCfg (and legacy .db).
76
+
77
+ Downstream:
78
+ • engine.install_from_objects(...) discovers these and registers
79
+ Providers with resolver precedence: op > table(model) > api > app.
80
+ """
81
+ spec = _normalize(ctx, **kw)
82
+
83
+ def _decorate(obj: Any):
84
+ # Op-level: functions or methods
85
+ if inspect.isfunction(obj) or inspect.ismethod(obj):
86
+ # New attribute name for clarity
87
+ setattr(obj, "__tigrbl_engine_ctx__", spec)
88
+ # Back-compat: some collectors still look for __tigrbl_db__
89
+ setattr(obj, "__tigrbl_db__", spec)
90
+ return obj
91
+
92
+ # ORM model class?
93
+ if inspect.isclass(obj) and hasattr(obj, "__tablename__"):
94
+ cfg = dict(getattr(obj, "table_config", {}) or {})
95
+ cfg["engine"] = spec
96
+ cfg["db"] = spec # legacy key for backward compatibility
97
+ setattr(obj, "table_config", cfg)
98
+ return obj
99
+
100
+ # API/App classes or instances: keep a simple attribute
101
+ setattr(obj, "engine", spec)
102
+ setattr(obj, "db", spec) # legacy attribute
103
+ return obj
104
+
105
+ return _decorate
106
+
107
+
108
+ __all__ = ["engine_ctx"]