tigrbl 0.3.0.dev4__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.dev4.dist-info → tigrbl-0.3.1.dist-info}/METADATA +19 -4
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.1.dist-info}/RECORD +44 -27
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.1.dist-info}/WHEEL +1 -1
- {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.1.dist-info/licenses}/LICENSE +0 -0
tigrbl/engine/engine_spec.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# tigrbl/v3/engine/engine_spec.py
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from dataclasses import dataclass, field
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
5
|
from typing import Optional, Mapping, Union, Any, Tuple
|
|
6
6
|
from urllib.parse import urlsplit, urlunsplit
|
|
7
7
|
|
|
@@ -14,7 +14,6 @@ from .builders import (
|
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
# The value stored by @engine_ctx on App/API/Table/Op.
|
|
17
|
-
# Accept either a DSN string, structured mapping, or pre-built objects.
|
|
18
17
|
EngineCfg = Union[str, Mapping[str, object], "EngineSpec", Provider, Engine]
|
|
19
18
|
|
|
20
19
|
|
|
@@ -31,28 +30,21 @@ class EngineSpec:
|
|
|
31
30
|
"postgresql://user:pwd@host:5432/db" ,
|
|
32
31
|
"postgresql+asyncpg://user:pwd@host:5432/db"
|
|
33
32
|
• Mapping (recommended for clarity/portability):
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# sqlite:
|
|
38
|
-
"path": "./file.db", # file-backed
|
|
39
|
-
"mode": "memory" | None, # memory uses StaticPool
|
|
40
|
-
# postgres:
|
|
41
|
-
"user": "app", "pwd": "secret",
|
|
42
|
-
"host": "localhost", "port": 5432, "db": "app_db",
|
|
43
|
-
"pool_size": 10, "max": 20 # max_overflow (sync) or max_size (async)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
This class *does not* open connections; call .to_provider() to obtain
|
|
47
|
-
a lazy Provider that builds (engine, sessionmaker) on first use.
|
|
33
|
+
{"kind":"sqlite","async":True,"path":"./file.db"}
|
|
34
|
+
{"kind":"postgres","async":True,"host":"db","db":"app_db",...}
|
|
35
|
+
{<external kind> ...} # for plugin engines
|
|
48
36
|
"""
|
|
49
37
|
|
|
50
|
-
# normalized
|
|
51
|
-
kind: Optional[str] = None # "sqlite" | "postgres"
|
|
38
|
+
# normalized
|
|
39
|
+
kind: Optional[str] = None # "sqlite" | "postgres" | <external>
|
|
52
40
|
async_: bool = False
|
|
53
41
|
|
|
42
|
+
# canonical DSN (optional) and raw mapping (for external engines)
|
|
43
|
+
dsn: Optional[str] = None
|
|
44
|
+
mapping: Optional[Mapping[str, object]] = None
|
|
45
|
+
|
|
54
46
|
# sqlite
|
|
55
|
-
path: Optional[str] = None # file path (None
|
|
47
|
+
path: Optional[str] = None # file path (None → memory)
|
|
56
48
|
memory: bool = False
|
|
57
49
|
|
|
58
50
|
# postgres
|
|
@@ -64,44 +56,11 @@ class EngineSpec:
|
|
|
64
56
|
pool_size: int = 10
|
|
65
57
|
max: int = 20 # max_overflow (sync) or max_size (async)
|
|
66
58
|
|
|
67
|
-
#
|
|
68
|
-
dsn: Optional[str] = None
|
|
69
|
-
mapping: Optional[Mapping[str, object]] = field(default=None, repr=False)
|
|
70
|
-
|
|
71
|
-
def __repr__(self) -> str: # pragma: no cover - representation logic
|
|
72
|
-
parts = []
|
|
73
|
-
for f in fields(self):
|
|
74
|
-
if not f.repr:
|
|
75
|
-
continue
|
|
76
|
-
value = getattr(self, f.name)
|
|
77
|
-
if f.name == "dsn":
|
|
78
|
-
value = self._redact_dsn(value)
|
|
79
|
-
if value is not None:
|
|
80
|
-
parts.append(f"{f.name}={value!r}")
|
|
81
|
-
return f"EngineSpec({', '.join(parts)})"
|
|
82
|
-
|
|
83
|
-
@staticmethod
|
|
84
|
-
def _redact_dsn(dsn: Optional[str]) -> Optional[str]:
|
|
85
|
-
if not dsn:
|
|
86
|
-
return dsn
|
|
87
|
-
try:
|
|
88
|
-
parts = urlsplit(dsn)
|
|
89
|
-
if parts.password:
|
|
90
|
-
netloc = parts.netloc.replace(parts.password, "***")
|
|
91
|
-
return urlunsplit(
|
|
92
|
-
(parts.scheme, netloc, parts.path, parts.query, parts.fragment)
|
|
93
|
-
)
|
|
94
|
-
except Exception:
|
|
95
|
-
pass
|
|
96
|
-
return dsn
|
|
97
|
-
|
|
98
|
-
# ---------- parsing / normalization ----------
|
|
59
|
+
# ---------- parsing ----------
|
|
99
60
|
|
|
100
61
|
@staticmethod
|
|
101
62
|
def from_any(x: EngineCfg | None) -> Optional["EngineSpec"]:
|
|
102
|
-
"""
|
|
103
|
-
Parse a DSN or mapping (as attached by @engine_ctx) into an EngineSpec.
|
|
104
|
-
"""
|
|
63
|
+
"""Parse DSN/Mapping/Provider/Engine into an :class:`EngineSpec`."""
|
|
105
64
|
if x is None:
|
|
106
65
|
return None
|
|
107
66
|
|
|
@@ -114,39 +73,57 @@ class EngineSpec:
|
|
|
114
73
|
if isinstance(x, Engine):
|
|
115
74
|
return x.spec
|
|
116
75
|
|
|
117
|
-
#
|
|
76
|
+
# DSN string
|
|
118
77
|
if isinstance(x, str):
|
|
119
78
|
s = x.strip()
|
|
120
|
-
# sqlite
|
|
121
|
-
if s
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
79
|
+
# sqlite async
|
|
80
|
+
if s.startswith("sqlite+aiosqlite://") or s.startswith("sqlite+aiosqlite:"):
|
|
81
|
+
path = urlsplit(s).path or ""
|
|
82
|
+
if s.startswith("sqlite+aiosqlite:////"):
|
|
83
|
+
if path.startswith("//"):
|
|
84
|
+
path = path[1:]
|
|
85
|
+
path = path or None
|
|
86
|
+
else:
|
|
87
|
+
path = path.lstrip("/") or None
|
|
88
|
+
mem = path in {None, ":memory:", "/:memory:"} or s.endswith(":memory:")
|
|
130
89
|
return EngineSpec(
|
|
131
|
-
kind="sqlite", async_=True, path=
|
|
90
|
+
kind="sqlite", async_=True, path=path, memory=mem, dsn=s
|
|
132
91
|
)
|
|
133
|
-
# sqlite sync
|
|
134
|
-
if s.startswith("sqlite
|
|
92
|
+
# sqlite sync
|
|
93
|
+
if s.startswith("sqlite://") or s.startswith("sqlite:"):
|
|
94
|
+
# handle sqlite://:memory: and sqlite:///file.db
|
|
95
|
+
if s.startswith("sqlite://:memory:") or s.endswith(":memory:"):
|
|
96
|
+
return EngineSpec(
|
|
97
|
+
kind="sqlite", async_=False, path=None, memory=True, dsn=s
|
|
98
|
+
)
|
|
99
|
+
# Take the path part after scheme; urlsplit handles both sqlite:// and sqlite:/// forms
|
|
100
|
+
p = urlsplit(s).path or ""
|
|
101
|
+
if s.startswith("sqlite:////"):
|
|
102
|
+
if p.startswith("//"):
|
|
103
|
+
p = p[1:]
|
|
104
|
+
p = p or None
|
|
105
|
+
else:
|
|
106
|
+
p = p.lstrip("/") or None
|
|
107
|
+
mem = p is None
|
|
135
108
|
return EngineSpec(
|
|
136
|
-
kind="sqlite", async_=False, path=
|
|
109
|
+
kind="sqlite", async_=False, path=p, memory=mem, dsn=s
|
|
137
110
|
)
|
|
111
|
+
|
|
138
112
|
# postgres async
|
|
139
|
-
if s.startswith("postgresql+asyncpg://")
|
|
113
|
+
if s.startswith("postgresql+asyncpg://") or s.startswith(
|
|
114
|
+
"postgres+asyncpg://"
|
|
115
|
+
):
|
|
140
116
|
return EngineSpec(kind="postgres", async_=True, dsn=s)
|
|
141
117
|
# postgres sync
|
|
142
|
-
if s.startswith("postgresql://"):
|
|
118
|
+
if s.startswith("postgresql://") or s.startswith("postgres://"):
|
|
143
119
|
return EngineSpec(kind="postgres", async_=False, dsn=s)
|
|
120
|
+
|
|
144
121
|
raise ValueError(f"Unsupported DSN: {s}")
|
|
145
122
|
|
|
146
123
|
# Mapping
|
|
147
124
|
m = x # type: ignore[assignment]
|
|
148
125
|
|
|
149
|
-
#
|
|
126
|
+
# Helpers
|
|
150
127
|
def _get_bool(key: str, *aliases: str, default: bool = False) -> bool:
|
|
151
128
|
for k in (key, *aliases):
|
|
152
129
|
if k in m:
|
|
@@ -166,14 +143,16 @@ class EngineSpec:
|
|
|
166
143
|
) -> Optional[int]:
|
|
167
144
|
for k in (key, *aliases):
|
|
168
145
|
if k in m and m[k] is not None:
|
|
169
|
-
|
|
146
|
+
try:
|
|
147
|
+
return int(m[k]) # type: ignore[index]
|
|
148
|
+
except Exception:
|
|
149
|
+
return default
|
|
170
150
|
return default
|
|
171
151
|
|
|
172
152
|
k = str(m.get("kind", m.get("engine", ""))).lower() # type: ignore[index]
|
|
173
153
|
if k == "sqlite":
|
|
174
154
|
async_ = _get_bool("async", "async_", default=False)
|
|
175
155
|
path = _get_str("path")
|
|
176
|
-
# support either {"mode": "memory"} or {"memory": True} or no path
|
|
177
156
|
memory = (
|
|
178
157
|
_get_bool("memory", default=False)
|
|
179
158
|
or (str(m.get("mode", "")).lower() == "memory")
|
|
@@ -182,8 +161,9 @@ class EngineSpec:
|
|
|
182
161
|
return EngineSpec(
|
|
183
162
|
kind="sqlite",
|
|
184
163
|
async_=async_,
|
|
185
|
-
path=
|
|
164
|
+
path=path,
|
|
186
165
|
memory=memory,
|
|
166
|
+
dsn=_get_str("dsn", "url"),
|
|
187
167
|
mapping=m,
|
|
188
168
|
)
|
|
189
169
|
|
|
@@ -199,10 +179,17 @@ class EngineSpec:
|
|
|
199
179
|
name=_get_str("db", "name"),
|
|
200
180
|
pool_size=_get_int("pool_size", default=10) or 10,
|
|
201
181
|
max=_get_int("max", "max_overflow", "max_size", default=20) or 20,
|
|
182
|
+
dsn=_get_str("dsn", "url"),
|
|
202
183
|
mapping=m,
|
|
203
184
|
)
|
|
204
185
|
|
|
205
|
-
|
|
186
|
+
# External / unknown kinds – keep mapping and defer to registry at build()
|
|
187
|
+
return EngineSpec(
|
|
188
|
+
kind=k or None,
|
|
189
|
+
async_=_get_bool("async", "async_", default=False),
|
|
190
|
+
dsn=_get_str("dsn", "url"),
|
|
191
|
+
mapping=m,
|
|
192
|
+
)
|
|
206
193
|
|
|
207
194
|
# ---------- realization ----------
|
|
208
195
|
|
|
@@ -222,40 +209,147 @@ class EngineSpec:
|
|
|
222
209
|
if self.kind == "postgres":
|
|
223
210
|
if self.dsn:
|
|
224
211
|
if self.async_:
|
|
225
|
-
return async_postgres_engine(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
kwargs = {
|
|
237
|
-
"pool_size": self.pool_size,
|
|
212
|
+
return async_postgres_engine(dsn=self.dsn)
|
|
213
|
+
return blocking_postgres_engine(dsn=self.dsn)
|
|
214
|
+
# keyword build
|
|
215
|
+
kwargs: dict[str, Any] = {
|
|
216
|
+
"user": self.user or "app",
|
|
217
|
+
"host": self.host or "localhost",
|
|
218
|
+
"port": self.port or 5432,
|
|
219
|
+
"db": self.name or "app_db",
|
|
220
|
+
"pool_size": int(self.pool_size or 10),
|
|
238
221
|
}
|
|
239
|
-
if self.async_:
|
|
240
|
-
kwargs["max_size"] = self.max
|
|
241
|
-
else:
|
|
242
|
-
kwargs["max_overflow"] = self.max
|
|
243
|
-
if self.user is not None:
|
|
244
|
-
kwargs["user"] = self.user
|
|
245
222
|
if self.pwd is not None:
|
|
246
223
|
kwargs["pwd"] = self.pwd
|
|
247
|
-
if self.host is not None:
|
|
248
|
-
kwargs["host"] = self.host
|
|
249
|
-
if self.port is not None:
|
|
250
|
-
kwargs["port"] = self.port
|
|
251
|
-
if self.name is not None:
|
|
252
|
-
kwargs["db"] = self.name
|
|
253
224
|
if self.async_:
|
|
225
|
+
kwargs["max_size"] = int(self.max or 20)
|
|
254
226
|
return async_postgres_engine(**kwargs)
|
|
255
|
-
|
|
227
|
+
else:
|
|
228
|
+
kwargs["max_overflow"] = int(self.max or 20)
|
|
229
|
+
return blocking_postgres_engine(**kwargs)
|
|
230
|
+
|
|
231
|
+
# External/registered engines
|
|
232
|
+
try:
|
|
233
|
+
from .plugins import load_engine_plugins
|
|
234
|
+
from .registry import get_engine_registration, known_engine_kinds
|
|
235
|
+
|
|
236
|
+
load_engine_plugins()
|
|
237
|
+
reg = get_engine_registration(self.kind or "")
|
|
238
|
+
except Exception:
|
|
239
|
+
reg = None
|
|
240
|
+
if reg:
|
|
241
|
+
mapping = self.mapping or {}
|
|
242
|
+
return reg.build(mapping=mapping, spec=self, dsn=self.dsn)
|
|
243
|
+
|
|
244
|
+
# No registration found: helpful error
|
|
245
|
+
try:
|
|
246
|
+
from .registry import known_engine_kinds # re-import defensive
|
|
247
|
+
|
|
248
|
+
kinds = ", ".join(known_engine_kinds()) or "(none)"
|
|
249
|
+
except Exception:
|
|
250
|
+
kinds = "(unknown)"
|
|
251
|
+
raise RuntimeError(
|
|
252
|
+
f"Unknown or unavailable engine kind '{self.kind}'. Installed engine kinds: {kinds}. "
|
|
253
|
+
f"If this is an optional extension, install its package (e.g., 'pip install tigrbl_engine_{self.kind}')."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def supports(self) -> dict[str, Any]:
|
|
257
|
+
"""Return capability dictionary for this engine spec.
|
|
258
|
+
For external kinds, consult the plugin registry if available.
|
|
259
|
+
"""
|
|
260
|
+
# Built-ins
|
|
261
|
+
if self.kind == "sqlite":
|
|
262
|
+
try:
|
|
263
|
+
from .capabilities import sqlite_capabilities
|
|
264
|
+
|
|
265
|
+
return sqlite_capabilities(async_=self.async_, memory=self.memory)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
if self.kind == "postgres":
|
|
269
|
+
try:
|
|
270
|
+
from .capabilities import postgres_capabilities
|
|
271
|
+
|
|
272
|
+
return postgres_capabilities(async_=self.async_)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
# External/registered engines
|
|
276
|
+
try:
|
|
277
|
+
from .plugins import load_engine_plugins
|
|
278
|
+
from .registry import get_engine_registration
|
|
256
279
|
|
|
257
|
-
|
|
280
|
+
load_engine_plugins()
|
|
281
|
+
reg = get_engine_registration(self.kind or "")
|
|
282
|
+
except Exception:
|
|
283
|
+
reg = None
|
|
284
|
+
if reg and getattr(reg, "capabilities", None):
|
|
285
|
+
try:
|
|
286
|
+
# Try flexible signature: capabilities(spec=..., mapping=...)
|
|
287
|
+
return reg.capabilities(spec=self, mapping=self.mapping)
|
|
288
|
+
except TypeError:
|
|
289
|
+
try:
|
|
290
|
+
return reg.capabilities()
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
# Fallback minimal shape
|
|
296
|
+
return {
|
|
297
|
+
"transactional": False,
|
|
298
|
+
"async_native": bool(self.async_),
|
|
299
|
+
"isolation_levels": set(),
|
|
300
|
+
"read_only_enforced": False,
|
|
301
|
+
"engine": self.kind or "unknown",
|
|
302
|
+
}
|
|
258
303
|
|
|
259
304
|
def to_provider(self) -> Provider:
|
|
260
305
|
"""Materialize a lazy :class:`Provider` for this spec."""
|
|
261
306
|
return Provider(self)
|
|
307
|
+
|
|
308
|
+
def __repr__(self) -> str: # pragma: no cover - deterministic output
|
|
309
|
+
def _redact_dsn(dsn: Optional[str]) -> Optional[str]:
|
|
310
|
+
if not dsn:
|
|
311
|
+
return dsn
|
|
312
|
+
try:
|
|
313
|
+
parts = urlsplit(dsn)
|
|
314
|
+
except Exception:
|
|
315
|
+
return dsn
|
|
316
|
+
if not parts.scheme or parts.password is None:
|
|
317
|
+
return dsn
|
|
318
|
+
user = parts.username or ""
|
|
319
|
+
userinfo = f"{user}:***" if user else "***"
|
|
320
|
+
host = parts.hostname or ""
|
|
321
|
+
netloc = f"{userinfo}@{host}" if host else userinfo
|
|
322
|
+
if parts.port is not None:
|
|
323
|
+
netloc = f"{netloc}:{parts.port}"
|
|
324
|
+
return urlunsplit(
|
|
325
|
+
(parts.scheme, netloc, parts.path, parts.query, parts.fragment)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _redact_mapping(
|
|
329
|
+
mapping: Optional[Mapping[str, object]],
|
|
330
|
+
) -> Optional[dict[str, object]]:
|
|
331
|
+
if mapping is None:
|
|
332
|
+
return None
|
|
333
|
+
redacted: dict[str, object] = {}
|
|
334
|
+
for key, value in mapping.items():
|
|
335
|
+
if str(key).lower() in {"pwd", "password", "pass", "secret"}:
|
|
336
|
+
redacted[key] = "***"
|
|
337
|
+
else:
|
|
338
|
+
redacted[key] = value
|
|
339
|
+
return redacted
|
|
340
|
+
|
|
341
|
+
fields = [
|
|
342
|
+
("kind", self.kind),
|
|
343
|
+
("async_", self.async_),
|
|
344
|
+
("dsn", _redact_dsn(self.dsn)),
|
|
345
|
+
("mapping", _redact_mapping(self.mapping)),
|
|
346
|
+
("path", self.path),
|
|
347
|
+
("memory", self.memory),
|
|
348
|
+
("user", self.user),
|
|
349
|
+
("host", self.host),
|
|
350
|
+
("port", self.port),
|
|
351
|
+
("name", self.name),
|
|
352
|
+
("pool_size", self.pool_size),
|
|
353
|
+
("max", self.max),
|
|
354
|
+
]
|
|
355
|
+
return "EngineSpec(" + ", ".join(f"{k}={v!r}" for k, v in fields) + ")"
|
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/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
|
|
|
@@ -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)
|