pyweber 1.5.0.dev0__tar.gz → 1.5.0.dev2__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.
- {pyweber-1.5.0.dev0/pyweber.egg-info → pyweber-1.5.0.dev2}/PKG-INFO +1 -1
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyproject.toml +2 -2
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/middleware.py +140 -31
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/openapi.py +13 -5
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/request.py +10 -2
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/response.py +110 -24
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/run.py +9 -5
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/pyweber/pyweber.py +69 -10
- pyweber-1.5.0.dev2/pyweber/static/html401.html +100 -0
- pyweber-1.5.0.dev2/pyweber/static/html404.html +101 -0
- pyweber-1.5.0.dev2/pyweber/static/html500.html +116 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/security.py +10 -6
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2/pyweber.egg-info}/PKG-INFO +1 -1
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_response.py +50 -14
- pyweber-1.5.0.dev0/pyweber/static/html401.html +0 -59
- pyweber-1.5.0.dev0/pyweber/static/html404.html +0 -56
- pyweber-1.5.0.dev0/pyweber/static/html500.html +0 -47
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/LICENSE +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/MANIFEST.in +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/NOTICE +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/README.md +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/admin/index.html +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/admin/src/script.js +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/admin/src/style.css +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/cli/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/cli/commands.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/form.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/general.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/input.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/config/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/config/config.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/http.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/reload.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/selector.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/session.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/websocket.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/element.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/events.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/html_parser.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/template.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/window.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/context.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/cookies.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/create_app.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/element.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/error_pages.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/field.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/field_storage.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/file.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/file_stream.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/handoff.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/headers.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/rate_limit.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/routes.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/security.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/stream_stats.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/strem_stats.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/task_manager.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/template_diff.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/ws_message.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/pyweber/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/.gitignore +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/config.toml +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/css.css +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/docs.html +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/android-chrome-192x192.png +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/android-chrome-512x512.png +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/apple-touch-icon.png +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/favicon-16x16.png +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/favicon-32x32.png +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/favicon.ico +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/pyweber.png +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/site.webmanifest +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/handlers.js +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/html.html +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/js.js +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/loading.html +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/main.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/pyweber.css +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/update.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/testing/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/testing/client.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/__init__.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/async_utils.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/deprecation.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/exceptions.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/icons.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/loads.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/mime.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/types.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/utils.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/SOURCES.txt +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/dependency_links.txt +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/entry_points.txt +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/requires.txt +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/top_level.txt +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/setup.cfg +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_config.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_context.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_cookies.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_public_api.py +0 -0
- {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_template_diff.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyweber"
|
|
7
|
-
version = "1.5.0.
|
|
7
|
+
version = "1.5.0.dev2"
|
|
8
8
|
description = "A lightweight Python framework for building and managing web applications."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "DevPythonMZ", email = "pypi.dev@gmail.com" }]
|
|
@@ -37,5 +37,5 @@ pyweber = ["static/**/*", "admin/**/*"]
|
|
|
37
37
|
omit = ["pyweber/cli/commands.py"]
|
|
38
38
|
|
|
39
39
|
[tool.coverage.report]
|
|
40
|
-
fail_under =
|
|
40
|
+
fail_under = 86
|
|
41
41
|
show_missing = true
|
|
@@ -33,29 +33,130 @@ class MiddlewareManager:
|
|
|
33
33
|
def get_onion_middlewares(self):
|
|
34
34
|
return self.__onion
|
|
35
35
|
|
|
36
|
-
def before_request(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
def before_request(
|
|
37
|
+
self,
|
|
38
|
+
fn: Callable | None = None,
|
|
39
|
+
*,
|
|
40
|
+
status_code: int = 200,
|
|
41
|
+
process_response: bool = True,
|
|
42
|
+
order: int = -1,
|
|
43
|
+
):
|
|
44
|
+
"""Flask-style hook: ``(request) -> None | body``.
|
|
45
|
+
|
|
46
|
+
Use as ``@app.before_request`` or ``@app.before_request()``.
|
|
47
|
+
Returning ``None`` continues; any other value short-circuits the route.
|
|
48
|
+
"""
|
|
49
|
+
if status_code != 200 or process_response is not True:
|
|
50
|
+
warn_deprecated(
|
|
51
|
+
'before_request(status_code=..., process_response=...)',
|
|
52
|
+
alternative='return a Response/Template with the desired status from the hook',
|
|
53
|
+
removal='2.0',
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def register(middleware: Callable[..., Any]):
|
|
57
|
+
self.add_before_request(
|
|
58
|
+
middleware,
|
|
59
|
+
status_code=status_code,
|
|
60
|
+
process_response=process_response,
|
|
61
|
+
order=order,
|
|
44
62
|
)
|
|
45
63
|
return middleware
|
|
46
|
-
return wrapper
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
if fn is not None and callable(fn):
|
|
66
|
+
return register(fn)
|
|
67
|
+
return register
|
|
68
|
+
|
|
69
|
+
def after_request(
|
|
70
|
+
self,
|
|
71
|
+
fn: Callable | None = None,
|
|
72
|
+
*,
|
|
73
|
+
status_code: int = None,
|
|
74
|
+
process_response: bool = True,
|
|
75
|
+
order: int = -1,
|
|
76
|
+
):
|
|
77
|
+
"""Flask-style hook: ``(response) -> Response``.
|
|
78
|
+
|
|
79
|
+
Use as ``@app.after_request`` or ``@app.after_request()``.
|
|
80
|
+
Returning ``None`` keeps the previous response.
|
|
81
|
+
"""
|
|
82
|
+
if status_code is not None or process_response is not True:
|
|
83
|
+
warn_deprecated(
|
|
84
|
+
'after_request(status_code=..., process_response=...)',
|
|
85
|
+
alternative='mutate and return the Response from the hook',
|
|
86
|
+
removal='2.0',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def register(middleware: Callable[..., Any]):
|
|
90
|
+
self.add_after_request(
|
|
91
|
+
middleware,
|
|
92
|
+
status_code=status_code,
|
|
93
|
+
process_response=process_response,
|
|
94
|
+
order=order,
|
|
56
95
|
)
|
|
57
96
|
return middleware
|
|
58
|
-
|
|
97
|
+
|
|
98
|
+
if fn is not None and callable(fn):
|
|
99
|
+
return register(fn)
|
|
100
|
+
return register
|
|
101
|
+
|
|
102
|
+
def add_before_request(
|
|
103
|
+
self,
|
|
104
|
+
middleware: Callable[..., Any],
|
|
105
|
+
*,
|
|
106
|
+
status_code: int = 200,
|
|
107
|
+
process_response: bool = True,
|
|
108
|
+
order: int = -1,
|
|
109
|
+
):
|
|
110
|
+
"""Register a before_request hook (Flask-style ``add_before_request``)."""
|
|
111
|
+
entry = self.set_middleware(
|
|
112
|
+
status_code=status_code,
|
|
113
|
+
middleware=middleware,
|
|
114
|
+
order=order,
|
|
115
|
+
process_response=process_response,
|
|
116
|
+
)
|
|
117
|
+
if order < 0 or order >= len(self.__before_request):
|
|
118
|
+
self.__before_request.append(entry)
|
|
119
|
+
else:
|
|
120
|
+
self.__before_request.insert(order, entry)
|
|
121
|
+
return middleware
|
|
122
|
+
|
|
123
|
+
def add_after_request(
|
|
124
|
+
self,
|
|
125
|
+
middleware: Callable[..., Any],
|
|
126
|
+
*,
|
|
127
|
+
status_code: int = None,
|
|
128
|
+
process_response: bool = True,
|
|
129
|
+
order: int = -1,
|
|
130
|
+
):
|
|
131
|
+
"""Register an after_request hook (Flask-style ``add_after_request``)."""
|
|
132
|
+
entry = self.set_middleware(
|
|
133
|
+
status_code=status_code,
|
|
134
|
+
middleware=middleware,
|
|
135
|
+
order=order,
|
|
136
|
+
process_response=process_response,
|
|
137
|
+
)
|
|
138
|
+
if order < 0 or order >= len(self.__after_request):
|
|
139
|
+
self.__after_request.append(entry)
|
|
140
|
+
else:
|
|
141
|
+
self.__after_request.insert(order, entry)
|
|
142
|
+
return middleware
|
|
143
|
+
|
|
144
|
+
# Docs / older aliases
|
|
145
|
+
def add_before_request_middleware(self, middleware: Callable[..., Any], **kwargs):
|
|
146
|
+
warn_deprecated(
|
|
147
|
+
'add_before_request_middleware',
|
|
148
|
+
alternative='add_before_request',
|
|
149
|
+
removal='2.0',
|
|
150
|
+
)
|
|
151
|
+
return self.add_before_request(middleware, **kwargs)
|
|
152
|
+
|
|
153
|
+
def add_after_request_middleware(self, middleware: Callable[..., Any], **kwargs):
|
|
154
|
+
warn_deprecated(
|
|
155
|
+
'add_after_request_middleware',
|
|
156
|
+
alternative='add_after_request',
|
|
157
|
+
removal='2.0',
|
|
158
|
+
)
|
|
159
|
+
return self.add_after_request(middleware, **kwargs)
|
|
59
160
|
|
|
60
161
|
def middleware(self, order: int = -1):
|
|
61
162
|
"""Register onion middleware: ``async def mw(request, call_next)``."""
|
|
@@ -88,7 +189,7 @@ class MiddlewareManager:
|
|
|
88
189
|
resp: Union[Request, Response, str],
|
|
89
190
|
middlewares: list[dict[str, Union[int, Callable, bool]]]
|
|
90
191
|
):
|
|
91
|
-
response, status_code, process_response = None,
|
|
192
|
+
response, status_code, process_response = None, 200, True
|
|
92
193
|
|
|
93
194
|
for middle_dict in middlewares:
|
|
94
195
|
status_code, middle, _, process_response = middle_dict.values()
|
|
@@ -112,22 +213,37 @@ class MiddlewareManager:
|
|
|
112
213
|
break
|
|
113
214
|
|
|
114
215
|
if not isinstance(resp, Response) and response:
|
|
216
|
+
# Prefer Response.status_code; otherwise decorator status_code (Flask-compat)
|
|
217
|
+
resolved_status = status_code if status_code is not None else 200
|
|
218
|
+
if isinstance(response, Response):
|
|
219
|
+
resolved_status = response.status_code
|
|
220
|
+
elif isinstance(response, Template) and (status_code is None or status_code == 200):
|
|
221
|
+
tmpl_code = getattr(response, 'status_code', None) or getattr(response, 'code', None)
|
|
222
|
+
if tmpl_code:
|
|
223
|
+
resolved_status = tmpl_code
|
|
224
|
+
|
|
115
225
|
return MiddlewareResult(
|
|
116
|
-
status_code=
|
|
117
|
-
process_response=process_response,
|
|
226
|
+
status_code=resolved_status,
|
|
227
|
+
process_response=process_response if process_response is not None else True,
|
|
118
228
|
content=response
|
|
119
229
|
)
|
|
120
230
|
|
|
121
231
|
if isinstance(resp, Response):
|
|
122
|
-
|
|
123
|
-
|
|
232
|
+
# after_request: None keeps previous response (tolerant)
|
|
233
|
+
if response is not None and not isinstance(response, Response):
|
|
234
|
+
raise TypeError(
|
|
235
|
+
f'All after request middleware need return Response instances, '
|
|
236
|
+
f'but got {type(response).__name__}'
|
|
237
|
+
)
|
|
124
238
|
|
|
125
239
|
return MiddlewareResult(
|
|
126
|
-
content=response
|
|
240
|
+
content=response if isinstance(response, Response) else resp,
|
|
127
241
|
status_code=resp.status_code,
|
|
128
242
|
process_response=None
|
|
129
243
|
)
|
|
130
244
|
|
|
245
|
+
return None
|
|
246
|
+
|
|
131
247
|
async def run_onion(
|
|
132
248
|
self,
|
|
133
249
|
request: Request,
|
|
@@ -186,13 +302,6 @@ class MiddlewareManager:
|
|
|
186
302
|
f"{middleware.__name__} has {len(params)} parameters"
|
|
187
303
|
)
|
|
188
304
|
|
|
189
|
-
if len(params) == 1:
|
|
190
|
-
warn_deprecated(
|
|
191
|
-
'single-parameter middleware',
|
|
192
|
-
alternative='@app.middleware with (request, call_next)',
|
|
193
|
-
removal='2.0',
|
|
194
|
-
)
|
|
195
|
-
|
|
196
305
|
if params and not all(
|
|
197
306
|
param.annotation in [Request, Response, inspect.Parameter.empty]
|
|
198
307
|
for param in params
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
2
|
import inspect
|
|
3
|
+
import types
|
|
3
4
|
from typing import Any, Callable, Union, get_args, get_origin
|
|
4
5
|
import dataclasses
|
|
5
6
|
import sys
|
|
@@ -28,10 +29,17 @@ _PRIMITIVE_TYPE_MAP: dict[str, type] = {
|
|
|
28
29
|
'bytes': bytes,
|
|
29
30
|
'bytearray': bytearray,
|
|
30
31
|
'None': type(None),
|
|
31
|
-
'NoneType': type(None),
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _is_union_origin(origin: Any) -> bool:
|
|
36
|
+
"""True for typing.Union and PEP 604 ``X | Y`` (types.UnionType on 3.10+)."""
|
|
37
|
+
if origin is Union:
|
|
38
|
+
return True
|
|
39
|
+
union_type = getattr(types, 'UnionType', None)
|
|
40
|
+
return union_type is not None and origin is union_type
|
|
41
|
+
|
|
42
|
+
|
|
35
43
|
@dataclasses.dataclass
|
|
36
44
|
class OpenAPIConfig:
|
|
37
45
|
"""App-level OpenAPI / Swagger configuration."""
|
|
@@ -209,8 +217,8 @@ class OpenApiProcessor:
|
|
|
209
217
|
|
|
210
218
|
origin = get_origin(annotation)
|
|
211
219
|
if origin is not None:
|
|
212
|
-
# Union / Optional → prefer first non-None arg's name
|
|
213
|
-
if
|
|
220
|
+
# Union / Optional / PEP 604 (int | None) → prefer first non-None arg's name
|
|
221
|
+
if _is_union_origin(origin):
|
|
214
222
|
args = [a for a in get_args(annotation) if a is not type(None)]
|
|
215
223
|
if args:
|
|
216
224
|
return cls.annotation_type_name(args[0])
|
|
@@ -664,8 +672,8 @@ class OpenApiProcessor:
|
|
|
664
672
|
origin = get_origin(annotation)
|
|
665
673
|
args = get_args(annotation)
|
|
666
674
|
|
|
667
|
-
# Union / Optional
|
|
668
|
-
if
|
|
675
|
+
# Union / Optional / PEP 604 (int | None)
|
|
676
|
+
if _is_union_origin(origin):
|
|
669
677
|
non_none = [a for a in args if a is not type(None)]
|
|
670
678
|
has_none = len(non_none) != len(args)
|
|
671
679
|
if not non_none:
|
|
@@ -131,12 +131,20 @@ class Request:
|
|
|
131
131
|
|
|
132
132
|
@property
|
|
133
133
|
def accept_control_request_headers(self):
|
|
134
|
-
|
|
134
|
+
# Headers are normalized to lowercase (WSGI parse + ASGI)
|
|
135
|
+
return (
|
|
136
|
+
self.headers.get('access-control-request-headers')
|
|
137
|
+
or self.headers.get('Access-Control-Request-Headers')
|
|
138
|
+
or ''
|
|
139
|
+
)
|
|
135
140
|
|
|
136
141
|
@property
|
|
137
142
|
def headers(self):
|
|
138
143
|
if self.request_mode.value == 'asgi':
|
|
139
|
-
return {
|
|
144
|
+
return {
|
|
145
|
+
header[0].decode().lower(): header[1].decode()
|
|
146
|
+
for header in self.__raw_headers
|
|
147
|
+
}
|
|
140
148
|
|
|
141
149
|
return self.__parse_headers_wsgi()
|
|
142
150
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
1
7
|
from pyweber.utils.types import ContentTypes, HTTPStatusCode
|
|
2
8
|
from pyweber.models.request import Request
|
|
3
|
-
from pyweber.config.config import config
|
|
4
9
|
from pyweber.utils.utils import PrintLine, Colors
|
|
5
10
|
from pyweber.utils.security import get_allowed_origins, https_enabled
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
11
|
|
|
8
12
|
DEFAULT_CSP = (
|
|
9
13
|
"default-src 'self'; "
|
|
@@ -16,32 +20,99 @@ DEFAULT_CSP = (
|
|
|
16
20
|
"form-action 'self'"
|
|
17
21
|
)
|
|
18
22
|
|
|
23
|
+
# Internal bookkeeping headers — stripped on ASGI wire format
|
|
24
|
+
INTERNAL_RESPONSE_HEADERS = frozenset({
|
|
25
|
+
'method',
|
|
26
|
+
'http-version',
|
|
27
|
+
'status',
|
|
28
|
+
'request-path',
|
|
29
|
+
'response-path',
|
|
30
|
+
'set-cookie', # handled separately
|
|
31
|
+
'code',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _coerce_body_and_type(
|
|
36
|
+
content: Any,
|
|
37
|
+
content_type: ContentTypes | str | None,
|
|
38
|
+
) -> tuple[bytes, ContentTypes]:
|
|
39
|
+
if content is None:
|
|
40
|
+
body = b''
|
|
41
|
+
inferred = ContentTypes.txt
|
|
42
|
+
elif isinstance(content, bytes):
|
|
43
|
+
body = content
|
|
44
|
+
inferred = ContentTypes.unkown
|
|
45
|
+
elif isinstance(content, dict) or isinstance(content, list):
|
|
46
|
+
body = json.dumps(content).encode('utf-8')
|
|
47
|
+
inferred = ContentTypes.json
|
|
48
|
+
elif isinstance(content, str):
|
|
49
|
+
body = content.encode('utf-8')
|
|
50
|
+
stripped = content.lstrip().lower()
|
|
51
|
+
inferred = ContentTypes.html if stripped.startswith('<') else ContentTypes.txt
|
|
52
|
+
else:
|
|
53
|
+
body = str(content).encode('utf-8')
|
|
54
|
+
inferred = ContentTypes.txt
|
|
55
|
+
|
|
56
|
+
if content_type is None:
|
|
57
|
+
return body, inferred
|
|
58
|
+
if isinstance(content_type, ContentTypes):
|
|
59
|
+
return body, content_type
|
|
60
|
+
# string mime → best-effort enum match
|
|
61
|
+
for ct in ContentTypes:
|
|
62
|
+
if ct.value == content_type or content_type.startswith(ct.value):
|
|
63
|
+
return body, ct
|
|
64
|
+
return body, inferred
|
|
65
|
+
|
|
19
66
|
|
|
20
67
|
class Response:
|
|
21
68
|
def __init__(
|
|
22
69
|
self,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
70
|
+
content: Any = None,
|
|
71
|
+
status: int = 200,
|
|
72
|
+
*,
|
|
73
|
+
request: Request | None = None,
|
|
74
|
+
cookies: dict[str, str] | None = None,
|
|
75
|
+
content_type: ContentTypes | str | None = None,
|
|
76
|
+
route: str | None = None,
|
|
77
|
+
headers: dict[str, Any] | None = None,
|
|
78
|
+
allowed_methods: list[str] | None = None,
|
|
79
|
+
# Legacy aliases (still supported)
|
|
80
|
+
response_content: bytes | None = None,
|
|
81
|
+
code: int | None = None,
|
|
82
|
+
response_type: ContentTypes | None = None,
|
|
30
83
|
):
|
|
31
|
-
|
|
84
|
+
if response_content is not None and content is None:
|
|
85
|
+
content = response_content
|
|
86
|
+
if code is not None:
|
|
87
|
+
status = code
|
|
88
|
+
if response_type is not None and content_type is None:
|
|
89
|
+
content_type = response_type
|
|
90
|
+
|
|
91
|
+
body, resolved_type = _coerce_body_and_type(content, content_type)
|
|
92
|
+
|
|
93
|
+
if request is None:
|
|
94
|
+
request = Request.stub(method='GET', path=route or '/')
|
|
95
|
+
|
|
96
|
+
cookies = cookies if cookies is not None else {}
|
|
97
|
+
route = route if route is not None else (getattr(request, 'path', None) or '/')
|
|
98
|
+
|
|
99
|
+
request_headers = (
|
|
100
|
+
request.accept_control_request_headers
|
|
101
|
+
or "Content-Type, Authorization, X-Requested-With, Accept, X-CSRF-Token"
|
|
102
|
+
)
|
|
32
103
|
self.__request = request
|
|
33
104
|
self.__allowed_methods = allowed_methods
|
|
34
|
-
self.__body =
|
|
35
|
-
self.__headers: dict[str,
|
|
36
|
-
"Content-Type": f"{
|
|
37
|
-
"Content-Length": len(
|
|
105
|
+
self.__body = body
|
|
106
|
+
self.__headers: dict[str, Any] = {
|
|
107
|
+
"Content-Type": f"{resolved_type.value}; charset=UTF-8",
|
|
108
|
+
"Content-Length": len(body),
|
|
38
109
|
"Connection": 'Close',
|
|
39
110
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
|
40
111
|
"Pragma": "no-cache",
|
|
41
112
|
"Vary": "Accept-Encoding",
|
|
42
113
|
"Method": request.method,
|
|
43
114
|
"Http-Version": request.scheme,
|
|
44
|
-
"Status":
|
|
115
|
+
"Status": status,
|
|
45
116
|
"Server": 'Pyweber/1.0',
|
|
46
117
|
"Date": datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
|
47
118
|
"Set-Cookie": cookies,
|
|
@@ -52,7 +123,7 @@ class Response:
|
|
|
52
123
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
53
124
|
"Content-Security-Policy": DEFAULT_CSP,
|
|
54
125
|
"X-Forwarded-Proto": "https" if https_enabled() else "http",
|
|
55
|
-
"X-Forwarded-Host": request
|
|
126
|
+
"X-Forwarded-Host": getattr(request, 'host', None) or 'localhost',
|
|
56
127
|
}
|
|
57
128
|
|
|
58
129
|
if https_enabled():
|
|
@@ -60,9 +131,25 @@ class Response:
|
|
|
60
131
|
|
|
61
132
|
self._apply_cors(request, request_headers)
|
|
62
133
|
|
|
63
|
-
|
|
134
|
+
if headers:
|
|
135
|
+
for key, value in headers.items():
|
|
136
|
+
self.__headers[key] = value
|
|
137
|
+
|
|
138
|
+
self.http_status_code = HTTPStatusCode.search_by_code(status)
|
|
64
139
|
self.__check_status_code()
|
|
65
140
|
|
|
141
|
+
@classmethod
|
|
142
|
+
def json(cls, data: Any, status: int = 200, **kwargs) -> Response:
|
|
143
|
+
return cls(content=data, status=status, content_type=ContentTypes.json, **kwargs)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def text(cls, data: str, status: int = 200, **kwargs) -> Response:
|
|
147
|
+
return cls(content=data, status=status, content_type=ContentTypes.txt, **kwargs)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def html(cls, data: str, status: int = 200, **kwargs) -> Response:
|
|
151
|
+
return cls(content=data, status=status, content_type=ContentTypes.html, **kwargs)
|
|
152
|
+
|
|
66
153
|
def _apply_cors(self, request: Request, request_headers: str):
|
|
67
154
|
origin = request.origin
|
|
68
155
|
allowed = get_allowed_origins()
|
|
@@ -78,20 +165,19 @@ class Response:
|
|
|
78
165
|
def __check_status_code(self):
|
|
79
166
|
aditional_code: tuple[str, str] = ()
|
|
80
167
|
|
|
81
|
-
|
|
82
|
-
aditional_code = ('WWW-Authenticate', f"Basic realm={config.get('app', 'name')}")
|
|
168
|
+
# WWW-Authenticate is NOT auto-added; set via headers= or set_header / security schemes
|
|
83
169
|
|
|
84
|
-
|
|
170
|
+
if self.status_code == 405:
|
|
85
171
|
allow = ', '.join(self.__allowed_methods) if self.__allowed_methods else 'GET, POST, PUT, DELETE'
|
|
86
172
|
aditional_code = ('Allow', allow)
|
|
87
173
|
|
|
88
174
|
elif self.status_code == 503:
|
|
89
175
|
aditional_code = ('Retry-After', '60')
|
|
90
176
|
|
|
91
|
-
elif self.status_code in range(300,400):
|
|
177
|
+
elif self.status_code in range(300, 400):
|
|
92
178
|
aditional_code = ('Location', self.response_path)
|
|
93
179
|
|
|
94
|
-
if self.status_code not in range(300,400):
|
|
180
|
+
if self.status_code not in range(300, 400):
|
|
95
181
|
self.set_header('Response-Path', self.request_path)
|
|
96
182
|
|
|
97
183
|
if aditional_code:
|
|
@@ -99,7 +185,7 @@ class Response:
|
|
|
99
185
|
self.http_status_code += f"\r\n{': '.join(aditional_code)}"
|
|
100
186
|
|
|
101
187
|
@property
|
|
102
|
-
def headers(self) -> dict[str,
|
|
188
|
+
def headers(self) -> dict[str, Any]:
|
|
103
189
|
return self.__headers
|
|
104
190
|
|
|
105
191
|
@property
|
|
@@ -123,7 +209,7 @@ class Response:
|
|
|
123
209
|
return self.__body
|
|
124
210
|
|
|
125
211
|
@property
|
|
126
|
-
def cookies(self) -> list
|
|
212
|
+
def cookies(self) -> list | dict:
|
|
127
213
|
return self.headers.get('Set-Cookie', [])
|
|
128
214
|
|
|
129
215
|
@property
|
|
@@ -86,14 +86,18 @@ def run(
|
|
|
86
86
|
|
|
87
87
|
CreateApp(target=target, **kwargs).run()
|
|
88
88
|
|
|
89
|
-
def encode_header(headers: dict[str, Any],
|
|
89
|
+
def encode_header(headers: dict[str, Any], /, *ignore_headers: str):
|
|
90
|
+
"""Encode response headers for ASGI, skipping internal bookkeeping keys."""
|
|
91
|
+
from pyweber.models.response import INTERNAL_RESPONSE_HEADERS
|
|
92
|
+
|
|
93
|
+
ignored = {h.strip().lower() for h in ignore_headers} | set(INTERNAL_RESPONSE_HEADERS)
|
|
90
94
|
byte_headers: list[tuple[bytes, bytes]] = []
|
|
91
95
|
|
|
92
96
|
for header, value in headers.items():
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
key = header.strip().lower()
|
|
98
|
+
if key in ignored:
|
|
99
|
+
continue
|
|
100
|
+
byte_headers.append((key.encode(), str(value).encode()))
|
|
97
101
|
|
|
98
102
|
return byte_headers
|
|
99
103
|
|
|
@@ -73,6 +73,7 @@ class StateResult:
|
|
|
73
73
|
process_response: bool
|
|
74
74
|
kwargs: dict[str, Any]
|
|
75
75
|
callback: Callable[..., Any]
|
|
76
|
+
response_headers: dict[str, str] | None = None
|
|
76
77
|
|
|
77
78
|
def update(
|
|
78
79
|
self,
|
|
@@ -82,7 +83,8 @@ class StateResult:
|
|
|
82
83
|
redirect_path = None,
|
|
83
84
|
process_response = None,
|
|
84
85
|
callback = None,
|
|
85
|
-
kwargs = {}
|
|
86
|
+
kwargs = {},
|
|
87
|
+
response_headers = None,
|
|
86
88
|
):
|
|
87
89
|
self.template = template if template else self.template
|
|
88
90
|
self.status_code = status_code if status_code else self.status_code
|
|
@@ -91,6 +93,8 @@ class StateResult:
|
|
|
91
93
|
self.process_response = process_response if process_response is not None else self.process_response
|
|
92
94
|
self.callback = callback if callback is not None else self.callback
|
|
93
95
|
self.kwargs = kwargs if kwargs else self.kwargs
|
|
96
|
+
if response_headers is not None:
|
|
97
|
+
self.response_headers = response_headers
|
|
94
98
|
|
|
95
99
|
return self
|
|
96
100
|
|
|
@@ -102,6 +106,7 @@ class TemplateResult:
|
|
|
102
106
|
process_response: bool
|
|
103
107
|
template: Union[Template, Element, dict, list, str]
|
|
104
108
|
allowed_methods: list[str] = None
|
|
109
|
+
response_headers: dict[str, str] | None = None
|
|
105
110
|
|
|
106
111
|
@dataclass
|
|
107
112
|
class ContentResult:
|
|
@@ -245,6 +250,10 @@ class Pyweber(
|
|
|
245
250
|
if rate_limited is not None:
|
|
246
251
|
return rate_limited
|
|
247
252
|
|
|
253
|
+
cors_preflight = self._cors_preflight_response(request)
|
|
254
|
+
if cors_preflight is not None:
|
|
255
|
+
return cors_preflight
|
|
256
|
+
|
|
248
257
|
self._ensure_session_cookie(request)
|
|
249
258
|
self._ensure_csrf_cookie(request)
|
|
250
259
|
|
|
@@ -330,8 +339,13 @@ class Pyweber(
|
|
|
330
339
|
cookies=dict(self.cookies),
|
|
331
340
|
route=template_result.redirect_path,
|
|
332
341
|
allowed_methods=template_result.allowed_methods,
|
|
342
|
+
headers=getattr(template_result, 'response_headers', None),
|
|
333
343
|
) if not isinstance(content_result, Response) else content_result
|
|
334
344
|
|
|
345
|
+
if isinstance(content_result, Response) and getattr(template_result, 'response_headers', None):
|
|
346
|
+
for key, value in template_result.response_headers.items():
|
|
347
|
+
response.set_header(key, value)
|
|
348
|
+
|
|
335
349
|
response = self._apply_static_etag(request, response, template_result)
|
|
336
350
|
|
|
337
351
|
after_request_response = await self.process_middleware(
|
|
@@ -788,14 +802,31 @@ class Pyweber(
|
|
|
788
802
|
requirements=requirements,
|
|
789
803
|
)
|
|
790
804
|
if not challenge.ok:
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
805
|
+
extra_headers = {}
|
|
806
|
+
if challenge.www_authenticate:
|
|
807
|
+
extra_headers['WWW-Authenticate'] = challenge.www_authenticate
|
|
808
|
+
|
|
809
|
+
prefers_html = self._prefers_html(request)
|
|
810
|
+
if prefers_html and challenge.status_code == 401:
|
|
811
|
+
state_result.update(
|
|
812
|
+
template=self.page_unauthorized,
|
|
813
|
+
status_code=challenge.status_code,
|
|
814
|
+
content_type=ContentTypes.html,
|
|
815
|
+
process_response=False,
|
|
816
|
+
callback=None,
|
|
817
|
+
redirect_path=path,
|
|
818
|
+
response_headers=extra_headers or None,
|
|
819
|
+
)
|
|
820
|
+
else:
|
|
821
|
+
state_result.update(
|
|
822
|
+
template={'detail': challenge.detail},
|
|
823
|
+
status_code=challenge.status_code,
|
|
824
|
+
content_type=ContentTypes.json,
|
|
825
|
+
process_response=False,
|
|
826
|
+
callback=None,
|
|
827
|
+
redirect_path=path,
|
|
828
|
+
response_headers=extra_headers or None,
|
|
829
|
+
)
|
|
799
830
|
return await self._process_templates(state_result=state_result)
|
|
800
831
|
|
|
801
832
|
request.auth = challenge.auth
|
|
@@ -956,7 +987,35 @@ class Pyweber(
|
|
|
956
987
|
content_type=state_result.content_type,
|
|
957
988
|
redirect_path=state_result.redirect_path,
|
|
958
989
|
process_response=state_result.process_response,
|
|
959
|
-
template=template
|
|
990
|
+
template=template,
|
|
991
|
+
response_headers=state_result.response_headers,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
def _prefers_html(self, request: Request) -> bool:
|
|
995
|
+
accept = (request.headers.get('accept') or '').lower()
|
|
996
|
+
if 'text/html' not in accept:
|
|
997
|
+
return False
|
|
998
|
+
if 'application/json' not in accept:
|
|
999
|
+
return True
|
|
1000
|
+
return accept.find('text/html') <= accept.find('application/json')
|
|
1001
|
+
|
|
1002
|
+
def _cors_preflight_response(self, request: Request) -> Response | None:
|
|
1003
|
+
"""Early OPTIONS 204 with CORS headers when Origin is whitelisted."""
|
|
1004
|
+
method = (request.method or '').upper()
|
|
1005
|
+
if method != 'OPTIONS':
|
|
1006
|
+
return None
|
|
1007
|
+
origin = request.origin
|
|
1008
|
+
from pyweber.utils.security import get_allowed_origins
|
|
1009
|
+
allowed = get_allowed_origins()
|
|
1010
|
+
if not origin or origin.rstrip('/') not in allowed:
|
|
1011
|
+
return None
|
|
1012
|
+
return Response(
|
|
1013
|
+
content=b'',
|
|
1014
|
+
status=204,
|
|
1015
|
+
request=request,
|
|
1016
|
+
cookies={},
|
|
1017
|
+
content_type=ContentTypes.txt,
|
|
1018
|
+
route=request.path or '/',
|
|
960
1019
|
)
|
|
961
1020
|
|
|
962
1021
|
async def process_route_middleware(self, resp: str, middlewares: list[Callable], status_code: int):
|