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.
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.dev4.dist-info → tigrbl-0.3.1.dist-info}/METADATA +19 -4
  42. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.1.dist-info}/RECORD +44 -27
  43. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.1.dist-info}/WHEEL +1 -1
  44. {tigrbl-0.3.0.dev4.dist-info → tigrbl-0.3.1.dist-info/licenses}/LICENSE +0 -0
@@ -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, fields
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
- "kind": "sqlite" | "postgres",
36
- "async": bool, # default False
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 fields
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 when memory=True)
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
- # raw passthroughs (for diagnostics)
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
- # String DSN
76
+ # DSN string
118
77
  if isinstance(x, str):
119
78
  s = x.strip()
120
- # sqlite memory
121
- if s == "sqlite://:memory:" or s.startswith("sqlite+memory://"):
122
- return EngineSpec(
123
- kind="sqlite",
124
- async_=s.startswith("sqlite+aiosqlite://"),
125
- memory=True,
126
- dsn=s,
127
- )
128
- # sqlite async file
129
- if s.startswith("sqlite+aiosqlite:///"):
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=s.split(":///")[1], dsn=s
90
+ kind="sqlite", async_=True, path=path, memory=mem, dsn=s
132
91
  )
133
- # sqlite sync file
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=s.split(":///")[1], dsn=s
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
- # allow a few common aliases for ergonomics
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
- return int(m[k]) # type: ignore[index]
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=None if memory else 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
- raise ValueError(f"Unsupported provider kind: {k!r}")
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
- dsn=self.dsn,
227
- pool_size=self.pool_size,
228
- max_size=self.max,
229
- )
230
- return blocking_postgres_engine(
231
- dsn=self.dsn,
232
- pool_size=self.pool_size,
233
- max_overflow=self.max,
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
- return blocking_postgres_engine(**kwargs)
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
- raise ValueError("EngineSpec has no kind")
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) + ")"
@@ -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())
@@ -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
 
@@ -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)