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/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()
@@ -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
+ }
@@ -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 = "&#8210;";
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(" ", "&nbsp"),
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 "&#8210;",
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)