tigrbl 0.3.0.dev4__py3-none-any.whl → 0.3.2__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/api/_api.py +26 -1
- tigrbl/api/tigrbl_api.py +6 -1
- tigrbl/app/_app.py +26 -1
- tigrbl/app/_model_registry.py +41 -0
- tigrbl/app/tigrbl_app.py +6 -1
- tigrbl/bindings/api/__init__.py +0 -1
- tigrbl/bindings/api/common.py +0 -1
- tigrbl/bindings/api/include.py +14 -2
- tigrbl/bindings/api/resource_proxy.py +0 -1
- tigrbl/bindings/api/rpc.py +0 -1
- tigrbl/bindings/handlers/__init__.py +0 -1
- tigrbl/bindings/handlers/builder.py +0 -1
- tigrbl/bindings/handlers/ctx.py +0 -1
- tigrbl/bindings/handlers/identifiers.py +0 -1
- tigrbl/bindings/handlers/namespaces.py +0 -1
- tigrbl/bindings/handlers/steps.py +0 -1
- tigrbl/bindings/rest/collection.py +24 -3
- tigrbl/bindings/rest/common.py +4 -0
- tigrbl/bindings/rest/io_headers.py +49 -0
- tigrbl/bindings/rest/member.py +19 -0
- tigrbl/bindings/rest/router.py +4 -0
- tigrbl/bindings/rest/routing.py +21 -1
- tigrbl/bindings/schemas/__init__.py +0 -1
- tigrbl/bindings/schemas/builder.py +0 -1
- tigrbl/bindings/schemas/defaults.py +0 -1
- tigrbl/bindings/schemas/utils.py +0 -1
- tigrbl/column/io_spec.py +3 -0
- tigrbl/core/crud/bulk.py +0 -1
- tigrbl/core/crud/helpers/db.py +0 -1
- tigrbl/core/crud/helpers/enum.py +0 -1
- tigrbl/core/crud/helpers/filters.py +0 -1
- tigrbl/core/crud/helpers/model.py +0 -1
- tigrbl/core/crud/helpers/normalize.py +0 -1
- tigrbl/core/crud/ops.py +0 -1
- tigrbl/docs/verbosity.md +35 -0
- tigrbl/engine/__init__.py +19 -0
- tigrbl/engine/_engine.py +14 -0
- tigrbl/engine/builders.py +0 -1
- tigrbl/engine/capabilities.py +29 -0
- tigrbl/engine/decorators.py +3 -1
- tigrbl/engine/docs/PLUGINS.md +49 -0
- tigrbl/engine/engine_spec.py +197 -103
- tigrbl/engine/plugins.py +52 -0
- tigrbl/engine/registry.py +36 -0
- tigrbl/engine/resolver.py +0 -1
- tigrbl/orm/mixins/upsertable.py +7 -0
- tigrbl/response/shortcuts.py +31 -4
- tigrbl/runtime/atoms/__init__.py +0 -1
- tigrbl/runtime/atoms/response/__init__.py +2 -0
- tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
- tigrbl/runtime/kernel.py +27 -11
- tigrbl/runtime/opview.py +5 -3
- tigrbl/schema/collect.py +26 -3
- tigrbl/schema/decorators.py +0 -1
- tigrbl/schema/get_schema.py +0 -1
- tigrbl/schema/utils.py +0 -1
- tigrbl/session/README.md +14 -0
- tigrbl/session/__init__.py +28 -0
- tigrbl/session/abc.py +76 -0
- tigrbl/session/base.py +151 -0
- tigrbl/session/decorators.py +43 -0
- tigrbl/session/default.py +118 -0
- tigrbl/session/shortcuts.py +50 -0
- tigrbl/session/spec.py +112 -0
- tigrbl/system/__init__.py +2 -1
- tigrbl/system/uvicorn.py +60 -0
- tigrbl/table/_base.py +39 -5
- tigrbl/types/__init__.py +3 -7
- tigrbl/types/uuid.py +55 -0
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info}/METADATA +19 -4
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info}/RECORD +73 -55
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info}/WHEEL +1 -1
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info/licenses}/LICENSE +0 -0
tigrbl/session/base.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
from .abc import SessionABC
|
|
9
|
+
from .spec import SessionSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TigrblSessionBase(SessionABC):
|
|
14
|
+
"""
|
|
15
|
+
Common session behavior:
|
|
16
|
+
- Tracks SessionSpec
|
|
17
|
+
- Tracks transaction state (_open) and write intent (_dirty)
|
|
18
|
+
- Queues accidentally-async add() work and resolves on flush/commit
|
|
19
|
+
- Enforces read-only both on write calls and at commit
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_spec: Optional[SessionSpec] = None
|
|
23
|
+
|
|
24
|
+
_open: bool = field(default=False, init=False)
|
|
25
|
+
_dirty: bool = field(default=False, init=False)
|
|
26
|
+
_pending: List[asyncio.Task] = field(default_factory=list, init=False)
|
|
27
|
+
|
|
28
|
+
# ---- utilities ----
|
|
29
|
+
def apply_spec(self, spec: SessionSpec | None) -> None:
|
|
30
|
+
self._spec = spec
|
|
31
|
+
|
|
32
|
+
async def run_sync(self, fn: Callable[[Any], Any]) -> Any:
|
|
33
|
+
"""
|
|
34
|
+
Default async marker: run the callback against *this* session.
|
|
35
|
+
Subclasses may override to pass the native handle.
|
|
36
|
+
"""
|
|
37
|
+
rv = fn(self)
|
|
38
|
+
if inspect.isawaitable(rv):
|
|
39
|
+
return await rv
|
|
40
|
+
return rv
|
|
41
|
+
|
|
42
|
+
# ---- TX template methods ----
|
|
43
|
+
async def begin(self) -> None:
|
|
44
|
+
await self._tx_begin_impl()
|
|
45
|
+
self._open = True
|
|
46
|
+
|
|
47
|
+
async def commit(self) -> None:
|
|
48
|
+
# late guard
|
|
49
|
+
if self._spec and self._spec.read_only and self._dirty:
|
|
50
|
+
raise RuntimeError("read-only session: writes detected before commit")
|
|
51
|
+
await self.flush()
|
|
52
|
+
await self._tx_commit_impl()
|
|
53
|
+
self._open = False
|
|
54
|
+
self._dirty = False
|
|
55
|
+
|
|
56
|
+
async def rollback(self) -> None:
|
|
57
|
+
# cancel queued add() tasks
|
|
58
|
+
for t in self._pending:
|
|
59
|
+
try:
|
|
60
|
+
t.cancel()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
self._pending.clear()
|
|
64
|
+
await self._tx_rollback_impl()
|
|
65
|
+
self._open = False
|
|
66
|
+
self._dirty = False
|
|
67
|
+
|
|
68
|
+
def in_transaction(self) -> bool:
|
|
69
|
+
return bool(self._open)
|
|
70
|
+
|
|
71
|
+
# ---- CRUD surface (template) ----
|
|
72
|
+
def add(self, obj: Any) -> None:
|
|
73
|
+
if self._spec and self._spec.read_only:
|
|
74
|
+
raise RuntimeError("write attempted in read-only session (add)")
|
|
75
|
+
self._dirty = True
|
|
76
|
+
rv = self._add_impl(obj)
|
|
77
|
+
if inspect.isawaitable(rv):
|
|
78
|
+
try:
|
|
79
|
+
loop = asyncio.get_running_loop()
|
|
80
|
+
except RuntimeError:
|
|
81
|
+
asyncio.run(rv)
|
|
82
|
+
else:
|
|
83
|
+
self._pending.append(loop.create_task(rv))
|
|
84
|
+
|
|
85
|
+
async def delete(self, obj: Any) -> None:
|
|
86
|
+
if self._spec and self._spec.read_only:
|
|
87
|
+
raise RuntimeError("write attempted in read-only session (delete)")
|
|
88
|
+
self._dirty = True
|
|
89
|
+
await self._delete_impl(obj)
|
|
90
|
+
|
|
91
|
+
async def flush(self) -> None:
|
|
92
|
+
if self._pending:
|
|
93
|
+
done, _ = await asyncio.wait(
|
|
94
|
+
self._pending, return_when=asyncio.ALL_COMPLETED
|
|
95
|
+
)
|
|
96
|
+
self._pending = []
|
|
97
|
+
# surface any exception
|
|
98
|
+
for t in done:
|
|
99
|
+
_ = t.result()
|
|
100
|
+
await self._flush_impl()
|
|
101
|
+
|
|
102
|
+
async def refresh(self, obj: Any) -> None:
|
|
103
|
+
await self._refresh_impl(obj)
|
|
104
|
+
|
|
105
|
+
async def get(self, model: type, ident: Any) -> Any | None:
|
|
106
|
+
return await self._get_impl(model, ident)
|
|
107
|
+
|
|
108
|
+
async def execute(self, stmt: Any) -> Any:
|
|
109
|
+
return await self._execute_impl(stmt)
|
|
110
|
+
|
|
111
|
+
async def close(self) -> None:
|
|
112
|
+
for t in self._pending:
|
|
113
|
+
try:
|
|
114
|
+
t.cancel()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
self._pending = []
|
|
118
|
+
await self._close_impl()
|
|
119
|
+
|
|
120
|
+
# ---- abstract primitives ----
|
|
121
|
+
async def _tx_begin_impl(self) -> None: # pragma: no cover - abstract hook
|
|
122
|
+
raise NotImplementedError
|
|
123
|
+
|
|
124
|
+
async def _tx_commit_impl(self) -> None: # pragma: no cover - abstract hook
|
|
125
|
+
raise NotImplementedError
|
|
126
|
+
|
|
127
|
+
async def _tx_rollback_impl(self) -> None: # pragma: no cover - abstract hook
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
def _add_impl(self, obj: Any) -> Any: # pragma: no cover - abstract hook
|
|
131
|
+
raise NotImplementedError
|
|
132
|
+
|
|
133
|
+
async def _delete_impl(self, obj: Any) -> None: # pragma: no cover - abstract hook
|
|
134
|
+
raise NotImplementedError
|
|
135
|
+
|
|
136
|
+
async def _flush_impl(self) -> None: # pragma: no cover - abstract hook
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
async def _refresh_impl(self, obj: Any) -> None: # pragma: no cover - abstract hook
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
async def _get_impl(
|
|
143
|
+
self, model: type, ident: Any
|
|
144
|
+
) -> Any | None: # pragma: no cover
|
|
145
|
+
raise NotImplementedError
|
|
146
|
+
|
|
147
|
+
async def _execute_impl(self, stmt: Any) -> Any: # pragma: no cover - abstract hook
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
|
|
150
|
+
async def _close_impl(self) -> None: # pragma: no cover - abstract hook
|
|
151
|
+
return
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from .spec import SessionSpec, SessionCfg
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _normalize(cfg: Optional[SessionCfg] = None, **kw: Any) -> SessionSpec:
|
|
8
|
+
if cfg is not None and kw:
|
|
9
|
+
raise ValueError("Pass either a mapping/spec or keyword args, not both")
|
|
10
|
+
return SessionSpec.from_any(cfg or kw) or SessionSpec()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def session_ctx(cfg: Optional[SessionCfg] = None, /, **kw: Any):
|
|
14
|
+
"""
|
|
15
|
+
Attach a SessionSpec to an App, API, Model/Table, or op handler.
|
|
16
|
+
|
|
17
|
+
Precedence is evaluated by the resolver using:
|
|
18
|
+
op > model > api > app
|
|
19
|
+
(Resolver is part of the runtime/engine layer and is independent of this decorator.)
|
|
20
|
+
"""
|
|
21
|
+
spec = _normalize(cfg, **kw)
|
|
22
|
+
|
|
23
|
+
def _apply(obj: Any) -> Any:
|
|
24
|
+
setattr(obj, "__tigrbl_session_ctx__", spec)
|
|
25
|
+
return obj
|
|
26
|
+
|
|
27
|
+
return _apply
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_only_session(obj: Any = None, /, *, isolation: Optional[str] = None):
|
|
31
|
+
"""
|
|
32
|
+
Convenience decorator for read-only sessions.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def _wrap(o: Any) -> Any:
|
|
36
|
+
setattr(
|
|
37
|
+
o,
|
|
38
|
+
"__tigrbl_session_ctx__",
|
|
39
|
+
SessionSpec(read_only=True, isolation=isolation),
|
|
40
|
+
)
|
|
41
|
+
return o
|
|
42
|
+
|
|
43
|
+
return _wrap(obj) if obj is not None else _wrap
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from .base import TigrblSessionBase
|
|
7
|
+
from .spec import SessionSpec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DefaultSession(TigrblSessionBase):
|
|
11
|
+
"""
|
|
12
|
+
Delegating session that wraps an underlying native session object
|
|
13
|
+
(sync or async) and exposes the Tigrbl Session ABC.
|
|
14
|
+
|
|
15
|
+
No third-party imports: we rely on duck-typed methods on the underlying
|
|
16
|
+
object (begin/commit/rollback, add/delete/flush/refresh/get/execute/close).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, underlying: Any, spec: Optional[SessionSpec] = None) -> None:
|
|
20
|
+
super().__init__(spec)
|
|
21
|
+
self._u = underlying
|
|
22
|
+
|
|
23
|
+
# ---- async marker ----
|
|
24
|
+
async def run_sync(self, fn: Callable[[Any], Any]) -> Any:
|
|
25
|
+
rv = fn(self._u)
|
|
26
|
+
if inspect.isawaitable(rv):
|
|
27
|
+
return await rv
|
|
28
|
+
return rv
|
|
29
|
+
|
|
30
|
+
# ---- TX primitives ----
|
|
31
|
+
async def _tx_begin_impl(self) -> None:
|
|
32
|
+
fn = getattr(self._u, "begin", None)
|
|
33
|
+
if not callable(fn):
|
|
34
|
+
raise RuntimeError("underlying session does not support begin()")
|
|
35
|
+
rv = fn()
|
|
36
|
+
if inspect.isawaitable(rv):
|
|
37
|
+
await rv
|
|
38
|
+
|
|
39
|
+
async def _tx_commit_impl(self) -> None:
|
|
40
|
+
fn = getattr(self._u, "commit", None)
|
|
41
|
+
if not callable(fn):
|
|
42
|
+
raise RuntimeError("underlying session does not support commit()")
|
|
43
|
+
rv = fn()
|
|
44
|
+
if inspect.isawaitable(rv):
|
|
45
|
+
await rv
|
|
46
|
+
|
|
47
|
+
async def _tx_rollback_impl(self) -> None:
|
|
48
|
+
fn = getattr(self._u, "rollback", None)
|
|
49
|
+
if not callable(fn):
|
|
50
|
+
raise RuntimeError("underlying session does not support rollback()")
|
|
51
|
+
rv = fn()
|
|
52
|
+
if inspect.isawaitable(rv):
|
|
53
|
+
await rv
|
|
54
|
+
|
|
55
|
+
def in_transaction(self) -> bool:
|
|
56
|
+
it = getattr(self._u, "in_transaction", None)
|
|
57
|
+
if callable(it):
|
|
58
|
+
try:
|
|
59
|
+
return bool(it())
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return super().in_transaction()
|
|
63
|
+
|
|
64
|
+
# ---- CRUD primitives ----
|
|
65
|
+
def _add_impl(self, obj: Any) -> Any:
|
|
66
|
+
fn = getattr(self._u, "add", None)
|
|
67
|
+
if not callable(fn):
|
|
68
|
+
raise NotImplementedError("underlying session does not implement add(obj)")
|
|
69
|
+
return fn(obj)
|
|
70
|
+
|
|
71
|
+
async def _delete_impl(self, obj: Any) -> None:
|
|
72
|
+
fn = getattr(self._u, "delete", None)
|
|
73
|
+
if not callable(fn):
|
|
74
|
+
raise NotImplementedError(
|
|
75
|
+
"underlying session does not implement delete(obj)"
|
|
76
|
+
)
|
|
77
|
+
rv = fn(obj)
|
|
78
|
+
if inspect.isawaitable(rv):
|
|
79
|
+
await rv
|
|
80
|
+
|
|
81
|
+
async def _flush_impl(self) -> None:
|
|
82
|
+
fn = getattr(self._u, "flush", None)
|
|
83
|
+
if callable(fn):
|
|
84
|
+
rv = fn()
|
|
85
|
+
if inspect.isawaitable(rv):
|
|
86
|
+
await rv
|
|
87
|
+
|
|
88
|
+
async def _refresh_impl(self, obj: Any) -> None:
|
|
89
|
+
fn = getattr(self._u, "refresh", None)
|
|
90
|
+
if callable(fn):
|
|
91
|
+
rv = fn(obj)
|
|
92
|
+
if inspect.isawaitable(rv):
|
|
93
|
+
await rv
|
|
94
|
+
|
|
95
|
+
async def _get_impl(self, model: type, ident: Any) -> Any | None:
|
|
96
|
+
fn = getattr(self._u, "get", None)
|
|
97
|
+
if not callable(fn):
|
|
98
|
+
raise NotImplementedError(
|
|
99
|
+
"underlying session does not implement get(model, ident)"
|
|
100
|
+
)
|
|
101
|
+
rv = fn(model, ident)
|
|
102
|
+
return await rv if inspect.isawaitable(rv) else rv
|
|
103
|
+
|
|
104
|
+
async def _execute_impl(self, stmt: Any) -> Any:
|
|
105
|
+
fn = getattr(self._u, "execute", None)
|
|
106
|
+
if not callable(fn):
|
|
107
|
+
raise NotImplementedError(
|
|
108
|
+
"underlying session does not implement execute(stmt)"
|
|
109
|
+
)
|
|
110
|
+
rv = fn(stmt)
|
|
111
|
+
return await rv if inspect.isawaitable(rv) else rv
|
|
112
|
+
|
|
113
|
+
async def _close_impl(self) -> None:
|
|
114
|
+
fn = getattr(self._u, "close", None)
|
|
115
|
+
if callable(fn):
|
|
116
|
+
rv = fn()
|
|
117
|
+
if inspect.isawaitable(rv):
|
|
118
|
+
await rv
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from .default import DefaultSession
|
|
6
|
+
from .spec import SessionSpec, SessionCfg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def session_spec(cfg: SessionCfg = None, /, **kw: Any) -> SessionSpec:
|
|
10
|
+
"""
|
|
11
|
+
Build a SessionSpec from either a mapping/spec or kwargs.
|
|
12
|
+
"""
|
|
13
|
+
if cfg is not None and kw:
|
|
14
|
+
raise ValueError("Provide either a mapping/spec or kwargs, not both")
|
|
15
|
+
return SessionSpec.from_any(cfg or kw) or SessionSpec()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Isolation presets
|
|
19
|
+
def tx_read_committed(*, read_only: Optional[bool] = None) -> SessionSpec:
|
|
20
|
+
return SessionSpec(isolation="read_committed", read_only=read_only)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tx_repeatable_read(*, read_only: Optional[bool] = None) -> SessionSpec:
|
|
24
|
+
return SessionSpec(isolation="repeatable_read", read_only=read_only)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tx_serializable(*, read_only: Optional[bool] = None) -> SessionSpec:
|
|
28
|
+
return SessionSpec(isolation="serializable", read_only=read_only)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def readonly() -> SessionSpec:
|
|
32
|
+
return SessionSpec(read_only=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Provider/sessionmaker wrapper
|
|
36
|
+
def wrap_sessionmaker(
|
|
37
|
+
maker: Callable[[], Any], spec: SessionSpec
|
|
38
|
+
) -> Callable[[], DefaultSession]:
|
|
39
|
+
"""
|
|
40
|
+
Wrap any provider's session factory to yield DefaultSession instances that
|
|
41
|
+
enforce the Tigrbl Session ABC and policy.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def _mk() -> DefaultSession:
|
|
45
|
+
underlying = maker()
|
|
46
|
+
s = DefaultSession(underlying, spec)
|
|
47
|
+
s.apply_spec(spec)
|
|
48
|
+
return s
|
|
49
|
+
|
|
50
|
+
return _mk
|
tigrbl/session/spec.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, fields
|
|
4
|
+
from typing import Any, Mapping, MutableMapping, Optional, Union
|
|
5
|
+
|
|
6
|
+
SessionCfg = Union["SessionSpec", Mapping[str, Any], None]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SessionSpec:
|
|
11
|
+
"""
|
|
12
|
+
Per-session policy for Tigrbl sessions.
|
|
13
|
+
|
|
14
|
+
These fields are backend-agnostic hints and constraints. Adapters should
|
|
15
|
+
validate and apply them where supported; critical ones (like isolation and
|
|
16
|
+
read_only) SHOULD be validated and enforced.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Transaction policy
|
|
20
|
+
isolation: Optional[str] = (
|
|
21
|
+
None # "read_committed" | "repeatable_read" | "snapshot" | "serializable"
|
|
22
|
+
)
|
|
23
|
+
read_only: Optional[bool] = None
|
|
24
|
+
autobegin: Optional[bool] = True
|
|
25
|
+
expire_on_commit: Optional[bool] = None
|
|
26
|
+
|
|
27
|
+
# Retries & backoff
|
|
28
|
+
retry_on_conflict: Optional[bool] = None
|
|
29
|
+
max_retries: int = 0
|
|
30
|
+
backoff_ms: int = 0
|
|
31
|
+
backoff_jitter: bool = True
|
|
32
|
+
|
|
33
|
+
# Timeouts / resources
|
|
34
|
+
statement_timeout_ms: Optional[int] = None
|
|
35
|
+
lock_timeout_ms: Optional[int] = None
|
|
36
|
+
fetch_rows: Optional[int] = None
|
|
37
|
+
stream_chunk_rows: Optional[int] = None
|
|
38
|
+
|
|
39
|
+
# Consistency coordinates
|
|
40
|
+
min_lsn: Optional[str] = None
|
|
41
|
+
as_of_ts: Optional[str] = None
|
|
42
|
+
consistency: Optional[str] = None # "strong" | "bounded_staleness" | "eventual"
|
|
43
|
+
staleness_ms: Optional[int] = None
|
|
44
|
+
|
|
45
|
+
# Tenancy & security
|
|
46
|
+
tenant_id: Optional[str] = None
|
|
47
|
+
role: Optional[str] = None
|
|
48
|
+
rls_context: Mapping[str, str] = None
|
|
49
|
+
|
|
50
|
+
# Observability
|
|
51
|
+
trace_id: Optional[str] = None
|
|
52
|
+
query_tag: Optional[str] = None
|
|
53
|
+
tag: Optional[str] = None
|
|
54
|
+
tracing_sample: Optional[float] = None
|
|
55
|
+
|
|
56
|
+
# Cache / index hints
|
|
57
|
+
cache_read: Optional[bool] = None
|
|
58
|
+
cache_write: Optional[bool] = None
|
|
59
|
+
namespace: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
# Data protection / compliance
|
|
62
|
+
kms_key_alias: Optional[str] = None
|
|
63
|
+
classification: Optional[str] = None
|
|
64
|
+
audit: Optional[bool] = None
|
|
65
|
+
|
|
66
|
+
# Idempotency & pagination
|
|
67
|
+
idempotency_key: Optional[str] = None
|
|
68
|
+
page_snapshot: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
def merge(self, higher: "SessionSpec | Mapping[str, Any] | None") -> "SessionSpec":
|
|
71
|
+
"""
|
|
72
|
+
Overlay another spec on top of this one (non-None fields take precedence).
|
|
73
|
+
Use to implement op > model > api > app precedence.
|
|
74
|
+
"""
|
|
75
|
+
if higher is None:
|
|
76
|
+
return self
|
|
77
|
+
h = higher if isinstance(higher, SessionSpec) else SessionSpec.from_any(higher)
|
|
78
|
+
if h is None:
|
|
79
|
+
return self
|
|
80
|
+
vals: MutableMapping[str, Any] = {
|
|
81
|
+
f.name: getattr(self, f.name) for f in fields(SessionSpec)
|
|
82
|
+
}
|
|
83
|
+
for f in fields(SessionSpec):
|
|
84
|
+
hv = getattr(h, f.name)
|
|
85
|
+
if hv is not None:
|
|
86
|
+
vals[f.name] = hv
|
|
87
|
+
return SessionSpec(**vals) # type: ignore[arg-type]
|
|
88
|
+
|
|
89
|
+
def to_kwargs(self) -> dict[str, Any]:
|
|
90
|
+
"""Return only non-None items as a plain dict (adapters can **kwargs this)."""
|
|
91
|
+
return {
|
|
92
|
+
f.name: getattr(self, f.name)
|
|
93
|
+
for f in fields(SessionSpec)
|
|
94
|
+
if getattr(self, f.name) is not None
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def from_any(x: SessionCfg) -> Optional["SessionSpec"]:
|
|
99
|
+
if x is None:
|
|
100
|
+
return None
|
|
101
|
+
if isinstance(x, SessionSpec):
|
|
102
|
+
return x
|
|
103
|
+
if isinstance(x, Mapping):
|
|
104
|
+
m = dict(x)
|
|
105
|
+
# aliases
|
|
106
|
+
if "readonly" in m and "read_only" not in m:
|
|
107
|
+
m["read_only"] = bool(m.pop("readonly"))
|
|
108
|
+
if "iso" in m and "isolation" not in m:
|
|
109
|
+
m["isolation"] = str(m.pop("iso"))
|
|
110
|
+
allowed = {f.name for f in fields(SessionSpec)}
|
|
111
|
+
return SessionSpec(**{k: v for k, v in m.items() if k in allowed})
|
|
112
|
+
raise TypeError(f"Unsupported SessionSpec input: {type(x)}")
|
tigrbl/system/__init__.py
CHANGED
tigrbl/system/uvicorn.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Utilities for running uvicorn during tests or tooling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _cancel_task(task: asyncio.Task) -> None:
|
|
11
|
+
if task.done():
|
|
12
|
+
return
|
|
13
|
+
task.cancel()
|
|
14
|
+
try:
|
|
15
|
+
await task
|
|
16
|
+
except asyncio.CancelledError:
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _close_servers(server: uvicorn.Server) -> None:
|
|
21
|
+
servers = []
|
|
22
|
+
primary = getattr(server, "server", None)
|
|
23
|
+
if primary is not None:
|
|
24
|
+
servers.append(primary)
|
|
25
|
+
extra = getattr(server, "servers", None)
|
|
26
|
+
if extra:
|
|
27
|
+
servers.extend(extra)
|
|
28
|
+
for srv in servers:
|
|
29
|
+
close = getattr(srv, "close", None)
|
|
30
|
+
if callable(close):
|
|
31
|
+
close()
|
|
32
|
+
wait_closed = getattr(srv, "wait_closed", None)
|
|
33
|
+
if callable(wait_closed):
|
|
34
|
+
await wait_closed()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def stop_uvicorn_server(
|
|
38
|
+
server: uvicorn.Server,
|
|
39
|
+
task: asyncio.Task,
|
|
40
|
+
*,
|
|
41
|
+
timeout: float = 5.0,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Request uvicorn shutdown and ensure the task exits."""
|
|
44
|
+
if task.done():
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
server.should_exit = True
|
|
48
|
+
try:
|
|
49
|
+
await asyncio.wait_for(task, timeout=timeout)
|
|
50
|
+
return
|
|
51
|
+
except asyncio.TimeoutError:
|
|
52
|
+
server.force_exit = True
|
|
53
|
+
shutdown = getattr(server, "shutdown", None)
|
|
54
|
+
if callable(shutdown):
|
|
55
|
+
try:
|
|
56
|
+
await asyncio.wait_for(shutdown(), timeout=timeout)
|
|
57
|
+
except asyncio.TimeoutError:
|
|
58
|
+
pass
|
|
59
|
+
await _close_servers(server)
|
|
60
|
+
await _cancel_task(task)
|
tigrbl/table/_base.py
CHANGED
|
@@ -99,6 +99,10 @@ def _materialize_colspecs_to_sqla(cls) -> None:
|
|
|
99
99
|
from tigrbl.column.column_spec import ColumnSpec
|
|
100
100
|
except Exception:
|
|
101
101
|
return
|
|
102
|
+
try:
|
|
103
|
+
from sqlalchemy.orm import InstrumentedAttribute
|
|
104
|
+
except Exception: # pragma: no cover - defensive for minimal SQLA envs
|
|
105
|
+
InstrumentedAttribute = None
|
|
102
106
|
|
|
103
107
|
# Prefer explicit registry if present; otherwise collect specs from the
|
|
104
108
|
# entire MRO so mixins contribute their ColumnSpec definitions.
|
|
@@ -123,6 +127,13 @@ def _materialize_colspecs_to_sqla(cls) -> None:
|
|
|
123
127
|
if not storage:
|
|
124
128
|
# Virtual (wire-only) column – no DB column
|
|
125
129
|
continue
|
|
130
|
+
existing_attr = getattr(cls, name, None)
|
|
131
|
+
if InstrumentedAttribute is not None and isinstance(
|
|
132
|
+
existing_attr, InstrumentedAttribute
|
|
133
|
+
):
|
|
134
|
+
# Column already mapped on a base class; avoid duplicating columns
|
|
135
|
+
# that trigger SQLAlchemy implicit combination warnings.
|
|
136
|
+
continue
|
|
126
137
|
|
|
127
138
|
dtype = getattr(storage, "type_", None)
|
|
128
139
|
if not dtype:
|
|
@@ -174,18 +185,41 @@ class Base(DeclarativeBase):
|
|
|
174
185
|
__allow_unmapped__ = True
|
|
175
186
|
|
|
176
187
|
def __init_subclass__(cls, **kw):
|
|
177
|
-
# 0) Remove any previously registered class with the same
|
|
188
|
+
# 0) Remove any previously registered class with the same module path.
|
|
178
189
|
try:
|
|
179
190
|
reg = Base.registry._class_registry
|
|
180
|
-
|
|
181
|
-
existing =
|
|
191
|
+
key = f"{cls.__module__}.{cls.__name__}"
|
|
192
|
+
existing = reg.get(key)
|
|
182
193
|
if existing is not None:
|
|
183
194
|
try:
|
|
184
195
|
Base.registry._dispose_cls(existing)
|
|
185
196
|
except Exception:
|
|
186
197
|
pass
|
|
187
|
-
|
|
188
|
-
|
|
198
|
+
reg.pop(key, None)
|
|
199
|
+
if reg.get(cls.__name__) is existing:
|
|
200
|
+
reg.pop(cls.__name__, None)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
# 0.5) If a table with the same name already exists, allow this class
|
|
205
|
+
# to extend it instead of raising duplicate-table errors.
|
|
206
|
+
try:
|
|
207
|
+
table_name = getattr(cls, "__tablename__", None)
|
|
208
|
+
if table_name and table_name in Base.metadata.tables:
|
|
209
|
+
table_args = getattr(cls, "__table_args__", None)
|
|
210
|
+
if table_args is None:
|
|
211
|
+
cls.__table_args__ = {"extend_existing": True}
|
|
212
|
+
elif isinstance(table_args, dict):
|
|
213
|
+
table_args = dict(table_args)
|
|
214
|
+
table_args["extend_existing"] = True
|
|
215
|
+
cls.__table_args__ = table_args
|
|
216
|
+
elif isinstance(table_args, tuple):
|
|
217
|
+
if table_args and isinstance(table_args[-1], dict):
|
|
218
|
+
table_dict = dict(table_args[-1])
|
|
219
|
+
table_dict["extend_existing"] = True
|
|
220
|
+
cls.__table_args__ = (*table_args[:-1], table_dict)
|
|
221
|
+
else:
|
|
222
|
+
cls.__table_args__ = (*table_args, {"extend_existing": True})
|
|
189
223
|
except Exception:
|
|
190
224
|
pass
|
|
191
225
|
|
tigrbl/types/__init__.py
CHANGED
|
@@ -25,7 +25,6 @@ from ..deps.sqlalchemy import (
|
|
|
25
25
|
ARRAY,
|
|
26
26
|
PgEnum,
|
|
27
27
|
JSONB,
|
|
28
|
-
_PgUUID,
|
|
29
28
|
TSVECTOR,
|
|
30
29
|
# ORM
|
|
31
30
|
Mapped,
|
|
@@ -67,6 +66,7 @@ from ..deps.fastapi import (
|
|
|
67
66
|
|
|
68
67
|
# ── Local Package ─────────────────────────────────────────────────────────
|
|
69
68
|
from .op import _Op, _SchemaVerb
|
|
69
|
+
from .uuid import PgUUID, SqliteUUID
|
|
70
70
|
from .authn_abc import AuthNProvider
|
|
71
71
|
from .table_config_provider import TableConfigProvider
|
|
72
72
|
from .nested_path_provider import NestedPathProvider
|
|
@@ -88,12 +88,6 @@ DateTime = _DateTime(timezone=False)
|
|
|
88
88
|
TZDateTime = _DateTime(timezone=True)
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
class PgUUID(_PgUUID):
|
|
92
|
-
@property
|
|
93
|
-
def hex(self):
|
|
94
|
-
return self.as_uuid.hex
|
|
95
|
-
|
|
96
|
-
|
|
97
91
|
# ── Public Re-exports (Backwards Compatibility) ──────────────────────────
|
|
98
92
|
__all__: list[str] = [
|
|
99
93
|
# local
|
|
@@ -110,6 +104,8 @@ __all__: list[str] = [
|
|
|
110
104
|
"list_request_extras_providers",
|
|
111
105
|
"list_response_extras_providers",
|
|
112
106
|
"OpConfigProvider",
|
|
107
|
+
# add ons
|
|
108
|
+
"SqliteUUID",
|
|
113
109
|
# builtin types
|
|
114
110
|
"MethodType",
|
|
115
111
|
"SimpleNamespace",
|