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/engine/plugins.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_loaded = False
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_engine_plugins() -> None:
|
|
8
|
+
"""Discover and load external engine plugins via entry points.
|
|
9
|
+
Safe and idempotent; does nothing if already loaded.
|
|
10
|
+
"""
|
|
11
|
+
global _loaded
|
|
12
|
+
if _loaded:
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
# importlib.metadata API differs across Python versions; support both.
|
|
16
|
+
eps = None
|
|
17
|
+
try:
|
|
18
|
+
from importlib.metadata import entry_points # Python >= 3.10
|
|
19
|
+
|
|
20
|
+
eps = entry_points()
|
|
21
|
+
# New API: .select(group="tigrbl.engine")
|
|
22
|
+
selected = (
|
|
23
|
+
eps.select(group="tigrbl.engine")
|
|
24
|
+
if hasattr(eps, "select")
|
|
25
|
+
else eps.get("tigrbl.engine", [])
|
|
26
|
+
)
|
|
27
|
+
except Exception:
|
|
28
|
+
try:
|
|
29
|
+
from importlib_metadata import entry_points as entry_points_backport
|
|
30
|
+
|
|
31
|
+
eps = entry_points_backport()
|
|
32
|
+
selected = (
|
|
33
|
+
eps.select(group="tigrbl.engine")
|
|
34
|
+
if hasattr(eps, "select")
|
|
35
|
+
else eps.get("tigrbl.engine", [])
|
|
36
|
+
)
|
|
37
|
+
except Exception:
|
|
38
|
+
selected = []
|
|
39
|
+
|
|
40
|
+
for ep in selected or []:
|
|
41
|
+
try:
|
|
42
|
+
fn = ep.load()
|
|
43
|
+
except Exception:
|
|
44
|
+
# Ignore broken entry points; the engine remains unavailable.
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
fn() # call plugin's register() to register_engine(kind, build, ...)
|
|
48
|
+
except Exception:
|
|
49
|
+
# Defensive: a broken plugin must not crash core import
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
_loaded = True
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Callable, Optional, Tuple, Dict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# A registration for an engine kind provided by an external (or built-in) package.
|
|
7
|
+
@dataclass
|
|
8
|
+
class EngineRegistration:
|
|
9
|
+
build: Callable[..., Tuple[Any, Callable[[], Any]]]
|
|
10
|
+
capabilities: Optional[Callable[[], Any]] = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_registry: Dict[str, EngineRegistration] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_engine(
|
|
17
|
+
kind: str,
|
|
18
|
+
build: Callable[..., Tuple[Any, Callable[[], Any]]],
|
|
19
|
+
capabilities: Optional[Callable[[], Any]] = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Register an engine kind → (builder, capabilities). Idempotent."""
|
|
22
|
+
k = (kind or "").strip().lower()
|
|
23
|
+
if not k:
|
|
24
|
+
raise ValueError("engine kind must be a non-empty string")
|
|
25
|
+
if k in _registry:
|
|
26
|
+
# idempotent registration
|
|
27
|
+
return
|
|
28
|
+
_registry[k] = EngineRegistration(build=build, capabilities=capabilities)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_engine_registration(kind: str) -> Optional[EngineRegistration]:
|
|
32
|
+
return _registry.get((kind or "").strip().lower())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def known_engine_kinds() -> list[str]:
|
|
36
|
+
return sorted(_registry.keys())
|
tigrbl/engine/resolver.py
CHANGED
|
@@ -10,7 +10,6 @@ from typing import Any, Callable, Optional
|
|
|
10
10
|
from ._engine import AsyncSession, Engine, Provider, Session
|
|
11
11
|
from .engine_spec import EngineSpec, EngineCfg
|
|
12
12
|
|
|
13
|
-
logging.getLogger("uvicorn").setLevel(logging.DEBUG)
|
|
14
13
|
logger = logging.getLogger("uvicorn")
|
|
15
14
|
|
|
16
15
|
# Registry with strict precedence: op > model > api > app
|
tigrbl/orm/mixins/upsertable.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# tigrbl/v3/mixins/upsertable.py
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
4
6
|
from typing import Any, Mapping, Sequence, Optional, Tuple
|
|
5
7
|
from sqlalchemy import and_, inspect as sa_inspect
|
|
6
8
|
from tigrbl.types import Session
|
|
@@ -18,6 +20,11 @@ class Upsertable:
|
|
|
18
20
|
|
|
19
21
|
def __init_subclass__(cls, **kw):
|
|
20
22
|
super().__init_subclass__(**kw)
|
|
23
|
+
warnings.warn(
|
|
24
|
+
"Upsertable is deprecated and will be removed in a future release.",
|
|
25
|
+
DeprecationWarning,
|
|
26
|
+
stacklevel=2,
|
|
27
|
+
)
|
|
21
28
|
cls._install_upsertable_hooks()
|
|
22
29
|
|
|
23
30
|
@classmethod
|
tigrbl/response/shortcuts.py
CHANGED
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import mimetypes
|
|
8
|
+
import base64
|
|
8
9
|
|
|
9
10
|
from ..deps.starlette import (
|
|
10
11
|
JSONResponse,
|
|
@@ -16,13 +17,39 @@ from ..deps.starlette import (
|
|
|
16
17
|
Response,
|
|
17
18
|
)
|
|
18
19
|
|
|
20
|
+
|
|
21
|
+
def _json_default(value: Any) -> Any:
|
|
22
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
23
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
24
|
+
if isinstance(value, Path):
|
|
25
|
+
return str(value)
|
|
26
|
+
return str(value)
|
|
27
|
+
|
|
28
|
+
|
|
19
29
|
try:
|
|
20
30
|
import orjson as _orjson
|
|
21
31
|
|
|
32
|
+
_ORJSON_OPTIONS = (
|
|
33
|
+
getattr(_orjson, "OPT_NON_STR_KEYS", 0)
|
|
34
|
+
| getattr(_orjson, "OPT_SERIALIZE_NUMPY", 0)
|
|
35
|
+
| getattr(_orjson, "OPT_SERIALIZE_BYTES", 0)
|
|
36
|
+
)
|
|
37
|
+
|
|
22
38
|
def _dumps(obj: Any) -> bytes:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
try:
|
|
40
|
+
return _orjson.dumps(
|
|
41
|
+
obj,
|
|
42
|
+
option=_ORJSON_OPTIONS,
|
|
43
|
+
default=_json_default,
|
|
44
|
+
)
|
|
45
|
+
except TypeError:
|
|
46
|
+
# Fallback for older orjson builds missing optional flags
|
|
47
|
+
return json.dumps(
|
|
48
|
+
obj,
|
|
49
|
+
separators=(",", ":"),
|
|
50
|
+
ensure_ascii=False,
|
|
51
|
+
default=_json_default,
|
|
52
|
+
).encode("utf-8")
|
|
26
53
|
except Exception: # pragma: no cover - fallback
|
|
27
54
|
|
|
28
55
|
def _dumps(obj: Any) -> bytes:
|
|
@@ -30,7 +57,7 @@ except Exception: # pragma: no cover - fallback
|
|
|
30
57
|
obj,
|
|
31
58
|
separators=(",", ":"),
|
|
32
59
|
ensure_ascii=False,
|
|
33
|
-
default=
|
|
60
|
+
default=_json_default,
|
|
34
61
|
).encode("utf-8")
|
|
35
62
|
|
|
36
63
|
|
tigrbl/runtime/atoms/__init__.py
CHANGED
|
@@ -5,12 +5,14 @@ from ... import events as _ev
|
|
|
5
5
|
from .template import run as _template
|
|
6
6
|
from .negotiate import run as _negotiate
|
|
7
7
|
from .render import run as _render
|
|
8
|
+
from . import headers_from_payload as _hdr_payload
|
|
8
9
|
|
|
9
10
|
RunFn = Callable[[Optional[object], Any], Any]
|
|
10
11
|
|
|
11
12
|
REGISTRY: Dict[Tuple[str, str], Tuple[str, RunFn]] = {
|
|
12
13
|
("response", "template"): (_ev.OUT_DUMP, _template),
|
|
13
14
|
("response", "negotiate"): (_ev.OUT_DUMP, _negotiate),
|
|
15
|
+
("response", "headers_from_payload"): (_hdr_payload.ANCHOR, _hdr_payload.run),
|
|
14
16
|
("response", "render"): (_ev.OUT_DUMP, _render),
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ... import events as _ev
|
|
4
|
+
from typing import Mapping
|
|
5
|
+
|
|
6
|
+
ANCHOR = _ev.OUT_BUILD # run after payload is prepared, before render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(_, ctx) -> None:
|
|
10
|
+
"""Mirror fields configured with ``io.header_out`` into HTTP response headers.
|
|
11
|
+
|
|
12
|
+
- Does NOT remove fields from the response body (no header-only behavior).
|
|
13
|
+
- Honors op-specific exposure via ``io.out_verbs``.
|
|
14
|
+
Complexity: O(#fields in opview).
|
|
15
|
+
"""
|
|
16
|
+
from tigrbl.response import ResponseHints
|
|
17
|
+
|
|
18
|
+
resp = getattr(ctx, "response", None)
|
|
19
|
+
if resp is None:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
hints = getattr(resp, "hints", None)
|
|
23
|
+
if hints is None:
|
|
24
|
+
hints = ResponseHints()
|
|
25
|
+
resp.hints = hints
|
|
26
|
+
|
|
27
|
+
payload = getattr(resp, "result", None)
|
|
28
|
+
if payload is None:
|
|
29
|
+
temp = getattr(ctx, "temp", None)
|
|
30
|
+
if isinstance(temp, Mapping):
|
|
31
|
+
payload = temp.get("response_payload")
|
|
32
|
+
if not isinstance(payload, dict):
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
specs = getattr(ctx, "specs", None)
|
|
36
|
+
if not isinstance(specs, Mapping):
|
|
37
|
+
model = getattr(ctx, "model", None)
|
|
38
|
+
specs = getattr(model, "__tigrbl_cols__", {}) if model is not None else {}
|
|
39
|
+
|
|
40
|
+
op = getattr(ctx, "op", None)
|
|
41
|
+
for field_name, spec in specs.items():
|
|
42
|
+
io = getattr(spec, "io", None)
|
|
43
|
+
if not io:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
header_name = getattr(io, "header_out", None)
|
|
47
|
+
if not header_name:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
out_verbs = set(getattr(io, "out_verbs", ()) or ())
|
|
51
|
+
if op not in out_verbs:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if field_name in payload:
|
|
55
|
+
value = payload[field_name]
|
|
56
|
+
if value is not None:
|
|
57
|
+
hints.headers[header_name] = str(value)
|
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,8 +9,8 @@ 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
|
-
logging.getLogger("uvicorn").setLevel(logging.DEBUG)
|
|
14
14
|
logger = logging.getLogger("uvicorn")
|
|
15
15
|
|
|
16
16
|
|
|
@@ -20,6 +20,24 @@ def collect_decorated_schemas(model: type) -> Dict[str, Dict[str, type]]:
|
|
|
20
20
|
logger.info("Collecting decorated schemas for %s", model.__name__)
|
|
21
21
|
out: Dict[str, Dict[str, type]] = {}
|
|
22
22
|
|
|
23
|
+
def _promote_schema(schema_cls: type) -> type:
|
|
24
|
+
if issubclass(schema_cls, BaseModel):
|
|
25
|
+
return schema_cls
|
|
26
|
+
annotations = getattr(schema_cls, "__annotations__", {}) or {}
|
|
27
|
+
fields = {}
|
|
28
|
+
for name, anno in annotations.items():
|
|
29
|
+
default = getattr(schema_cls, name, ...)
|
|
30
|
+
fields[name] = (anno, default)
|
|
31
|
+
promoted = create_model( # type: ignore[call-arg]
|
|
32
|
+
schema_cls.__name__,
|
|
33
|
+
__base__=BaseModel,
|
|
34
|
+
**fields,
|
|
35
|
+
)
|
|
36
|
+
promoted.__module__ = schema_cls.__module__
|
|
37
|
+
promoted.__qualname__ = schema_cls.__qualname__
|
|
38
|
+
promoted.__doc__ = schema_cls.__doc__
|
|
39
|
+
return promoted
|
|
40
|
+
|
|
23
41
|
# Explicit registrations (MRO-merged)
|
|
24
42
|
for base in reversed(model.__mro__):
|
|
25
43
|
mapping: Dict[str, Dict[str, type]] = (
|
|
@@ -31,7 +49,12 @@ def collect_decorated_schemas(model: type) -> Dict[str, Dict[str, type]]:
|
|
|
31
49
|
)
|
|
32
50
|
for alias, kinds in mapping.items():
|
|
33
51
|
bucket = out.setdefault(alias, {})
|
|
34
|
-
bucket.update(
|
|
52
|
+
bucket.update(
|
|
53
|
+
{
|
|
54
|
+
kind: _promote_schema(schema)
|
|
55
|
+
for kind, schema in (kinds or {}).items()
|
|
56
|
+
}
|
|
57
|
+
)
|
|
35
58
|
|
|
36
59
|
# Nested classes with __tigrbl_schema_decl__
|
|
37
60
|
for base in reversed(model.__mro__):
|
|
@@ -46,7 +69,7 @@ def collect_decorated_schemas(model: type) -> Dict[str, Dict[str, type]]:
|
|
|
46
69
|
)
|
|
47
70
|
continue
|
|
48
71
|
bucket = out.setdefault(decl.alias, {})
|
|
49
|
-
bucket[decl.kind] = obj
|
|
72
|
+
bucket[decl.kind] = _promote_schema(obj)
|
|
50
73
|
|
|
51
74
|
logger.debug("Collected schema aliases: %s", list(out.keys()))
|
|
52
75
|
return out
|
tigrbl/schema/decorators.py
CHANGED
tigrbl/schema/get_schema.py
CHANGED
tigrbl/schema/utils.py
CHANGED
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
|
+
"""
|