wellapi 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wellapi/__init__.py +5 -0
- wellapi/__main__.py +3 -0
- wellapi/applications.py +389 -0
- wellapi/awsmodel.py +17 -0
- wellapi/build/__init__.py +0 -0
- wellapi/build/cdk.py +141 -0
- wellapi/build/packager.py +82 -0
- wellapi/build/sam_openapi.py +10 -0
- wellapi/cli/__init__.py +0 -0
- wellapi/cli/main.py +67 -0
- wellapi/convertors.py +89 -0
- wellapi/datastructures.py +383 -0
- wellapi/dependencies/__init__.py +0 -0
- wellapi/dependencies/models.py +138 -0
- wellapi/dependencies/utils.py +923 -0
- wellapi/exceptions.py +53 -0
- wellapi/local/__init__.py +0 -0
- wellapi/local/reloader.py +94 -0
- wellapi/local/router.py +116 -0
- wellapi/local/server.py +154 -0
- wellapi/middleware/__init__.py +0 -0
- wellapi/middleware/base.py +18 -0
- wellapi/middleware/error.py +239 -0
- wellapi/middleware/exceptions.py +74 -0
- wellapi/middleware/main.py +26 -0
- wellapi/models.py +150 -0
- wellapi/openapi/__init__.py +0 -0
- wellapi/openapi/docs.py +344 -0
- wellapi/openapi/models.py +404 -0
- wellapi/openapi/utils.py +535 -0
- wellapi/params.py +481 -0
- wellapi/routing.py +248 -0
- wellapi/security.py +82 -0
- wellapi/utils.py +37 -0
- wellapi-0.2.1.dist-info/METADATA +32 -0
- wellapi-0.2.1.dist-info/RECORD +38 -0
- wellapi-0.2.1.dist-info/WHEEL +4 -0
- wellapi-0.2.1.dist-info/entry_points.txt +2 -0
wellapi/exceptions.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import http
|
|
2
|
+
from collections.abc import Mapping, Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WellAPIError(Exception): ...
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HTTPException(Exception):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
status_code: int,
|
|
13
|
+
detail: str | None = None,
|
|
14
|
+
headers: Mapping[str, str] | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
if detail is None:
|
|
17
|
+
detail = http.HTTPStatus(status_code).phrase
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.detail = detail
|
|
20
|
+
self.headers = headers
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
return f"{self.status_code}: {self.detail}"
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
class_name = self.__class__.__name__
|
|
27
|
+
return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationException(Exception):
|
|
31
|
+
def __init__(self, errors: Sequence[Any]) -> None:
|
|
32
|
+
self._errors = errors
|
|
33
|
+
|
|
34
|
+
def errors(self) -> Sequence[Any]:
|
|
35
|
+
return self._errors
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RequestValidationError(ValidationException):
|
|
39
|
+
def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None:
|
|
40
|
+
super().__init__(errors)
|
|
41
|
+
self.body = body
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ResponseValidationError(ValidationException):
|
|
45
|
+
def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None:
|
|
46
|
+
super().__init__(errors)
|
|
47
|
+
self.body = body
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
message = f"{len(self._errors)} validation errors:\n"
|
|
51
|
+
for err in self._errors:
|
|
52
|
+
message += f" {err}\n"
|
|
53
|
+
return message
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from watchdog.events import FileSystemEventHandler
|
|
7
|
+
from watchdog.observers import Observer
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.INFO, format="%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Конфігурація
|
|
14
|
+
WATCH_DIRECTORIES = ["."] # Директорії для відстеження змін
|
|
15
|
+
WATCH_EXTENSIONS = [".py"] # Розширення файлів для відстеження
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModuleReloader(FileSystemEventHandler):
|
|
19
|
+
def __init__(self, server):
|
|
20
|
+
self.server = server
|
|
21
|
+
self.last_reload_time = time.time()
|
|
22
|
+
self.reload_delay = 1 # Мінімальний час між перезавантаженнями (секунди)
|
|
23
|
+
|
|
24
|
+
def get_module_name_from_path(self, file_path):
|
|
25
|
+
"""Отримати назву модуля Python з шляху до файлу"""
|
|
26
|
+
try:
|
|
27
|
+
path = Path(file_path)
|
|
28
|
+
|
|
29
|
+
# Перевіряємо, чи це файл Python
|
|
30
|
+
if path.suffix != ".py":
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
# Отримуємо абсолютний шлях до директорії проекту
|
|
34
|
+
project_dir = Path(os.getcwd())
|
|
35
|
+
|
|
36
|
+
# Обчислюємо відносний шлях до файлу
|
|
37
|
+
rel_path = path.relative_to(project_dir)
|
|
38
|
+
|
|
39
|
+
# Перетворюємо шлях у назву модуля
|
|
40
|
+
module_path = str(rel_path.with_suffix(""))
|
|
41
|
+
module_name = module_path.replace(os.sep, ".")
|
|
42
|
+
|
|
43
|
+
return module_name
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logging.error(f"Помилка при визначенні назви модуля: {e}")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def on_any_event(self, event):
|
|
49
|
+
# Перевіряємо, чи файл має потрібне розширення
|
|
50
|
+
if event.is_directory:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
file_ext = os.path.splitext(event.src_path)[1].lower()
|
|
54
|
+
if file_ext not in WATCH_EXTENSIONS:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Запобігаємо багаторазовим перезавантаженням
|
|
58
|
+
current_time = time.time()
|
|
59
|
+
if (current_time - self.last_reload_time) < self.reload_delay:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
self.last_reload_time = current_time
|
|
63
|
+
|
|
64
|
+
logging.info(f"Зміни виявлено у файлі: {event.src_path}")
|
|
65
|
+
|
|
66
|
+
# Отримуємо назву модуля з шляху до файлу
|
|
67
|
+
module_name = self.get_module_name_from_path(event.src_path)
|
|
68
|
+
if not module_name:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
self.server.on_reload()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_with_reloader(server):
|
|
75
|
+
reloader = ModuleReloader(server)
|
|
76
|
+
observer = Observer()
|
|
77
|
+
|
|
78
|
+
for directory in WATCH_DIRECTORIES:
|
|
79
|
+
abs_path = os.path.abspath(directory)
|
|
80
|
+
logging.info(f"Відстеження змін у {abs_path}")
|
|
81
|
+
observer.schedule(reloader, abs_path, recursive=True)
|
|
82
|
+
|
|
83
|
+
observer.start()
|
|
84
|
+
|
|
85
|
+
server.start_server()
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
while True:
|
|
89
|
+
time.sleep(1)
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
logging.info("Завершення роботи...")
|
|
92
|
+
observer.stop()
|
|
93
|
+
|
|
94
|
+
observer.join()
|
wellapi/local/router.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from wellapi import Scope
|
|
6
|
+
from wellapi.applications import Lambda
|
|
7
|
+
from wellapi.routing import compile_path
|
|
8
|
+
from wellapi.utils import import_app, load_handlers
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Match(Enum):
|
|
12
|
+
NONE = 0
|
|
13
|
+
PARTIAL = 1
|
|
14
|
+
FULL = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_route_path(path: str) -> str:
|
|
18
|
+
root_path = ""
|
|
19
|
+
if not root_path:
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
if not path.startswith(root_path):
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
if path == root_path:
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
if path[len(root_path)] == "/":
|
|
29
|
+
return path[len(root_path) :]
|
|
30
|
+
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Route:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
path: str,
|
|
38
|
+
function: Callable,
|
|
39
|
+
method: str = "GET",
|
|
40
|
+
include_in_schema: bool = True,
|
|
41
|
+
):
|
|
42
|
+
self.path = path
|
|
43
|
+
self.method = method
|
|
44
|
+
self.endpoint_module = function.__module__
|
|
45
|
+
self.endpoint_name = function.__name__
|
|
46
|
+
self.include_in_schema = include_in_schema
|
|
47
|
+
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
|
|
48
|
+
|
|
49
|
+
def __repr__(self):
|
|
50
|
+
return f"Route(path={self.path}, method={self.method})"
|
|
51
|
+
|
|
52
|
+
def __call__(self, *args, **kwargs):
|
|
53
|
+
module = sys.modules[self.endpoint_module]
|
|
54
|
+
return getattr(module, self.endpoint_name)(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
def matches(self, scope: Scope, method, path) -> tuple[Match, Scope]:
|
|
57
|
+
route_path = get_route_path(path)
|
|
58
|
+
match = self.path_regex.match(route_path)
|
|
59
|
+
if match:
|
|
60
|
+
matched_params = match.groupdict()
|
|
61
|
+
for key, value in matched_params.items():
|
|
62
|
+
matched_params[key] = self.param_convertors[key].convert(value)
|
|
63
|
+
path_params = dict(scope.get("pathParameters", {}))
|
|
64
|
+
path_params.update(matched_params)
|
|
65
|
+
child_scope = {"pathParameters": path_params}
|
|
66
|
+
|
|
67
|
+
if self.method == method:
|
|
68
|
+
return Match.FULL, child_scope
|
|
69
|
+
|
|
70
|
+
return Match.NONE, {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Router:
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self.routes: list[Route] = []
|
|
76
|
+
|
|
77
|
+
def add_route(self, path: str, method: str, function: Callable):
|
|
78
|
+
route = Route(path, function, method)
|
|
79
|
+
self.routes.append(route)
|
|
80
|
+
return route
|
|
81
|
+
|
|
82
|
+
def discover_handlers(self, app_srt, path_to_handlers_dir):
|
|
83
|
+
load_handlers(path_to_handlers_dir)
|
|
84
|
+
|
|
85
|
+
app = import_app(app_srt)
|
|
86
|
+
|
|
87
|
+
self.routes = []
|
|
88
|
+
e: Lambda
|
|
89
|
+
for e in app.lambdas:
|
|
90
|
+
path = e.path
|
|
91
|
+
method = e.method
|
|
92
|
+
if e.type_ == "queue":
|
|
93
|
+
path = f"/queue_/{e.path}"
|
|
94
|
+
method = "POST"
|
|
95
|
+
elif e.type_ == "job":
|
|
96
|
+
path = f"/job_/{e.name}"
|
|
97
|
+
method = "POST"
|
|
98
|
+
|
|
99
|
+
self.add_route(path, method, e.endpoint)
|
|
100
|
+
|
|
101
|
+
def __call__(self, scope: Scope, method, path):
|
|
102
|
+
for route in self.routes:
|
|
103
|
+
match, child_scope = route.matches(scope, method, path)
|
|
104
|
+
if match == Match.FULL:
|
|
105
|
+
scope.update(child_scope)
|
|
106
|
+
return route(scope, {})
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"statusCode": 404,
|
|
110
|
+
"headers": {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"My-Custom-Header": "Custom Value",
|
|
113
|
+
},
|
|
114
|
+
"body": "Not Found",
|
|
115
|
+
"isBase64Encoded": False,
|
|
116
|
+
}
|
wellapi/local/server.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
6
|
+
|
|
7
|
+
from wellapi.local.reloader import run_with_reloader
|
|
8
|
+
from wellapi.local.router import Router
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_request_handler(router: Router):
|
|
12
|
+
class RequestHandler(BaseHTTPRequestHandler):
|
|
13
|
+
def do_GET(self):
|
|
14
|
+
self._handle_request("GET")
|
|
15
|
+
|
|
16
|
+
def do_POST(self):
|
|
17
|
+
self._handle_request("POST")
|
|
18
|
+
|
|
19
|
+
def do_PUT(self):
|
|
20
|
+
self._handle_request("PUT")
|
|
21
|
+
|
|
22
|
+
def do_DELETE(self):
|
|
23
|
+
self._handle_request("DELETE")
|
|
24
|
+
|
|
25
|
+
def _handle_request(self, method):
|
|
26
|
+
path = self.path.split("?")[0] # Видаляємо query параметри
|
|
27
|
+
|
|
28
|
+
# Створюємо мок для AWS Lambda event
|
|
29
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
30
|
+
body = (
|
|
31
|
+
self.rfile.read(content_length).decode() if content_length > 0 else None
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if self.path.startswith("/job_"):
|
|
35
|
+
event = self.create_job_event()
|
|
36
|
+
elif self.path.startswith("/queue_"):
|
|
37
|
+
event = self.create_queue_event(body)
|
|
38
|
+
else:
|
|
39
|
+
event = self.create_api_event(method, path, body)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
result = router(event, method, path)
|
|
43
|
+
|
|
44
|
+
self.send_response(result["statusCode"])
|
|
45
|
+
for key, value in result["headers"].items():
|
|
46
|
+
self.send_header(key, value)
|
|
47
|
+
self.end_headers()
|
|
48
|
+
self.wfile.write(result["body"].encode())
|
|
49
|
+
except Exception as e:
|
|
50
|
+
self.send_response(500)
|
|
51
|
+
self.send_header("Content-type", "application/json")
|
|
52
|
+
self.end_headers()
|
|
53
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode())
|
|
54
|
+
|
|
55
|
+
def create_job_event(self):
|
|
56
|
+
return {
|
|
57
|
+
"version": "0",
|
|
58
|
+
"id": "53dc4d37-cffa-4f76-80c9-8b7d4a4d2eaa",
|
|
59
|
+
"detail-type": "Scheduled Event",
|
|
60
|
+
"source": "aws.events",
|
|
61
|
+
"account": "123456789012",
|
|
62
|
+
"time": "2015-10-08T16:53:06Z",
|
|
63
|
+
"region": "us-east-1",
|
|
64
|
+
"resources": [
|
|
65
|
+
"arn:aws:events:us-east-1:123456789012:rule/my-scheduled-rule"
|
|
66
|
+
],
|
|
67
|
+
"detail": {},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def create_queue_event(self, body):
|
|
71
|
+
record_template = {
|
|
72
|
+
"messageId": "059f36b4-87a3-44ab-83d2-661975830a7d",
|
|
73
|
+
"receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...",
|
|
74
|
+
"body": "test",
|
|
75
|
+
"attributes": {
|
|
76
|
+
"ApproximateReceiveCount": "1",
|
|
77
|
+
"SentTimestamp": "1545082649183",
|
|
78
|
+
"SenderId": "AIDAIENQZJOLO23YVJ4VO",
|
|
79
|
+
"ApproximateFirstReceiveTimestamp": "1545082649185",
|
|
80
|
+
},
|
|
81
|
+
"messageAttributes": {},
|
|
82
|
+
"md5OfBody": "098f6bcd4621d373cade4e832627b4f6",
|
|
83
|
+
"eventSource": "aws:sqs",
|
|
84
|
+
"eventSourceARN": "arn:aws:sqs:us-east-1:111122223333:my-queue",
|
|
85
|
+
"awsRegion": "us-east-1",
|
|
86
|
+
}
|
|
87
|
+
body = json.loads(body)
|
|
88
|
+
if isinstance(body, dict):
|
|
89
|
+
return {"Records": [record_template | {"body": json.dumps(body)}]}
|
|
90
|
+
if isinstance(body, list):
|
|
91
|
+
return {
|
|
92
|
+
"Records": [record_template | {"body": json.dumps(b)} for b in body]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def create_api_event(self, method, path, body):
|
|
96
|
+
headers = {}
|
|
97
|
+
for key, value in self.headers.items():
|
|
98
|
+
headers.setdefault(key, []).append(value)
|
|
99
|
+
|
|
100
|
+
event = {
|
|
101
|
+
"httpMethod": method,
|
|
102
|
+
"path": path,
|
|
103
|
+
"multiValueHeaders": headers,
|
|
104
|
+
"body": body,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Парсимо query параметри
|
|
108
|
+
if "?" in self.path:
|
|
109
|
+
query_string = self.path.split("?")[1]
|
|
110
|
+
query_params = {}
|
|
111
|
+
for param in query_string.split("&"):
|
|
112
|
+
if "=" in param:
|
|
113
|
+
key, value = param.split("=")
|
|
114
|
+
query_params.setdefault(key, []).append(value)
|
|
115
|
+
event["multiValueQueryStringParameters"] = query_params
|
|
116
|
+
|
|
117
|
+
return event
|
|
118
|
+
|
|
119
|
+
return RequestHandler
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Server:
|
|
123
|
+
def __init__(self, app_srt, handlers_dir, host, port):
|
|
124
|
+
self.router = Router()
|
|
125
|
+
self.app_srt = app_srt
|
|
126
|
+
self.handlers_dir = handlers_dir
|
|
127
|
+
self.server = HTTPServer((host, port), get_request_handler(self.router))
|
|
128
|
+
|
|
129
|
+
self.handlers_module = handlers_dir.split("/")[-1]
|
|
130
|
+
self.app_module = app_srt.split(":")[0]
|
|
131
|
+
|
|
132
|
+
def start_server(self):
|
|
133
|
+
self.router.discover_handlers(self.app_srt, self.handlers_dir)
|
|
134
|
+
|
|
135
|
+
logging.info(
|
|
136
|
+
f"Starting server on {self.server.server_address[0]}:{self.server.server_address[1]}"
|
|
137
|
+
)
|
|
138
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
139
|
+
|
|
140
|
+
def on_reload(self):
|
|
141
|
+
for module_name in list(sys.modules.keys()):
|
|
142
|
+
if self.handlers_module in module_name or module_name == self.app_module:
|
|
143
|
+
del sys.modules[module_name]
|
|
144
|
+
|
|
145
|
+
self.router.discover_handlers(self.app_srt, self.handlers_dir)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_local_server(app_srt, handlers_dir, host, port, autoreload):
|
|
149
|
+
server = Server(app_srt, handlers_dir, host, port)
|
|
150
|
+
|
|
151
|
+
if autoreload:
|
|
152
|
+
run_with_reloader(server)
|
|
153
|
+
else:
|
|
154
|
+
server.start_server()
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
|
|
4
|
+
from wellapi.models import RequestAPIGateway, ResponseAPIGateway
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseMiddleware:
|
|
8
|
+
def __init__(self, next_call: Callable, dispatch: Callable | None = None) -> None:
|
|
9
|
+
self.next_call = next_call
|
|
10
|
+
self.dispatch_func = self.dispatch if dispatch is None else dispatch
|
|
11
|
+
|
|
12
|
+
def __call__(self, request: RequestAPIGateway) -> ResponseAPIGateway:
|
|
13
|
+
return self.dispatch_func(request, self.next_call)
|
|
14
|
+
|
|
15
|
+
def dispatch(
|
|
16
|
+
self, request: RequestAPIGateway, call_next: typing.Callable
|
|
17
|
+
) -> ResponseAPIGateway:
|
|
18
|
+
raise NotImplementedError() # pragma: no cover
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import inspect
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from wellapi.models import RequestAPIGateway, ResponseAPIGateway
|
|
10
|
+
|
|
11
|
+
STYLES = """
|
|
12
|
+
p {
|
|
13
|
+
color: #211c1c;
|
|
14
|
+
}
|
|
15
|
+
.traceback-container {
|
|
16
|
+
border: 1px solid #038BB8;
|
|
17
|
+
}
|
|
18
|
+
.traceback-title {
|
|
19
|
+
background-color: #038BB8;
|
|
20
|
+
color: lemonchiffon;
|
|
21
|
+
padding: 12px;
|
|
22
|
+
font-size: 20px;
|
|
23
|
+
margin-top: 0px;
|
|
24
|
+
}
|
|
25
|
+
.frame-line {
|
|
26
|
+
padding-left: 10px;
|
|
27
|
+
font-family: monospace;
|
|
28
|
+
}
|
|
29
|
+
.frame-filename {
|
|
30
|
+
font-family: monospace;
|
|
31
|
+
}
|
|
32
|
+
.center-line {
|
|
33
|
+
background-color: #038BB8;
|
|
34
|
+
color: #f9f6e1;
|
|
35
|
+
padding: 5px 0px 5px 5px;
|
|
36
|
+
}
|
|
37
|
+
.lineno {
|
|
38
|
+
margin-right: 5px;
|
|
39
|
+
}
|
|
40
|
+
.frame-title {
|
|
41
|
+
font-weight: unset;
|
|
42
|
+
padding: 10px 10px 10px 10px;
|
|
43
|
+
background-color: #E4F4FD;
|
|
44
|
+
margin-right: 10px;
|
|
45
|
+
color: #191f21;
|
|
46
|
+
font-size: 17px;
|
|
47
|
+
border: 1px solid #c7dce8;
|
|
48
|
+
}
|
|
49
|
+
.collapse-btn {
|
|
50
|
+
float: right;
|
|
51
|
+
padding: 0px 5px 1px 5px;
|
|
52
|
+
border: solid 1px #96aebb;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
.collapsed {
|
|
56
|
+
display: none;
|
|
57
|
+
}
|
|
58
|
+
.source-code {
|
|
59
|
+
font-family: courier;
|
|
60
|
+
font-size: small;
|
|
61
|
+
padding-bottom: 10px;
|
|
62
|
+
}
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
JS = """
|
|
66
|
+
<script type="text/javascript">
|
|
67
|
+
function collapse(element){
|
|
68
|
+
const frameId = element.getAttribute("data-frame-id");
|
|
69
|
+
const frame = document.getElementById(frameId);
|
|
70
|
+
|
|
71
|
+
if (frame.classList.contains("collapsed")){
|
|
72
|
+
element.innerHTML = "‒";
|
|
73
|
+
frame.classList.remove("collapsed");
|
|
74
|
+
} else {
|
|
75
|
+
element.innerHTML = "+";
|
|
76
|
+
frame.classList.add("collapsed");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
TEMPLATE = """
|
|
83
|
+
<html>
|
|
84
|
+
<head>
|
|
85
|
+
<style type='text/css'>
|
|
86
|
+
{styles}
|
|
87
|
+
</style>
|
|
88
|
+
<title>Starlette Debugger</title>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<h1>500 Server Error</h1>
|
|
92
|
+
<h2>{error}</h2>
|
|
93
|
+
<div class="traceback-container">
|
|
94
|
+
<p class="traceback-title">Traceback</p>
|
|
95
|
+
<div>{exc_html}</div>
|
|
96
|
+
</div>
|
|
97
|
+
{js}
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
FRAME_TEMPLATE = """
|
|
103
|
+
<div>
|
|
104
|
+
<p class="frame-title">File <span class="frame-filename">{frame_filename}</span>,
|
|
105
|
+
line <i>{frame_lineno}</i>,
|
|
106
|
+
in <b>{frame_name}</b>
|
|
107
|
+
<span class="collapse-btn" data-frame-id="{frame_filename}-{frame_lineno}" onclick="collapse(this)">{collapse_button}</span>
|
|
108
|
+
</p>
|
|
109
|
+
<div id="{frame_filename}-{frame_lineno}" class="source-code {collapsed}">{code_context}</div>
|
|
110
|
+
</div>
|
|
111
|
+
""" # noqa: E501
|
|
112
|
+
|
|
113
|
+
LINE = """
|
|
114
|
+
<p><span class="frame-line">
|
|
115
|
+
<span class="lineno">{lineno}.</span> {line}</span></p>
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
CENTER_LINE = """
|
|
119
|
+
<p class="center-line"><span class="frame-line center-line">
|
|
120
|
+
<span class="lineno">{lineno}.</span> {line}</span></p>
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ServerErrorMiddleware:
|
|
125
|
+
"""
|
|
126
|
+
Handles returning 500 responses when a server error occurs.
|
|
127
|
+
|
|
128
|
+
If 'debug' is set, then traceback responses will be returned,
|
|
129
|
+
otherwise the designated 'handler' will be called.
|
|
130
|
+
|
|
131
|
+
This middleware class should generally be used to wrap *everything*
|
|
132
|
+
else up, so that unhandled exceptions anywhere in the stack
|
|
133
|
+
always result in an appropriate 500 response.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
next_call: typing.Callable,
|
|
139
|
+
handler: typing.Callable[[RequestAPIGateway, Exception], typing.Any]
|
|
140
|
+
| None = None,
|
|
141
|
+
debug: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
self.next_call = next_call
|
|
144
|
+
self.handler = handler
|
|
145
|
+
self.debug = debug
|
|
146
|
+
|
|
147
|
+
def __call__(self, request: RequestAPIGateway) -> ResponseAPIGateway:
|
|
148
|
+
try:
|
|
149
|
+
response = self.next_call(request)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
if self.debug:
|
|
152
|
+
# In debug mode, return traceback responses.
|
|
153
|
+
response = self.debug_response(request, exc)
|
|
154
|
+
elif self.handler is None:
|
|
155
|
+
# Use our default 500 error handler.
|
|
156
|
+
response = self.error_response(request, exc)
|
|
157
|
+
else:
|
|
158
|
+
response = self.handler(request, exc)
|
|
159
|
+
|
|
160
|
+
return response
|
|
161
|
+
|
|
162
|
+
def format_line(
|
|
163
|
+
self, index: int, line: str, frame_lineno: int, frame_index: int
|
|
164
|
+
) -> str:
|
|
165
|
+
values = {
|
|
166
|
+
# HTML escape - line could contain < or >
|
|
167
|
+
"line": html.escape(line).replace(" ", " "),
|
|
168
|
+
"lineno": (frame_lineno - frame_index) + index,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if index != frame_index:
|
|
172
|
+
return LINE.format(**values)
|
|
173
|
+
return CENTER_LINE.format(**values)
|
|
174
|
+
|
|
175
|
+
def generate_frame_html(self, frame: inspect.FrameInfo, is_collapsed: bool) -> str:
|
|
176
|
+
code_context = "".join(
|
|
177
|
+
self.format_line(
|
|
178
|
+
index,
|
|
179
|
+
line,
|
|
180
|
+
frame.lineno,
|
|
181
|
+
frame.index, # type: ignore[arg-type]
|
|
182
|
+
)
|
|
183
|
+
for index, line in enumerate(frame.code_context or [])
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
values = {
|
|
187
|
+
# HTML escape - filename could contain < or >, especially if it's a virtual
|
|
188
|
+
# file e.g. <stdin> in the REPL
|
|
189
|
+
"frame_filename": html.escape(frame.filename),
|
|
190
|
+
"frame_lineno": frame.lineno,
|
|
191
|
+
# HTML escape - if you try very hard it's possible to name a function with <
|
|
192
|
+
# or >
|
|
193
|
+
"frame_name": html.escape(frame.function),
|
|
194
|
+
"code_context": code_context,
|
|
195
|
+
"collapsed": "collapsed" if is_collapsed else "",
|
|
196
|
+
"collapse_button": "+" if is_collapsed else "‒",
|
|
197
|
+
}
|
|
198
|
+
return FRAME_TEMPLATE.format(**values)
|
|
199
|
+
|
|
200
|
+
def generate_html(self, exc: Exception, limit: int = 7) -> str:
|
|
201
|
+
traceback_obj = traceback.TracebackException.from_exception(
|
|
202
|
+
exc, capture_locals=True
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
exc_html = ""
|
|
206
|
+
is_collapsed = False
|
|
207
|
+
exc_traceback = exc.__traceback__
|
|
208
|
+
if exc_traceback is not None:
|
|
209
|
+
frames = inspect.getinnerframes(exc_traceback, limit)
|
|
210
|
+
for frame in reversed(frames):
|
|
211
|
+
exc_html += self.generate_frame_html(frame, is_collapsed)
|
|
212
|
+
is_collapsed = True
|
|
213
|
+
|
|
214
|
+
if sys.version_info >= (3, 13): # pragma: no cover
|
|
215
|
+
exc_type_str = traceback_obj.exc_type_str
|
|
216
|
+
else: # pragma: no cover
|
|
217
|
+
exc_type_str = traceback_obj.exc_type.__name__
|
|
218
|
+
|
|
219
|
+
# escape error class and text
|
|
220
|
+
error = f"{html.escape(exc_type_str)}: {html.escape(str(traceback_obj))}"
|
|
221
|
+
|
|
222
|
+
return TEMPLATE.format(styles=STYLES, js=JS, error=error, exc_html=exc_html)
|
|
223
|
+
|
|
224
|
+
def generate_plain_text(self, exc: Exception) -> str:
|
|
225
|
+
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
226
|
+
|
|
227
|
+
def debug_response(
|
|
228
|
+
self, request: RequestAPIGateway, exc: Exception
|
|
229
|
+
) -> ResponseAPIGateway:
|
|
230
|
+
accept = request.headers.get("accept", "")
|
|
231
|
+
|
|
232
|
+
if "text/html" in accept:
|
|
233
|
+
content = self.generate_html(exc)
|
|
234
|
+
return ResponseAPIGateway(content, status_code=500)
|
|
235
|
+
content = self.generate_plain_text(exc)
|
|
236
|
+
return ResponseAPIGateway(content, status_code=500)
|
|
237
|
+
|
|
238
|
+
def error_response(self, _: RequestAPIGateway, __: Exception) -> ResponseAPIGateway:
|
|
239
|
+
return ResponseAPIGateway("Internal Server Error", status_code=500)
|