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
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
@@ -8,5 +8,6 @@ Tigrbl v3 – System/Diagnostics helpers.
8
8
  from __future__ import annotations
9
9
 
10
10
  from .diagnostics import mount_diagnostics
11
+ from .uvicorn import stop_uvicorn_server
11
12
 
12
- __all__ = ["mount_diagnostics"]
13
+ __all__ = ["mount_diagnostics", "stop_uvicorn_server"]
@@ -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 name.
188
+ # 0) Remove any previously registered class with the same module path.
178
189
  try:
179
190
  reg = Base.registry._class_registry
180
- keys = [cls.__name__, f"{cls.__module__}.{cls.__name__}"]
181
- existing = next((reg.get(k) for k in keys if reg.get(k) is not None), None)
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
- for k in keys:
188
- reg.pop(k, None)
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",