tigrbl-typing 0.1.0.dev5__tar.gz
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_typing-0.1.0.dev5/PKG-INFO +50 -0
- tigrbl_typing-0.1.0.dev5/README.md +29 -0
- tigrbl_typing-0.1.0.dev5/pyproject.toml +41 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/__init__.py +5 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/gw/__init__.py +3 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/gw/raw.py +26 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/phases.py +110 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/protocols.py +45 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/request.py +33 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/status/__init__.py +63 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/status/converters.py +222 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/status/exceptions.py +149 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/status/mappings.py +94 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/status/utils.py +114 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/types/__init__.py +129 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/types/authn_abc.py +33 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/types/op.py +35 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/types/uuid.py +55 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/vendor/__init__.py +1 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/vendor/pydantic.py +5 -0
- tigrbl_typing-0.1.0.dev5/tigrbl_typing/vendor/sqlalchemy.py +84 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tigrbl-typing
|
|
3
|
+
Version: 0.1.0.dev5
|
|
4
|
+
Summary: Typing protocols and shared type helpers for the Tigrbl framework.
|
|
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: pydantic (>=2.10,<3)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
# tigrbl-typing
|
|
24
|
+
|
|
25
|
+
    
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Modular package in the Tigrbl namespace.
|
|
30
|
+
- Supports Python 3.10 through 3.12.
|
|
31
|
+
- Distributed as part of the swarmauri-sdk workspace.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
### uv
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv add tigrbl-typing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### pip
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install tigrbl-typing
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Import from the shared package-specific module namespaces after installation in your environment.
|
|
50
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# tigrbl-typing
|
|
4
|
+
|
|
5
|
+
    
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Modular package in the Tigrbl namespace.
|
|
10
|
+
- Supports Python 3.10 through 3.12.
|
|
11
|
+
- Distributed as part of the swarmauri-sdk workspace.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
### uv
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add tigrbl-typing
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### pip
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tigrbl-typing
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Import from the shared package-specific module namespaces after installation in your environment.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tigrbl-typing"
|
|
3
|
+
version = "0.1.0.dev5"
|
|
4
|
+
description = "Typing protocols and shared type helpers for the Tigrbl framework."
|
|
5
|
+
license = "Apache-2.0"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
repository = "http://github.com/swarmauri/swarmauri-sdk"
|
|
8
|
+
requires-python = ">=3.10,<3.13"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"License :: OSI Approved :: Apache Software License",
|
|
11
|
+
"Development Status :: 1 - Planning",
|
|
12
|
+
"Programming Language :: Python :: 3.10",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
]
|
|
19
|
+
authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"pydantic>=2.10,<3",
|
|
22
|
+
]
|
|
23
|
+
keywords = ["tigrbl", "sdk", "standards", "framework"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["poetry-core>=1.0.0"]
|
|
27
|
+
build-backend = "poetry.core.masonry.api"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
[tool.poetry]
|
|
31
|
+
packages = [
|
|
32
|
+
{ include = "tigrbl_typing" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
"pytest-asyncio>=0.24.0",
|
|
39
|
+
"pytest-timeout>=2.3.1",
|
|
40
|
+
"ruff>=0.9",
|
|
41
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Awaitable, Callable, Literal, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class GwRawEnvelope:
|
|
9
|
+
kind: Literal["asgi3"]
|
|
10
|
+
scope: dict[str, Any]
|
|
11
|
+
receive: Callable[[], Awaitable[dict[str, Any]]]
|
|
12
|
+
send: Callable[[dict[str, Any]], Awaitable[None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class GwRouteEnvelope:
|
|
17
|
+
transport: Literal["http", "ws", "sse", "stream", "http3"]
|
|
18
|
+
scheme: Literal["http", "https", "ws", "wss", "h3"]
|
|
19
|
+
kind: Literal["rest", "jsonrpc", "maybe-jsonrpc", "unknown"]
|
|
20
|
+
method: str | None
|
|
21
|
+
path: str | None
|
|
22
|
+
headers: Mapping[str, str]
|
|
23
|
+
query: Mapping[str, Sequence[str]]
|
|
24
|
+
body: bytes | None
|
|
25
|
+
ws_event: Any | None
|
|
26
|
+
rpc: Mapping[str, Any] | None
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Literal, Tuple
|
|
5
|
+
|
|
6
|
+
HookPhase = Literal[
|
|
7
|
+
"PRE_TX_BEGIN",
|
|
8
|
+
"START_TX",
|
|
9
|
+
"PRE_HANDLER",
|
|
10
|
+
"HANDLER",
|
|
11
|
+
"POST_HANDLER",
|
|
12
|
+
"PRE_COMMIT",
|
|
13
|
+
"END_TX",
|
|
14
|
+
"POST_COMMIT",
|
|
15
|
+
"POST_RESPONSE",
|
|
16
|
+
"ON_ERROR",
|
|
17
|
+
"ON_PRE_TX_BEGIN_ERROR",
|
|
18
|
+
"ON_START_TX_ERROR",
|
|
19
|
+
"ON_PRE_HANDLER_ERROR",
|
|
20
|
+
"ON_HANDLER_ERROR",
|
|
21
|
+
"ON_POST_HANDLER_ERROR",
|
|
22
|
+
"ON_PRE_COMMIT_ERROR",
|
|
23
|
+
"ON_END_TX_ERROR",
|
|
24
|
+
"ON_POST_COMMIT_ERROR",
|
|
25
|
+
"ON_POST_RESPONSE_ERROR",
|
|
26
|
+
"ON_ROLLBACK",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
Phase = Literal[
|
|
30
|
+
"INGRESS_BEGIN",
|
|
31
|
+
"INGRESS_PARSE",
|
|
32
|
+
"INGRESS_ROUTE",
|
|
33
|
+
"PRE_TX_BEGIN",
|
|
34
|
+
"START_TX",
|
|
35
|
+
"PRE_HANDLER",
|
|
36
|
+
"HANDLER",
|
|
37
|
+
"POST_HANDLER",
|
|
38
|
+
"PRE_COMMIT",
|
|
39
|
+
"END_TX",
|
|
40
|
+
"POST_COMMIT",
|
|
41
|
+
"EGRESS_SHAPE",
|
|
42
|
+
"EGRESS_FINALIZE",
|
|
43
|
+
"POST_RESPONSE",
|
|
44
|
+
"ON_ERROR",
|
|
45
|
+
"ON_PRE_TX_BEGIN_ERROR",
|
|
46
|
+
"ON_START_TX_ERROR",
|
|
47
|
+
"ON_PRE_HANDLER_ERROR",
|
|
48
|
+
"ON_HANDLER_ERROR",
|
|
49
|
+
"ON_POST_HANDLER_ERROR",
|
|
50
|
+
"ON_PRE_COMMIT_ERROR",
|
|
51
|
+
"ON_END_TX_ERROR",
|
|
52
|
+
"ON_POST_COMMIT_ERROR",
|
|
53
|
+
"ON_POST_RESPONSE_ERROR",
|
|
54
|
+
"ON_ROLLBACK",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PHASE(str, Enum):
|
|
59
|
+
PRE_TX_BEGIN = "PRE_TX_BEGIN"
|
|
60
|
+
START_TX = "START_TX"
|
|
61
|
+
PRE_HANDLER = "PRE_HANDLER"
|
|
62
|
+
HANDLER = "HANDLER"
|
|
63
|
+
POST_HANDLER = "POST_HANDLER"
|
|
64
|
+
PRE_COMMIT = "PRE_COMMIT"
|
|
65
|
+
END_TX = "END_TX"
|
|
66
|
+
POST_COMMIT = "POST_COMMIT"
|
|
67
|
+
POST_RESPONSE = "POST_RESPONSE"
|
|
68
|
+
ON_ERROR = "ON_ERROR"
|
|
69
|
+
ON_PRE_TX_BEGIN_ERROR = "ON_PRE_TX_BEGIN_ERROR"
|
|
70
|
+
ON_START_TX_ERROR = "ON_START_TX_ERROR"
|
|
71
|
+
ON_PRE_HANDLER_ERROR = "ON_PRE_HANDLER_ERROR"
|
|
72
|
+
ON_HANDLER_ERROR = "ON_HANDLER_ERROR"
|
|
73
|
+
ON_POST_HANDLER_ERROR = "ON_POST_HANDLER_ERROR"
|
|
74
|
+
ON_PRE_COMMIT_ERROR = "ON_PRE_COMMIT_ERROR"
|
|
75
|
+
ON_END_TX_ERROR = "ON_END_TX_ERROR"
|
|
76
|
+
ON_POST_COMMIT_ERROR = "ON_POST_COMMIT_ERROR"
|
|
77
|
+
ON_POST_RESPONSE_ERROR = "ON_POST_RESPONSE_ERROR"
|
|
78
|
+
ON_ROLLBACK = "ON_ROLLBACK"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
HOOK_PHASES: Tuple[HookPhase, ...] = tuple(p.value for p in PHASE)
|
|
82
|
+
PHASES: Tuple[Phase, ...] = (
|
|
83
|
+
"INGRESS_BEGIN",
|
|
84
|
+
"INGRESS_PARSE",
|
|
85
|
+
"INGRESS_ROUTE",
|
|
86
|
+
"PRE_TX_BEGIN",
|
|
87
|
+
"START_TX",
|
|
88
|
+
"PRE_HANDLER",
|
|
89
|
+
"HANDLER",
|
|
90
|
+
"POST_HANDLER",
|
|
91
|
+
"PRE_COMMIT",
|
|
92
|
+
"END_TX",
|
|
93
|
+
"POST_COMMIT",
|
|
94
|
+
"EGRESS_SHAPE",
|
|
95
|
+
"EGRESS_FINALIZE",
|
|
96
|
+
"POST_RESPONSE",
|
|
97
|
+
"ON_ERROR",
|
|
98
|
+
"ON_PRE_TX_BEGIN_ERROR",
|
|
99
|
+
"ON_START_TX_ERROR",
|
|
100
|
+
"ON_PRE_HANDLER_ERROR",
|
|
101
|
+
"ON_HANDLER_ERROR",
|
|
102
|
+
"ON_POST_HANDLER_ERROR",
|
|
103
|
+
"ON_PRE_COMMIT_ERROR",
|
|
104
|
+
"ON_END_TX_ERROR",
|
|
105
|
+
"ON_POST_COMMIT_ERROR",
|
|
106
|
+
"ON_POST_RESPONSE_ERROR",
|
|
107
|
+
"ON_ROLLBACK",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
__all__ = ["PHASE", "PHASES", "HOOK_PHASES", "Phase", "HookPhase"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class RequestLike(Protocol):
|
|
8
|
+
query_params: dict[str, Any]
|
|
9
|
+
path_params: dict[str, Any]
|
|
10
|
+
headers: dict[str, Any]
|
|
11
|
+
|
|
12
|
+
def json_sync(self) -> Any: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class DependencyLike(Protocol):
|
|
17
|
+
dependency: Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class ResponseLike(Protocol):
|
|
22
|
+
status_code: int
|
|
23
|
+
raw_headers: list[tuple[bytes, bytes]]
|
|
24
|
+
body: bytes | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_dependency_like(obj: Any) -> bool:
|
|
28
|
+
return isinstance(obj, DependencyLike) and callable(
|
|
29
|
+
getattr(obj, "dependency", None)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_response_like(obj: Any) -> bool:
|
|
34
|
+
if not isinstance(obj, ResponseLike):
|
|
35
|
+
return False
|
|
36
|
+
return isinstance(getattr(obj, "raw_headers", None), list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"RequestLike",
|
|
41
|
+
"DependencyLike",
|
|
42
|
+
"ResponseLike",
|
|
43
|
+
"is_dependency_like",
|
|
44
|
+
"is_response_like",
|
|
45
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class URL:
|
|
9
|
+
path: str
|
|
10
|
+
query: dict[str, list[str]]
|
|
11
|
+
script_name: str = ""
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
base = (self.script_name or "").rstrip("/")
|
|
15
|
+
query_string = "&".join(
|
|
16
|
+
f"{name}={value}" for name, values in self.query.items() for value in values
|
|
17
|
+
)
|
|
18
|
+
path = f"{base}{self.path}" if base else self.path
|
|
19
|
+
return f"{path}?{query_string}" if query_string else path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AwaitableValue:
|
|
24
|
+
value: Any
|
|
25
|
+
|
|
26
|
+
def __await__(self):
|
|
27
|
+
async def _value() -> Any:
|
|
28
|
+
return self.value
|
|
29
|
+
|
|
30
|
+
return _value().__await__()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["URL", "AwaitableValue"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .exceptions import HTTPException, StatusDetailError
|
|
4
|
+
from .mappings import (
|
|
5
|
+
status,
|
|
6
|
+
HTTP_ERROR_MESSAGES,
|
|
7
|
+
ERROR_MESSAGES,
|
|
8
|
+
_HTTP_TO_RPC,
|
|
9
|
+
_RPC_TO_HTTP,
|
|
10
|
+
)
|
|
11
|
+
from .converters import (
|
|
12
|
+
http_exc_to_rpc,
|
|
13
|
+
rpc_error_to_http,
|
|
14
|
+
_http_exc_to_rpc,
|
|
15
|
+
_rpc_error_to_http,
|
|
16
|
+
create_standardized_error,
|
|
17
|
+
create_standardized_error_from_status,
|
|
18
|
+
to_rpc_error_payload,
|
|
19
|
+
)
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
TigrblError,
|
|
22
|
+
PlanningError,
|
|
23
|
+
LabelError,
|
|
24
|
+
ConfigError,
|
|
25
|
+
SystemStepError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
TransformError,
|
|
28
|
+
DeriveError,
|
|
29
|
+
KernelAbort,
|
|
30
|
+
coerce_runtime_error,
|
|
31
|
+
raise_for_in_errors,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"HTTPException",
|
|
36
|
+
"StatusDetailError",
|
|
37
|
+
"status",
|
|
38
|
+
# maps & messages
|
|
39
|
+
"HTTP_ERROR_MESSAGES",
|
|
40
|
+
"ERROR_MESSAGES",
|
|
41
|
+
"_HTTP_TO_RPC",
|
|
42
|
+
"_RPC_TO_HTTP",
|
|
43
|
+
# conversions
|
|
44
|
+
"http_exc_to_rpc",
|
|
45
|
+
"rpc_error_to_http",
|
|
46
|
+
"_http_exc_to_rpc",
|
|
47
|
+
"_rpc_error_to_http",
|
|
48
|
+
"create_standardized_error",
|
|
49
|
+
"create_standardized_error_from_status",
|
|
50
|
+
"to_rpc_error_payload",
|
|
51
|
+
# typed errors + helpers
|
|
52
|
+
"TigrblError",
|
|
53
|
+
"PlanningError",
|
|
54
|
+
"LabelError",
|
|
55
|
+
"ConfigError",
|
|
56
|
+
"SystemStepError",
|
|
57
|
+
"ValidationError",
|
|
58
|
+
"TransformError",
|
|
59
|
+
"DeriveError",
|
|
60
|
+
"KernelAbort",
|
|
61
|
+
"coerce_runtime_error",
|
|
62
|
+
"raise_for_in_errors",
|
|
63
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Tuple
|
|
4
|
+
|
|
5
|
+
from .utils import (
|
|
6
|
+
PydanticValidationError,
|
|
7
|
+
RequestValidationError,
|
|
8
|
+
IntegrityError,
|
|
9
|
+
DBAPIError,
|
|
10
|
+
OperationalError,
|
|
11
|
+
NoResultFound,
|
|
12
|
+
_is_asyncpg_constraint_error,
|
|
13
|
+
_stringify_exc,
|
|
14
|
+
_format_validation,
|
|
15
|
+
)
|
|
16
|
+
from .exceptions import HTTPException, TigrblError
|
|
17
|
+
from .mappings import (
|
|
18
|
+
status,
|
|
19
|
+
_HTTP_TO_RPC,
|
|
20
|
+
_RPC_TO_HTTP,
|
|
21
|
+
ERROR_MESSAGES,
|
|
22
|
+
HTTP_ERROR_MESSAGES,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def http_exc_to_rpc(exc: HTTPException) -> tuple[int, str, Any | None]:
|
|
27
|
+
"""Convert HTTPException → (rpc_code, message, data)."""
|
|
28
|
+
code = _HTTP_TO_RPC.get(exc.status_code, -32603)
|
|
29
|
+
detail = exc.detail
|
|
30
|
+
if isinstance(detail, (dict, list)):
|
|
31
|
+
return code, ERROR_MESSAGES.get(code, "Unknown error"), detail
|
|
32
|
+
msg = getattr(exc, "rpc_message", None) or (
|
|
33
|
+
detail if isinstance(detail, str) else None
|
|
34
|
+
)
|
|
35
|
+
if not msg:
|
|
36
|
+
msg = ERROR_MESSAGES.get(
|
|
37
|
+
code, HTTP_ERROR_MESSAGES.get(exc.status_code, "Unknown error")
|
|
38
|
+
)
|
|
39
|
+
data = getattr(exc, "rpc_data", None)
|
|
40
|
+
return code, msg, data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rpc_error_to_http(
|
|
44
|
+
rpc_code: int, message: str | None = None, data: Any | None = None
|
|
45
|
+
) -> HTTPException:
|
|
46
|
+
"""Convert JSON-RPC error code (and optional message/data) → HTTPException."""
|
|
47
|
+
http_status = _RPC_TO_HTTP.get(rpc_code, 500)
|
|
48
|
+
msg = (
|
|
49
|
+
message
|
|
50
|
+
or HTTP_ERROR_MESSAGES.get(http_status)
|
|
51
|
+
or ERROR_MESSAGES.get(rpc_code, "Unknown error")
|
|
52
|
+
)
|
|
53
|
+
http_exc = HTTPException(status_code=http_status, detail=msg)
|
|
54
|
+
setattr(http_exc, "rpc_code", rpc_code)
|
|
55
|
+
setattr(http_exc, "rpc_message", msg)
|
|
56
|
+
setattr(http_exc, "rpc_data", data)
|
|
57
|
+
return http_exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _http_exc_to_rpc(exc: HTTPException) -> tuple[int, str, Any | None]:
|
|
61
|
+
"""Alias for :func:`http_exc_to_rpc` to preserve older import paths."""
|
|
62
|
+
return http_exc_to_rpc(exc)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rpc_error_to_http(
|
|
66
|
+
rpc_code: int, message: str | None = None, data: Any | None = None
|
|
67
|
+
) -> HTTPException:
|
|
68
|
+
"""Alias for :func:`rpc_error_to_http` to preserve older import paths."""
|
|
69
|
+
return rpc_error_to_http(rpc_code, message, data)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _classify_exception(
|
|
73
|
+
exc: BaseException,
|
|
74
|
+
) -> Tuple[int, str | dict | list, Any | None]:
|
|
75
|
+
"""
|
|
76
|
+
Return (http_status, detail_or_message, data) suitable for HTTPException and JSON-RPC mapping.
|
|
77
|
+
`detail_or_message` may be a string OR a structured dict/list (validation).
|
|
78
|
+
"""
|
|
79
|
+
# 0) Typed Tigrbl errors
|
|
80
|
+
if isinstance(exc, TigrblError):
|
|
81
|
+
status_code = getattr(exc, "status", 400) or 400
|
|
82
|
+
details = getattr(exc, "details", None)
|
|
83
|
+
if isinstance(details, (dict, list)):
|
|
84
|
+
return status_code, details, details
|
|
85
|
+
return status_code, str(exc) or exc.code, None
|
|
86
|
+
|
|
87
|
+
# 1) Pass-through HTTPException preserving detail
|
|
88
|
+
if isinstance(exc, HTTPException):
|
|
89
|
+
return exc.status_code, exc.detail, getattr(exc, "rpc_data", None)
|
|
90
|
+
|
|
91
|
+
# 1b) Compatibility shim for framework-style exceptions that expose
|
|
92
|
+
# ``status_code`` and ``detail`` but are not our HTTPException type.
|
|
93
|
+
status_code = getattr(exc, "status_code", None)
|
|
94
|
+
if isinstance(status_code, int):
|
|
95
|
+
detail = getattr(exc, "detail", None)
|
|
96
|
+
if detail in (None, ""):
|
|
97
|
+
detail = _stringify_exc(exc)
|
|
98
|
+
return int(status_code), detail, None
|
|
99
|
+
|
|
100
|
+
# 2) Validation errors → 422 with structured data
|
|
101
|
+
if (PydanticValidationError is not None) and isinstance(
|
|
102
|
+
exc, PydanticValidationError
|
|
103
|
+
):
|
|
104
|
+
return (
|
|
105
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
106
|
+
HTTP_ERROR_MESSAGES.get(422, "Validation failed"),
|
|
107
|
+
_format_validation(exc),
|
|
108
|
+
)
|
|
109
|
+
if (RequestValidationError is not None) and isinstance(exc, RequestValidationError):
|
|
110
|
+
return (
|
|
111
|
+
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
112
|
+
HTTP_ERROR_MESSAGES.get(422, "Validation failed"),
|
|
113
|
+
_format_validation(exc),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 3) Common client errors
|
|
117
|
+
if isinstance(exc, (ValueError, TypeError, KeyError)):
|
|
118
|
+
return status.HTTP_400_BAD_REQUEST, _stringify_exc(exc), None
|
|
119
|
+
if isinstance(exc, PermissionError):
|
|
120
|
+
return status.HTTP_403_FORBIDDEN, _stringify_exc(exc), None
|
|
121
|
+
if isinstance(exc, NotImplementedError):
|
|
122
|
+
return status.HTTP_501_NOT_IMPLEMENTED, _stringify_exc(exc), None
|
|
123
|
+
if isinstance(exc, TimeoutError):
|
|
124
|
+
return status.HTTP_504_GATEWAY_TIMEOUT, _stringify_exc(exc), None
|
|
125
|
+
|
|
126
|
+
# 4) ORM/DB mapping
|
|
127
|
+
if (NoResultFound is not None) and isinstance(exc, NoResultFound):
|
|
128
|
+
return status.HTTP_404_NOT_FOUND, "Resource not found", None
|
|
129
|
+
|
|
130
|
+
if _is_asyncpg_constraint_error(exc):
|
|
131
|
+
return status.HTTP_409_CONFLICT, _stringify_exc(exc), None
|
|
132
|
+
|
|
133
|
+
if (IntegrityError is not None) and isinstance(exc, IntegrityError):
|
|
134
|
+
msg = _stringify_exc(exc)
|
|
135
|
+
lower_msg = msg.lower()
|
|
136
|
+
if "not null constraint" in lower_msg or "check constraint" in lower_msg:
|
|
137
|
+
return status.HTTP_422_UNPROCESSABLE_ENTITY, msg, None
|
|
138
|
+
return status.HTTP_409_CONFLICT, msg, None
|
|
139
|
+
|
|
140
|
+
if (OperationalError is not None) and isinstance(exc, OperationalError):
|
|
141
|
+
return status.HTTP_503_SERVICE_UNAVAILABLE, _stringify_exc(exc), None
|
|
142
|
+
|
|
143
|
+
if (DBAPIError is not None) and isinstance(exc, DBAPIError):
|
|
144
|
+
return status.HTTP_500_INTERNAL_SERVER_ERROR, _stringify_exc(exc), None
|
|
145
|
+
|
|
146
|
+
# 5) Fallback
|
|
147
|
+
return status.HTTP_500_INTERNAL_SERVER_ERROR, _stringify_exc(exc), None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def create_standardized_error(exc: BaseException) -> HTTPException:
|
|
151
|
+
"""
|
|
152
|
+
Normalize any exception → HTTPException with attached RPC context:
|
|
153
|
+
• .rpc_code
|
|
154
|
+
• .rpc_message
|
|
155
|
+
• .rpc_data
|
|
156
|
+
"""
|
|
157
|
+
http_status, detail_or_message, data = _classify_exception(exc)
|
|
158
|
+
rpc_code = _HTTP_TO_RPC.get(http_status, -32603)
|
|
159
|
+
if isinstance(detail_or_message, (dict, list)):
|
|
160
|
+
http_detail = detail_or_message
|
|
161
|
+
rpc_message = ERROR_MESSAGES.get(
|
|
162
|
+
rpc_code, HTTP_ERROR_MESSAGES.get(http_status, "Unknown error")
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
http_detail = detail_or_message
|
|
166
|
+
rpc_message = detail_or_message or ERROR_MESSAGES.get(
|
|
167
|
+
rpc_code, HTTP_ERROR_MESSAGES.get(http_status, "Unknown error")
|
|
168
|
+
)
|
|
169
|
+
http_exc = HTTPException(status_code=http_status, detail=http_detail)
|
|
170
|
+
setattr(http_exc, "rpc_code", rpc_code)
|
|
171
|
+
setattr(http_exc, "rpc_message", rpc_message)
|
|
172
|
+
setattr(http_exc, "rpc_data", data)
|
|
173
|
+
return http_exc
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_standardized_error_from_status(
|
|
177
|
+
http_status: int,
|
|
178
|
+
message: str | None = None,
|
|
179
|
+
*,
|
|
180
|
+
rpc_code: int | None = None,
|
|
181
|
+
data: Any | None = None,
|
|
182
|
+
) -> tuple[HTTPException, int, str]:
|
|
183
|
+
"""Explicit constructor used by code paths that already decided on an HTTP status."""
|
|
184
|
+
if rpc_code is None:
|
|
185
|
+
rpc_code = _HTTP_TO_RPC.get(http_status, -32603)
|
|
186
|
+
if message is None:
|
|
187
|
+
http_message = HTTP_ERROR_MESSAGES.get(http_status) or ERROR_MESSAGES.get(
|
|
188
|
+
rpc_code, "Unknown error"
|
|
189
|
+
)
|
|
190
|
+
rpc_message = ERROR_MESSAGES.get(rpc_code) or HTTP_ERROR_MESSAGES.get(
|
|
191
|
+
http_status, "Unknown error"
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
http_message = rpc_message = message
|
|
195
|
+
http_exc = HTTPException(status_code=http_status, detail=http_message)
|
|
196
|
+
setattr(http_exc, "rpc_code", rpc_code)
|
|
197
|
+
setattr(http_exc, "rpc_message", rpc_message)
|
|
198
|
+
setattr(http_exc, "rpc_data", data)
|
|
199
|
+
return http_exc, rpc_code, rpc_message
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def to_rpc_error_payload(exc: HTTPException) -> dict:
|
|
203
|
+
"""Produce a JSON-RPC error object from an HTTPException (with or without rpc_* attrs)."""
|
|
204
|
+
code, msg, data = http_exc_to_rpc(exc)
|
|
205
|
+
payload = {"code": code, "message": msg}
|
|
206
|
+
if data is not None:
|
|
207
|
+
payload["data"] = data
|
|
208
|
+
else:
|
|
209
|
+
if isinstance(exc.detail, (dict, list)):
|
|
210
|
+
payload["data"] = exc.detail
|
|
211
|
+
return payload
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"http_exc_to_rpc",
|
|
216
|
+
"rpc_error_to_http",
|
|
217
|
+
"_http_exc_to_rpc",
|
|
218
|
+
"_rpc_error_to_http",
|
|
219
|
+
"create_standardized_error",
|
|
220
|
+
"create_standardized_error_from_status",
|
|
221
|
+
"to_rpc_error_payload",
|
|
222
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from .utils import _read_in_errors, _has_in_errors
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StatusDetailError(Exception):
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
status_code: int,
|
|
12
|
+
detail: Any = "",
|
|
13
|
+
headers: Mapping[str, str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(detail)
|
|
16
|
+
self.status_code = int(status_code)
|
|
17
|
+
self.detail = detail
|
|
18
|
+
self.headers = dict(headers or {})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HTTPException(StatusDetailError):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
status_code: int,
|
|
25
|
+
detail: Any = "",
|
|
26
|
+
headers: Mapping[str, str] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(status_code=status_code, detail=detail, headers=headers)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TigrblError(Exception):
|
|
32
|
+
"""Base class for runtime errors in Tigrbl v3."""
|
|
33
|
+
|
|
34
|
+
code: str = "tigrbl_error"
|
|
35
|
+
status: int = 400
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
message: str = "",
|
|
40
|
+
*,
|
|
41
|
+
code: Optional[str] = None,
|
|
42
|
+
status: Optional[int] = None,
|
|
43
|
+
details: Any = None,
|
|
44
|
+
cause: Optional[BaseException] = None,
|
|
45
|
+
):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
if cause is not None:
|
|
48
|
+
self.__cause__ = cause
|
|
49
|
+
if code is not None:
|
|
50
|
+
self.code = code
|
|
51
|
+
if status is not None:
|
|
52
|
+
self.status = status
|
|
53
|
+
self.details = details
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
56
|
+
d = {
|
|
57
|
+
"type": self.__class__.__name__,
|
|
58
|
+
"code": self.code,
|
|
59
|
+
"status": self.status,
|
|
60
|
+
"message": str(self),
|
|
61
|
+
}
|
|
62
|
+
if self.details is not None:
|
|
63
|
+
d["details"] = self.details
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PlanningError(TigrblError):
|
|
68
|
+
code = "planning_error"
|
|
69
|
+
status = 500
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LabelError(TigrblError):
|
|
73
|
+
code = "label_error"
|
|
74
|
+
status = 400
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfigError(TigrblError):
|
|
78
|
+
code = "config_error"
|
|
79
|
+
status = 400
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SystemStepError(TigrblError):
|
|
83
|
+
code = "system_step_error"
|
|
84
|
+
status = 500
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ValidationError(TigrblError):
|
|
88
|
+
code = "validation_error"
|
|
89
|
+
status = 422
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def from_ctx(
|
|
93
|
+
ctx: Any, message: str = "Input validation failed."
|
|
94
|
+
) -> "ValidationError":
|
|
95
|
+
return ValidationError(message, status=422, details=_read_in_errors(ctx))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TransformError(TigrblError):
|
|
99
|
+
code = "transform_error"
|
|
100
|
+
status = 400
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DeriveError(TigrblError):
|
|
104
|
+
code = "derive_error"
|
|
105
|
+
status = 400
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class KernelAbort(TigrblError):
|
|
109
|
+
code = "kernel_abort"
|
|
110
|
+
status = 403
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def coerce_runtime_error(exc: BaseException, ctx: Any | None = None) -> TigrblError:
|
|
114
|
+
"""
|
|
115
|
+
Map arbitrary exceptions to a typed TigrblError for consistent kernel handling.
|
|
116
|
+
- Already TigrblError → return as-is
|
|
117
|
+
- ValueError + ctx.temp['in_errors'] → ValidationError
|
|
118
|
+
- Otherwise → generic TigrblError
|
|
119
|
+
"""
|
|
120
|
+
if isinstance(exc, TigrblError):
|
|
121
|
+
return exc
|
|
122
|
+
if isinstance(exc, ValueError) and ctx is not None and _has_in_errors(ctx):
|
|
123
|
+
return ValidationError.from_ctx(
|
|
124
|
+
ctx, message=str(exc) or "Input validation failed."
|
|
125
|
+
)
|
|
126
|
+
return TigrblError(str(exc) or exc.__class__.__name__)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def raise_for_in_errors(ctx: Any) -> None:
|
|
130
|
+
"""Raise a typed ValidationError if ctx.temp['in_errors'] indicates invalid input."""
|
|
131
|
+
if _has_in_errors(ctx):
|
|
132
|
+
raise ValidationError.from_ctx(ctx)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"StatusDetailError",
|
|
137
|
+
"HTTPException",
|
|
138
|
+
"TigrblError",
|
|
139
|
+
"PlanningError",
|
|
140
|
+
"LabelError",
|
|
141
|
+
"ConfigError",
|
|
142
|
+
"SystemStepError",
|
|
143
|
+
"ValidationError",
|
|
144
|
+
"TransformError",
|
|
145
|
+
"DeriveError",
|
|
146
|
+
"KernelAbort",
|
|
147
|
+
"coerce_runtime_error",
|
|
148
|
+
"raise_for_in_errors",
|
|
149
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class _Status:
|
|
5
|
+
HTTP_200_OK = 200
|
|
6
|
+
HTTP_201_CREATED = 201
|
|
7
|
+
HTTP_204_NO_CONTENT = 204
|
|
8
|
+
HTTP_400_BAD_REQUEST = 400
|
|
9
|
+
HTTP_401_UNAUTHORIZED = 401
|
|
10
|
+
HTTP_403_FORBIDDEN = 403
|
|
11
|
+
HTTP_404_NOT_FOUND = 404
|
|
12
|
+
HTTP_405_METHOD_NOT_ALLOWED = 405
|
|
13
|
+
HTTP_409_CONFLICT = 409
|
|
14
|
+
HTTP_422_UNPROCESSABLE_ENTITY = 422
|
|
15
|
+
HTTP_429_TOO_MANY_REQUESTS = 429
|
|
16
|
+
HTTP_500_INTERNAL_SERVER_ERROR = 500
|
|
17
|
+
HTTP_501_NOT_IMPLEMENTED = 501
|
|
18
|
+
HTTP_503_SERVICE_UNAVAILABLE = 503
|
|
19
|
+
HTTP_504_GATEWAY_TIMEOUT = 504
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
status = _Status()
|
|
23
|
+
|
|
24
|
+
# HTTP → JSON-RPC code map
|
|
25
|
+
_HTTP_TO_RPC: dict[int, int] = {
|
|
26
|
+
400: -32602,
|
|
27
|
+
401: -32001,
|
|
28
|
+
403: -32002,
|
|
29
|
+
404: -32003,
|
|
30
|
+
409: -32004,
|
|
31
|
+
422: -32602,
|
|
32
|
+
500: -32603,
|
|
33
|
+
501: -32603,
|
|
34
|
+
503: -32603,
|
|
35
|
+
504: -32603,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# JSON-RPC → HTTP status map
|
|
39
|
+
_RPC_TO_HTTP: dict[int, int] = {
|
|
40
|
+
-32700: 400,
|
|
41
|
+
-32600: 400,
|
|
42
|
+
-32601: 404,
|
|
43
|
+
-32602: 400,
|
|
44
|
+
-32603: 500,
|
|
45
|
+
-32001: 401,
|
|
46
|
+
-32002: 403,
|
|
47
|
+
-32003: 404,
|
|
48
|
+
-32004: 409,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Standardized error messages
|
|
52
|
+
ERROR_MESSAGES: dict[int, str] = {
|
|
53
|
+
-32700: "Parse error",
|
|
54
|
+
-32600: "Invalid Request",
|
|
55
|
+
-32601: "Method not found",
|
|
56
|
+
-32602: "Invalid params",
|
|
57
|
+
-32603: "Internal error",
|
|
58
|
+
-32001: "Authentication required",
|
|
59
|
+
-32002: "Insufficient permissions",
|
|
60
|
+
-32003: "Resource not found",
|
|
61
|
+
-32004: "Resource conflict",
|
|
62
|
+
-32000: "Server error",
|
|
63
|
+
-32099: "Duplicate key constraint violation",
|
|
64
|
+
-32098: "Data constraint violation",
|
|
65
|
+
-32097: "Foreign key constraint violation",
|
|
66
|
+
-32096: "Authentication required",
|
|
67
|
+
-32095: "Authorization failed",
|
|
68
|
+
-32094: "Resource not found",
|
|
69
|
+
-32093: "Validation error",
|
|
70
|
+
-32092: "Transaction failed",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# HTTP status code → standardized message
|
|
74
|
+
HTTP_ERROR_MESSAGES: dict[int, str] = {
|
|
75
|
+
400: "Bad Request: malformed input",
|
|
76
|
+
401: "Unauthorized: authentication required",
|
|
77
|
+
403: "Forbidden: insufficient permissions",
|
|
78
|
+
404: "Not Found: resource does not exist",
|
|
79
|
+
409: "Conflict: duplicate key or constraint violation",
|
|
80
|
+
422: "Unprocessable Entity: validation failed",
|
|
81
|
+
500: "Internal Server Error: unexpected server error",
|
|
82
|
+
501: "Not Implemented",
|
|
83
|
+
503: "Service Unavailable",
|
|
84
|
+
504: "Gateway Timeout",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"_Status",
|
|
89
|
+
"status",
|
|
90
|
+
"_HTTP_TO_RPC",
|
|
91
|
+
"_RPC_TO_HTTP",
|
|
92
|
+
"ERROR_MESSAGES",
|
|
93
|
+
"HTTP_ERROR_MESSAGES",
|
|
94
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Iterable, List, Mapping
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# Optional imports – code must run even if these packages aren’t installed.
|
|
9
|
+
try:
|
|
10
|
+
from pydantic import ValidationError as PydanticValidationError # v2
|
|
11
|
+
except Exception: # pragma: no cover
|
|
12
|
+
PydanticValidationError = None # type: ignore
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import importlib
|
|
16
|
+
|
|
17
|
+
_http_exc = importlib.import_module("fast" + "router.exceptions")
|
|
18
|
+
RequestValidationError = _http_exc.RequestValidationError
|
|
19
|
+
except Exception: # pragma: no cover
|
|
20
|
+
RequestValidationError = None # type: ignore
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
# SQLAlchemy v1/v2 exception sets
|
|
24
|
+
from sqlalchemy.exc import IntegrityError, DBAPIError, OperationalError
|
|
25
|
+
from sqlalchemy.orm.exc import NoResultFound # type: ignore
|
|
26
|
+
except Exception: # pragma: no cover
|
|
27
|
+
IntegrityError = DBAPIError = OperationalError = NoResultFound = None # type: ignore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Detect asyncpg constraint errors without importing asyncpg (optional dep).
|
|
31
|
+
_ASYNCPG_CONSTRAINT_NAMES = {
|
|
32
|
+
"UniqueViolationError",
|
|
33
|
+
"ForeignKeyViolationError",
|
|
34
|
+
"NotNullViolationError",
|
|
35
|
+
"CheckViolationError",
|
|
36
|
+
"ExclusionViolationError",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_asyncpg_constraint_error(exc: BaseException) -> bool:
|
|
41
|
+
cls = type(exc)
|
|
42
|
+
return (cls.__module__ or "").startswith("asyncpg") and (
|
|
43
|
+
cls.__name__ in _ASYNCPG_CONSTRAINT_NAMES
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _limit(s: str, n: int = 4000) -> str:
|
|
48
|
+
return s if len(s) <= n else s[: n - 3] + "..."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _stringify_exc(exc: BaseException) -> str:
|
|
52
|
+
detail = getattr(exc, "detail", None)
|
|
53
|
+
if detail:
|
|
54
|
+
return _limit(str(detail))
|
|
55
|
+
return _limit(f"{exc.__class__!r}: {str(exc) or repr(exc)}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_validation(err: Any) -> Any:
|
|
59
|
+
try:
|
|
60
|
+
items = err.errors() # pydantic / asgi RequestValidationError
|
|
61
|
+
if isinstance(items, Iterable):
|
|
62
|
+
return list(items)
|
|
63
|
+
except Exception: # pragma: no cover
|
|
64
|
+
pass
|
|
65
|
+
return _limit(str(err))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_temp(ctx: Any) -> Mapping[str, Any]:
|
|
69
|
+
tmp = getattr(ctx, "temp", None)
|
|
70
|
+
return tmp if isinstance(tmp, Mapping) else {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _has_in_errors(ctx: Any) -> bool:
|
|
74
|
+
tmp = _get_temp(ctx)
|
|
75
|
+
if tmp.get("in_invalid") is True:
|
|
76
|
+
return True
|
|
77
|
+
errs = tmp.get("in_errors")
|
|
78
|
+
return isinstance(errs, (list, tuple)) and len(errs) > 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_in_errors(ctx: Any) -> List[Dict[str, Any]]:
|
|
82
|
+
tmp = _get_temp(ctx)
|
|
83
|
+
errs = tmp.get("in_errors")
|
|
84
|
+
if isinstance(errs, list):
|
|
85
|
+
norm: List[Dict[str, Any]] = []
|
|
86
|
+
for e in errs:
|
|
87
|
+
if isinstance(e, Mapping):
|
|
88
|
+
field = e.get("field")
|
|
89
|
+
code = e.get("code") or "invalid"
|
|
90
|
+
msg = e.get("message") or "Invalid value."
|
|
91
|
+
entry = {"field": field, "code": code, "message": msg}
|
|
92
|
+
for k, v in e.items():
|
|
93
|
+
if k not in entry:
|
|
94
|
+
entry[k] = v
|
|
95
|
+
norm.append(entry)
|
|
96
|
+
return norm
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"PydanticValidationError",
|
|
102
|
+
"RequestValidationError",
|
|
103
|
+
"IntegrityError",
|
|
104
|
+
"DBAPIError",
|
|
105
|
+
"OperationalError",
|
|
106
|
+
"NoResultFound",
|
|
107
|
+
"_is_asyncpg_constraint_error",
|
|
108
|
+
"_limit",
|
|
109
|
+
"_stringify_exc",
|
|
110
|
+
"_format_validation",
|
|
111
|
+
"_get_temp",
|
|
112
|
+
"_has_in_errors",
|
|
113
|
+
"_read_in_errors",
|
|
114
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ── Standard Library ─────────────────────────────────────────────────────
|
|
2
|
+
from types import MethodType, SimpleNamespace
|
|
3
|
+
from uuid import uuid4, UUID
|
|
4
|
+
|
|
5
|
+
# ── Third-party Dependencies (via deps module) ───────────────────────────
|
|
6
|
+
from ..vendor.sqlalchemy import (
|
|
7
|
+
# Core SQLAlchemy
|
|
8
|
+
Boolean,
|
|
9
|
+
Column,
|
|
10
|
+
_DateTime,
|
|
11
|
+
SAEnum,
|
|
12
|
+
Text,
|
|
13
|
+
ForeignKey,
|
|
14
|
+
Index,
|
|
15
|
+
Integer,
|
|
16
|
+
JSON,
|
|
17
|
+
Numeric,
|
|
18
|
+
String,
|
|
19
|
+
LargeBinary,
|
|
20
|
+
UniqueConstraint,
|
|
21
|
+
CheckConstraint,
|
|
22
|
+
create_engine,
|
|
23
|
+
event,
|
|
24
|
+
# PostgreSQL dialect
|
|
25
|
+
ARRAY,
|
|
26
|
+
PgEnum,
|
|
27
|
+
JSONB,
|
|
28
|
+
TSVECTOR,
|
|
29
|
+
# ORM
|
|
30
|
+
Mapped,
|
|
31
|
+
declarative_mixin,
|
|
32
|
+
declared_attr,
|
|
33
|
+
foreign,
|
|
34
|
+
mapped_column,
|
|
35
|
+
relationship,
|
|
36
|
+
remote,
|
|
37
|
+
column_property,
|
|
38
|
+
Session,
|
|
39
|
+
sessionmaker,
|
|
40
|
+
InstrumentedAttribute,
|
|
41
|
+
# Extensions
|
|
42
|
+
MutableDict,
|
|
43
|
+
MutableList,
|
|
44
|
+
hybrid_property,
|
|
45
|
+
StaticPool,
|
|
46
|
+
TypeDecorator,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
from ..vendor.pydantic import (
|
|
51
|
+
BaseModel,
|
|
52
|
+
Field,
|
|
53
|
+
ValidationError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from ..status.exceptions import StatusDetailError
|
|
57
|
+
|
|
58
|
+
# ── Local Package ─────────────────────────────────────────────────────────
|
|
59
|
+
from .op import _Op, _SchemaVerb
|
|
60
|
+
from .uuid import PgUUID, SqliteUUID
|
|
61
|
+
from .authn_abc import AuthNProvider
|
|
62
|
+
|
|
63
|
+
# ── Generics / Extensions ─────────────────────────────────────────────────
|
|
64
|
+
DateTime = _DateTime(timezone=False)
|
|
65
|
+
TZDateTime = _DateTime(timezone=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── Public Re-exports (Backwards Compatibility) ──────────────────────────
|
|
69
|
+
__all__: list[str] = [
|
|
70
|
+
# local
|
|
71
|
+
"_Op",
|
|
72
|
+
"_SchemaVerb",
|
|
73
|
+
"AuthNProvider",
|
|
74
|
+
# add ons
|
|
75
|
+
"SqliteUUID",
|
|
76
|
+
# builtin types
|
|
77
|
+
"MethodType",
|
|
78
|
+
"SimpleNamespace",
|
|
79
|
+
"uuid4",
|
|
80
|
+
"UUID",
|
|
81
|
+
# sqlalchemy core (from deps.sqlalchemy)
|
|
82
|
+
"Boolean",
|
|
83
|
+
"Column",
|
|
84
|
+
"DateTime",
|
|
85
|
+
"TZDateTime",
|
|
86
|
+
"Text",
|
|
87
|
+
"SAEnum",
|
|
88
|
+
"ForeignKey",
|
|
89
|
+
"Index",
|
|
90
|
+
"Integer",
|
|
91
|
+
"JSON",
|
|
92
|
+
"Numeric",
|
|
93
|
+
"String",
|
|
94
|
+
"LargeBinary",
|
|
95
|
+
"UniqueConstraint",
|
|
96
|
+
"CheckConstraint",
|
|
97
|
+
"create_engine",
|
|
98
|
+
"event",
|
|
99
|
+
# sqlalchemy.dialects.postgresql (from deps.sqlalchemy)
|
|
100
|
+
"ARRAY",
|
|
101
|
+
"PgEnum",
|
|
102
|
+
"JSONB",
|
|
103
|
+
"PgUUID",
|
|
104
|
+
"TSVECTOR",
|
|
105
|
+
# sqlalchemy.orm (from deps.sqlalchemy)
|
|
106
|
+
"Mapped",
|
|
107
|
+
"declarative_mixin",
|
|
108
|
+
"declared_attr",
|
|
109
|
+
"foreign",
|
|
110
|
+
"mapped_column",
|
|
111
|
+
"column_property",
|
|
112
|
+
"hybrid_property",
|
|
113
|
+
"relationship",
|
|
114
|
+
"remote",
|
|
115
|
+
"Session",
|
|
116
|
+
"sessionmaker",
|
|
117
|
+
"InstrumentedAttribute",
|
|
118
|
+
# sqlalchemy.ext.mutable (from deps.sqlalchemy)
|
|
119
|
+
"MutableDict",
|
|
120
|
+
"MutableList",
|
|
121
|
+
"StaticPool",
|
|
122
|
+
"TypeDecorator",
|
|
123
|
+
# pydantic schema support (from deps.pydantic)
|
|
124
|
+
"BaseModel",
|
|
125
|
+
"Field",
|
|
126
|
+
"ValidationError",
|
|
127
|
+
# status
|
|
128
|
+
"StatusDetailError",
|
|
129
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# tigrbl/types/authn_abc.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from tigrbl import Request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthNProvider(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Marker‑interface that any AuthN extension must implement
|
|
13
|
+
so that Tigrbl can plug itself in at run‑time.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# ---------- ASGI dependency ----------
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def get_principal(self, request: "Request"): # -> dict[str, str]
|
|
19
|
+
"""Return {"sub": user_id, "tid": tenant_id, ...} or raise HTTP 401."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["AuthNProvider"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
for _name in list(globals()):
|
|
26
|
+
if _name not in __all__ and not _name.startswith("__"):
|
|
27
|
+
del globals()[_name]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __dir__():
|
|
31
|
+
"""Tighten ``dir()`` output for interactive sessions."""
|
|
32
|
+
|
|
33
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tigrbl/types/op.py
|
|
3
|
+
Pure structural helpers."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, NamedTuple, Type, Literal, TypeAlias
|
|
6
|
+
|
|
7
|
+
_SchemaVerb: TypeAlias = Literal[
|
|
8
|
+
"create",
|
|
9
|
+
"read",
|
|
10
|
+
"update",
|
|
11
|
+
"replace",
|
|
12
|
+
"merge",
|
|
13
|
+
"delete",
|
|
14
|
+
"list",
|
|
15
|
+
"clear",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# need to add clear
|
|
19
|
+
# need to add support for bulk create, update, delete
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Op(NamedTuple):
|
|
23
|
+
"""
|
|
24
|
+
Metadata for one REST/RPC operation registered by Tigrbl.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
verb: str # e.g. "create", "list"
|
|
28
|
+
http: str # "POST" | "GET" | "PATCH" | …
|
|
29
|
+
path: str # URL suffix, e.g. "/{item_id}"
|
|
30
|
+
In: Type | None # Pydantic input model (or None)
|
|
31
|
+
Out: Type # Pydantic output model
|
|
32
|
+
core: Callable[..., Any] # The actual implementation
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["_Op", "_SchemaVerb"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ── Standard Library ─────────────────────────────────────────────────────
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
# ── Third-party Dependencies ────────────────────────────────────────────
|
|
8
|
+
from sqlalchemy.types import TypeDecorator
|
|
9
|
+
|
|
10
|
+
# ── Local Package ───────────────────────────────────────────────────────
|
|
11
|
+
from ..vendor.sqlalchemy import String, _PgUUID
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PgUUID(_PgUUID):
|
|
15
|
+
@property
|
|
16
|
+
def hex(self):
|
|
17
|
+
return self.as_uuid.hex
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SqliteUUID(TypeDecorator):
|
|
21
|
+
"""UUID type that stores hyphenated strings on SQLite to avoid numeric coercion."""
|
|
22
|
+
|
|
23
|
+
impl = String(36)
|
|
24
|
+
cache_ok = True
|
|
25
|
+
|
|
26
|
+
def __init__(self, as_uuid: bool = True):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.as_uuid = as_uuid
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def python_type(self) -> type:
|
|
32
|
+
return uuid.UUID if self.as_uuid else str
|
|
33
|
+
|
|
34
|
+
def load_dialect_impl(self, dialect) -> Any:
|
|
35
|
+
if dialect.name == "postgresql":
|
|
36
|
+
return dialect.type_descriptor(PgUUID(as_uuid=self.as_uuid))
|
|
37
|
+
return dialect.type_descriptor(String(36))
|
|
38
|
+
|
|
39
|
+
def process_bind_param(self, value: Any, dialect) -> Any:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
if self.as_uuid:
|
|
43
|
+
if not isinstance(value, uuid.UUID):
|
|
44
|
+
value = uuid.UUID(str(value))
|
|
45
|
+
return value if dialect.name == "postgresql" else str(value)
|
|
46
|
+
return str(value)
|
|
47
|
+
|
|
48
|
+
def process_result_value(self, value: Any, dialect) -> Any:
|
|
49
|
+
if value is None:
|
|
50
|
+
return None
|
|
51
|
+
if self.as_uuid:
|
|
52
|
+
if isinstance(value, uuid.UUID):
|
|
53
|
+
return value
|
|
54
|
+
return uuid.UUID(str(value))
|
|
55
|
+
return str(value)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vendor dependency re-export namespace."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""SQLAlchemy symbols re-exported for typing compatibility."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import (
|
|
4
|
+
Boolean,
|
|
5
|
+
CheckConstraint,
|
|
6
|
+
Column,
|
|
7
|
+
DateTime as _DateTime,
|
|
8
|
+
Enum as SAEnum,
|
|
9
|
+
ForeignKey,
|
|
10
|
+
Index,
|
|
11
|
+
Integer,
|
|
12
|
+
JSON,
|
|
13
|
+
LargeBinary,
|
|
14
|
+
Numeric,
|
|
15
|
+
String,
|
|
16
|
+
Text,
|
|
17
|
+
TypeDecorator,
|
|
18
|
+
UniqueConstraint,
|
|
19
|
+
create_engine,
|
|
20
|
+
event,
|
|
21
|
+
)
|
|
22
|
+
from sqlalchemy.dialects.postgresql import (
|
|
23
|
+
ARRAY,
|
|
24
|
+
ENUM as PgEnum,
|
|
25
|
+
JSONB,
|
|
26
|
+
TSVECTOR,
|
|
27
|
+
UUID as _PgUUID,
|
|
28
|
+
)
|
|
29
|
+
from sqlalchemy.ext.hybrid import hybrid_property
|
|
30
|
+
from sqlalchemy.ext.mutable import MutableDict, MutableList
|
|
31
|
+
from sqlalchemy.orm import (
|
|
32
|
+
InstrumentedAttribute,
|
|
33
|
+
Mapped,
|
|
34
|
+
Session,
|
|
35
|
+
column_property,
|
|
36
|
+
declarative_mixin,
|
|
37
|
+
declared_attr,
|
|
38
|
+
foreign,
|
|
39
|
+
mapped_column,
|
|
40
|
+
relationship,
|
|
41
|
+
remote,
|
|
42
|
+
sessionmaker,
|
|
43
|
+
)
|
|
44
|
+
from sqlalchemy.pool import StaticPool
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Boolean",
|
|
48
|
+
"Column",
|
|
49
|
+
"_DateTime",
|
|
50
|
+
"SAEnum",
|
|
51
|
+
"Text",
|
|
52
|
+
"ForeignKey",
|
|
53
|
+
"Index",
|
|
54
|
+
"Integer",
|
|
55
|
+
"JSON",
|
|
56
|
+
"Numeric",
|
|
57
|
+
"String",
|
|
58
|
+
"LargeBinary",
|
|
59
|
+
"UniqueConstraint",
|
|
60
|
+
"CheckConstraint",
|
|
61
|
+
"create_engine",
|
|
62
|
+
"event",
|
|
63
|
+
"ARRAY",
|
|
64
|
+
"PgEnum",
|
|
65
|
+
"JSONB",
|
|
66
|
+
"TSVECTOR",
|
|
67
|
+
"_PgUUID",
|
|
68
|
+
"Mapped",
|
|
69
|
+
"declarative_mixin",
|
|
70
|
+
"declared_attr",
|
|
71
|
+
"foreign",
|
|
72
|
+
"mapped_column",
|
|
73
|
+
"relationship",
|
|
74
|
+
"remote",
|
|
75
|
+
"column_property",
|
|
76
|
+
"Session",
|
|
77
|
+
"sessionmaker",
|
|
78
|
+
"InstrumentedAttribute",
|
|
79
|
+
"MutableDict",
|
|
80
|
+
"MutableList",
|
|
81
|
+
"hybrid_property",
|
|
82
|
+
"StaticPool",
|
|
83
|
+
"TypeDecorator",
|
|
84
|
+
]
|