tigrbl-base 0.1.0.dev1__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_base/_base/__init__.py +24 -0
- tigrbl_base/_base/_hook_base.py +25 -0
- tigrbl_base/_base/_middleware_base.py +146 -0
- tigrbl_base/_base/_op_base.py +12 -0
- tigrbl_base/_base/_request_base.py +32 -0
- tigrbl_base/_base/_response_base.py +231 -0
- tigrbl_base/_base/_schema_base.py +16 -0
- tigrbl_base/_base/_security_base.py +69 -0
- tigrbl_base/_base/_session_abc.py +47 -0
- tigrbl_base/_base/_session_base.py +132 -0
- tigrbl_base/_base/_storage.py +13 -0
- tigrbl_base/_base/_table_base.py +435 -0
- tigrbl_base/_base/_table_registry_base.py +55 -0
- tigrbl_base-0.1.0.dev1.dist-info/METADATA +55 -0
- tigrbl_base-0.1.0.dev1.dist-info/RECORD +16 -0
- tigrbl_base-0.1.0.dev1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Base class implementations for tigrbl internals."""
|
|
2
|
+
|
|
3
|
+
from ._hook_base import HookBase
|
|
4
|
+
from ._storage import ForeignKeyBase
|
|
5
|
+
from ._op_base import OpBase
|
|
6
|
+
from ._request_base import RequestBase
|
|
7
|
+
from ._schema_base import SchemaBase
|
|
8
|
+
from ._session_abc import SessionABC
|
|
9
|
+
from ._session_base import TigrblSessionBase
|
|
10
|
+
from ._table_base import TableBase
|
|
11
|
+
from ._table_registry_base import TableRegistryBase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"HookBase",
|
|
16
|
+
"ForeignKeyBase",
|
|
17
|
+
"OpBase",
|
|
18
|
+
"RequestBase",
|
|
19
|
+
"SchemaBase",
|
|
20
|
+
"SessionABC",
|
|
21
|
+
"TigrblSessionBase",
|
|
22
|
+
"TableBase",
|
|
23
|
+
"TableRegistryBase",
|
|
24
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Base runtime hook wrapper for Tigrbl v3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Iterable, Optional, Union
|
|
7
|
+
|
|
8
|
+
from tigrbl_core._spec.hook_spec import HookSpec
|
|
9
|
+
from tigrbl_runtime.runtime.hook_types import HookPhase, StepFn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class HookBase(HookSpec):
|
|
14
|
+
"""Base hook bound to a phase and one or more ops."""
|
|
15
|
+
|
|
16
|
+
phase: HookPhase
|
|
17
|
+
fn: StepFn
|
|
18
|
+
ops: Union[str, Iterable[str]] = "*"
|
|
19
|
+
order: int = 0
|
|
20
|
+
when: Optional[object] = None
|
|
21
|
+
name: Optional[str] = None
|
|
22
|
+
description: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["HookBase"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Request/response middleware base with ``dispatch`` + ``call_next`` semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from tigrbl_concrete._concrete._request import Request
|
|
9
|
+
from tigrbl_concrete._concrete._request_adapters import request_from_asgi
|
|
10
|
+
from tigrbl_concrete._concrete._response import Response
|
|
11
|
+
from tigrbl_atoms.atoms.egress.asgi_send import finalize_transport_response
|
|
12
|
+
|
|
13
|
+
from tigrbl_core._spec.middleware_spec import (
|
|
14
|
+
ASGIReceive,
|
|
15
|
+
ASGISend,
|
|
16
|
+
Message,
|
|
17
|
+
MiddlewareSpec,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MiddlewareBase(MiddlewareSpec):
|
|
22
|
+
"""Base middleware for intercepting HTTP requests in ASGI mode."""
|
|
23
|
+
|
|
24
|
+
async def dispatch(
|
|
25
|
+
self,
|
|
26
|
+
request: Request,
|
|
27
|
+
call_next: Any,
|
|
28
|
+
) -> Response:
|
|
29
|
+
"""Process the request and optionally delegate to downstream middleware/app."""
|
|
30
|
+
|
|
31
|
+
return await call_next(request)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _scope_from_request(scope: dict[str, Any], request: Request) -> dict[str, Any]:
|
|
35
|
+
query_string = urlencode(
|
|
36
|
+
[
|
|
37
|
+
(name, value)
|
|
38
|
+
for name, values in request.query.items()
|
|
39
|
+
for value in values
|
|
40
|
+
],
|
|
41
|
+
doseq=True,
|
|
42
|
+
).encode("latin-1")
|
|
43
|
+
return {
|
|
44
|
+
**scope,
|
|
45
|
+
"method": request.method,
|
|
46
|
+
"path": request.path,
|
|
47
|
+
"query_string": query_string,
|
|
48
|
+
"headers": [
|
|
49
|
+
(key.encode("latin-1"), value.encode("latin-1"))
|
|
50
|
+
for key, value in request.headers.items()
|
|
51
|
+
],
|
|
52
|
+
"root_path": request.script_name,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async def asgi(
|
|
56
|
+
self,
|
|
57
|
+
scope: dict[str, Any],
|
|
58
|
+
receive: ASGIReceive,
|
|
59
|
+
send: ASGISend,
|
|
60
|
+
) -> None:
|
|
61
|
+
if scope.get("type") != "http":
|
|
62
|
+
await self.app(scope, receive, send)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
request_body = b""
|
|
66
|
+
more_body = True
|
|
67
|
+
while more_body:
|
|
68
|
+
message = await receive()
|
|
69
|
+
request_body += message.get("body", b"")
|
|
70
|
+
more_body = message.get("more_body", False)
|
|
71
|
+
|
|
72
|
+
request = request_from_asgi(None, scope, request_body)
|
|
73
|
+
|
|
74
|
+
async def call_next(forward_request: Request | None = None) -> Response:
|
|
75
|
+
target_request = forward_request or request
|
|
76
|
+
target_scope = self._scope_from_request(scope, target_request)
|
|
77
|
+
|
|
78
|
+
messages: list[Message] = []
|
|
79
|
+
body_sent = False
|
|
80
|
+
|
|
81
|
+
async def receive_for_app() -> Message:
|
|
82
|
+
nonlocal body_sent
|
|
83
|
+
if body_sent:
|
|
84
|
+
return {"type": "http.request", "body": b"", "more_body": False}
|
|
85
|
+
body_sent = True
|
|
86
|
+
return {
|
|
87
|
+
"type": "http.request",
|
|
88
|
+
"body": target_request.body,
|
|
89
|
+
"more_body": False,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async def send_from_app(message: Message) -> None:
|
|
93
|
+
messages.append(message)
|
|
94
|
+
|
|
95
|
+
await self.app(target_scope, receive_for_app, send_from_app)
|
|
96
|
+
|
|
97
|
+
start = next(
|
|
98
|
+
message
|
|
99
|
+
for message in messages
|
|
100
|
+
if message.get("type") == "http.response.start"
|
|
101
|
+
)
|
|
102
|
+
raw_headers = list(start.get("headers", []))
|
|
103
|
+
body = b"".join(
|
|
104
|
+
message.get("body", b"")
|
|
105
|
+
for message in messages
|
|
106
|
+
if message.get("type") == "http.response.body"
|
|
107
|
+
)
|
|
108
|
+
headers, finalized_body = finalize_transport_response(
|
|
109
|
+
target_scope,
|
|
110
|
+
int(start.get("status", 200)),
|
|
111
|
+
raw_headers,
|
|
112
|
+
body,
|
|
113
|
+
)
|
|
114
|
+
return Response(
|
|
115
|
+
status_code=int(start.get("status", 200)),
|
|
116
|
+
headers=[
|
|
117
|
+
(key.decode("latin-1"), value.decode("latin-1"))
|
|
118
|
+
for key, value in headers
|
|
119
|
+
],
|
|
120
|
+
body=finalized_body,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
response = await self.dispatch(request, call_next)
|
|
124
|
+
headers, finalized_body = finalize_transport_response(
|
|
125
|
+
scope,
|
|
126
|
+
response.status_code,
|
|
127
|
+
response.raw_headers,
|
|
128
|
+
response.body,
|
|
129
|
+
)
|
|
130
|
+
await send(
|
|
131
|
+
{
|
|
132
|
+
"type": "http.response.start",
|
|
133
|
+
"status": response.status_code,
|
|
134
|
+
"headers": headers,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
await send(
|
|
138
|
+
{
|
|
139
|
+
"type": "http.response.body",
|
|
140
|
+
"body": finalized_body,
|
|
141
|
+
"more_body": False,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = ["MiddlewareBase"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tigrbl_core._spec.request_spec import RequestSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RequestBase(RequestSpec):
|
|
9
|
+
"""Base request model behavior shared by concrete request implementations."""
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_scope(
|
|
13
|
+
cls,
|
|
14
|
+
scope: dict[str, Any],
|
|
15
|
+
receive: Any | None = None,
|
|
16
|
+
*,
|
|
17
|
+
app: Any | None = None,
|
|
18
|
+
state: Any | None = None,
|
|
19
|
+
) -> "RequestBase":
|
|
20
|
+
"""Construct a request from an ASGI scope.
|
|
21
|
+
|
|
22
|
+
Middleware/tests may resolve ``Request`` through base/spec surfaces during
|
|
23
|
+
import cycles. Delegate to the concrete request model to keep
|
|
24
|
+
``Request.from_scope(...)`` consistently available.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from tigrbl_concrete._concrete._request import Request
|
|
28
|
+
|
|
29
|
+
return Request.from_scope(scope, receive, app=app, state=state)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["RequestBase"]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as json_module
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from http.cookies import SimpleCookie
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from tigrbl_concrete._concrete._headers import HeaderCookies, Headers
|
|
9
|
+
|
|
10
|
+
from tigrbl_core._spec.response_spec import ResponseSpec, TemplateSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _JSONDualMethod:
|
|
14
|
+
def __get__(
|
|
15
|
+
self,
|
|
16
|
+
obj: "ResponseBase" | None,
|
|
17
|
+
owner: type["ResponseBase"],
|
|
18
|
+
):
|
|
19
|
+
if obj is None:
|
|
20
|
+
|
|
21
|
+
def _factory(
|
|
22
|
+
data: Any,
|
|
23
|
+
status_code: int = 200,
|
|
24
|
+
headers: Mapping[str, str] | None = None,
|
|
25
|
+
) -> "ResponseBase":
|
|
26
|
+
return owner.from_json(data, status_code=status_code, headers=headers)
|
|
27
|
+
|
|
28
|
+
return _factory
|
|
29
|
+
|
|
30
|
+
def _instance_json() -> Any:
|
|
31
|
+
return obj.json_body()
|
|
32
|
+
|
|
33
|
+
return _instance_json
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResponseBase(ResponseSpec):
|
|
37
|
+
"""Concrete HTTP response object that also implements ``ResponseSpec``."""
|
|
38
|
+
|
|
39
|
+
json = _JSONDualMethod()
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
status_code: int = 200,
|
|
45
|
+
headers: Mapping[str, str] | list[tuple[str, str]] | None = None,
|
|
46
|
+
body: bytes | None = None,
|
|
47
|
+
content: bytes | None = None,
|
|
48
|
+
media_type: str | None = None,
|
|
49
|
+
kind: str = "auto",
|
|
50
|
+
envelope: bool | None = None,
|
|
51
|
+
template: TemplateSpec | None = None,
|
|
52
|
+
filename: str | None = None,
|
|
53
|
+
download: bool | None = None,
|
|
54
|
+
etag: str | None = None,
|
|
55
|
+
cache_control: str | None = None,
|
|
56
|
+
redirect_to: str | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
if body is not None and content is not None:
|
|
59
|
+
raise TypeError(
|
|
60
|
+
"ResponseBase: provide either 'body' or 'content', not both"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
payload = (
|
|
64
|
+
body if body is not None else (content if content is not None else b"")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
super().__init__(
|
|
68
|
+
kind=kind,
|
|
69
|
+
media_type=media_type,
|
|
70
|
+
status_code=status_code,
|
|
71
|
+
headers={
|
|
72
|
+
k: v
|
|
73
|
+
for k, v in (
|
|
74
|
+
headers.items() if hasattr(headers, "items") else (headers or [])
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
envelope=envelope,
|
|
78
|
+
template=template,
|
|
79
|
+
filename=filename,
|
|
80
|
+
download=download,
|
|
81
|
+
etag=etag,
|
|
82
|
+
cache_control=cache_control,
|
|
83
|
+
redirect_to=redirect_to,
|
|
84
|
+
)
|
|
85
|
+
self.status_code = status_code
|
|
86
|
+
self.headers = Headers(headers or {})
|
|
87
|
+
self.body = payload
|
|
88
|
+
self.media_type = media_type
|
|
89
|
+
self._headers = self.headers
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _status_text(code: int) -> str:
|
|
93
|
+
return {
|
|
94
|
+
200: "OK",
|
|
95
|
+
201: "Created",
|
|
96
|
+
205: "Reset Content",
|
|
97
|
+
204: "No Content",
|
|
98
|
+
301: "Moved Permanently",
|
|
99
|
+
302: "Found",
|
|
100
|
+
307: "Temporary Redirect",
|
|
101
|
+
308: "Permanent Redirect",
|
|
102
|
+
400: "Bad Request",
|
|
103
|
+
401: "Unauthorized",
|
|
104
|
+
403: "Forbidden",
|
|
105
|
+
404: "Not Found",
|
|
106
|
+
405: "Method Not Allowed",
|
|
107
|
+
422: "Unprocessable Entity",
|
|
108
|
+
500: "Internal Server Error",
|
|
109
|
+
}.get(code, "OK")
|
|
110
|
+
|
|
111
|
+
def status_line(self) -> str:
|
|
112
|
+
return f"{self.status_code} {self._status_text(self.status_code)}"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def raw_headers(self) -> list[tuple[bytes, bytes]]:
|
|
116
|
+
return [
|
|
117
|
+
(k.encode("latin-1"), v.encode("latin-1")) for k, v in self.headers.items()
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def headers_map(self) -> Headers:
|
|
122
|
+
return self.headers
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def body_text(self) -> str:
|
|
126
|
+
return self.body.decode("utf-8")
|
|
127
|
+
|
|
128
|
+
def json_body(self) -> Any:
|
|
129
|
+
if not self.body:
|
|
130
|
+
return None
|
|
131
|
+
return json_module.loads(self.body.decode("utf-8"))
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def cookies(self) -> HeaderCookies:
|
|
135
|
+
cookie = SimpleCookie()
|
|
136
|
+
for name, value in self.headers.items():
|
|
137
|
+
if name == "set-cookie":
|
|
138
|
+
cookie.load(value)
|
|
139
|
+
return HeaderCookies({name: morsel.value for name, morsel in cookie.items()})
|
|
140
|
+
|
|
141
|
+
def set_cookie(
|
|
142
|
+
self,
|
|
143
|
+
key: str,
|
|
144
|
+
value: str,
|
|
145
|
+
*,
|
|
146
|
+
path: str = "/",
|
|
147
|
+
domain: str | None = None,
|
|
148
|
+
secure: bool = False,
|
|
149
|
+
httponly: bool = False,
|
|
150
|
+
samesite: str | None = None,
|
|
151
|
+
max_age: int | None = None,
|
|
152
|
+
expires: str | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
cookie = SimpleCookie()
|
|
155
|
+
cookie[key] = value
|
|
156
|
+
morsel = cookie[key]
|
|
157
|
+
morsel["path"] = path
|
|
158
|
+
if domain is not None:
|
|
159
|
+
morsel["domain"] = domain
|
|
160
|
+
if secure:
|
|
161
|
+
morsel["secure"] = True
|
|
162
|
+
if httponly:
|
|
163
|
+
morsel["httponly"] = True
|
|
164
|
+
if samesite is not None:
|
|
165
|
+
morsel["samesite"] = samesite
|
|
166
|
+
if max_age is not None:
|
|
167
|
+
morsel["max-age"] = str(max_age)
|
|
168
|
+
if expires is not None:
|
|
169
|
+
morsel["expires"] = expires
|
|
170
|
+
self.headers["set-cookie"] = cookie.output(header="").strip()
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_json(
|
|
174
|
+
cls,
|
|
175
|
+
data: Any,
|
|
176
|
+
status_code: int = 200,
|
|
177
|
+
headers: Mapping[str, str] | None = None,
|
|
178
|
+
) -> "ResponseBase":
|
|
179
|
+
payload = json_module.dumps(
|
|
180
|
+
data, ensure_ascii=False, separators=(",", ":"), default=str
|
|
181
|
+
).encode("utf-8")
|
|
182
|
+
hdrs = [("content-type", "application/json; charset=utf-8")]
|
|
183
|
+
for k, v in (headers or {}).items():
|
|
184
|
+
hdrs.append((k.lower(), v))
|
|
185
|
+
return cls(
|
|
186
|
+
status_code=status_code,
|
|
187
|
+
headers=hdrs,
|
|
188
|
+
body=payload,
|
|
189
|
+
media_type="application/json",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def html(
|
|
194
|
+
cls,
|
|
195
|
+
html: str,
|
|
196
|
+
status_code: int = 200,
|
|
197
|
+
headers: Mapping[str, str] | None = None,
|
|
198
|
+
) -> "ResponseBase":
|
|
199
|
+
payload = html.encode("utf-8")
|
|
200
|
+
hdrs = [("content-type", "text/html; charset=utf-8")]
|
|
201
|
+
for k, v in (headers or {}).items():
|
|
202
|
+
hdrs.append((k.lower(), v))
|
|
203
|
+
return cls(
|
|
204
|
+
status_code=status_code,
|
|
205
|
+
headers=hdrs,
|
|
206
|
+
body=payload,
|
|
207
|
+
media_type="text/html",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@classmethod
|
|
211
|
+
def text(
|
|
212
|
+
cls,
|
|
213
|
+
text: str,
|
|
214
|
+
status_code: int = 200,
|
|
215
|
+
headers: Mapping[str, str] | None = None,
|
|
216
|
+
) -> "ResponseBase":
|
|
217
|
+
payload = text.encode("utf-8")
|
|
218
|
+
hdrs = [("content-type", "text/plain; charset=utf-8")]
|
|
219
|
+
for k, v in (headers or {}).items():
|
|
220
|
+
hdrs.append((k.lower(), v))
|
|
221
|
+
return cls(
|
|
222
|
+
status_code=status_code,
|
|
223
|
+
headers=hdrs,
|
|
224
|
+
body=payload,
|
|
225
|
+
media_type="text/plain",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class TemplateBase(TemplateSpec):
|
|
231
|
+
"""Concrete template configuration used at runtime."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SchemaBase:
|
|
5
|
+
"""Shared schema helpers used by concrete schema wrappers."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def collect(cls, model: type) -> dict[str, dict[str, type]]:
|
|
9
|
+
from tigrbl_canon.mapping.collect_decorated_schemas import (
|
|
10
|
+
collect_decorated_schemas,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
return collect_decorated_schemas(model)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["SchemaBase"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Core OpenAPI security scheme primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
_VALID_SECURITY_SCHEME_TYPES = {
|
|
8
|
+
"http",
|
|
9
|
+
"apiKey",
|
|
10
|
+
"oauth2",
|
|
11
|
+
"openIdConnect",
|
|
12
|
+
"mutualTLS",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpenAPISecurityDependency:
|
|
17
|
+
"""Base security dependency with OpenAPI document metadata."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
scheme_name: str,
|
|
23
|
+
scheme: Mapping[str, Any],
|
|
24
|
+
scopes: Sequence[str] | None = None,
|
|
25
|
+
auto_error: bool = True,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.scheme_name = scheme_name
|
|
28
|
+
self.auto_error = auto_error
|
|
29
|
+
self._scheme = dict(scheme)
|
|
30
|
+
self._scopes = list(scopes or [])
|
|
31
|
+
validate_openapi_security_scheme(self._scheme)
|
|
32
|
+
|
|
33
|
+
def openapi_security_scheme(self) -> dict[str, Any]:
|
|
34
|
+
return dict(self._scheme)
|
|
35
|
+
|
|
36
|
+
def openapi_security_requirement(self) -> dict[str, list[str]]:
|
|
37
|
+
return {self.scheme_name: list(self._scopes)}
|
|
38
|
+
|
|
39
|
+
def __call__(self, request: Any) -> Any | None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_openapi_security_scheme(scheme: Mapping[str, Any]) -> None:
|
|
44
|
+
scheme_type = scheme.get("type")
|
|
45
|
+
if scheme_type not in _VALID_SECURITY_SCHEME_TYPES:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"OpenAPI security scheme 'type' must be one of: "
|
|
48
|
+
f"{sorted(_VALID_SECURITY_SCHEME_TYPES)}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if "scheme" in scheme and scheme_type != "http":
|
|
52
|
+
raise ValueError("OpenAPI 'scheme' is only valid when type is 'http'.")
|
|
53
|
+
|
|
54
|
+
if scheme_type == "http" and not scheme.get("scheme"):
|
|
55
|
+
raise ValueError("OpenAPI type='http' requires a non-empty 'scheme'.")
|
|
56
|
+
|
|
57
|
+
if scheme_type == "apiKey":
|
|
58
|
+
if scheme.get("in") not in {"header", "query", "cookie"}:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"OpenAPI type='apiKey' requires 'in' to be header/query/cookie."
|
|
61
|
+
)
|
|
62
|
+
if not scheme.get("name"):
|
|
63
|
+
raise ValueError("OpenAPI type='apiKey' requires 'name'.")
|
|
64
|
+
|
|
65
|
+
if scheme_type == "oauth2" and not isinstance(scheme.get("flows"), Mapping):
|
|
66
|
+
raise ValueError("OpenAPI type='oauth2' requires a 'flows' object.")
|
|
67
|
+
|
|
68
|
+
if scheme_type == "openIdConnect" and not scheme.get("openIdConnectUrl"):
|
|
69
|
+
raise ValueError("OpenAPI type='openIdConnect' requires 'openIdConnectUrl'.")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SessionABC(ABC):
|
|
8
|
+
"""Authoritative Tigrbl session interface."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def begin(self) -> None: ...
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def commit(self) -> None: ...
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def rollback(self) -> None: ...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def in_transaction(self) -> bool: ...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def get(self, model: type, ident: Any) -> Any | None: ...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def add(self, obj: Any) -> None: ...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def delete(self, obj: Any) -> None: ...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def flush(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def refresh(self, obj: Any) -> None: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def execute(self, stmt: Any) -> Any: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def close(self) -> None: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def run_sync(self, fn: Callable[[Any], Any]) -> Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["SessionABC"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
from ._session_abc import SessionABC
|
|
9
|
+
from tigrbl_core._spec.session_spec import SessionSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TigrblSessionBase(SessionABC):
|
|
14
|
+
_spec: Optional[SessionSpec] = None
|
|
15
|
+
_open: bool = field(default=False, init=False)
|
|
16
|
+
_dirty: bool = field(default=False, init=False)
|
|
17
|
+
_pending: List[asyncio.Task] = field(default_factory=list, init=False)
|
|
18
|
+
|
|
19
|
+
def apply_spec(self, spec: SessionSpec | None) -> None:
|
|
20
|
+
self._spec = spec
|
|
21
|
+
|
|
22
|
+
async def run_sync(self, fn: Callable[[Any], Any]) -> Any:
|
|
23
|
+
rv = fn(self)
|
|
24
|
+
if inspect.isawaitable(rv):
|
|
25
|
+
return await rv
|
|
26
|
+
return rv
|
|
27
|
+
|
|
28
|
+
async def begin(self) -> None:
|
|
29
|
+
await self._tx_begin_impl()
|
|
30
|
+
self._open = True
|
|
31
|
+
|
|
32
|
+
async def commit(self) -> None:
|
|
33
|
+
if self._spec and self._spec.read_only and self._dirty:
|
|
34
|
+
raise RuntimeError("read-only session: writes detected before commit")
|
|
35
|
+
await self.flush()
|
|
36
|
+
await self._tx_commit_impl()
|
|
37
|
+
self._open = False
|
|
38
|
+
self._dirty = False
|
|
39
|
+
|
|
40
|
+
async def rollback(self) -> None:
|
|
41
|
+
for t in self._pending:
|
|
42
|
+
try:
|
|
43
|
+
t.cancel()
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
self._pending.clear()
|
|
47
|
+
await self._tx_rollback_impl()
|
|
48
|
+
self._open = False
|
|
49
|
+
self._dirty = False
|
|
50
|
+
|
|
51
|
+
def in_transaction(self) -> bool:
|
|
52
|
+
return bool(self._open)
|
|
53
|
+
|
|
54
|
+
def add(self, obj: Any) -> None:
|
|
55
|
+
if self._spec and self._spec.read_only:
|
|
56
|
+
raise RuntimeError("write attempted in read-only session (add)")
|
|
57
|
+
self._dirty = True
|
|
58
|
+
rv = self._add_impl(obj)
|
|
59
|
+
if inspect.isawaitable(rv):
|
|
60
|
+
try:
|
|
61
|
+
loop = asyncio.get_running_loop()
|
|
62
|
+
except RuntimeError:
|
|
63
|
+
asyncio.run(rv)
|
|
64
|
+
else:
|
|
65
|
+
self._pending.append(loop.create_task(rv))
|
|
66
|
+
|
|
67
|
+
async def delete(self, obj: Any) -> None:
|
|
68
|
+
if self._spec and self._spec.read_only:
|
|
69
|
+
raise RuntimeError("write attempted in read-only session (delete)")
|
|
70
|
+
self._dirty = True
|
|
71
|
+
await self._delete_impl(obj)
|
|
72
|
+
|
|
73
|
+
async def flush(self) -> None:
|
|
74
|
+
if self._pending:
|
|
75
|
+
done, _ = await asyncio.wait(
|
|
76
|
+
self._pending, return_when=asyncio.ALL_COMPLETED
|
|
77
|
+
)
|
|
78
|
+
self._pending = []
|
|
79
|
+
for t in done:
|
|
80
|
+
_ = t.result()
|
|
81
|
+
await self._flush_impl()
|
|
82
|
+
|
|
83
|
+
async def refresh(self, obj: Any) -> None:
|
|
84
|
+
await self._refresh_impl(obj)
|
|
85
|
+
|
|
86
|
+
async def get(self, model: type, ident: Any) -> Any | None:
|
|
87
|
+
return await self._get_impl(model, ident)
|
|
88
|
+
|
|
89
|
+
async def execute(self, stmt: Any) -> Any:
|
|
90
|
+
return await self._execute_impl(stmt)
|
|
91
|
+
|
|
92
|
+
async def close(self) -> None:
|
|
93
|
+
for t in self._pending:
|
|
94
|
+
try:
|
|
95
|
+
t.cancel()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
self._pending = []
|
|
99
|
+
await self._close_impl()
|
|
100
|
+
|
|
101
|
+
async def _tx_begin_impl(self) -> None:
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
async def _tx_commit_impl(self) -> None:
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
async def _tx_rollback_impl(self) -> None:
|
|
108
|
+
raise NotImplementedError
|
|
109
|
+
|
|
110
|
+
def _add_impl(self, obj: Any) -> Any:
|
|
111
|
+
raise NotImplementedError
|
|
112
|
+
|
|
113
|
+
async def _delete_impl(self, obj: Any) -> None:
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
async def _flush_impl(self) -> None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
async def _refresh_impl(self, obj: Any) -> None:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
async def _get_impl(self, model: type, ident: Any) -> Any | None:
|
|
123
|
+
raise NotImplementedError
|
|
124
|
+
|
|
125
|
+
async def _execute_impl(self, stmt: Any) -> Any:
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
async def _close_impl(self) -> None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["TigrblSessionBase"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Base storage-layer primitives."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from tigrbl_core._spec.storage_spec import ForeignKeySpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ForeignKeyBase(ForeignKeySpec):
|
|
10
|
+
"""Base foreign-key configuration shared by concrete implementations."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["ForeignKeyBase"]
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# tigrbl/tigrbl/table/_base.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional, Union, get_args, get_origin
|
|
5
|
+
from enum import Enum as PyEnum
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, declared_attr, mapped_column
|
|
8
|
+
from sqlalchemy import CheckConstraint, ForeignKey, MetaData
|
|
9
|
+
from sqlalchemy.types import Enum as SAEnum, String
|
|
10
|
+
|
|
11
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
# Helpers – type inference & SA type instantiation
|
|
13
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _unwrap_optional(t: Any) -> Any:
|
|
17
|
+
"""Optional[T] / Union[T, None] → T"""
|
|
18
|
+
if get_origin(t) is Union:
|
|
19
|
+
args = [a for a in get_args(t) if a is not type(None)]
|
|
20
|
+
return args[0] if args else t
|
|
21
|
+
return t
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _infer_py_type(cls, name: str, spec: Any) -> Optional[type]:
|
|
25
|
+
"""
|
|
26
|
+
Prefer FieldSpec.py_type if provided; otherwise unwrap Mapped[...] / Optional[...]
|
|
27
|
+
from the class' annotation to get the real Python type for the column.
|
|
28
|
+
"""
|
|
29
|
+
fld = getattr(spec, "field", None)
|
|
30
|
+
py = getattr(fld, "py_type", None)
|
|
31
|
+
if isinstance(py, type):
|
|
32
|
+
return py
|
|
33
|
+
|
|
34
|
+
ann = getattr(cls, "__annotations__", {}).get(name)
|
|
35
|
+
if ann is None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
# Mapped[T] → T (then unwrap Optional)
|
|
39
|
+
try:
|
|
40
|
+
from sqlalchemy.orm import Mapped
|
|
41
|
+
|
|
42
|
+
if get_origin(ann) is Mapped:
|
|
43
|
+
inner = get_args(ann)[0]
|
|
44
|
+
return _unwrap_optional(inner)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# Optional[T]/Union[T, None] → T
|
|
49
|
+
return _unwrap_optional(ann)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _instantiate_dtype(
|
|
53
|
+
dtype: Any, py_type: Any, spec: Any, cls_name: str, col_name: str
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Create a SQLAlchemy TypeEngine instance from either a type CLASS or an instance.
|
|
57
|
+
- SAEnum: instantiate from the actual Enum class with a stable name
|
|
58
|
+
- String: honor FieldSpec.constraints['max_length'] if present
|
|
59
|
+
- UUID (PG): prefer as_uuid=True when available
|
|
60
|
+
"""
|
|
61
|
+
# Already an instance? keep it.
|
|
62
|
+
try:
|
|
63
|
+
from sqlalchemy.sql.type_api import TypeEngine
|
|
64
|
+
|
|
65
|
+
if isinstance(dtype, TypeEngine):
|
|
66
|
+
return dtype
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# SAEnum from a Python Enum class
|
|
71
|
+
if dtype is SAEnum and isinstance(py_type, type) and issubclass(py_type, PyEnum):
|
|
72
|
+
enum_name = f"{cls_name.lower()}_{col_name.lower()}"
|
|
73
|
+
return SAEnum(py_type, name=enum_name, native_enum=True, validate_strings=True)
|
|
74
|
+
|
|
75
|
+
# String – pick up max_length from FieldSpec
|
|
76
|
+
if dtype is String:
|
|
77
|
+
max_len = getattr(getattr(spec, "field", None), "constraints", {}).get(
|
|
78
|
+
"max_length"
|
|
79
|
+
)
|
|
80
|
+
return String(max_len) if max_len else String()
|
|
81
|
+
|
|
82
|
+
# PostgreSQL UUID (or similar) – try as_uuid=True first
|
|
83
|
+
try:
|
|
84
|
+
return dtype(as_uuid=True) # e.g., PG UUID
|
|
85
|
+
except TypeError:
|
|
86
|
+
try:
|
|
87
|
+
return dtype()
|
|
88
|
+
except TypeError:
|
|
89
|
+
# As a last resort, return the class; SQLA will raise clearly if unusable
|
|
90
|
+
return dtype
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _materialize_colspecs_to_sqla(cls, *, map_columns: bool = True) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Replace ColumnSpec attributes with sqlalchemy.orm.mapped_column(...) BEFORE mapping.
|
|
96
|
+
Keep the original specs in __tigrbl_cols__ for downstream builders.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
from tigrbl_core._spec.column_spec import ColumnSpec
|
|
100
|
+
except Exception:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
from sqlalchemy.orm import InstrumentedAttribute, MappedColumn
|
|
104
|
+
except Exception: # pragma: no cover - defensive for minimal SQLA envs
|
|
105
|
+
InstrumentedAttribute = None
|
|
106
|
+
MappedColumn = None
|
|
107
|
+
|
|
108
|
+
# Prefer explicit registry if present; otherwise collect specs from the
|
|
109
|
+
# entire MRO so mixins contribute their ColumnSpec definitions.
|
|
110
|
+
specs: dict[str, ColumnSpec] = {}
|
|
111
|
+
for base in reversed(cls.__mro__):
|
|
112
|
+
base_specs = getattr(base, "__tigrbl_cols__", None)
|
|
113
|
+
if isinstance(base_specs, dict) and base_specs:
|
|
114
|
+
specs.update(base_specs)
|
|
115
|
+
continue
|
|
116
|
+
for name, attr in getattr(base, "__dict__", {}).items():
|
|
117
|
+
if isinstance(attr, ColumnSpec):
|
|
118
|
+
specs.setdefault(name, attr)
|
|
119
|
+
|
|
120
|
+
if not specs:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
if map_columns:
|
|
124
|
+
for name, spec in specs.items():
|
|
125
|
+
storage = getattr(spec, "storage", None)
|
|
126
|
+
if not storage:
|
|
127
|
+
# Virtual (wire-only) column – ensure SQLAlchemy ignores it.
|
|
128
|
+
if MappedColumn is not None and isinstance(spec, MappedColumn):
|
|
129
|
+
annotations = getattr(cls, "__annotations__", {}) or {}
|
|
130
|
+
if name not in annotations:
|
|
131
|
+
replacement = ColumnSpec(
|
|
132
|
+
storage=None,
|
|
133
|
+
field=getattr(spec, "field", None),
|
|
134
|
+
io=getattr(spec, "io", None),
|
|
135
|
+
default_factory=getattr(spec, "default_factory", None),
|
|
136
|
+
read_producer=getattr(spec, "read_producer", None),
|
|
137
|
+
)
|
|
138
|
+
setattr(cls, name, replacement)
|
|
139
|
+
specs[name] = replacement
|
|
140
|
+
continue
|
|
141
|
+
existing_attr = getattr(cls, name, None)
|
|
142
|
+
if InstrumentedAttribute is not None and isinstance(
|
|
143
|
+
existing_attr, InstrumentedAttribute
|
|
144
|
+
):
|
|
145
|
+
# Column already mapped on a base class; avoid duplicating columns
|
|
146
|
+
# that trigger SQLAlchemy implicit combination warnings.
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
dtype = getattr(storage, "type_", None)
|
|
150
|
+
if not dtype:
|
|
151
|
+
# No SA dtype specified – cannot materialize
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
py_type = _infer_py_type(cls, name, spec)
|
|
155
|
+
dtype_inst = _instantiate_dtype(dtype, py_type, spec, cls.__name__, name)
|
|
156
|
+
|
|
157
|
+
# Foreign key (if any)
|
|
158
|
+
fk = getattr(storage, "fk", None)
|
|
159
|
+
fk_arg = None
|
|
160
|
+
if fk is not None:
|
|
161
|
+
# ForeignKeySpec: target="table(col)", on_delete/on_update: "CASCADE"/...
|
|
162
|
+
fk_arg = ForeignKey(
|
|
163
|
+
fk.target, ondelete=fk.on_delete, onupdate=fk.on_update
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
check = getattr(storage, "check", None)
|
|
167
|
+
args: list[Any] = []
|
|
168
|
+
if fk_arg is not None:
|
|
169
|
+
args.append(fk_arg)
|
|
170
|
+
if check is not None:
|
|
171
|
+
cname = f"ck_{cls.__name__.lower()}_{name}"
|
|
172
|
+
args.append(CheckConstraint(check, name=cname))
|
|
173
|
+
|
|
174
|
+
# Build mapped_column from StorageSpec flags
|
|
175
|
+
mc = mapped_column(
|
|
176
|
+
dtype_inst,
|
|
177
|
+
*args,
|
|
178
|
+
primary_key=getattr(storage, "primary_key", False),
|
|
179
|
+
nullable=getattr(storage, "nullable", True),
|
|
180
|
+
unique=getattr(storage, "unique", False),
|
|
181
|
+
index=getattr(storage, "index", False),
|
|
182
|
+
default=getattr(storage, "default", None),
|
|
183
|
+
onupdate=getattr(storage, "onupdate", None),
|
|
184
|
+
server_default=getattr(storage, "server_default", None),
|
|
185
|
+
comment=getattr(storage, "comment", None),
|
|
186
|
+
autoincrement=getattr(storage, "autoincrement", None),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
setattr(cls, name, mc)
|
|
190
|
+
|
|
191
|
+
# Ensure downstream code can find the spec map
|
|
192
|
+
setattr(cls, "__tigrbl_cols__", dict(specs))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _ensure_instrumented_attr_accessors() -> None:
|
|
196
|
+
"""Expose ColumnSpec metadata on SQLAlchemy InstrumentedAttribute objects."""
|
|
197
|
+
try:
|
|
198
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
199
|
+
except Exception: # pragma: no cover - defensive for minimal SQLA envs
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if not hasattr(InstrumentedAttribute, "storage"):
|
|
203
|
+
|
|
204
|
+
def _storage(self): # type: ignore[no-untyped-def]
|
|
205
|
+
spec = getattr(self.class_, "__tigrbl_cols__", {}).get(self.key)
|
|
206
|
+
return getattr(spec, "storage", None)
|
|
207
|
+
|
|
208
|
+
InstrumentedAttribute.storage = property(_storage) # type: ignore[attr-defined]
|
|
209
|
+
|
|
210
|
+
if not hasattr(InstrumentedAttribute, "field"):
|
|
211
|
+
|
|
212
|
+
def _field(self): # type: ignore[no-untyped-def]
|
|
213
|
+
spec = getattr(self.class_, "__tigrbl_cols__", {}).get(self.key)
|
|
214
|
+
return getattr(spec, "field", None)
|
|
215
|
+
|
|
216
|
+
InstrumentedAttribute.field = property(_field) # type: ignore[attr-defined]
|
|
217
|
+
|
|
218
|
+
if not hasattr(InstrumentedAttribute, "io"):
|
|
219
|
+
|
|
220
|
+
def _io(self): # type: ignore[no-untyped-def]
|
|
221
|
+
spec = getattr(self.class_, "__tigrbl_cols__", {}).get(self.key)
|
|
222
|
+
return getattr(spec, "io", None)
|
|
223
|
+
|
|
224
|
+
InstrumentedAttribute.io = property(_io) # type: ignore[attr-defined]
|
|
225
|
+
|
|
226
|
+
if not hasattr(InstrumentedAttribute, "default_factory"):
|
|
227
|
+
|
|
228
|
+
def _default_factory(self): # type: ignore[no-untyped-def]
|
|
229
|
+
spec = getattr(self.class_, "__tigrbl_cols__", {}).get(self.key)
|
|
230
|
+
return getattr(spec, "default_factory", None)
|
|
231
|
+
|
|
232
|
+
InstrumentedAttribute.default_factory = property(_default_factory) # type: ignore[attr-defined]
|
|
233
|
+
|
|
234
|
+
if not hasattr(InstrumentedAttribute, "read_producer"):
|
|
235
|
+
|
|
236
|
+
def _read_producer(self): # type: ignore[no-untyped-def]
|
|
237
|
+
spec = getattr(self.class_, "__tigrbl_cols__", {}).get(self.key)
|
|
238
|
+
return getattr(spec, "read_producer", None)
|
|
239
|
+
|
|
240
|
+
InstrumentedAttribute.read_producer = property(_read_producer) # type: ignore[attr-defined]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
244
|
+
# Declarative Base
|
|
245
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TableBase(DeclarativeBase):
|
|
249
|
+
__allow_unmapped__ = True
|
|
250
|
+
|
|
251
|
+
def __init_subclass__(cls, **kw):
|
|
252
|
+
# 0) Remove any previously registered class with the same module path.
|
|
253
|
+
try:
|
|
254
|
+
reg = TableBase.registry._class_registry
|
|
255
|
+
name = cls.__name__
|
|
256
|
+
existing = reg.get(name)
|
|
257
|
+
if existing is not None:
|
|
258
|
+
try:
|
|
259
|
+
TableBase.registry._dispose_cls(existing)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
reg.pop(name, None)
|
|
263
|
+
module_reg = reg.get("_sa_module_registry")
|
|
264
|
+
if module_reg is not None:
|
|
265
|
+
marker = module_reg
|
|
266
|
+
for part in cls.__module__.split("."):
|
|
267
|
+
contents = getattr(marker, "contents", None)
|
|
268
|
+
if not isinstance(contents, dict) or part not in contents:
|
|
269
|
+
marker = None
|
|
270
|
+
break
|
|
271
|
+
marker = contents.get(part)
|
|
272
|
+
if marker is not None and isinstance(
|
|
273
|
+
getattr(marker, "contents", None), dict
|
|
274
|
+
):
|
|
275
|
+
marker.contents.pop(name, None)
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
# 0.5) If a table with the same name already exists, allow this class
|
|
280
|
+
# to extend it instead of raising duplicate-table errors.
|
|
281
|
+
try:
|
|
282
|
+
table_name = getattr(cls, "__tablename__", None)
|
|
283
|
+
if table_name and table_name in TableBase.metadata.tables:
|
|
284
|
+
table_args = getattr(cls, "__table_args__", None)
|
|
285
|
+
if table_args is None:
|
|
286
|
+
cls.__table_args__ = {"extend_existing": True}
|
|
287
|
+
elif isinstance(table_args, dict):
|
|
288
|
+
table_args = dict(table_args)
|
|
289
|
+
table_args["extend_existing"] = True
|
|
290
|
+
cls.__table_args__ = table_args
|
|
291
|
+
elif isinstance(table_args, tuple):
|
|
292
|
+
if table_args and isinstance(table_args[-1], dict):
|
|
293
|
+
table_dict = dict(table_args[-1])
|
|
294
|
+
table_dict["extend_existing"] = True
|
|
295
|
+
cls.__table_args__ = (*table_args[:-1], table_dict)
|
|
296
|
+
else:
|
|
297
|
+
cls.__table_args__ = (*table_args, {"extend_existing": True})
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
# 1) Determine whether this class should be mapped.
|
|
302
|
+
try:
|
|
303
|
+
from sqlalchemy import Column as _SAColumn
|
|
304
|
+
from sqlalchemy.orm import MappedColumn as _MappedColumn
|
|
305
|
+
except Exception: # pragma: no cover - defensive
|
|
306
|
+
_SAColumn = None
|
|
307
|
+
_MappedColumn = None
|
|
308
|
+
|
|
309
|
+
def _has_mappable_columns() -> bool:
|
|
310
|
+
for base in cls.__mro__:
|
|
311
|
+
for attr in getattr(base, "__dict__", {}).values():
|
|
312
|
+
if _SAColumn is not None and isinstance(attr, _SAColumn):
|
|
313
|
+
return True
|
|
314
|
+
if _MappedColumn is not None and isinstance(attr, _MappedColumn):
|
|
315
|
+
return True
|
|
316
|
+
storage = getattr(attr, "storage", None)
|
|
317
|
+
if storage is not None:
|
|
318
|
+
return True
|
|
319
|
+
mapping = getattr(base, "__tigrbl_cols__", None)
|
|
320
|
+
if isinstance(mapping, dict):
|
|
321
|
+
for spec in mapping.values():
|
|
322
|
+
if _MappedColumn is not None and isinstance(
|
|
323
|
+
spec, _MappedColumn
|
|
324
|
+
):
|
|
325
|
+
return True
|
|
326
|
+
storage = getattr(spec, "storage", None)
|
|
327
|
+
if storage is not None:
|
|
328
|
+
return True
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
def _has_primary_key() -> bool:
|
|
332
|
+
mapper_args = getattr(cls, "__mapper_args__", None)
|
|
333
|
+
if isinstance(mapper_args, dict) and mapper_args.get("primary_key"):
|
|
334
|
+
return True
|
|
335
|
+
for base in cls.__mro__:
|
|
336
|
+
for attr in getattr(base, "__dict__", {}).values():
|
|
337
|
+
if _SAColumn is not None and isinstance(attr, _SAColumn):
|
|
338
|
+
if getattr(attr, "primary_key", False):
|
|
339
|
+
return True
|
|
340
|
+
if _MappedColumn is not None and isinstance(attr, _MappedColumn):
|
|
341
|
+
if getattr(attr, "primary_key", False):
|
|
342
|
+
return True
|
|
343
|
+
storage = getattr(attr, "storage", None)
|
|
344
|
+
if storage is not None and getattr(storage, "primary_key", False):
|
|
345
|
+
return True
|
|
346
|
+
mapping = getattr(base, "__tigrbl_cols__", None)
|
|
347
|
+
if isinstance(mapping, dict):
|
|
348
|
+
for spec in mapping.values():
|
|
349
|
+
storage = getattr(spec, "storage", None)
|
|
350
|
+
if storage is not None and getattr(
|
|
351
|
+
storage, "primary_key", False
|
|
352
|
+
):
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
explicit_abstract = "__abstract__" in cls.__dict__
|
|
357
|
+
if not explicit_abstract:
|
|
358
|
+
if not _has_mappable_columns() or not _has_primary_key():
|
|
359
|
+
cls.__abstract__ = True
|
|
360
|
+
else:
|
|
361
|
+
cls.__abstract__ = False
|
|
362
|
+
|
|
363
|
+
should_map = not getattr(cls, "__abstract__", False)
|
|
364
|
+
|
|
365
|
+
# 1.5) BEFORE SQLAlchemy maps: turn ColumnSpecs into real mapped_column(...)
|
|
366
|
+
_materialize_colspecs_to_sqla(cls, map_columns=should_map)
|
|
367
|
+
_ensure_instrumented_attr_accessors()
|
|
368
|
+
|
|
369
|
+
# 2) Let SQLAlchemy map the class (PK now exists)
|
|
370
|
+
super().__init_subclass__(**kw)
|
|
371
|
+
|
|
372
|
+
# 2.5) Surface ctx-only op declarations for lightweight introspection.
|
|
373
|
+
if not hasattr(cls, "__tigrbl_ops__"):
|
|
374
|
+
for attr in cls.__dict__.values():
|
|
375
|
+
target = getattr(attr, "__func__", attr)
|
|
376
|
+
if getattr(target, "__tigrbl_op_spec__", None) is not None:
|
|
377
|
+
cls.__tigrbl_ops__ = tuple()
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
# 2.6) Collect response specs declared via @response_ctx.
|
|
381
|
+
responses = {}
|
|
382
|
+
for name, obj in cls.__dict__.items():
|
|
383
|
+
spec = getattr(obj, "__tigrbl_response_spec__", None)
|
|
384
|
+
if spec is None:
|
|
385
|
+
continue
|
|
386
|
+
alias = getattr(obj, "__tigrbl_response_alias__", None) or name
|
|
387
|
+
responses[alias] = spec
|
|
388
|
+
if responses:
|
|
389
|
+
cls.responses = responses
|
|
390
|
+
cls.response = next(iter(responses.values()))
|
|
391
|
+
|
|
392
|
+
# 3) Seed model namespaces / index specs (ops/hooks/etc.) – idempotent
|
|
393
|
+
try:
|
|
394
|
+
from tigrbl_canon.mapping import model as _model_bind
|
|
395
|
+
|
|
396
|
+
_model_bind.bind(cls)
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
# 3) AUTO-BUILD CRUD schemas from ColumnSpecs so /docs has them
|
|
401
|
+
try:
|
|
402
|
+
from tigrbl_canon.mapping import build_schemas as _build_schemas
|
|
403
|
+
|
|
404
|
+
_build_schemas(
|
|
405
|
+
cls
|
|
406
|
+
) # attaches request/response models to the model/registry
|
|
407
|
+
except Exception:
|
|
408
|
+
# Surface during development if needed:
|
|
409
|
+
# raise
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
metadata = MetaData(
|
|
413
|
+
naming_convention={
|
|
414
|
+
"pk": "pk_%(table_name)s",
|
|
415
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
416
|
+
"ix": "ix_%(table_name)s_%(column_0_name)s",
|
|
417
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
418
|
+
"ck": "ck_%(table_name)s_%(column_0_name)s_%(constraint_type)s",
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
@declared_attr.directive
|
|
423
|
+
def __tablename__(cls) -> str: # noqa: N805
|
|
424
|
+
return cls.__name__.lower()
|
|
425
|
+
|
|
426
|
+
@declared_attr.directive
|
|
427
|
+
def resource_name(cls): # noqa: N805
|
|
428
|
+
return getattr(cls, "__resource__", cls.__name__.lower())
|
|
429
|
+
|
|
430
|
+
def __getitem__(self, key: str) -> Any:
|
|
431
|
+
"""Allow dict-style access to model attributes."""
|
|
432
|
+
return getattr(self, key)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
__all__ = ["TableBase"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TableRegistryBase(dict[str, Any]):
|
|
7
|
+
"""Dict-like registry used for table/model lookups."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, tables: Iterable[Any] = ()) -> None:
|
|
10
|
+
resolved_tables = tuple(tables or ())
|
|
11
|
+
dict.__init__(self)
|
|
12
|
+
self.tables = resolved_tables
|
|
13
|
+
self.register_many(resolved_tables)
|
|
14
|
+
|
|
15
|
+
def __getattr__(self, name: str) -> Any:
|
|
16
|
+
try:
|
|
17
|
+
value = self[name]
|
|
18
|
+
except KeyError as exc:
|
|
19
|
+
raise AttributeError(name) from exc
|
|
20
|
+
|
|
21
|
+
# Preserve historic table-registry attribute behavior where
|
|
22
|
+
# ``registry.Widget`` surfaced SQLAlchemy table metadata while key-based
|
|
23
|
+
# lookup remains model-centric for runtime mapping.
|
|
24
|
+
if isinstance(value, type):
|
|
25
|
+
table = getattr(value, "__table__", None)
|
|
26
|
+
if table is not None:
|
|
27
|
+
return table
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
31
|
+
if name == "tables" or name.startswith("_"):
|
|
32
|
+
super().__setattr__(name, value)
|
|
33
|
+
return
|
|
34
|
+
self[name] = value
|
|
35
|
+
|
|
36
|
+
def register(self, entry: Any) -> None:
|
|
37
|
+
if isinstance(entry, tuple) and len(entry) == 2 and isinstance(entry[0], str):
|
|
38
|
+
alias, model = entry
|
|
39
|
+
self[alias] = model
|
|
40
|
+
model_name = getattr(model, "__name__", alias)
|
|
41
|
+
self.setdefault(model_name, model)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
model = entry
|
|
45
|
+
model_name = getattr(model, "__name__", None)
|
|
46
|
+
if model_name is None:
|
|
47
|
+
model_name = str(model)
|
|
48
|
+
self[model_name] = model
|
|
49
|
+
|
|
50
|
+
def register_many(self, tables: Iterable[Any]) -> None:
|
|
51
|
+
for entry in tables:
|
|
52
|
+
self.register(entry)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["TableRegistryBase"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tigrbl-base
|
|
3
|
+
Version: 0.1.0.dev1
|
|
4
|
+
Summary: Abstract base interfaces for Tigrbl APIs and runtime components.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Keywords: tigrbl,sdk,standards,framework
|
|
7
|
+
Author: Jacob Stewart
|
|
8
|
+
Author-email: jacob@swarmauri.com
|
|
9
|
+
Requires-Python: >=3.10,<3.13
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Development Status :: 1 - Planning
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Requires-Dist: tigrbl-atoms
|
|
19
|
+
Requires-Dist: tigrbl-canon
|
|
20
|
+
Requires-Dist: tigrbl-concrete
|
|
21
|
+
Requires-Dist: tigrbl-core
|
|
22
|
+
Requires-Dist: tigrbl-runtime
|
|
23
|
+
Requires-Dist: tigrbl-typing
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
# tigrbl-base
|
|
29
|
+
|
|
30
|
+
    
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Modular package in the Tigrbl namespace.
|
|
35
|
+
- Supports Python 3.10 through 3.12.
|
|
36
|
+
- Distributed as part of the swarmauri-sdk workspace.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
### uv
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv add tigrbl-base
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### pip
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install tigrbl-base
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
Import from the shared package-specific module namespaces after installation in your environment.
|
|
55
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
tigrbl_base/_base/__init__.py,sha256=ozQWEKKarRB6CgjRAwuVADq9dxKPd8QUblW6Ptq1FQ4,590
|
|
2
|
+
tigrbl_base/_base/_hook_base.py,sha256=bFcG4ghm6oU3LoVGjgXxaD_ngyf-6GZSGBl9RJQvhXo,624
|
|
3
|
+
tigrbl_base/_base/_middleware_base.py,sha256=lv2W9kXUEb0k3-YuFkFEh4sYfNm2lu4xNzFTXTjaxu4,4598
|
|
4
|
+
tigrbl_base/_base/_op_base.py,sha256=4kejZkpj6tTcIfF2JAFNxh49z3lgIhvxn9NBw-pUyhc,230
|
|
5
|
+
tigrbl_base/_base/_request_base.py,sha256=pHHd9IiIG2SkJTTaJHm_66EjvYMPyCukBS8F9-ePM1E,892
|
|
6
|
+
tigrbl_base/_base/_response_base.py,sha256=1u1xla8l9BBTHitR0t9BypYQDpWJ82fLCzTezUk-Tt4,6818
|
|
7
|
+
tigrbl_base/_base/_schema_base.py,sha256=R1Y117j8ZtDmyrJ5GZHKtv2HjAxLvVByM8k_mUDblrk,398
|
|
8
|
+
tigrbl_base/_base/_security_base.py,sha256=LTv3TYeW6WX60KixLsEtIPta5ap4lmqDiSfBB1MP2YE,2284
|
|
9
|
+
tigrbl_base/_base/_session_abc.py,sha256=GxJBBcSXDgE7P_XGouZmMEnezD7fmd9MWU6KK8Qw24g,1028
|
|
10
|
+
tigrbl_base/_base/_session_base.py,sha256=D4HapOTe_mMIp7Z-O8ARWHEbmy1mFgzwhlFA6khBnMM,3879
|
|
11
|
+
tigrbl_base/_base/_storage.py,sha256=Pw8QYjZxL1Sg9IGRTIr6Hn2J9Caz4v-lsQAU0_MyFnw,303
|
|
12
|
+
tigrbl_base/_base/_table_base.py,sha256=zveQbRB1j-nRNGkZc3NOlnZ7u6hD5bzl3tJQLHTTwT0,18134
|
|
13
|
+
tigrbl_base/_base/_table_registry_base.py,sha256=0bkl0g6JFonzaaDiP5dVPxJ8g2LVqq25vyu9JniY1zM,1801
|
|
14
|
+
tigrbl_base-0.1.0.dev1.dist-info/METADATA,sha256=dfBp0JliSd6rJmMSocAm8MC82W6Nfm8QL_KNyqxcWdo,1755
|
|
15
|
+
tigrbl_base-0.1.0.dev1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
16
|
+
tigrbl_base-0.1.0.dev1.dist-info/RECORD,,
|