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.
Files changed (107) hide show
  1. {pyweber-1.5.0.dev0/pyweber.egg-info → pyweber-1.5.0.dev2}/PKG-INFO +1 -1
  2. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyproject.toml +2 -2
  3. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/middleware.py +140 -31
  4. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/openapi.py +13 -5
  5. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/request.py +10 -2
  6. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/response.py +110 -24
  7. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/run.py +9 -5
  8. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/pyweber/pyweber.py +69 -10
  9. pyweber-1.5.0.dev2/pyweber/static/html401.html +100 -0
  10. pyweber-1.5.0.dev2/pyweber/static/html404.html +101 -0
  11. pyweber-1.5.0.dev2/pyweber/static/html500.html +116 -0
  12. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/security.py +10 -6
  13. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2/pyweber.egg-info}/PKG-INFO +1 -1
  14. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_response.py +50 -14
  15. pyweber-1.5.0.dev0/pyweber/static/html401.html +0 -59
  16. pyweber-1.5.0.dev0/pyweber/static/html404.html +0 -56
  17. pyweber-1.5.0.dev0/pyweber/static/html500.html +0 -47
  18. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/LICENSE +0 -0
  19. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/MANIFEST.in +0 -0
  20. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/NOTICE +0 -0
  21. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/README.md +0 -0
  22. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/__init__.py +0 -0
  23. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/admin/index.html +0 -0
  24. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/admin/src/script.js +0 -0
  25. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/admin/src/style.css +0 -0
  26. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/cli/__init__.py +0 -0
  27. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/cli/commands.py +0 -0
  28. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/__init__.py +0 -0
  29. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/form.py +0 -0
  30. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/general.py +0 -0
  31. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/components/input.py +0 -0
  32. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/config/__init__.py +0 -0
  33. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/config/config.py +0 -0
  34. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/__init__.py +0 -0
  35. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/http.py +0 -0
  36. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/reload.py +0 -0
  37. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/selector.py +0 -0
  38. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/session.py +0 -0
  39. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/connection/websocket.py +0 -0
  40. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/__init__.py +0 -0
  41. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/element.py +0 -0
  42. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/events.py +0 -0
  43. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/html_parser.py +0 -0
  44. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/template.py +0 -0
  45. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/core/window.py +0 -0
  46. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/__init__.py +0 -0
  47. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/context.py +0 -0
  48. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/cookies.py +0 -0
  49. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/create_app.py +0 -0
  50. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/element.py +0 -0
  51. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/error_pages.py +0 -0
  52. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/field.py +0 -0
  53. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/field_storage.py +0 -0
  54. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/file.py +0 -0
  55. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/file_stream.py +0 -0
  56. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/handoff.py +0 -0
  57. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/headers.py +0 -0
  58. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/rate_limit.py +0 -0
  59. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/routes.py +0 -0
  60. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/security.py +0 -0
  61. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/stream_stats.py +0 -0
  62. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/strem_stats.py +0 -0
  63. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/task_manager.py +0 -0
  64. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/template_diff.py +0 -0
  65. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/models/ws_message.py +0 -0
  66. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/pyweber/__init__.py +0 -0
  67. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/.gitignore +0 -0
  68. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/config.toml +0 -0
  69. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/css.css +0 -0
  70. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/docs.html +0 -0
  71. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/android-chrome-192x192.png +0 -0
  72. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/android-chrome-512x512.png +0 -0
  73. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/apple-touch-icon.png +0 -0
  74. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/favicon-16x16.png +0 -0
  75. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/favicon-32x32.png +0 -0
  76. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/favicon.ico +0 -0
  77. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/pyweber.png +0 -0
  78. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/favicon/site.webmanifest +0 -0
  79. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/handlers.js +0 -0
  80. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/html.html +0 -0
  81. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/js.js +0 -0
  82. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/loading.html +0 -0
  83. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/main.py +0 -0
  84. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/pyweber.css +0 -0
  85. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/static/update.py +0 -0
  86. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/testing/__init__.py +0 -0
  87. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/testing/client.py +0 -0
  88. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/__init__.py +0 -0
  89. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/async_utils.py +0 -0
  90. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/deprecation.py +0 -0
  91. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/exceptions.py +0 -0
  92. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/icons.py +0 -0
  93. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/loads.py +0 -0
  94. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/mime.py +0 -0
  95. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/types.py +0 -0
  96. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber/utils/utils.py +0 -0
  97. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/SOURCES.txt +0 -0
  98. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/dependency_links.txt +0 -0
  99. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/entry_points.txt +0 -0
  100. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/requires.txt +0 -0
  101. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/pyweber.egg-info/top_level.txt +0 -0
  102. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/setup.cfg +0 -0
  103. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_config.py +0 -0
  104. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_context.py +0 -0
  105. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_cookies.py +0 -0
  106. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_public_api.py +0 -0
  107. {pyweber-1.5.0.dev0 → pyweber-1.5.0.dev2}/tests/test_template_diff.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyweber
3
- Version: 1.5.0.dev0
3
+ Version: 1.5.0.dev2
4
4
  Summary: A lightweight Python framework for building and managing web applications.
5
5
  Author-email: DevPythonMZ <pypi.dev@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyweber"
7
- version = "1.5.0.dev0"
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 = 87.5
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(self, status_code: int = 200, process_response: bool = True, order: int = -1):
37
- def wrapper(middleware: Callable[..., Template | Element | str | None]):
38
- self.__before_request.insert(order, self.set_middleware(
39
- status_code=status_code,
40
- middleware=middleware,
41
- order=order,
42
- process_response=process_response
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
- def after_request(self, status_code: int = None, process_response: bool = True, order: int = -1):
49
- def wrapper(middleware: Callable[..., Response | None]):
50
- self.__after_request.insert(order, self.set_middleware(
51
- status_code=status_code,
52
- middleware=middleware,
53
- order=order,
54
- process_response=process_response
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
- return wrapper
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, None, 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=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
- if middlewares and not isinstance(response, Response):
123
- raise TypeError(f'All after request middleware need return Response instances, but got {type(response).__name__}')
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 or resp,
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 origin is getattr(__import__('typing'), 'Union', None) or str(origin) == 'typing.Union':
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 origin is Union or str(origin) == 'typing.Union':
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
- return self.headers.get('Access-Control-Request-Headers', '')
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 {header[0].decode(): header[1].decode() for header in self.__raw_headers}
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
- request: Request,
24
- response_content: bytes,
25
- code: int,
26
- cookies: dict[str, str],
27
- response_type: ContentTypes,
28
- route: str,
29
- allowed_methods: list[str] = None,
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
- request_headers = request.accept_control_request_headers or "Content-Type, Authorization, X-Requested-With, Accept, X-CSRF-Token"
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 = response_content
35
- self.__headers: dict[str, bytes] = {
36
- "Content-Type": f"{response_type.value}; charset=UTF-8",
37
- "Content-Length": len(response_content),
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": code,
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.host,
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
- self.http_status_code = HTTPStatusCode.search_by_code(code)
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
- if self.status_code == 401:
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
- elif self.status_code == 405:
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, (int, str, bytes)]:
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[str]:
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], /,*ignore_headers: str):
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
- header = header.strip().lower()
94
-
95
- if header not in set(map(lambda el: el.strip().lower(), ignore_headers)):
96
- byte_headers.append((header.encode(), str(value).encode()))
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
- state_result.update(
792
- template={'detail': challenge.detail},
793
- status_code=challenge.status_code,
794
- content_type=ContentTypes.json,
795
- process_response=False,
796
- callback=None,
797
- redirect_path=path,
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):