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.
- tigrbl/README.md +94 -0
- tigrbl/__init__.py +139 -14
- tigrbl/api/__init__.py +6 -0
- tigrbl/api/_api.py +72 -0
- tigrbl/api/api_spec.py +30 -0
- tigrbl/api/mro_collect.py +43 -0
- tigrbl/api/shortcuts.py +56 -0
- tigrbl/api/tigrbl_api.py +286 -0
- tigrbl/app/__init__.py +0 -0
- tigrbl/app/_app.py +61 -0
- tigrbl/app/app_spec.py +42 -0
- tigrbl/app/mro_collect.py +67 -0
- tigrbl/app/shortcuts.py +65 -0
- tigrbl/app/tigrbl_app.py +314 -0
- tigrbl/bindings/__init__.py +73 -0
- tigrbl/bindings/api/__init__.py +12 -0
- tigrbl/bindings/api/common.py +109 -0
- tigrbl/bindings/api/include.py +256 -0
- tigrbl/bindings/api/resource_proxy.py +149 -0
- tigrbl/bindings/api/rpc.py +111 -0
- tigrbl/bindings/columns.py +49 -0
- tigrbl/bindings/handlers/__init__.py +11 -0
- tigrbl/bindings/handlers/builder.py +119 -0
- tigrbl/bindings/handlers/ctx.py +74 -0
- tigrbl/bindings/handlers/identifiers.py +228 -0
- tigrbl/bindings/handlers/namespaces.py +51 -0
- tigrbl/bindings/handlers/steps.py +276 -0
- tigrbl/bindings/hooks.py +311 -0
- tigrbl/bindings/model.py +194 -0
- tigrbl/bindings/model_helpers.py +139 -0
- tigrbl/bindings/model_registry.py +77 -0
- tigrbl/bindings/rest/__init__.py +7 -0
- tigrbl/bindings/rest/attach.py +34 -0
- tigrbl/bindings/rest/collection.py +265 -0
- tigrbl/bindings/rest/common.py +116 -0
- tigrbl/bindings/rest/fastapi.py +76 -0
- tigrbl/bindings/rest/helpers.py +119 -0
- tigrbl/bindings/rest/io.py +317 -0
- tigrbl/bindings/rest/member.py +367 -0
- tigrbl/bindings/rest/router.py +292 -0
- tigrbl/bindings/rest/routing.py +133 -0
- tigrbl/bindings/rpc.py +364 -0
- tigrbl/bindings/schemas/__init__.py +11 -0
- tigrbl/bindings/schemas/builder.py +348 -0
- tigrbl/bindings/schemas/defaults.py +260 -0
- tigrbl/bindings/schemas/utils.py +193 -0
- tigrbl/column/README.md +62 -0
- tigrbl/column/__init__.py +72 -0
- tigrbl/column/_column.py +96 -0
- tigrbl/column/column_spec.py +40 -0
- tigrbl/column/field_spec.py +31 -0
- tigrbl/column/infer/__init__.py +25 -0
- tigrbl/column/infer/core.py +92 -0
- tigrbl/column/infer/jsonhints.py +44 -0
- tigrbl/column/infer/planning.py +133 -0
- tigrbl/column/infer/types.py +102 -0
- tigrbl/column/infer/utils.py +59 -0
- tigrbl/column/io_spec.py +133 -0
- tigrbl/column/mro_collect.py +59 -0
- tigrbl/column/shortcuts.py +89 -0
- tigrbl/column/storage_spec.py +65 -0
- tigrbl/config/__init__.py +19 -0
- tigrbl/config/constants.py +224 -0
- tigrbl/config/defaults.py +29 -0
- tigrbl/config/resolver.py +295 -0
- tigrbl/core/__init__.py +47 -0
- tigrbl/core/crud/__init__.py +36 -0
- tigrbl/core/crud/bulk.py +168 -0
- tigrbl/core/crud/helpers/__init__.py +76 -0
- tigrbl/core/crud/helpers/db.py +92 -0
- tigrbl/core/crud/helpers/enum.py +86 -0
- tigrbl/core/crud/helpers/filters.py +162 -0
- tigrbl/core/crud/helpers/model.py +123 -0
- tigrbl/core/crud/helpers/normalize.py +99 -0
- tigrbl/core/crud/ops.py +235 -0
- tigrbl/ddl/__init__.py +344 -0
- tigrbl/decorators.py +17 -0
- tigrbl/deps/__init__.py +20 -0
- tigrbl/deps/fastapi.py +45 -0
- tigrbl/deps/favicon.svg +4 -0
- tigrbl/deps/jinja.py +27 -0
- tigrbl/deps/pydantic.py +10 -0
- tigrbl/deps/sqlalchemy.py +94 -0
- tigrbl/deps/starlette.py +36 -0
- tigrbl/engine/__init__.py +26 -0
- tigrbl/engine/_engine.py +130 -0
- tigrbl/engine/bind.py +33 -0
- tigrbl/engine/builders.py +236 -0
- tigrbl/engine/collect.py +111 -0
- tigrbl/engine/decorators.py +108 -0
- tigrbl/engine/engine_spec.py +261 -0
- tigrbl/engine/resolver.py +224 -0
- tigrbl/engine/shortcuts.py +216 -0
- tigrbl/hook/__init__.py +21 -0
- tigrbl/hook/_hook.py +22 -0
- tigrbl/hook/decorators.py +28 -0
- tigrbl/hook/hook_spec.py +24 -0
- tigrbl/hook/mro_collect.py +98 -0
- tigrbl/hook/shortcuts.py +44 -0
- tigrbl/hook/types.py +76 -0
- tigrbl/op/__init__.py +50 -0
- tigrbl/op/_op.py +31 -0
- tigrbl/op/canonical.py +31 -0
- tigrbl/op/collect.py +11 -0
- tigrbl/op/decorators.py +238 -0
- tigrbl/op/model_registry.py +301 -0
- tigrbl/op/mro_collect.py +99 -0
- tigrbl/op/resolver.py +216 -0
- tigrbl/op/types.py +136 -0
- tigrbl/orm/__init__.py +1 -0
- tigrbl/orm/mixins/_RowBound.py +83 -0
- tigrbl/orm/mixins/__init__.py +95 -0
- tigrbl/orm/mixins/bootstrappable.py +113 -0
- tigrbl/orm/mixins/bound.py +47 -0
- tigrbl/orm/mixins/edges.py +40 -0
- tigrbl/orm/mixins/fields.py +165 -0
- tigrbl/orm/mixins/hierarchy.py +54 -0
- tigrbl/orm/mixins/key_digest.py +44 -0
- tigrbl/orm/mixins/lifecycle.py +115 -0
- tigrbl/orm/mixins/locks.py +51 -0
- tigrbl/orm/mixins/markers.py +16 -0
- tigrbl/orm/mixins/operations.py +57 -0
- tigrbl/orm/mixins/ownable.py +337 -0
- tigrbl/orm/mixins/principals.py +98 -0
- tigrbl/orm/mixins/tenant_bound.py +301 -0
- tigrbl/orm/mixins/upsertable.py +111 -0
- tigrbl/orm/mixins/utils.py +49 -0
- tigrbl/orm/tables/__init__.py +72 -0
- tigrbl/orm/tables/_base.py +8 -0
- tigrbl/orm/tables/audit.py +56 -0
- tigrbl/orm/tables/client.py +25 -0
- tigrbl/orm/tables/group.py +29 -0
- tigrbl/orm/tables/org.py +30 -0
- tigrbl/orm/tables/rbac.py +76 -0
- tigrbl/orm/tables/status.py +106 -0
- tigrbl/orm/tables/tenant.py +22 -0
- tigrbl/orm/tables/user.py +39 -0
- tigrbl/response/README.md +34 -0
- tigrbl/response/__init__.py +33 -0
- tigrbl/response/bind.py +12 -0
- tigrbl/response/decorators.py +37 -0
- tigrbl/response/resolver.py +83 -0
- tigrbl/response/shortcuts.py +144 -0
- tigrbl/response/types.py +49 -0
- tigrbl/rest/__init__.py +27 -0
- tigrbl/runtime/README.md +129 -0
- tigrbl/runtime/__init__.py +20 -0
- tigrbl/runtime/atoms/__init__.py +102 -0
- tigrbl/runtime/atoms/emit/__init__.py +42 -0
- tigrbl/runtime/atoms/emit/paired_post.py +158 -0
- tigrbl/runtime/atoms/emit/paired_pre.py +106 -0
- tigrbl/runtime/atoms/emit/readtime_alias.py +120 -0
- tigrbl/runtime/atoms/out/__init__.py +38 -0
- tigrbl/runtime/atoms/out/masking.py +135 -0
- tigrbl/runtime/atoms/refresh/__init__.py +38 -0
- tigrbl/runtime/atoms/refresh/demand.py +130 -0
- tigrbl/runtime/atoms/resolve/__init__.py +40 -0
- tigrbl/runtime/atoms/resolve/assemble.py +167 -0
- tigrbl/runtime/atoms/resolve/paired_gen.py +147 -0
- tigrbl/runtime/atoms/response/__init__.py +17 -0
- tigrbl/runtime/atoms/response/negotiate.py +30 -0
- tigrbl/runtime/atoms/response/negotiation.py +43 -0
- tigrbl/runtime/atoms/response/render.py +36 -0
- tigrbl/runtime/atoms/response/renderer.py +116 -0
- tigrbl/runtime/atoms/response/template.py +44 -0
- tigrbl/runtime/atoms/response/templates.py +88 -0
- tigrbl/runtime/atoms/schema/__init__.py +40 -0
- tigrbl/runtime/atoms/schema/collect_in.py +21 -0
- tigrbl/runtime/atoms/schema/collect_out.py +21 -0
- tigrbl/runtime/atoms/storage/__init__.py +38 -0
- tigrbl/runtime/atoms/storage/to_stored.py +167 -0
- tigrbl/runtime/atoms/wire/__init__.py +45 -0
- tigrbl/runtime/atoms/wire/build_in.py +166 -0
- tigrbl/runtime/atoms/wire/build_out.py +87 -0
- tigrbl/runtime/atoms/wire/dump.py +206 -0
- tigrbl/runtime/atoms/wire/validate_in.py +227 -0
- tigrbl/runtime/context.py +206 -0
- tigrbl/runtime/errors/__init__.py +61 -0
- tigrbl/runtime/errors/converters.py +214 -0
- tigrbl/runtime/errors/exceptions.py +124 -0
- tigrbl/runtime/errors/mappings.py +71 -0
- tigrbl/runtime/errors/utils.py +150 -0
- tigrbl/runtime/events.py +209 -0
- tigrbl/runtime/executor/__init__.py +6 -0
- tigrbl/runtime/executor/guards.py +132 -0
- tigrbl/runtime/executor/helpers.py +88 -0
- tigrbl/runtime/executor/invoke.py +150 -0
- tigrbl/runtime/executor/types.py +84 -0
- tigrbl/runtime/kernel.py +628 -0
- tigrbl/runtime/labels.py +353 -0
- tigrbl/runtime/opview.py +87 -0
- tigrbl/runtime/ordering.py +256 -0
- tigrbl/runtime/system.py +279 -0
- tigrbl/runtime/trace.py +330 -0
- tigrbl/schema/__init__.py +38 -0
- tigrbl/schema/_schema.py +27 -0
- tigrbl/schema/builder/__init__.py +17 -0
- tigrbl/schema/builder/build_schema.py +209 -0
- tigrbl/schema/builder/cache.py +24 -0
- tigrbl/schema/builder/compat.py +16 -0
- tigrbl/schema/builder/extras.py +85 -0
- tigrbl/schema/builder/helpers.py +51 -0
- tigrbl/schema/builder/list_params.py +117 -0
- tigrbl/schema/builder/strip_parent_fields.py +70 -0
- tigrbl/schema/collect.py +55 -0
- tigrbl/schema/decorators.py +68 -0
- tigrbl/schema/get_schema.py +86 -0
- tigrbl/schema/schema_spec.py +20 -0
- tigrbl/schema/shortcuts.py +42 -0
- tigrbl/schema/types.py +34 -0
- tigrbl/schema/utils.py +143 -0
- tigrbl/shortcuts.py +22 -0
- tigrbl/specs.py +44 -0
- tigrbl/system/__init__.py +12 -0
- tigrbl/system/diagnostics/__init__.py +24 -0
- tigrbl/system/diagnostics/compat.py +31 -0
- tigrbl/system/diagnostics/healthz.py +41 -0
- tigrbl/system/diagnostics/hookz.py +51 -0
- tigrbl/system/diagnostics/kernelz.py +20 -0
- tigrbl/system/diagnostics/methodz.py +43 -0
- tigrbl/system/diagnostics/router.py +73 -0
- tigrbl/system/diagnostics/utils.py +43 -0
- tigrbl/table/__init__.py +9 -0
- tigrbl/table/_base.py +237 -0
- tigrbl/table/_table.py +54 -0
- tigrbl/table/mro_collect.py +69 -0
- tigrbl/table/shortcuts.py +57 -0
- tigrbl/table/table_spec.py +28 -0
- tigrbl/transport/__init__.py +74 -0
- tigrbl/transport/jsonrpc/__init__.py +19 -0
- tigrbl/transport/jsonrpc/dispatcher.py +352 -0
- tigrbl/transport/jsonrpc/helpers.py +115 -0
- tigrbl/transport/jsonrpc/models.py +41 -0
- tigrbl/transport/rest/__init__.py +25 -0
- tigrbl/transport/rest/aggregator.py +132 -0
- tigrbl/types/__init__.py +174 -0
- tigrbl/types/allow_anon_provider.py +19 -0
- tigrbl/types/authn_abc.py +30 -0
- tigrbl/types/nested_path_provider.py +22 -0
- tigrbl/types/op.py +35 -0
- tigrbl/types/op_config_provider.py +17 -0
- tigrbl/types/op_verb_alias_provider.py +33 -0
- tigrbl/types/request_extras_provider.py +22 -0
- tigrbl/types/response_extras_provider.py +22 -0
- tigrbl/types/table_config_provider.py +13 -0
- tigrbl-0.3.0.dev3.dist-info/LICENSE +201 -0
- tigrbl-0.3.0.dev3.dist-info/METADATA +501 -0
- tigrbl-0.3.0.dev3.dist-info/RECORD +249 -0
- tigrbl/ExampleAgent.py +0 -1
- tigrbl-0.0.1.dev1.dist-info/METADATA +0 -18
- tigrbl-0.0.1.dev1.dist-info/RECORD +0 -5
- {tigrbl-0.0.1.dev1.dist-info → tigrbl-0.3.0.dev3.dist-info}/WHEEL +0 -0
tigrbl/engine/_engine.py
ADDED
|
@@ -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
|
+
)
|
tigrbl/engine/collect.py
ADDED
|
@@ -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"]
|