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.
Files changed (73) hide show
  1. tigrbl/api/_api.py +26 -1
  2. tigrbl/api/tigrbl_api.py +6 -1
  3. tigrbl/app/_app.py +26 -1
  4. tigrbl/app/_model_registry.py +41 -0
  5. tigrbl/app/tigrbl_app.py +6 -1
  6. tigrbl/bindings/api/__init__.py +0 -1
  7. tigrbl/bindings/api/common.py +0 -1
  8. tigrbl/bindings/api/include.py +14 -2
  9. tigrbl/bindings/api/resource_proxy.py +0 -1
  10. tigrbl/bindings/api/rpc.py +0 -1
  11. tigrbl/bindings/handlers/__init__.py +0 -1
  12. tigrbl/bindings/handlers/builder.py +0 -1
  13. tigrbl/bindings/handlers/ctx.py +0 -1
  14. tigrbl/bindings/handlers/identifiers.py +0 -1
  15. tigrbl/bindings/handlers/namespaces.py +0 -1
  16. tigrbl/bindings/handlers/steps.py +0 -1
  17. tigrbl/bindings/rest/collection.py +24 -3
  18. tigrbl/bindings/rest/common.py +4 -0
  19. tigrbl/bindings/rest/io_headers.py +49 -0
  20. tigrbl/bindings/rest/member.py +19 -0
  21. tigrbl/bindings/rest/router.py +4 -0
  22. tigrbl/bindings/rest/routing.py +21 -1
  23. tigrbl/bindings/schemas/__init__.py +0 -1
  24. tigrbl/bindings/schemas/builder.py +0 -1
  25. tigrbl/bindings/schemas/defaults.py +0 -1
  26. tigrbl/bindings/schemas/utils.py +0 -1
  27. tigrbl/column/io_spec.py +3 -0
  28. tigrbl/core/crud/bulk.py +0 -1
  29. tigrbl/core/crud/helpers/db.py +0 -1
  30. tigrbl/core/crud/helpers/enum.py +0 -1
  31. tigrbl/core/crud/helpers/filters.py +0 -1
  32. tigrbl/core/crud/helpers/model.py +0 -1
  33. tigrbl/core/crud/helpers/normalize.py +0 -1
  34. tigrbl/core/crud/ops.py +0 -1
  35. tigrbl/docs/verbosity.md +35 -0
  36. tigrbl/engine/__init__.py +19 -0
  37. tigrbl/engine/_engine.py +14 -0
  38. tigrbl/engine/builders.py +0 -1
  39. tigrbl/engine/capabilities.py +29 -0
  40. tigrbl/engine/decorators.py +3 -1
  41. tigrbl/engine/docs/PLUGINS.md +49 -0
  42. tigrbl/engine/engine_spec.py +197 -103
  43. tigrbl/engine/plugins.py +52 -0
  44. tigrbl/engine/registry.py +36 -0
  45. tigrbl/engine/resolver.py +0 -1
  46. tigrbl/orm/mixins/upsertable.py +7 -0
  47. tigrbl/response/shortcuts.py +31 -4
  48. tigrbl/runtime/atoms/__init__.py +0 -1
  49. tigrbl/runtime/atoms/response/__init__.py +2 -0
  50. tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
  51. tigrbl/runtime/kernel.py +27 -11
  52. tigrbl/runtime/opview.py +5 -3
  53. tigrbl/schema/collect.py +26 -3
  54. tigrbl/schema/decorators.py +0 -1
  55. tigrbl/schema/get_schema.py +0 -1
  56. tigrbl/schema/utils.py +0 -1
  57. tigrbl/session/README.md +14 -0
  58. tigrbl/session/__init__.py +28 -0
  59. tigrbl/session/abc.py +76 -0
  60. tigrbl/session/base.py +151 -0
  61. tigrbl/session/decorators.py +43 -0
  62. tigrbl/session/default.py +118 -0
  63. tigrbl/session/shortcuts.py +50 -0
  64. tigrbl/session/spec.py +112 -0
  65. tigrbl/system/__init__.py +2 -1
  66. tigrbl/system/uvicorn.py +60 -0
  67. tigrbl/table/_base.py +39 -5
  68. tigrbl/types/__init__.py +3 -7
  69. tigrbl/types/uuid.py +55 -0
  70. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info}/METADATA +19 -4
  71. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info}/RECORD +73 -55
  72. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info}/WHEEL +1 -1
  73. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.2.dist-info/licenses}/LICENSE +0 -0
@@ -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
@@ -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
@@ -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
- return _orjson.dumps(
24
- obj, option=_orjson.OPT_NON_STR_KEYS | _orjson.OPT_SERIALIZE_NUMPY
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=str,
60
+ default=_json_default,
34
61
  ).encode("utf-8")
35
62
 
36
63
 
@@ -23,7 +23,6 @@ RunFn = Callable[[Optional[object], Any], None]
23
23
  #: { (domain, subject): (anchor, runner) }
24
24
  REGISTRY: Dict[Tuple[str, str], Tuple[str, RunFn]] = {}
25
25
 
26
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
27
26
  logger = logging.getLogger("uvicorn")
28
27
 
29
28
 
@@ -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
- self._atoms_cache: Optional[list[_DiscoveredAtom]] = (
267
- list(atoms) if atoms else None
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: _WeakMaybeDict[Any, Dict[Tuple[type, str], OpView]] = (
271
- _WeakMaybeDict()
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: Dict[Tuple[type, str], OpView] = self._opviews.setdefault(app, {})
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 .kernel import _default_kernel as K # single, app-scoped kernel
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 K.get_opview(app, model, alias)
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 K._compile_opview_from_specs(specs, SimpleNamespace(alias=alias))
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(kinds or {})
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
@@ -7,7 +7,6 @@ from typing import Dict, Optional
7
7
 
8
8
  from ..config.constants import TIGRBL_SCHEMA_DECLS_ATTR
9
9
 
10
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
11
10
  logger = logging.getLogger("uvicorn")
12
11
 
13
12
 
@@ -5,7 +5,6 @@ from typing import Literal, Optional, Type
5
5
 
6
6
  from pydantic import BaseModel
7
7
 
8
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
9
8
  logger = logging.getLogger("uvicorn")
10
9
 
11
10
 
tigrbl/schema/utils.py CHANGED
@@ -5,7 +5,6 @@ from typing import Any, Dict, List, Type
5
5
 
6
6
  from pydantic import BaseModel, ConfigDict, Field, RootModel, create_model
7
7
 
8
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
9
8
  logger = logging.getLogger("uvicorn")
10
9
 
11
10
 
@@ -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
+ """