swiftpy-framework 0.1.0__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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/core/context.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import weakref
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# Use None as default to avoid shared mutable default reference
|
|
10
|
+
_context: ContextVar[dict[str, Any] | None] = ContextVar(
|
|
11
|
+
"swiftpy_context",
|
|
12
|
+
default=None,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_WATCHERS_KEY = "__swiftpy_context_watchers__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _current_task_or_none() -> asyncio.Task[Any] | None:
|
|
19
|
+
try:
|
|
20
|
+
return asyncio.current_task()
|
|
21
|
+
except RuntimeError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Context:
|
|
26
|
+
"""
|
|
27
|
+
Request-local context storage for SwiftPY.
|
|
28
|
+
|
|
29
|
+
Backed by Python's contextvars.ContextVar with copy-on-write task isolation.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _get_dict() -> dict[str, Any]:
|
|
34
|
+
"""Internal helper to fetch or initialize the request context dict."""
|
|
35
|
+
ctx = _context.get()
|
|
36
|
+
if ctx is None:
|
|
37
|
+
ctx = {}
|
|
38
|
+
_context.set(ctx)
|
|
39
|
+
return ctx
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _notify_watchers(
|
|
43
|
+
ctx_dict: dict[str, Any] | None,
|
|
44
|
+
key: str | None,
|
|
45
|
+
value: Any,
|
|
46
|
+
) -> None:
|
|
47
|
+
if not ctx_dict:
|
|
48
|
+
return
|
|
49
|
+
watchers_map = ctx_dict.get(_WATCHERS_KEY)
|
|
50
|
+
if not isinstance(watchers_map, dict):
|
|
51
|
+
return
|
|
52
|
+
cur_task = _current_task_or_none()
|
|
53
|
+
target_keys = list(watchers_map.keys()) if key is None else [key]
|
|
54
|
+
for watch_key in target_keys:
|
|
55
|
+
entries = watchers_map.get(watch_key)
|
|
56
|
+
if not entries:
|
|
57
|
+
continue
|
|
58
|
+
for owner_task, cb_ref in entries:
|
|
59
|
+
if owner_task is not cur_task:
|
|
60
|
+
continue
|
|
61
|
+
cb = cb_ref() if isinstance(cb_ref, weakref.ref) else cb_ref
|
|
62
|
+
if cb is not None:
|
|
63
|
+
cb(value)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def watch(
|
|
67
|
+
key: str,
|
|
68
|
+
callback: weakref.ref[Callable[[Any], None]] | Callable[[Any], None],
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Register a task-scoped watcher invoked when `key` changes or context clears."""
|
|
71
|
+
current = Context._get_dict()
|
|
72
|
+
raw_watchers = current.get(_WATCHERS_KEY)
|
|
73
|
+
watchers_map: dict[str, tuple[tuple[Any, Any], ...]] = (
|
|
74
|
+
dict(raw_watchers) if isinstance(raw_watchers, dict) else {}
|
|
75
|
+
)
|
|
76
|
+
cur_task = _current_task_or_none()
|
|
77
|
+
existing = watchers_map.get(key, ())
|
|
78
|
+
live_entries = [
|
|
79
|
+
entry
|
|
80
|
+
for entry in existing
|
|
81
|
+
if not (isinstance(entry[1], weakref.ref) and entry[1]() is None)
|
|
82
|
+
]
|
|
83
|
+
live_entries.append((cur_task, callback))
|
|
84
|
+
watchers_map[key] = tuple(live_entries)
|
|
85
|
+
|
|
86
|
+
new_ctx = current.copy()
|
|
87
|
+
new_ctx[_WATCHERS_KEY] = watchers_map
|
|
88
|
+
_context.set(new_ctx)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def set(key: str, value: Any) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Store a key-value pair in the current task context.
|
|
94
|
+
Uses copy-on-write to isolate mutations across task boundaries.
|
|
95
|
+
"""
|
|
96
|
+
current = Context._get_dict()
|
|
97
|
+
# Create a shallow copy of the dict container for the write operation
|
|
98
|
+
new_ctx = current.copy()
|
|
99
|
+
new_ctx[key] = value
|
|
100
|
+
_context.set(new_ctx)
|
|
101
|
+
if key != _WATCHERS_KEY:
|
|
102
|
+
Context._notify_watchers(new_ctx, key, value)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def get(key: str, default: Any = None) -> Any:
|
|
106
|
+
"""Retrieve a value from the current task context."""
|
|
107
|
+
ctx = _context.get()
|
|
108
|
+
if ctx is None:
|
|
109
|
+
return default
|
|
110
|
+
return ctx.get(key, default)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def has(key: str) -> bool:
|
|
114
|
+
"""Return True if the key exists in the current context."""
|
|
115
|
+
ctx = _context.get()
|
|
116
|
+
return key in ctx if ctx is not None else False
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def all() -> dict[str, Any]:
|
|
120
|
+
"""Return a shallow copy of all context variables to prevent external mutation."""
|
|
121
|
+
ctx = _context.get()
|
|
122
|
+
if ctx is None:
|
|
123
|
+
return {}
|
|
124
|
+
return {k: v for k, v in ctx.items() if k != _WATCHERS_KEY}
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def clear() -> None:
|
|
128
|
+
"""Reset the current task context."""
|
|
129
|
+
previous = _context.get()
|
|
130
|
+
_context.set(None)
|
|
131
|
+
Context._notify_watchers(previous, None, None)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def push(key: str, value: Any) -> None:
|
|
135
|
+
stack = Context.get(key, [])
|
|
136
|
+
Context.set(key, [*stack, value])
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def pop(key: str) -> Any:
|
|
140
|
+
stack = Context.get(key, [])
|
|
141
|
+
|
|
142
|
+
if not stack:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
value = stack[-1]
|
|
146
|
+
Context.set(key, stack[:-1])
|
|
147
|
+
|
|
148
|
+
return value
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import traceback
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Any, TypeVar, get_type_hints
|
|
8
|
+
|
|
9
|
+
from swiftpy.database.exceptions import ModelNotFoundError, RecordNotFoundError
|
|
10
|
+
from swiftpy.filesystem.exceptions import FileValidationError
|
|
11
|
+
from swiftpy.http.exceptions import (
|
|
12
|
+
ClientDisconnect,
|
|
13
|
+
HttpException,
|
|
14
|
+
PayloadTooLargeError,
|
|
15
|
+
)
|
|
16
|
+
from swiftpy.http.response import Response, json_response, unprocessable
|
|
17
|
+
from swiftpy.validation.exceptions import ValidationException
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from swiftpy.http.request import Request
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("swiftpy.core.exceptions")
|
|
23
|
+
E = TypeVar("E", bound=Exception)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(HttpException):
|
|
27
|
+
"""Exception mapped automatically to HTTP 404 Not Found."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str = "Not Found",
|
|
32
|
+
headers: dict[str, str] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message=message, status_code=404, headers=headers)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnauthorizedError(HttpException):
|
|
38
|
+
"""Exception mapped automatically to HTTP 401 Unauthorized."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
message: str = "Unauthorized",
|
|
43
|
+
headers: dict[str, str] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(message=message, status_code=401, headers=headers)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ForbiddenError(HttpException):
|
|
49
|
+
"""Exception mapped automatically to HTTP 403 Forbidden."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
message: str = "Forbidden",
|
|
54
|
+
headers: dict[str, str] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(message=message, status_code=403, headers=headers)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ValidationError(HttpException):
|
|
60
|
+
"""Exception mapped automatically to HTTP 422 Unprocessable Entity."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
errors: dict[str, list[str]] | None = None,
|
|
65
|
+
message: str = "Validation failed",
|
|
66
|
+
headers: dict[str, str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.errors = errors or {}
|
|
69
|
+
super().__init__(message=message, status_code=422, headers=headers)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MethodNotAllowedError(HttpException):
|
|
73
|
+
"""Exception mapped automatically to HTTP 405 Method Not Allowed."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
message: str = "Method Not Allowed",
|
|
78
|
+
headers: dict[str, str] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(message=message, status_code=405, headers=headers)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BadRequestError(HttpException):
|
|
84
|
+
"""Exception mapped automatically to HTTP 400 Bad Request."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
message: str = "Bad Request",
|
|
89
|
+
headers: dict[str, str] | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(message=message, status_code=400, headers=headers)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InternalServerError(HttpException):
|
|
95
|
+
"""Exception mapped automatically to HTTP 500 Internal Server Error."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
message: str = "Internal Server Error",
|
|
100
|
+
headers: dict[str, str] | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__(message=message, status_code=500, headers=headers)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _infer_exception_handler_order(
|
|
106
|
+
handler: Callable[..., Any],
|
|
107
|
+
*,
|
|
108
|
+
exception_type: type[Exception] | None = None,
|
|
109
|
+
) -> tuple[str, str]:
|
|
110
|
+
"""Resolve the supported (request, exc) or (exc, request) convention.
|
|
111
|
+
|
|
112
|
+
We accept both conventions, but require an unambiguous result derived from
|
|
113
|
+
the parameter names or the resolved type hints. Ambiguous or unsupported
|
|
114
|
+
signatures raise TypeError during registration so the handler contract is
|
|
115
|
+
explicit instead of guessing at runtime.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
sig = inspect.signature(handler)
|
|
119
|
+
except (ValueError, TypeError) as exc: # pragma: no cover - defensive
|
|
120
|
+
raise TypeError(f"Invalid exception handler signature for {handler!r}") from exc
|
|
121
|
+
|
|
122
|
+
params = list(sig.parameters.values())
|
|
123
|
+
if len(params) != 2:
|
|
124
|
+
raise TypeError(
|
|
125
|
+
"Exception handlers must accept exactly two parameters: (request, exc) or (exc, request)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
hints = get_type_hints(handler)
|
|
130
|
+
except (AttributeError, NameError, TypeError, ValueError):
|
|
131
|
+
hints = {}
|
|
132
|
+
|
|
133
|
+
first_name = params[0].name.lower()
|
|
134
|
+
second_name = params[1].name.lower()
|
|
135
|
+
first_hint = hints.get(params[0].name, params[0].annotation)
|
|
136
|
+
second_hint = hints.get(params[1].name, params[1].annotation)
|
|
137
|
+
|
|
138
|
+
request_like = (
|
|
139
|
+
first_name in {"request", "req"}
|
|
140
|
+
or getattr(first_hint, "__name__", "") in {"Request", "HttpRequest"}
|
|
141
|
+
)
|
|
142
|
+
exception_like = (
|
|
143
|
+
first_name in {"exc", "error"}
|
|
144
|
+
or (
|
|
145
|
+
exception_type is not None
|
|
146
|
+
and isinstance(first_hint, type)
|
|
147
|
+
and issubclass(first_hint, BaseException)
|
|
148
|
+
and issubclass(first_hint, exception_type)
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
second_request_like = (
|
|
152
|
+
second_name in {"request", "req"}
|
|
153
|
+
or getattr(second_hint, "__name__", "") in {"Request", "HttpRequest"}
|
|
154
|
+
)
|
|
155
|
+
second_exception_like = (
|
|
156
|
+
second_name in {"exc", "error"}
|
|
157
|
+
or (
|
|
158
|
+
exception_type is not None
|
|
159
|
+
and isinstance(second_hint, type)
|
|
160
|
+
and issubclass(second_hint, BaseException)
|
|
161
|
+
and issubclass(second_hint, exception_type)
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if request_like and second_exception_like:
|
|
166
|
+
return "request_first", "request_first"
|
|
167
|
+
if exception_like and second_request_like:
|
|
168
|
+
return "exception_first", "exception_first"
|
|
169
|
+
|
|
170
|
+
if first_name in {"request", "req"} and second_name in {"exc", "error"}:
|
|
171
|
+
return "request_first", "request_first"
|
|
172
|
+
if first_name in {"exc", "error"} and second_name in {"request", "req"}:
|
|
173
|
+
return "exception_first", "exception_first"
|
|
174
|
+
|
|
175
|
+
if request_like and second_request_like:
|
|
176
|
+
raise TypeError(
|
|
177
|
+
"Ambiguous exception handler signature: both parameters appear to be Request-like. "
|
|
178
|
+
"Use (request, exc) or (exc, request) with explicit annotations."
|
|
179
|
+
)
|
|
180
|
+
if exception_like and second_exception_like:
|
|
181
|
+
raise TypeError(
|
|
182
|
+
"Ambiguous exception handler signature: both parameters appear to be exception-like. "
|
|
183
|
+
"Use (request, exc) or (exc, request) with explicit annotations."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
raise TypeError(
|
|
187
|
+
"Unsupported exception handler signature; expected (request, exc) or (exc, request)."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ExceptionHandler:
|
|
192
|
+
"""
|
|
193
|
+
Global application exception handler.
|
|
194
|
+
Formats HTTP status codes, structured validation errors,
|
|
195
|
+
and returns full tracebacks in development or safe messages in production.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, debug: bool = False) -> None:
|
|
199
|
+
self.debug = debug
|
|
200
|
+
self._custom_handlers: dict[type[Exception], Callable[..., Any]] = {}
|
|
201
|
+
|
|
202
|
+
def register(
|
|
203
|
+
self,
|
|
204
|
+
exc_type: type[E],
|
|
205
|
+
handler: Callable[..., Any],
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Register a custom handler for a specific exception class."""
|
|
208
|
+
_infer_exception_handler_order(handler, exception_type=exc_type)
|
|
209
|
+
self._custom_handlers[exc_type] = handler
|
|
210
|
+
|
|
211
|
+
def __call__(
|
|
212
|
+
self,
|
|
213
|
+
exc_type: type[E],
|
|
214
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
215
|
+
"""Decorator to register a custom exception handler."""
|
|
216
|
+
|
|
217
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
218
|
+
self.register(exc_type, handler)
|
|
219
|
+
return handler
|
|
220
|
+
|
|
221
|
+
return decorator
|
|
222
|
+
|
|
223
|
+
async def handle(
|
|
224
|
+
self,
|
|
225
|
+
exc: Exception,
|
|
226
|
+
request: Request | None = None,
|
|
227
|
+
) -> Response:
|
|
228
|
+
"""Process an exception and return a typed HTTP Response."""
|
|
229
|
+
req_method = request.method if request is not None else "-"
|
|
230
|
+
req_path = request.path if request is not None else "-"
|
|
231
|
+
|
|
232
|
+
# 1. Custom exception handlers selected by MRO specificity
|
|
233
|
+
for cls in type(exc).__mro__:
|
|
234
|
+
if not issubclass(cls, Exception):
|
|
235
|
+
continue
|
|
236
|
+
handler = self._custom_handlers.get(cls)
|
|
237
|
+
if handler is not None:
|
|
238
|
+
try:
|
|
239
|
+
order = _infer_exception_handler_order(handler, exception_type=cls)
|
|
240
|
+
if order[0] == "request_first":
|
|
241
|
+
res = handler(request, exc)
|
|
242
|
+
else:
|
|
243
|
+
res = handler(exc, request)
|
|
244
|
+
|
|
245
|
+
if inspect.isawaitable(res):
|
|
246
|
+
res = await res
|
|
247
|
+
if isinstance(res, Response):
|
|
248
|
+
return res
|
|
249
|
+
return json_response(res)
|
|
250
|
+
except Exception:
|
|
251
|
+
logger.exception(
|
|
252
|
+
"Custom exception handler for %s raised an unhandled error",
|
|
253
|
+
cls.__name__,
|
|
254
|
+
)
|
|
255
|
+
return json_response(
|
|
256
|
+
{"detail": "Internal Server Error"},
|
|
257
|
+
status_code=500,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# 2. Database Record / Model Not Found errors automatically map to HTTP 404
|
|
261
|
+
if isinstance(exc, (ModelNotFoundError, RecordNotFoundError)):
|
|
262
|
+
logger.info(
|
|
263
|
+
"HTTP 404 %s %s: %s",
|
|
264
|
+
req_method,
|
|
265
|
+
req_path,
|
|
266
|
+
exc.message,
|
|
267
|
+
)
|
|
268
|
+
return json_response(
|
|
269
|
+
{"detail": exc.message},
|
|
270
|
+
status_code=404,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# 3. Framework validation errors ONLY (no duck-typing on .errors)
|
|
274
|
+
if isinstance(exc, (ValidationError, ValidationException, FileValidationError)):
|
|
275
|
+
logger.info(
|
|
276
|
+
"HTTP 422 %s %s: %s",
|
|
277
|
+
req_method,
|
|
278
|
+
req_path,
|
|
279
|
+
exc.message,
|
|
280
|
+
)
|
|
281
|
+
headers = exc.headers or {}
|
|
282
|
+
resp = unprocessable(exc.errors)
|
|
283
|
+
if headers:
|
|
284
|
+
resp.headers.update(headers)
|
|
285
|
+
return resp
|
|
286
|
+
|
|
287
|
+
# 4. HTTP Exceptions (HttpException, NotFoundError, UnauthorizedError, etc.)
|
|
288
|
+
if isinstance(exc, HttpException):
|
|
289
|
+
if exc.status_code >= 500:
|
|
290
|
+
logger.error(
|
|
291
|
+
"HTTP %d %s %s: %s",
|
|
292
|
+
exc.status_code,
|
|
293
|
+
req_method,
|
|
294
|
+
req_path,
|
|
295
|
+
exc.message,
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
logger.info(
|
|
299
|
+
"HTTP %d %s %s: %s",
|
|
300
|
+
exc.status_code,
|
|
301
|
+
req_method,
|
|
302
|
+
req_path,
|
|
303
|
+
exc.message,
|
|
304
|
+
)
|
|
305
|
+
headers = exc.headers or {}
|
|
306
|
+
detail = (
|
|
307
|
+
"Internal Server Error"
|
|
308
|
+
if (not self.debug and exc.status_code >= 500)
|
|
309
|
+
else exc.message
|
|
310
|
+
)
|
|
311
|
+
resp = json_response(
|
|
312
|
+
{"detail": detail},
|
|
313
|
+
status_code=exc.status_code,
|
|
314
|
+
)
|
|
315
|
+
if headers:
|
|
316
|
+
resp.headers.update(headers)
|
|
317
|
+
return resp
|
|
318
|
+
|
|
319
|
+
# 5. Unhandled server exceptions
|
|
320
|
+
logger.exception("Unhandled exception during request processing")
|
|
321
|
+
|
|
322
|
+
if self.debug:
|
|
323
|
+
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
324
|
+
return json_response(
|
|
325
|
+
{
|
|
326
|
+
"error": "Internal Server Error",
|
|
327
|
+
"exception": exc.__class__.__name__,
|
|
328
|
+
"message": str(exc),
|
|
329
|
+
"traceback": tb,
|
|
330
|
+
},
|
|
331
|
+
status_code=500,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return json_response(
|
|
335
|
+
{"detail": "Internal Server Error"},
|
|
336
|
+
status_code=500,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
__all__ = [
|
|
341
|
+
"BadRequestError",
|
|
342
|
+
"ClientDisconnect",
|
|
343
|
+
"ExceptionHandler",
|
|
344
|
+
"ForbiddenError",
|
|
345
|
+
"HttpException",
|
|
346
|
+
"InternalServerError",
|
|
347
|
+
"MethodNotAllowedError",
|
|
348
|
+
"NotFoundError",
|
|
349
|
+
"PayloadTooLargeError",
|
|
350
|
+
"UnauthorizedError",
|
|
351
|
+
"ValidationError",
|
|
352
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from swiftpy.core.container import Container
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ServiceProvider(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Base service provider.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, container: Container) -> None:
|
|
14
|
+
self.container = container
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def register(self) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Register services into the container.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def boot(self) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Optional synchronous boot hook.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
async def startup(self) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Optional asynchronous startup hook invoked during ASGI lifespan startup.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
async def shutdown(self) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Optional asynchronous shutdown hook invoked during ASGI lifespan shutdown.
|
|
35
|
+
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.database.exceptions import (
|
|
4
|
+
DatabaseError,
|
|
5
|
+
ModelNotFoundError,
|
|
6
|
+
RecordNotFoundError,
|
|
7
|
+
)
|
|
8
|
+
from swiftpy.database.migrations.blueprint import Blueprint
|
|
9
|
+
from swiftpy.database.migrations.schema import Schema
|
|
10
|
+
from swiftpy.database.pagination import Paginator, SimplePaginator
|
|
11
|
+
from swiftpy.database.pool import DatabasePool
|
|
12
|
+
from swiftpy.database.query_builder import Query, QueryBuilder, QueryError, escape_like
|
|
13
|
+
from swiftpy.database.seeder import Seeder
|
|
14
|
+
from swiftpy.database.transaction import (
|
|
15
|
+
Database,
|
|
16
|
+
TransactionClosedError,
|
|
17
|
+
TransactionConnection,
|
|
18
|
+
db,
|
|
19
|
+
on_commit,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Blueprint",
|
|
24
|
+
"Database",
|
|
25
|
+
"DatabaseError",
|
|
26
|
+
"DatabasePool",
|
|
27
|
+
"ModelNotFoundError",
|
|
28
|
+
"Paginator",
|
|
29
|
+
"Query",
|
|
30
|
+
"QueryBuilder",
|
|
31
|
+
"QueryError",
|
|
32
|
+
"RecordNotFoundError",
|
|
33
|
+
"Schema",
|
|
34
|
+
"Seeder",
|
|
35
|
+
"SimplePaginator",
|
|
36
|
+
"TransactionClosedError",
|
|
37
|
+
"TransactionConnection",
|
|
38
|
+
"db",
|
|
39
|
+
"escape_like",
|
|
40
|
+
"on_commit",
|
|
41
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DatabaseError(Exception):
|
|
7
|
+
"""Base exception for all database subsystem errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RecordNotFoundError(DatabaseError, LookupError):
|
|
11
|
+
"""Raised when a specific raw query record is not found."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str = "Record not found.") -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelNotFoundError(RecordNotFoundError):
|
|
19
|
+
"""Raised when an ORM model query using *_or_fail fails to find a matching instance."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
model: str | type[Any],
|
|
24
|
+
primary_key: Any = None,
|
|
25
|
+
message: str | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.model = model if isinstance(model, str) else model.__name__
|
|
28
|
+
self.primary_key = primary_key
|
|
29
|
+
if message is None:
|
|
30
|
+
if primary_key is not None:
|
|
31
|
+
message = f"No query results for model [{self.model}] with primary key [{primary_key}]."
|
|
32
|
+
else:
|
|
33
|
+
message = f"No query results for model [{self.model}]."
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DatabaseError",
|
|
39
|
+
"ModelNotFoundError",
|
|
40
|
+
"RecordNotFoundError",
|
|
41
|
+
]
|
|
42
|
+
|