tigrbl 0.3.0.dev3__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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/rest/collection.py +24 -3
  7. tigrbl/bindings/rest/common.py +4 -0
  8. tigrbl/bindings/rest/io_headers.py +49 -0
  9. tigrbl/bindings/rest/member.py +19 -0
  10. tigrbl/bindings/rest/router.py +4 -0
  11. tigrbl/bindings/rest/routing.py +21 -1
  12. tigrbl/column/io_spec.py +3 -0
  13. tigrbl/engine/__init__.py +19 -0
  14. tigrbl/engine/_engine.py +14 -0
  15. tigrbl/engine/capabilities.py +29 -0
  16. tigrbl/engine/decorators.py +3 -1
  17. tigrbl/engine/docs/PLUGINS.md +49 -0
  18. tigrbl/engine/engine_spec.py +197 -103
  19. tigrbl/engine/plugins.py +52 -0
  20. tigrbl/engine/registry.py +36 -0
  21. tigrbl/orm/mixins/upsertable.py +7 -0
  22. tigrbl/response/shortcuts.py +31 -4
  23. tigrbl/runtime/atoms/response/__init__.py +2 -0
  24. tigrbl/runtime/atoms/response/headers_from_payload.py +57 -0
  25. tigrbl/runtime/kernel.py +27 -11
  26. tigrbl/runtime/opview.py +5 -3
  27. tigrbl/schema/collect.py +26 -2
  28. tigrbl/session/README.md +14 -0
  29. tigrbl/session/__init__.py +28 -0
  30. tigrbl/session/abc.py +76 -0
  31. tigrbl/session/base.py +151 -0
  32. tigrbl/session/decorators.py +43 -0
  33. tigrbl/session/default.py +118 -0
  34. tigrbl/session/shortcuts.py +50 -0
  35. tigrbl/session/spec.py +112 -0
  36. tigrbl/system/__init__.py +2 -1
  37. tigrbl/system/uvicorn.py +60 -0
  38. tigrbl/table/_base.py +28 -5
  39. tigrbl/types/__init__.py +3 -7
  40. tigrbl/types/uuid.py +55 -0
  41. {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info}/METADATA +19 -4
  42. {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info}/RECORD +44 -27
  43. {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info}/WHEEL +1 -1
  44. {tigrbl-0.3.0.dev3.dist-info → tigrbl-0.3.1.dist-info/licenses}/LICENSE +0 -0
tigrbl/api/_api.py CHANGED
@@ -6,7 +6,9 @@ from types import SimpleNamespace
6
6
  from ..deps.fastapi import APIRouter as ApiRouter
7
7
  from ..engine.engine_spec import EngineCfg
8
8
  from ..engine import install_from_objects
9
+ from ..ddl import initialize as _ddl_initialize
9
10
  from ..engine import resolver as _resolver
11
+ from ..app._model_registry import initialize_model_registry
10
12
  from .api_spec import APISpec
11
13
 
12
14
 
@@ -41,7 +43,7 @@ class Api(APISpec, ApiRouter):
41
43
  self.deps = tuple(getattr(self, "DEPS", ()))
42
44
  self.response = getattr(self, "RESPONSE", None)
43
45
  # ``models`` is expected to be a dict at runtime for registry lookups.
44
- self.models: dict[str, type] = {}
46
+ self.models = initialize_model_registry(getattr(self, "MODELS", ()))
45
47
 
46
48
  ApiRouter.__init__(
47
49
  self,
@@ -70,3 +72,26 @@ class Api(APISpec, ApiRouter):
70
72
  install_from_objects(app=self, api=a, models=models)
71
73
  else:
72
74
  install_from_objects(app=self, api=None, models=models)
75
+
76
+ def _collect_tables(self) -> list[Any]:
77
+ seen = set()
78
+ tables = []
79
+ for model in self.models.values():
80
+ if not hasattr(model, "__table__"):
81
+ try: # pragma: no cover - defensive remap
82
+ from ..table import Base
83
+ from ..table._base import _materialize_colspecs_to_sqla
84
+
85
+ _materialize_colspecs_to_sqla(model)
86
+ Base.registry.map_declaratively(model)
87
+ except Exception:
88
+ pass
89
+ table = getattr(model, "__table__", None)
90
+ if table is not None and not table.columns:
91
+ continue
92
+ if table is not None and table not in seen:
93
+ seen.add(table)
94
+ tables.append(table)
95
+ return tables
96
+
97
+ initialize = _ddl_initialize
tigrbl/api/tigrbl_api.py CHANGED
@@ -32,6 +32,7 @@ from ..bindings.rest import build_router_and_attach as _build_router_and_attach
32
32
  from ..transport import mount_jsonrpc as _mount_jsonrpc
33
33
  from ..system import mount_diagnostics as _mount_diagnostics
34
34
  from ..op import get_registry, OpSpec
35
+ from ..app._model_registry import initialize_model_registry
35
36
 
36
37
 
37
38
  class TigrblApi(_Api):
@@ -73,7 +74,7 @@ class TigrblApi(_Api):
73
74
  self.system_prefix = system_prefix
74
75
 
75
76
  # public containers (mirrors used by bindings.api)
76
- self.models: Dict[str, type] = {}
77
+ self.models = initialize_model_registry(getattr(self, "MODELS", ()))
77
78
  self.schemas = SimpleNamespace()
78
79
  self.handlers = SimpleNamespace()
79
80
  self.hooks = SimpleNamespace()
@@ -231,6 +232,8 @@ class TigrblApi(_Api):
231
232
  ) -> None:
232
233
  if authn is not None:
233
234
  self._authn = authn
235
+ if allow_anon is None:
236
+ allow_anon = False
234
237
  if allow_anon is not None:
235
238
  self._allow_anon = bool(allow_anon)
236
239
  if authorize is not None:
@@ -270,6 +273,8 @@ class TigrblApi(_Api):
270
273
  tables = []
271
274
  for m in self.models.values():
272
275
  t = getattr(m, "__table__", None)
276
+ if t is not None and not t.columns:
277
+ continue
273
278
  if t is not None and t not in seen:
274
279
  seen.add(t)
275
280
  tables.append(t)
tigrbl/app/_app.py CHANGED
@@ -6,6 +6,8 @@ from ..deps.fastapi import FastAPI
6
6
  from ..engine.engine_spec import EngineCfg
7
7
  from ..engine import resolver as _resolver
8
8
  from ..engine import install_from_objects
9
+ from ..ddl import initialize as _ddl_initialize
10
+ from ._model_registry import initialize_model_registry
9
11
  from .app_spec import AppSpec
10
12
 
11
13
 
@@ -23,7 +25,7 @@ class App(AppSpec, FastAPI):
23
25
  self.ops = tuple(getattr(self, "OPS", ()))
24
26
  # Runtime registries use mutable containers (dict/namespace), but the
25
27
  # dataclass fields expect sequences. Storing a dict here satisfies both.
26
- self.models = {}
28
+ self.models = initialize_model_registry(getattr(self, "MODELS", ()))
27
29
  self.schemas = tuple(getattr(self, "SCHEMAS", ()))
28
30
  self.hooks = tuple(getattr(self, "HOOKS", ()))
29
31
  self.security_deps = tuple(getattr(self, "SECURITY_DEPS", ()))
@@ -59,3 +61,26 @@ class App(AppSpec, FastAPI):
59
61
  install_from_objects(app=self, api=a, models=models)
60
62
  else:
61
63
  install_from_objects(app=self, api=None, models=models)
64
+
65
+ def _collect_tables(self) -> list[Any]:
66
+ seen = set()
67
+ tables = []
68
+ for model in self.models.values():
69
+ if not hasattr(model, "__table__"):
70
+ try: # pragma: no cover - defensive remap
71
+ from ..table import Base
72
+ from ..table._base import _materialize_colspecs_to_sqla
73
+
74
+ _materialize_colspecs_to_sqla(model)
75
+ Base.registry.map_declaratively(model)
76
+ except Exception:
77
+ pass
78
+ table = getattr(model, "__table__", None)
79
+ if table is not None and not table.columns:
80
+ continue
81
+ if table is not None and table not in seen:
82
+ seen.add(table)
83
+ tables.append(table)
84
+ return tables
85
+
86
+ initialize = _ddl_initialize
@@ -0,0 +1,41 @@
1
+ """Utilities for initializing model registries on App and API facades."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable
6
+
7
+
8
+ def initialize_model_registry(models: Iterable[Any]) -> dict[str, Any]:
9
+ """Build the default ``models`` mapping for an App or Api instance.
10
+
11
+ ``defineAppSpec``/``defineApiSpec`` allow authors to declare default models
12
+ using bare model classes or ``("alias", Model)`` tuples. Runtime facades,
13
+ however, expect ``self.models`` to be a dictionary keyed by model name so
14
+ that lookups like ``app.models["Widget"]`` Just Work.
15
+
16
+ This helper normalizes the declared sequence into that dictionary shape and
17
+ preserves declaration order. When an alias is provided we register both the
18
+ alias and the model's ``__name__`` so either lookup style succeeds.
19
+ """
20
+
21
+ registry: dict[str, Any] = {}
22
+
23
+ for entry in models or ():
24
+ # Support ``("Alias", Model)`` declarations in addition to bare models.
25
+ if isinstance(entry, tuple) and len(entry) == 2 and isinstance(entry[0], str):
26
+ alias, model = entry
27
+ registry[alias] = model
28
+ model_name = getattr(model, "__name__", alias)
29
+ registry.setdefault(model_name, model)
30
+ continue
31
+
32
+ model = entry
33
+ model_name = getattr(model, "__name__", None)
34
+ if model_name is None:
35
+ model_name = str(model)
36
+ registry[model_name] = model
37
+
38
+ return registry
39
+
40
+
41
+ __all__ = ["initialize_model_registry"]
tigrbl/app/tigrbl_app.py CHANGED
@@ -32,6 +32,7 @@ from ..bindings.rest import build_router_and_attach as _build_router_and_attach
32
32
  from ..transport import mount_jsonrpc as _mount_jsonrpc
33
33
  from ..system import mount_diagnostics as _mount_diagnostics
34
34
  from ..op import get_registry, OpSpec
35
+ from ._model_registry import initialize_model_registry
35
36
 
36
37
 
37
38
  # optional compat: legacy transactional decorator
@@ -93,7 +94,7 @@ class TigrblApp(_App):
93
94
  self.system_prefix = system_prefix
94
95
 
95
96
  # public containers (mirrors used by bindings.api)
96
- self.models: Dict[str, type] = {}
97
+ self.models = initialize_model_registry(getattr(self, "MODELS", ()))
97
98
  self.schemas = SimpleNamespace()
98
99
  self.handlers = SimpleNamespace()
99
100
  self.hooks = SimpleNamespace()
@@ -262,6 +263,8 @@ class TigrblApp(_App):
262
263
  ) -> None:
263
264
  if authn is not None:
264
265
  self._authn = authn
266
+ if allow_anon is None:
267
+ allow_anon = False
265
268
  if allow_anon is not None:
266
269
  self._allow_anon = bool(allow_anon)
267
270
  if authorize is not None:
@@ -301,6 +304,8 @@ class TigrblApp(_App):
301
304
  tables = []
302
305
  for m in self.models.values():
303
306
  t = getattr(m, "__table__", None)
307
+ if t is not None and not t.columns:
308
+ continue
304
309
  if t is not None and t not in seen:
305
310
  seen.add(t)
306
311
  tables.append(t)
@@ -35,6 +35,8 @@ from .common import (
35
35
  _status_for,
36
36
  )
37
37
 
38
+ from .io_headers import _make_header_dep
39
+
38
40
  from ...runtime.executor.types import _Ctx
39
41
 
40
42
 
@@ -103,6 +105,7 @@ def _make_collection_endpoint(
103
105
  ) -> Callable[..., Awaitable[Any]]:
104
106
  alias, target, nested_vars = sp.alias, sp.target, list(nested_vars or [])
105
107
  status_code = _status_for(sp)
108
+ hdr_dep = _make_header_dep(model, alias)
106
109
 
107
110
  if target in {"list", "clear"}:
108
111
  list_dep = _make_list_query_dep(model, alias) if target == "list" else None
@@ -110,6 +113,7 @@ def _make_collection_endpoint(
110
113
  async def _endpoint(
111
114
  request: Request,
112
115
  db: Any = Depends(db_dep),
116
+ h: Mapping[str, Any] = Depends(hdr_dep),
113
117
  q: Mapping[str, Any] | None = None,
114
118
  **kw: Any,
115
119
  ):
@@ -120,6 +124,8 @@ def _make_collection_endpoint(
120
124
  payload = _validate_query(model, alias, target, query)
121
125
  else:
122
126
  payload = dict(parent_kw)
127
+ if isinstance(h, Mapping):
128
+ payload = {**payload, **dict(h)}
123
129
  ctx = _ctx(model, alias, target, request, db, payload, parent_kw, api)
124
130
  ctx["response_serializer"] = lambda r: _serialize_output(
125
131
  model, alias, target, sp, r
@@ -154,16 +160,20 @@ def _make_collection_endpoint(
154
160
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
155
161
  annotation=Annotated[Any, Depends(db_dep)],
156
162
  ),
163
+ inspect.Parameter(
164
+ "h",
165
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
166
+ annotation=Annotated[Mapping[str, Any], Depends(hdr_dep)],
167
+ ),
157
168
  ]
158
169
  )
159
170
  if target == "list":
160
- params.insert(
161
- len(nested_vars) + 1,
171
+ params.append(
162
172
  inspect.Parameter(
163
173
  "q",
164
174
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
165
175
  annotation=Annotated[Mapping[str, Any], Depends(list_dep)],
166
- ),
176
+ )
167
177
  )
168
178
  _endpoint.__signature__ = inspect.Signature(params)
169
179
  else:
@@ -192,12 +202,18 @@ def _make_collection_endpoint(
192
202
  async def _endpoint(
193
203
  request: Request,
194
204
  db: Any = Depends(db_dep),
205
+ h: Mapping[str, Any] = Depends(hdr_dep),
195
206
  body=Body(...),
196
207
  **kw: Any,
197
208
  ):
198
209
  parent_kw = {k: kw[k] for k in nested_vars if k in kw}
199
210
  _coerce_parent_kw(model, parent_kw)
200
211
  payload = _validate_body(model, alias, target, body)
212
+ if isinstance(h, Mapping):
213
+ if isinstance(payload, Mapping):
214
+ payload = {**payload, **dict(h)}
215
+ else:
216
+ payload = [{**dict(item), **dict(h)} for item in payload]
201
217
  is_seq = (
202
218
  target in {"create", "update", "replace", "merge"}
203
219
  and isinstance(payload, Sequence)
@@ -248,6 +264,11 @@ def _make_collection_endpoint(
248
264
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
249
265
  annotation=Annotated[Any, Depends(db_dep)],
250
266
  ),
267
+ inspect.Parameter(
268
+ "h",
269
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
270
+ annotation=Annotated[Mapping[str, Any], Depends(hdr_dep)],
271
+ ),
251
272
  inspect.Parameter(
252
273
  "body",
253
274
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
@@ -45,6 +45,8 @@ from .routing import (
45
45
  _default_path_suffix,
46
46
  _normalize_deps,
47
47
  _normalize_secdeps,
48
+ _require_auth_header,
49
+ _requires_auth_header,
48
50
  _path_for_spec,
49
51
  _request_model_for,
50
52
  _response_model_for,
@@ -106,6 +108,8 @@ __all__ = [
106
108
  "_optionalize_list_in_model",
107
109
  "_normalize_deps",
108
110
  "_normalize_secdeps",
111
+ "_require_auth_header",
112
+ "_requires_auth_header",
109
113
  "_status_for",
110
114
  "_RESPONSES_META",
111
115
  "_DEFAULT_METHODS",
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Mapping, Any
5
+
6
+ from fastapi import Header
7
+
8
+
9
+ def _build_signature_with_header_params(
10
+ hdr_fields: list[tuple[str, str, bool]],
11
+ ) -> inspect.Signature:
12
+ params: list[inspect.Parameter] = []
13
+ for _field, header, required in hdr_fields:
14
+ param = header.lower().replace("-", "_")
15
+ default = Header(... if required else None, alias=header)
16
+ params.append(
17
+ inspect.Parameter(
18
+ name=param,
19
+ kind=inspect.Parameter.KEYWORD_ONLY,
20
+ default=default,
21
+ annotation=str | None,
22
+ )
23
+ )
24
+ return inspect.Signature(parameters=params, return_annotation=Mapping[str, object])
25
+
26
+
27
+ def _make_header_dep(model: type, alias: str):
28
+ hdr_fields: list[tuple[str, str, bool]] = []
29
+ for name, spec in getattr(model, "__tigrbl_cols__", {}).items():
30
+ io = getattr(spec, "io", None)
31
+ if not io or not getattr(io, "header_in", None):
32
+ continue
33
+ if alias not in set(getattr(io, "in_verbs", ()) or ()): # honor IO.in_verbs
34
+ continue
35
+ hdr_fields.append(
36
+ (name, io.header_in, bool(getattr(io, "header_required_in", False)))
37
+ )
38
+
39
+ async def _dep(**kw: Any) -> Mapping[str, object]:
40
+ out: dict[str, object] = {}
41
+ for field, header, _req in hdr_fields:
42
+ param = header.lower().replace("-", "_")
43
+ v = kw.get(param)
44
+ if v is not None:
45
+ out[field] = v
46
+ return out
47
+
48
+ _dep.__signature__ = _build_signature_with_header_params(hdr_fields)
49
+ return _dep
@@ -35,6 +35,8 @@ from .common import (
35
35
  _status_for,
36
36
  )
37
37
 
38
+ from .io_headers import _make_header_dep
39
+
38
40
  from ...runtime.executor.types import _Ctx
39
41
 
40
42
 
@@ -58,6 +60,7 @@ def _make_member_endpoint(
58
60
  real_pk = _pk_name(model)
59
61
  pk_names = _pk_names(model)
60
62
  nested_vars = list(nested_vars or [])
63
+ hdr_dep = _make_header_dep(model, alias)
61
64
 
62
65
  # --- No body on GET read / DELETE delete ---
63
66
  if target in {"read", "delete"}:
@@ -66,11 +69,14 @@ def _make_member_endpoint(
66
69
  item_id: Any,
67
70
  request: Request,
68
71
  db: Any = Depends(db_dep),
72
+ h: Mapping[str, Any] = Depends(hdr_dep),
69
73
  **kw: Any,
70
74
  ):
71
75
  parent_kw = {k: kw[k] for k in nested_vars if k in kw}
72
76
  _coerce_parent_kw(model, parent_kw)
73
77
  payload: Mapping[str, Any] = dict(parent_kw)
78
+ if isinstance(h, Mapping):
79
+ payload = {**payload, **dict(h)}
74
80
  path_params = {real_pk: item_id, pk_param: item_id, **parent_kw}
75
81
  ctx: Dict[str, Any] = {
76
82
  "request": request,
@@ -142,6 +148,11 @@ def _make_member_endpoint(
142
148
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
143
149
  annotation=Annotated[Any, Depends(db_dep)],
144
150
  ),
151
+ inspect.Parameter(
152
+ "h",
153
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
154
+ annotation=Annotated[Mapping[str, Any], Depends(hdr_dep)],
155
+ ),
145
156
  ]
146
157
  )
147
158
  _endpoint.__signature__ = inspect.Signature(params)
@@ -257,12 +268,15 @@ def _make_member_endpoint(
257
268
  item_id: Any,
258
269
  request: Request,
259
270
  db: Any = Depends(db_dep),
271
+ h: Mapping[str, Any] = Depends(hdr_dep),
260
272
  body=body_default,
261
273
  **kw: Any,
262
274
  ):
263
275
  parent_kw = {k: kw[k] for k in nested_vars if k in kw}
264
276
  _coerce_parent_kw(model, parent_kw)
265
277
  payload = _validate_body(model, alias, target, body)
278
+ if isinstance(h, Mapping):
279
+ payload = {**payload, **dict(h)}
266
280
 
267
281
  # Enforce path-PK canonicality. If body echoes PK: drop if equal, 409 if mismatch.
268
282
  for k in pk_names:
@@ -350,6 +364,11 @@ def _make_member_endpoint(
350
364
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
351
365
  annotation=Annotated[Any, Depends(db_dep)],
352
366
  ),
367
+ inspect.Parameter(
368
+ "h",
369
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
370
+ annotation=Annotated[Mapping[str, Any], Depends(hdr_dep)],
371
+ ),
353
372
  inspect.Parameter(
354
373
  "body",
355
374
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
@@ -25,6 +25,8 @@ from .common import (
25
25
  _normalize_secdeps,
26
26
  _optionalize_list_in_model,
27
27
  _path_for_spec,
28
+ _require_auth_header,
29
+ _requires_auth_header,
28
30
  _req_state_db,
29
31
  _resource_name,
30
32
  _status,
@@ -265,6 +267,8 @@ def _build_router(
265
267
 
266
268
  secdeps: list[Any] = []
267
269
  if auth_dep and sp.alias not in allow_anon and sp.target not in allow_anon:
270
+ if _requires_auth_header(auth_dep):
271
+ secdeps.append(_require_auth_header)
268
272
  secdeps.append(auth_dep)
269
273
  secdeps.extend(getattr(sp, "secdeps", ()))
270
274
  route_secdeps = _normalize_secdeps(secdeps)
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
+ import inspect
2
3
  import logging
3
4
 
4
5
  from types import SimpleNamespace
5
6
  from typing import Any, Dict, Optional, Sequence, Tuple
6
7
 
7
8
 
8
- from .fastapi import Depends, Security, _status
9
+ from fastapi.security import HTTPBearer
10
+ from .fastapi import Depends, HTTPException, Request, Security, _status
9
11
  from ...op import OpSpec
10
12
  from ...op.types import CANON
11
13
 
@@ -35,6 +37,24 @@ def _normalize_secdeps(secdeps: Optional[Sequence[Any]]) -> list[Any]:
35
37
  return out
36
38
 
37
39
 
40
+ def _requires_auth_header(auth_dep: Any) -> bool:
41
+ try:
42
+ sig = inspect.signature(auth_dep)
43
+ except (TypeError, ValueError):
44
+ return False
45
+ for param in sig.parameters.values():
46
+ default = param.default
47
+ dep = getattr(default, "dependency", None)
48
+ if isinstance(dep, HTTPBearer) and getattr(dep, "auto_error", True):
49
+ return True
50
+ return False
51
+
52
+
53
+ def _require_auth_header(request: Request) -> None:
54
+ if not request.headers.get("Authorization"):
55
+ raise HTTPException(status_code=_status.HTTP_403_FORBIDDEN, detail="Forbidden")
56
+
57
+
38
58
  def _status_for(sp: OpSpec) -> int:
39
59
  if sp.status_code is not None:
40
60
  return sp.status_code
tigrbl/column/io_spec.py CHANGED
@@ -57,6 +57,9 @@ class IOSpec:
57
57
  mutable_verbs: Tuple[str, ...] = ()
58
58
  alias_in: str | None = None
59
59
  alias_out: str | None = None
60
+ header_in: str | None = None # e.g., "X-Worker-Key"
61
+ header_out: str | None = None # e.g., "ETag"
62
+ header_required_in: bool = False
60
63
  sensitive: bool = False
61
64
  redact_last: int | None = None
62
65
  filter_ops: Tuple[str, ...] = ()
tigrbl/engine/__init__.py CHANGED
@@ -24,3 +24,22 @@ __all__ = [
24
24
  "Engine",
25
25
  "engine",
26
26
  ]
27
+
28
+
29
+ # Optional engine plugin support
30
+ from .plugins import load_engine_plugins
31
+ from .registry import register_engine, known_engine_kinds, get_engine_registration
32
+
33
+ # Load external engines automatically on import (idempotent)
34
+ try:
35
+ load_engine_plugins()
36
+ except Exception:
37
+ # Import-time plugin load should never fail the package import
38
+ pass
39
+
40
+ __all__ += [
41
+ "load_engine_plugins",
42
+ "register_engine",
43
+ "known_engine_kinds",
44
+ "get_engine_registration",
45
+ ]
tigrbl/engine/_engine.py CHANGED
@@ -27,6 +27,13 @@ if TYPE_CHECKING: # pragma: no cover - for type checkers only
27
27
 
28
28
  @dataclass(frozen=True)
29
29
  class Provider:
30
+ # supports() exposes engine capabilities for compatibility checks
31
+ def supports(self) -> dict:
32
+ try:
33
+ return self.spec.supports()
34
+ except Exception:
35
+ return {"engine": self.spec.kind or "unknown"}
36
+
30
37
  """Lazily builds an engine + sessionmaker from an :class:`EngineSpec`."""
31
38
 
32
39
  spec: "EngineSpec"
@@ -87,6 +94,13 @@ class Provider:
87
94
 
88
95
  @dataclass
89
96
  class Engine:
97
+ # Delegate to provider/spec for capability reporting
98
+ def supports(self) -> dict:
99
+ try:
100
+ return self.provider.supports()
101
+ except Exception:
102
+ return {"engine": self.spec.kind or "unknown"}
103
+
90
104
  """Thin façade over an :class:`EngineSpec` with convenient (a)context managers."""
91
105
 
92
106
  spec: "EngineSpec"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict
3
+
4
+
5
+ def sqlite_capabilities(*, async_: bool, memory: bool) -> Dict[str, Any]:
6
+ # SQLite supports transactional semantics; isolation semantics differ from server DBs.
7
+ # We expose a conservative set that higher layers can reason about.
8
+ return {
9
+ "transactional": True,
10
+ "async_native": async_, # async layer is provided by aiosqlite/SQLAlchemy AsyncEngine
11
+ "isolation_levels": {"read_committed", "serializable"},
12
+ "read_only_enforced": False, # app layer should enforce; file-open RO may be used externally
13
+ "supports_timeouts": True,
14
+ "supports_ddl": True,
15
+ "engine": "sqlite",
16
+ "memory": bool(memory),
17
+ }
18
+
19
+
20
+ def postgres_capabilities(*, async_: bool) -> Dict[str, Any]:
21
+ return {
22
+ "transactional": True,
23
+ "async_native": async_,
24
+ "isolation_levels": {"read_committed", "repeatable_read", "serializable"},
25
+ "read_only_enforced": True,
26
+ "supports_timeouts": True,
27
+ "supports_ddl": True,
28
+ "engine": "postgres",
29
+ }
@@ -56,7 +56,9 @@ def _normalize(ctx: Optional[EngineCfg] = None, **kw: Any) -> EngineCfg:
56
56
  if k in kw:
57
57
  m[k] = kw[k]
58
58
  else:
59
- raise ValueError("kind must be 'sqlite' or 'postgres'")
59
+ # Allow external engine kinds; pass mapping through unchanged.
60
+ # Keep provided keys as-is so external builders can interpret them.
61
+ m.update({k: v for k, v in kw.items() if k not in m})
60
62
 
61
63
  return m
62
64
 
@@ -0,0 +1,49 @@
1
+
2
+ # Tigrbl Engine Plugins
3
+
4
+ Tigrbl supports external engine kinds via an entry-point group: `tigrbl.engine`.
5
+
6
+ An external package registers itself by exposing a `register()` function and
7
+ declaring an entry point:
8
+
9
+ ```toml
10
+ [project.entry-points."tigrbl.engine"]
11
+ duckdb = "tigrbl_engine_duckdb.plugin:register"
12
+ ```
13
+
14
+ Inside `register()` call:
15
+
16
+ ```python
17
+ from tigrbl.engine.registry import register_engine
18
+ from .builder import duckdb_engine, duckdb_capabilities
19
+
20
+ def register():
21
+ register_engine("duckdb", duckdb_engine, duckdb_capabilities)
22
+ ```
23
+
24
+ At runtime, `EngineSpec(kind="duckdb")` will look up the registration and use
25
+ the external builder or raise a helpful `RuntimeError` if the plugin is not
26
+ installed.
27
+
28
+
29
+ ## Capabilities / supports()
30
+
31
+ External engines should expose a capabilities callable when registering:
32
+
33
+ ```python
34
+ from tigrbl.engine.registry import register_engine
35
+
36
+ def my_engine_builder(...): ...
37
+ def my_engine_capabilities(**kw):
38
+ # Return a dict describing what the engine supports
39
+ return {
40
+ "transactional": True,
41
+ "isolation_levels": {"read_committed","serializable"},
42
+ "read_only_enforced": True,
43
+ "async_native": False,
44
+ "engine": "myengine",
45
+ }
46
+
47
+ def register():
48
+ register_engine("myengine", my_engine_builder, capabilities=my_engine_capabilities)
49
+ ```