tigrbl 0.3.0.dev3__py3-none-any.whl → 0.3.1__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/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/column/io_spec.py +3 -0
- tigrbl/engine/__init__.py +19 -0
- tigrbl/engine/_engine.py +14 -0
- 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/orm/mixins/upsertable.py +7 -0
- tigrbl/response/shortcuts.py +31 -4
- 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 -2
- 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 +28 -5
- tigrbl/types/__init__.py +3 -7
- tigrbl/types/uuid.py +55 -0
- {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info}/METADATA +19 -4
- {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info}/RECORD +44 -27
- {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info}/WHEEL +1 -1
- {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info/licenses}/LICENSE +0 -0
tigrbl/runtime/kernel.py
CHANGED
|
@@ -11,6 +11,7 @@ from types import SimpleNamespace
|
|
|
11
11
|
from typing import (
|
|
12
12
|
Any,
|
|
13
13
|
Callable,
|
|
14
|
+
ClassVar,
|
|
14
15
|
Dict,
|
|
15
16
|
Iterable,
|
|
16
17
|
List,
|
|
@@ -262,18 +263,27 @@ class Kernel:
|
|
|
262
263
|
Auto-primed under the hood. Downstream users never touch this.
|
|
263
264
|
"""
|
|
264
265
|
|
|
266
|
+
_instance: ClassVar["Kernel | None"] = None
|
|
267
|
+
|
|
268
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> "Kernel":
|
|
269
|
+
if cls._instance is None:
|
|
270
|
+
cls._instance = super().__new__(cls)
|
|
271
|
+
return cls._instance
|
|
272
|
+
|
|
265
273
|
def __init__(self, atoms: Optional[Sequence[_DiscoveredAtom]] = None):
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
if atoms is None and getattr(self, "_singleton_initialized", False):
|
|
275
|
+
self._reset(atoms)
|
|
276
|
+
return
|
|
277
|
+
self._reset(atoms)
|
|
278
|
+
if atoms is None:
|
|
279
|
+
self._singleton_initialized = True
|
|
280
|
+
|
|
281
|
+
def _reset(self, atoms: Optional[Sequence[_DiscoveredAtom]] = None) -> None:
|
|
282
|
+
self._atoms_cache = list(atoms) if atoms else None
|
|
269
283
|
self._specs_cache = _SpecsOnceCache()
|
|
270
|
-
self._opviews
|
|
271
|
-
|
|
272
|
-
)
|
|
273
|
-
self._kernelz_payload: _WeakMaybeDict[Any, Dict[str, Dict[str, List[str]]]] = (
|
|
274
|
-
_WeakMaybeDict()
|
|
275
|
-
)
|
|
276
|
-
self._primed: _WeakMaybeDict[Any, bool] = _WeakMaybeDict()
|
|
284
|
+
self._opviews = _WeakMaybeDict()
|
|
285
|
+
self._kernelz_payload = _WeakMaybeDict()
|
|
286
|
+
self._primed = _WeakMaybeDict()
|
|
277
287
|
self._lock = threading.Lock()
|
|
278
288
|
|
|
279
289
|
# ——— atoms ———
|
|
@@ -396,9 +406,15 @@ class Kernel:
|
|
|
396
406
|
|
|
397
407
|
def get_opview(self, app: Any, model: type, alias: str) -> OpView:
|
|
398
408
|
"""Return OpView for (model, alias); compile on-demand if missing."""
|
|
409
|
+
ov_map = self._opviews.get(app)
|
|
410
|
+
if isinstance(ov_map, dict):
|
|
411
|
+
ov = ov_map.get((model, alias))
|
|
412
|
+
if ov is not None:
|
|
413
|
+
return ov
|
|
414
|
+
|
|
399
415
|
self.ensure_primed(app)
|
|
400
416
|
|
|
401
|
-
ov_map
|
|
417
|
+
ov_map = self._opviews.setdefault(app, {})
|
|
402
418
|
ov = ov_map.get((model, alias))
|
|
403
419
|
if ov is not None:
|
|
404
420
|
return ov
|
tigrbl/runtime/opview.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
from typing import Any, Mapping, Dict
|
|
3
3
|
from types import SimpleNamespace
|
|
4
4
|
|
|
5
|
-
from .
|
|
5
|
+
from . import kernel as _kernel # single, app-scoped kernel
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def _ensure_temp(ctx: Any) -> Dict[str, Any]:
|
|
@@ -36,12 +36,14 @@ def opview_from_ctx(ctx: Any):
|
|
|
36
36
|
|
|
37
37
|
if app and model and alias:
|
|
38
38
|
# One-kernel-per-app, prime once; raises if not compiled
|
|
39
|
-
return
|
|
39
|
+
return _kernel._default_kernel.get_opview(app, model, alias)
|
|
40
40
|
|
|
41
41
|
if alias:
|
|
42
42
|
specs = getattr(ctx, "specs", None)
|
|
43
43
|
if specs is not None:
|
|
44
|
-
return
|
|
44
|
+
return _kernel._default_kernel._compile_opview_from_specs(
|
|
45
|
+
specs, SimpleNamespace(alias=alias)
|
|
46
|
+
)
|
|
45
47
|
|
|
46
48
|
missing = []
|
|
47
49
|
if not alias:
|
tigrbl/schema/collect.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Dict
|
|
|
9
9
|
from ..config.constants import TIGRBL_SCHEMA_DECLS_ATTR
|
|
10
10
|
|
|
11
11
|
from .decorators import _SchemaDecl
|
|
12
|
+
from pydantic import BaseModel, create_model
|
|
12
13
|
|
|
13
14
|
logging.getLogger("uvicorn").setLevel(logging.DEBUG)
|
|
14
15
|
logger = logging.getLogger("uvicorn")
|
|
@@ -20,6 +21,24 @@ def collect_decorated_schemas(model: type) -> Dict[str, Dict[str, type]]:
|
|
|
20
21
|
logger.info("Collecting decorated schemas for %s", model.__name__)
|
|
21
22
|
out: Dict[str, Dict[str, type]] = {}
|
|
22
23
|
|
|
24
|
+
def _promote_schema(schema_cls: type) -> type:
|
|
25
|
+
if issubclass(schema_cls, BaseModel):
|
|
26
|
+
return schema_cls
|
|
27
|
+
annotations = getattr(schema_cls, "__annotations__", {}) or {}
|
|
28
|
+
fields = {}
|
|
29
|
+
for name, anno in annotations.items():
|
|
30
|
+
default = getattr(schema_cls, name, ...)
|
|
31
|
+
fields[name] = (anno, default)
|
|
32
|
+
promoted = create_model( # type: ignore[call-arg]
|
|
33
|
+
schema_cls.__name__,
|
|
34
|
+
__base__=BaseModel,
|
|
35
|
+
**fields,
|
|
36
|
+
)
|
|
37
|
+
promoted.__module__ = schema_cls.__module__
|
|
38
|
+
promoted.__qualname__ = schema_cls.__qualname__
|
|
39
|
+
promoted.__doc__ = schema_cls.__doc__
|
|
40
|
+
return promoted
|
|
41
|
+
|
|
23
42
|
# Explicit registrations (MRO-merged)
|
|
24
43
|
for base in reversed(model.__mro__):
|
|
25
44
|
mapping: Dict[str, Dict[str, type]] = (
|
|
@@ -31,7 +50,12 @@ def collect_decorated_schemas(model: type) -> Dict[str, Dict[str, type]]:
|
|
|
31
50
|
)
|
|
32
51
|
for alias, kinds in mapping.items():
|
|
33
52
|
bucket = out.setdefault(alias, {})
|
|
34
|
-
bucket.update(
|
|
53
|
+
bucket.update(
|
|
54
|
+
{
|
|
55
|
+
kind: _promote_schema(schema)
|
|
56
|
+
for kind, schema in (kinds or {}).items()
|
|
57
|
+
}
|
|
58
|
+
)
|
|
35
59
|
|
|
36
60
|
# Nested classes with __tigrbl_schema_decl__
|
|
37
61
|
for base in reversed(model.__mro__):
|
|
@@ -46,7 +70,7 @@ def collect_decorated_schemas(model: type) -> Dict[str, Dict[str, type]]:
|
|
|
46
70
|
)
|
|
47
71
|
continue
|
|
48
72
|
bucket = out.setdefault(decl.alias, {})
|
|
49
|
-
bucket[decl.kind] = obj
|
|
73
|
+
bucket[decl.kind] = _promote_schema(obj)
|
|
50
74
|
|
|
51
75
|
logger.debug("Collected schema aliases: %s", list(out.keys()))
|
|
52
76
|
return out
|
tigrbl/session/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
# tigrbl.session
|
|
3
|
+
|
|
4
|
+
`tigrbl.session` provides the transaction-aware session contract and helpers for Tigrbl:
|
|
5
|
+
|
|
6
|
+
- `SessionABC`: required interface (native transactions).
|
|
7
|
+
- `SessionSpec`: per-session policy (isolation, read-only, timeouts, retries, etc.).
|
|
8
|
+
- `TigrblSessionBase`: abstract base with guardrails (read-only enforcement, queued add()).
|
|
9
|
+
- `DefaultSession`: delegating wrapper for native driver sessions.
|
|
10
|
+
- `session_ctx` / `read_only_session`: decorators to attach policy at app/api/model/op scopes.
|
|
11
|
+
- `session_spec` / `tx_*` / `readonly`: shortcuts to build policy objects.
|
|
12
|
+
- `wrap_sessionmaker`: helper to adapt provider session factories to Tigrbl sessions.
|
|
13
|
+
|
|
14
|
+
This module is backend-agnostic and does not import any database libraries.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .abc import SessionABC
|
|
2
|
+
from .spec import SessionSpec
|
|
3
|
+
from .base import TigrblSessionBase
|
|
4
|
+
from .default import DefaultSession
|
|
5
|
+
from .decorators import session_ctx, read_only_session
|
|
6
|
+
from .shortcuts import (
|
|
7
|
+
session_spec,
|
|
8
|
+
tx_read_committed,
|
|
9
|
+
tx_repeatable_read,
|
|
10
|
+
tx_serializable,
|
|
11
|
+
readonly,
|
|
12
|
+
wrap_sessionmaker,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SessionABC",
|
|
17
|
+
"SessionSpec",
|
|
18
|
+
"TigrblSessionBase",
|
|
19
|
+
"DefaultSession",
|
|
20
|
+
"session_ctx",
|
|
21
|
+
"read_only_session",
|
|
22
|
+
"session_spec",
|
|
23
|
+
"tx_read_committed",
|
|
24
|
+
"tx_repeatable_read",
|
|
25
|
+
"tx_serializable",
|
|
26
|
+
"readonly",
|
|
27
|
+
"wrap_sessionmaker",
|
|
28
|
+
]
|
tigrbl/session/abc.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SessionABC(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Authoritative Tigrbl session interface.
|
|
10
|
+
|
|
11
|
+
All concrete sessions MUST be natively transactional and implement the
|
|
12
|
+
methods below. This ABC is intentionally minimal and backend-agnostic.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# ---- Transactions ----
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def begin(self) -> None:
|
|
18
|
+
"""Open a native transaction for this session."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def commit(self) -> None:
|
|
22
|
+
"""Commit the current transaction."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def rollback(self) -> None:
|
|
26
|
+
"""Rollback the current transaction."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def in_transaction(self) -> bool:
|
|
30
|
+
"""Return True iff a transaction is currently open."""
|
|
31
|
+
|
|
32
|
+
# ---- CRUD surface ----
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def get(self, model: type, ident: Any) -> Any | None:
|
|
35
|
+
"""Fetch one instance by primary key (model, ident)."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def add(self, obj: Any) -> None:
|
|
39
|
+
"""Stage a new/dirty object for persistence."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def delete(self, obj: Any) -> None:
|
|
43
|
+
"""Stage an object for deletion."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def flush(self) -> None:
|
|
47
|
+
"""Flush staged changes to the underlying store (still in TX)."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def refresh(self, obj: Any) -> None:
|
|
51
|
+
"""Refresh the object from the store (respecting the current TX view)."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
async def execute(self, stmt: Any) -> Any:
|
|
55
|
+
"""
|
|
56
|
+
Execute a backend-native statement.
|
|
57
|
+
|
|
58
|
+
The result (if any) SHOULD provide a minimal facade compatible with:
|
|
59
|
+
- .scalars().all()
|
|
60
|
+
- .scalar_one()
|
|
61
|
+
to ease integration with higher-level helpers.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
# ---- Lifecycle / async marker ----
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
"""Release underlying resources (connections, cursors, etc.)."""
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def run_sync(self, fn: Callable[[Any], Any]) -> Any:
|
|
71
|
+
"""
|
|
72
|
+
Execute a callback against the underlying native handle.
|
|
73
|
+
|
|
74
|
+
Presence of this method also acts as the "async session" marker for code
|
|
75
|
+
paths that need to distinguish sync-vs-async sessions.
|
|
76
|
+
"""
|
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
|