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
@@ -7,7 +7,6 @@ import logging
7
7
  from . import AsyncSession, Session
8
8
  from .model import _model_columns, _single_pk_name
9
9
 
10
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
11
10
  logger = logging.getLogger("uvicorn")
12
11
 
13
12
 
@@ -6,7 +6,6 @@ import logging
6
6
 
7
7
  from . import SAEnum
8
8
 
9
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
10
9
  logger = logging.getLogger("uvicorn")
11
10
 
12
11
 
@@ -7,7 +7,6 @@ import logging
7
7
  from . import select, and_, asc, desc
8
8
  from .model import _model_columns, _colspecs
9
9
 
10
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
11
10
  logger = logging.getLogger("uvicorn")
12
11
 
13
12
  _CANON_OPS = {
@@ -6,7 +6,6 @@ import logging
6
6
 
7
7
  from ....column.mro_collect import mro_collect_columns
8
8
 
9
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
10
9
  logger = logging.getLogger("uvicorn")
11
10
 
12
11
 
@@ -6,7 +6,6 @@ import logging
6
6
 
7
7
  from . import AsyncSession, Session
8
8
 
9
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
10
9
  logger = logging.getLogger("uvicorn")
11
10
 
12
11
 
tigrbl/core/crud/ops.py CHANGED
@@ -27,7 +27,6 @@ from .helpers import (
27
27
  _validate_enum_values,
28
28
  )
29
29
 
30
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
31
30
  logger = logging.getLogger("uvicorn")
32
31
 
33
32
 
@@ -0,0 +1,35 @@
1
+ # Tigrbl verbosity and uvicorn logging
2
+
3
+ Tigrbl logs through the `uvicorn` logger. This keeps its output aligned with the
4
+ server's log configuration, so debug statements only appear when uvicorn is
5
+ started in verbose mode.
6
+
7
+ ## Default behavior
8
+
9
+ By default, uvicorn runs at `INFO` level and suppresses debug output. Tigrbl does
10
+ not override that setting, so debug messages stay hidden unless you opt in.
11
+
12
+ ## Enabling verbose output
13
+
14
+ Use uvicorn's log-level to opt into debug output:
15
+
16
+ ```bash
17
+ uvicorn your_module:app --log-level debug
18
+ ```
19
+
20
+ Or configure it programmatically:
21
+
22
+ ```python
23
+ import uvicorn
24
+
25
+ config = uvicorn.Config("your_module:app", log_level="debug")
26
+ server = uvicorn.Server(config)
27
+ ```
28
+
29
+ ## Disabling verbose output
30
+
31
+ Set the log level back to `info` (or higher) to suppress debug messages:
32
+
33
+ ```bash
34
+ uvicorn your_module:app --log-level info
35
+ ```
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"
tigrbl/engine/builders.py CHANGED
@@ -9,7 +9,6 @@ from sqlalchemy.orm import sessionmaker
9
9
 
10
10
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
11
11
 
12
- logging.getLogger("uvicorn").setLevel(logging.DEBUG)
13
12
  logger = logging.getLogger("uvicorn")
14
13
 
15
14
 
@@ -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
+ ```
@@ -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) + ")"