django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.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.
Potentially problematic release.
This version of django-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.abi3.so +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
django_bolt/binding.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parameter binding and extraction with pre-compiled extractors.
|
|
3
|
+
|
|
4
|
+
This module provides high-performance parameter extraction using pre-compiled
|
|
5
|
+
extractor functions that avoid runtime type checking.
|
|
6
|
+
"""
|
|
7
|
+
import inspect
|
|
8
|
+
import msgspec
|
|
9
|
+
from typing import Any, Dict, List, Tuple, Callable, Optional
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
from .typing import is_msgspec_struct, is_optional, unwrap_optional
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"convert_primitive",
|
|
16
|
+
"create_extractor",
|
|
17
|
+
"coerce_to_response_type",
|
|
18
|
+
"coerce_to_response_type_async",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Cache for msgspec decoders (performance optimization)
|
|
23
|
+
_DECODER_CACHE: Dict[Any, msgspec.json.Decoder] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_msgspec_decoder(type_: Any) -> msgspec.json.Decoder:
|
|
27
|
+
"""Get or create a cached msgspec decoder for a type."""
|
|
28
|
+
if type_ not in _DECODER_CACHE:
|
|
29
|
+
_DECODER_CACHE[type_] = msgspec.json.Decoder(type_)
|
|
30
|
+
return _DECODER_CACHE[type_]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def convert_primitive(value: str, annotation: Any) -> Any:
|
|
34
|
+
"""
|
|
35
|
+
Convert string value to the appropriate type based on annotation.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
value: Raw string value from request
|
|
39
|
+
annotation: Target type annotation
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Converted value
|
|
43
|
+
"""
|
|
44
|
+
tp = unwrap_optional(annotation)
|
|
45
|
+
|
|
46
|
+
if tp is str or tp is Any or tp is None or tp is inspect._empty:
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
if tp is int:
|
|
50
|
+
try:
|
|
51
|
+
return int(value)
|
|
52
|
+
except ValueError:
|
|
53
|
+
from .exceptions import HTTPException
|
|
54
|
+
raise HTTPException(422, detail=f"Invalid integer value: '{value}'")
|
|
55
|
+
|
|
56
|
+
if tp is float:
|
|
57
|
+
try:
|
|
58
|
+
return float(value)
|
|
59
|
+
except ValueError:
|
|
60
|
+
from .exceptions import HTTPException
|
|
61
|
+
raise HTTPException(422, detail=f"Invalid float value: '{value}'")
|
|
62
|
+
|
|
63
|
+
if tp is bool:
|
|
64
|
+
v = value.lower()
|
|
65
|
+
if v in ("1", "true", "t", "yes", "y", "on"):
|
|
66
|
+
return True
|
|
67
|
+
if v in ("0", "false", "f", "no", "n", "off"):
|
|
68
|
+
return False
|
|
69
|
+
return bool(value)
|
|
70
|
+
|
|
71
|
+
# Fallback: try msgspec decode for JSON in value
|
|
72
|
+
try:
|
|
73
|
+
return msgspec.json.decode(value.encode())
|
|
74
|
+
except Exception:
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_path_extractor(name: str, annotation: Any, alias: Optional[str] = None) -> Callable:
|
|
79
|
+
"""Create a pre-compiled extractor for path parameters."""
|
|
80
|
+
key = alias or name
|
|
81
|
+
converter = lambda v: convert_primitive(str(v), annotation)
|
|
82
|
+
|
|
83
|
+
def extract(params_map: Dict[str, Any]) -> Any:
|
|
84
|
+
if key not in params_map:
|
|
85
|
+
raise ValueError(f"Missing required path parameter: {key}")
|
|
86
|
+
return converter(params_map[key])
|
|
87
|
+
|
|
88
|
+
return extract
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_query_extractor(
|
|
92
|
+
name: str,
|
|
93
|
+
annotation: Any,
|
|
94
|
+
default: Any,
|
|
95
|
+
alias: Optional[str] = None
|
|
96
|
+
) -> Callable:
|
|
97
|
+
"""Create a pre-compiled extractor for query parameters."""
|
|
98
|
+
key = alias or name
|
|
99
|
+
optional = default is not inspect.Parameter.empty or is_optional(annotation)
|
|
100
|
+
converter = lambda v: convert_primitive(str(v), annotation)
|
|
101
|
+
|
|
102
|
+
if optional:
|
|
103
|
+
default_value = None if default is inspect.Parameter.empty else default
|
|
104
|
+
def extract(query_map: Dict[str, Any]) -> Any:
|
|
105
|
+
return converter(query_map[key]) if key in query_map else default_value
|
|
106
|
+
else:
|
|
107
|
+
def extract(query_map: Dict[str, Any]) -> Any:
|
|
108
|
+
if key not in query_map:
|
|
109
|
+
raise ValueError(f"Missing required query parameter: {key}")
|
|
110
|
+
return converter(query_map[key])
|
|
111
|
+
|
|
112
|
+
return extract
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_header_extractor(
|
|
116
|
+
name: str,
|
|
117
|
+
annotation: Any,
|
|
118
|
+
default: Any,
|
|
119
|
+
alias: Optional[str] = None
|
|
120
|
+
) -> Callable:
|
|
121
|
+
"""Create a pre-compiled extractor for HTTP headers."""
|
|
122
|
+
key = (alias or name).lower()
|
|
123
|
+
optional = default is not inspect.Parameter.empty or is_optional(annotation)
|
|
124
|
+
converter = lambda v: convert_primitive(str(v), annotation)
|
|
125
|
+
|
|
126
|
+
if optional:
|
|
127
|
+
default_value = None if default is inspect.Parameter.empty else default
|
|
128
|
+
def extract(headers_map: Dict[str, str]) -> Any:
|
|
129
|
+
return converter(headers_map[key]) if key in headers_map else default_value
|
|
130
|
+
else:
|
|
131
|
+
def extract(headers_map: Dict[str, str]) -> Any:
|
|
132
|
+
if key not in headers_map:
|
|
133
|
+
raise ValueError(f"Missing required header: {key}")
|
|
134
|
+
return converter(headers_map[key])
|
|
135
|
+
|
|
136
|
+
return extract
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_cookie_extractor(
|
|
140
|
+
name: str,
|
|
141
|
+
annotation: Any,
|
|
142
|
+
default: Any,
|
|
143
|
+
alias: Optional[str] = None
|
|
144
|
+
) -> Callable:
|
|
145
|
+
"""Create a pre-compiled extractor for cookies."""
|
|
146
|
+
key = alias or name
|
|
147
|
+
optional = default is not inspect.Parameter.empty or is_optional(annotation)
|
|
148
|
+
converter = lambda v: convert_primitive(str(v), annotation)
|
|
149
|
+
|
|
150
|
+
if optional:
|
|
151
|
+
default_value = None if default is inspect.Parameter.empty else default
|
|
152
|
+
def extract(cookies_map: Dict[str, str]) -> Any:
|
|
153
|
+
return converter(cookies_map[key]) if key in cookies_map else default_value
|
|
154
|
+
else:
|
|
155
|
+
def extract(cookies_map: Dict[str, str]) -> Any:
|
|
156
|
+
if key not in cookies_map:
|
|
157
|
+
raise ValueError(f"Missing required cookie: {key}")
|
|
158
|
+
return converter(cookies_map[key])
|
|
159
|
+
|
|
160
|
+
return extract
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_form_extractor(
|
|
164
|
+
name: str,
|
|
165
|
+
annotation: Any,
|
|
166
|
+
default: Any,
|
|
167
|
+
alias: Optional[str] = None
|
|
168
|
+
) -> Callable:
|
|
169
|
+
"""Create a pre-compiled extractor for form fields."""
|
|
170
|
+
key = alias or name
|
|
171
|
+
optional = default is not inspect.Parameter.empty or is_optional(annotation)
|
|
172
|
+
converter = lambda v: convert_primitive(str(v), annotation)
|
|
173
|
+
|
|
174
|
+
if optional:
|
|
175
|
+
default_value = None if default is inspect.Parameter.empty else default
|
|
176
|
+
def extract(form_map: Dict[str, Any]) -> Any:
|
|
177
|
+
return converter(form_map[key]) if key in form_map else default_value
|
|
178
|
+
else:
|
|
179
|
+
def extract(form_map: Dict[str, Any]) -> Any:
|
|
180
|
+
if key not in form_map:
|
|
181
|
+
raise ValueError(f"Missing required form field: {key}")
|
|
182
|
+
return converter(form_map[key])
|
|
183
|
+
|
|
184
|
+
return extract
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def create_file_extractor(
|
|
188
|
+
name: str,
|
|
189
|
+
annotation: Any,
|
|
190
|
+
default: Any,
|
|
191
|
+
alias: Optional[str] = None
|
|
192
|
+
) -> Callable:
|
|
193
|
+
"""Create a pre-compiled extractor for file uploads."""
|
|
194
|
+
key = alias or name
|
|
195
|
+
optional = default is not inspect.Parameter.empty or is_optional(annotation)
|
|
196
|
+
|
|
197
|
+
if optional:
|
|
198
|
+
default_value = None if default is inspect.Parameter.empty else default
|
|
199
|
+
def extract(files_map: Dict[str, Any]) -> Any:
|
|
200
|
+
return files_map.get(key, default_value)
|
|
201
|
+
else:
|
|
202
|
+
def extract(files_map: Dict[str, Any]) -> Any:
|
|
203
|
+
if key not in files_map:
|
|
204
|
+
raise ValueError(f"Missing required file: {key}")
|
|
205
|
+
return files_map[key]
|
|
206
|
+
|
|
207
|
+
return extract
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_body_extractor(name: str, annotation: Any) -> Callable:
|
|
211
|
+
"""
|
|
212
|
+
Create a pre-compiled extractor for request body.
|
|
213
|
+
|
|
214
|
+
Uses cached msgspec decoder for maximum performance.
|
|
215
|
+
Converts msgspec.DecodeError (JSON parsing errors) to RequestValidationError for proper 422 responses.
|
|
216
|
+
"""
|
|
217
|
+
from .exceptions import RequestValidationError, parse_msgspec_decode_error
|
|
218
|
+
|
|
219
|
+
if is_msgspec_struct(annotation):
|
|
220
|
+
decoder = get_msgspec_decoder(annotation)
|
|
221
|
+
def extract(body_bytes: bytes) -> Any:
|
|
222
|
+
try:
|
|
223
|
+
return decoder.decode(body_bytes)
|
|
224
|
+
except msgspec.ValidationError:
|
|
225
|
+
# Re-raise ValidationError as-is (field validation errors handled by error_handlers.py)
|
|
226
|
+
# IMPORTANT: Must catch ValidationError BEFORE DecodeError since ValidationError subclasses DecodeError
|
|
227
|
+
raise
|
|
228
|
+
except msgspec.DecodeError as e:
|
|
229
|
+
# JSON parsing error (malformed JSON) - return 422 with error details including line/column
|
|
230
|
+
error_detail = parse_msgspec_decode_error(e, body_bytes)
|
|
231
|
+
raise RequestValidationError(
|
|
232
|
+
errors=[error_detail],
|
|
233
|
+
body=body_bytes,
|
|
234
|
+
) from e
|
|
235
|
+
else:
|
|
236
|
+
# Fallback to generic msgspec decode
|
|
237
|
+
def extract(body_bytes: bytes) -> Any:
|
|
238
|
+
try:
|
|
239
|
+
return msgspec.json.decode(body_bytes, type=annotation)
|
|
240
|
+
except msgspec.ValidationError:
|
|
241
|
+
# Re-raise ValidationError as-is (field validation errors handled by error_handlers.py)
|
|
242
|
+
# IMPORTANT: Must catch ValidationError BEFORE DecodeError since ValidationError subclasses DecodeError
|
|
243
|
+
raise
|
|
244
|
+
except msgspec.DecodeError as e:
|
|
245
|
+
# JSON parsing error (malformed JSON) - return 422 with error details including line/column
|
|
246
|
+
error_detail = parse_msgspec_decode_error(e, body_bytes)
|
|
247
|
+
raise RequestValidationError(
|
|
248
|
+
errors=[error_detail],
|
|
249
|
+
body=body_bytes,
|
|
250
|
+
) from e
|
|
251
|
+
|
|
252
|
+
return extract
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def create_extractor(field: Dict[str, Any]) -> Callable:
|
|
256
|
+
"""
|
|
257
|
+
Create an optimized extractor function for a parameter field.
|
|
258
|
+
|
|
259
|
+
This is a factory that returns a specialized extractor based on the
|
|
260
|
+
parameter source. The returned function is optimized to avoid runtime
|
|
261
|
+
type checking.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
field: Field metadata dictionary
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Extractor function that takes request data and returns parameter value
|
|
268
|
+
"""
|
|
269
|
+
source = field["source"]
|
|
270
|
+
name = field["name"]
|
|
271
|
+
annotation = field["annotation"]
|
|
272
|
+
default = field["default"]
|
|
273
|
+
alias = field.get("alias")
|
|
274
|
+
|
|
275
|
+
# Return appropriate extractor based on source
|
|
276
|
+
if source == "path":
|
|
277
|
+
return create_path_extractor(name, annotation, alias)
|
|
278
|
+
elif source == "query":
|
|
279
|
+
return create_query_extractor(name, annotation, default, alias)
|
|
280
|
+
elif source == "header":
|
|
281
|
+
return create_header_extractor(name, annotation, default, alias)
|
|
282
|
+
elif source == "cookie":
|
|
283
|
+
return create_cookie_extractor(name, annotation, default, alias)
|
|
284
|
+
elif source == "form":
|
|
285
|
+
return create_form_extractor(name, annotation, default, alias)
|
|
286
|
+
elif source == "file":
|
|
287
|
+
return create_file_extractor(name, annotation, default, alias)
|
|
288
|
+
elif source == "body":
|
|
289
|
+
return create_body_extractor(name, annotation)
|
|
290
|
+
elif source == "request":
|
|
291
|
+
# Request object is passed through directly
|
|
292
|
+
return lambda request: request
|
|
293
|
+
else:
|
|
294
|
+
# Fallback for unknown sources
|
|
295
|
+
def extract(*args, **kwargs):
|
|
296
|
+
if default is not inspect.Parameter.empty:
|
|
297
|
+
return default
|
|
298
|
+
raise ValueError(f"Cannot extract parameter {name} with source {source}")
|
|
299
|
+
return extract
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def coerce_to_response_type_async(value: Any, annotation: Any) -> Any:
|
|
303
|
+
"""
|
|
304
|
+
Async version that handles Django QuerySets.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
value: Value to coerce
|
|
308
|
+
annotation: Target type annotation
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Coerced value
|
|
312
|
+
"""
|
|
313
|
+
# Check if value is a Django QuerySet
|
|
314
|
+
if hasattr(value, '_iterable_class') and hasattr(value, 'model'):
|
|
315
|
+
# It's a QuerySet - convert to list asynchronously
|
|
316
|
+
result = []
|
|
317
|
+
async for item in value:
|
|
318
|
+
result.append(item)
|
|
319
|
+
value = result
|
|
320
|
+
|
|
321
|
+
return coerce_to_response_type(value, annotation)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def coerce_to_response_type(value: Any, annotation: Any) -> Any:
|
|
325
|
+
"""
|
|
326
|
+
Coerce arbitrary Python objects (including Django models) into the
|
|
327
|
+
declared response type using msgspec.
|
|
328
|
+
|
|
329
|
+
Supports:
|
|
330
|
+
- msgspec.Struct: build mapping from attributes if needed
|
|
331
|
+
- list[T]: recursively coerce elements
|
|
332
|
+
- dict/primitive: defer to msgspec.convert
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
value: Value to coerce
|
|
336
|
+
annotation: Target type annotation
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Coerced value
|
|
340
|
+
"""
|
|
341
|
+
from typing import get_origin, get_args, List
|
|
342
|
+
|
|
343
|
+
origin = get_origin(annotation)
|
|
344
|
+
|
|
345
|
+
# Handle List[T]
|
|
346
|
+
if origin in (list, List):
|
|
347
|
+
args = get_args(annotation)
|
|
348
|
+
elem_type = args[0] if args else Any
|
|
349
|
+
return [coerce_to_response_type(elem, elem_type) for elem in (value or [])]
|
|
350
|
+
|
|
351
|
+
# Handle Struct
|
|
352
|
+
if is_msgspec_struct(annotation):
|
|
353
|
+
if isinstance(value, annotation):
|
|
354
|
+
return value
|
|
355
|
+
if isinstance(value, dict):
|
|
356
|
+
return msgspec.convert(value, annotation)
|
|
357
|
+
# Build mapping from attributes based on struct annotations
|
|
358
|
+
fields = getattr(annotation, "__annotations__", {})
|
|
359
|
+
mapped = {name: getattr(value, name, None) for name in fields.keys()}
|
|
360
|
+
return msgspec.convert(mapped, annotation)
|
|
361
|
+
|
|
362
|
+
# Default convert path
|
|
363
|
+
return msgspec.convert(value, annotation)
|
django_bolt/bootstrap.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ensure_django_ready() -> dict:
|
|
10
|
+
"""Ensure Django is properly configured using the project's settings module."""
|
|
11
|
+
if settings.configured:
|
|
12
|
+
return _info()
|
|
13
|
+
|
|
14
|
+
settings_module = os.getenv("DJANGO_SETTINGS_MODULE")
|
|
15
|
+
if not settings_module:
|
|
16
|
+
# Try to detect settings module from manage.py location
|
|
17
|
+
settings_module = _detect_settings_module()
|
|
18
|
+
if settings_module:
|
|
19
|
+
os.environ["DJANGO_SETTINGS_MODULE"] = settings_module
|
|
20
|
+
|
|
21
|
+
if not settings_module:
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"Django settings module not found. Please ensure:\n"
|
|
24
|
+
"1. You are running from a Django project directory (with manage.py)\n"
|
|
25
|
+
"2. DJANGO_SETTINGS_MODULE environment variable is set\n"
|
|
26
|
+
"3. Your project has a valid settings.py file"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
importlib.import_module(settings_module)
|
|
31
|
+
import django
|
|
32
|
+
django.setup()
|
|
33
|
+
return _info()
|
|
34
|
+
except ImportError as e:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
f"Failed to import Django settings module '{settings_module}': {e}\n"
|
|
37
|
+
"Please check that your settings module exists and is valid."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detect_settings_module() -> str | None:
|
|
42
|
+
"""Try to auto-detect Django settings module from project structure."""
|
|
43
|
+
# Look for manage.py in current directory or parent directories
|
|
44
|
+
current = Path.cwd()
|
|
45
|
+
for path in [current] + list(current.parents)[:3]: # Check up to 3 levels up
|
|
46
|
+
manage_py = path / "manage.py"
|
|
47
|
+
if manage_py.exists():
|
|
48
|
+
# Read manage.py to find settings module
|
|
49
|
+
content = manage_py.read_text()
|
|
50
|
+
if "DJANGO_SETTINGS_MODULE" in content:
|
|
51
|
+
# Extract the settings module from manage.py
|
|
52
|
+
for line in content.split('\n'):
|
|
53
|
+
if "DJANGO_SETTINGS_MODULE" in line and "setdefault" in line:
|
|
54
|
+
# Parse line like: os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
|
55
|
+
parts = line.split("'")
|
|
56
|
+
if len(parts) >= 4:
|
|
57
|
+
return parts[3] # Return the settings module string
|
|
58
|
+
parts = line.split('"')
|
|
59
|
+
if len(parts) >= 4:
|
|
60
|
+
return parts[3]
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _info() -> dict:
|
|
65
|
+
"""Get information about the current Django configuration."""
|
|
66
|
+
from django.conf import settings as dj_settings
|
|
67
|
+
db = dj_settings.DATABASES.get("default", {})
|
|
68
|
+
return {
|
|
69
|
+
"mode": "django_project",
|
|
70
|
+
"debug": bool(getattr(dj_settings, "DEBUG", False)),
|
|
71
|
+
"database": db.get("ENGINE"),
|
|
72
|
+
"database_name": db.get("NAME"),
|
|
73
|
+
"settings_module": os.getenv("DJANGO_SETTINGS_MODULE"),
|
|
74
|
+
"base_dir": str(getattr(dj_settings, "BASE_DIR", "")),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
django_bolt/cli.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from .api import BoltAPI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
10
|
+
def main():
|
|
11
|
+
"""Django-Bolt command line interface."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.command()
|
|
15
|
+
def init():
|
|
16
|
+
"""Initialize Django-Bolt in an existing Django project."""
|
|
17
|
+
# Find Django project root (look for manage.py)
|
|
18
|
+
current_dir = Path.cwd()
|
|
19
|
+
project_root = None
|
|
20
|
+
for path in [current_dir] + list(current_dir.parents):
|
|
21
|
+
if (path / "manage.py").exists():
|
|
22
|
+
project_root = path
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
if not project_root:
|
|
26
|
+
click.echo("Error: No Django project found (manage.py not found)", err=True)
|
|
27
|
+
click.echo("Please run this command from within a Django project directory.")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
click.echo(f"Found Django project at: {project_root}")
|
|
31
|
+
|
|
32
|
+
# Find settings.py to determine project name
|
|
33
|
+
settings_files = list(project_root.glob("*/settings.py"))
|
|
34
|
+
if not settings_files:
|
|
35
|
+
click.echo("Error: Could not find settings.py", err=True)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
project_name = settings_files[0].parent.name
|
|
39
|
+
settings_file = settings_files[0]
|
|
40
|
+
|
|
41
|
+
click.echo(f"Project name: {project_name}")
|
|
42
|
+
|
|
43
|
+
# 1. Add django_bolt to INSTALLED_APPS
|
|
44
|
+
settings_content = settings_file.read_text()
|
|
45
|
+
if "'django_bolt'" not in settings_content and '"django_bolt"' not in settings_content:
|
|
46
|
+
if "INSTALLED_APPS" in settings_content:
|
|
47
|
+
# Find INSTALLED_APPS and add django_bolt
|
|
48
|
+
lines = settings_content.splitlines()
|
|
49
|
+
new_lines = []
|
|
50
|
+
in_installed_apps = False
|
|
51
|
+
added = False
|
|
52
|
+
|
|
53
|
+
for line in lines:
|
|
54
|
+
if "INSTALLED_APPS" in line and "=" in line:
|
|
55
|
+
in_installed_apps = True
|
|
56
|
+
elif in_installed_apps and not added:
|
|
57
|
+
# Look for the first app entry and add django_bolt before it
|
|
58
|
+
if line.strip().startswith(("'", '"')) and not added:
|
|
59
|
+
new_lines.append(' "django_bolt",')
|
|
60
|
+
added = True
|
|
61
|
+
elif "]" in line and not added:
|
|
62
|
+
# End of INSTALLED_APPS, add before closing
|
|
63
|
+
new_lines.append(' "django_bolt",')
|
|
64
|
+
added = True
|
|
65
|
+
in_installed_apps = False
|
|
66
|
+
|
|
67
|
+
new_lines.append(line)
|
|
68
|
+
|
|
69
|
+
if added:
|
|
70
|
+
settings_file.write_text("\n".join(new_lines))
|
|
71
|
+
click.echo("ā Added 'django_bolt' to INSTALLED_APPS")
|
|
72
|
+
else:
|
|
73
|
+
click.echo("Warning: Could not automatically add to INSTALLED_APPS. Please add 'django_bolt' manually.")
|
|
74
|
+
else:
|
|
75
|
+
click.echo("Warning: INSTALLED_APPS not found in settings.py. Please add 'django_bolt' manually.")
|
|
76
|
+
else:
|
|
77
|
+
click.echo("ā 'django_bolt' already in INSTALLED_APPS")
|
|
78
|
+
|
|
79
|
+
# 2. Create api.py template
|
|
80
|
+
api_file = project_root / project_name / "api.py"
|
|
81
|
+
if not api_file.exists():
|
|
82
|
+
api_template = '''"""Django-Bolt API routes."""
|
|
83
|
+
from django_bolt import BoltAPI
|
|
84
|
+
import msgspec
|
|
85
|
+
from typing import Optional
|
|
86
|
+
|
|
87
|
+
api = BoltAPI()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@api.get("/")
|
|
91
|
+
async def root():
|
|
92
|
+
"""Root endpoint."""
|
|
93
|
+
return {"message": "Welcome to Django-Bolt!"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@api.get("/health")
|
|
97
|
+
async def health():
|
|
98
|
+
"""Health check endpoint."""
|
|
99
|
+
return {"status": "ok", "service": "django-bolt"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Example with path parameters
|
|
103
|
+
@api.get("/items/{item_id}")
|
|
104
|
+
async def get_item(item_id: int, q: Optional[str] = None):
|
|
105
|
+
"""Get an item by ID."""
|
|
106
|
+
return {"item_id": item_id, "q": q}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Example with request body validation using msgspec
|
|
110
|
+
class Item(msgspec.Struct):
|
|
111
|
+
name: str
|
|
112
|
+
price: float
|
|
113
|
+
is_offer: Optional[bool] = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@api.post("/items")
|
|
117
|
+
async def create_item(item: Item):
|
|
118
|
+
"""Create a new item."""
|
|
119
|
+
return {"item": item, "created": True}
|
|
120
|
+
'''
|
|
121
|
+
api_file.write_text(api_template)
|
|
122
|
+
click.echo(f"ā Created {api_file.relative_to(project_root)}")
|
|
123
|
+
else:
|
|
124
|
+
click.echo(f"ā {api_file.relative_to(project_root)} already exists")
|
|
125
|
+
|
|
126
|
+
click.echo("\nš Django-Bolt initialization complete!")
|
|
127
|
+
click.echo("\nNext steps:")
|
|
128
|
+
click.echo("1. Run migrations: python manage.py migrate")
|
|
129
|
+
click.echo("2. Start the server: python manage.py runbolt")
|
|
130
|
+
click.echo(f"3. Edit your API routes in {project_name}/api.py")
|
|
131
|
+
click.echo("\nFor more information, visit: https://github.com/yourusername/django-bolt")
|
|
132
|
+
|
|
133
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compression configuration for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides configuration options for response compression (gzip, brotli, zstd).
|
|
5
|
+
Inspired by Litestar's compression config.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Optional, Literal
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CompressionConfig:
|
|
13
|
+
"""Configuration for response compression.
|
|
14
|
+
|
|
15
|
+
To enable response compression, pass an instance of this class to the BoltAPI
|
|
16
|
+
constructor using the compression parameter.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
backend: The compression backend to use (default: "brotli")
|
|
20
|
+
If the value is "gzip", "brotli", or "zstd", built-in compression is used.
|
|
21
|
+
minimum_size: Minimum response size in bytes to enable compression (default: 500)
|
|
22
|
+
Responses smaller than this will not be compressed.
|
|
23
|
+
gzip_compress_level: Range [0-9] for gzip compression (default: 9)
|
|
24
|
+
Higher values provide better compression but are slower.
|
|
25
|
+
brotli_quality: Range [0-11] for brotli compression (default: 5)
|
|
26
|
+
Controls compression-speed vs compression-density tradeoff.
|
|
27
|
+
brotli_lgwin: Base 2 logarithm of window size for brotli (default: 22)
|
|
28
|
+
Range is 10 to 24. Larger values can improve compression for large files.
|
|
29
|
+
zstd_level: Range [1-22] for zstd compression (default: 3)
|
|
30
|
+
Higher values provide better compression but are slower.
|
|
31
|
+
gzip_fallback: Use GZIP if the client doesn't support the configured backend (default: True)
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
# Default compression (brotli with gzip fallback)
|
|
35
|
+
api = BoltAPI(compression=CompressionConfig())
|
|
36
|
+
|
|
37
|
+
# Aggressive brotli compression
|
|
38
|
+
api = BoltAPI(compression=CompressionConfig(
|
|
39
|
+
backend="brotli",
|
|
40
|
+
brotli_quality=11,
|
|
41
|
+
minimum_size=256
|
|
42
|
+
))
|
|
43
|
+
|
|
44
|
+
# Gzip only (maximum compression)
|
|
45
|
+
api = BoltAPI(compression=CompressionConfig(
|
|
46
|
+
backend="gzip",
|
|
47
|
+
gzip_compress_level=9,
|
|
48
|
+
gzip_fallback=False
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
# Fast zstd compression
|
|
52
|
+
api = BoltAPI(compression=CompressionConfig(
|
|
53
|
+
backend="zstd",
|
|
54
|
+
zstd_level=1
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
# Disable compression
|
|
58
|
+
api = BoltAPI(compression=None)
|
|
59
|
+
"""
|
|
60
|
+
backend: Literal["gzip", "brotli", "zstd"] = "brotli"
|
|
61
|
+
minimum_size: int = 500
|
|
62
|
+
gzip_compress_level: int = 9
|
|
63
|
+
brotli_quality: int = 5
|
|
64
|
+
brotli_lgwin: int = 22
|
|
65
|
+
zstd_level: int = 3
|
|
66
|
+
gzip_fallback: bool = True
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
# Validate backend
|
|
70
|
+
valid_backends = {"gzip", "brotli", "zstd"}
|
|
71
|
+
if self.backend not in valid_backends:
|
|
72
|
+
raise ValueError(f"Invalid backend: {self.backend}. Must be one of {valid_backends}")
|
|
73
|
+
|
|
74
|
+
# Validate minimum_size
|
|
75
|
+
if self.minimum_size < 0:
|
|
76
|
+
raise ValueError("minimum_size must be non-negative")
|
|
77
|
+
|
|
78
|
+
# Validate gzip_compress_level
|
|
79
|
+
if not (0 <= self.gzip_compress_level <= 9):
|
|
80
|
+
raise ValueError("gzip_compress_level must be between 0 and 9")
|
|
81
|
+
|
|
82
|
+
# Validate brotli_quality
|
|
83
|
+
if not (0 <= self.brotli_quality <= 11):
|
|
84
|
+
raise ValueError("brotli_quality must be between 0 and 11")
|
|
85
|
+
|
|
86
|
+
# Validate brotli_lgwin
|
|
87
|
+
if not (10 <= self.brotli_lgwin <= 24):
|
|
88
|
+
raise ValueError("brotli_lgwin must be between 10 and 24")
|
|
89
|
+
|
|
90
|
+
# Validate zstd_level
|
|
91
|
+
if not (1 <= self.zstd_level <= 22):
|
|
92
|
+
raise ValueError("zstd_level must be between 1 and 22")
|
|
93
|
+
|
|
94
|
+
def to_rust_config(self) -> dict:
|
|
95
|
+
"""Convert to dictionary for passing to Rust."""
|
|
96
|
+
return {
|
|
97
|
+
"backend": self.backend,
|
|
98
|
+
"minimum_size": self.minimum_size,
|
|
99
|
+
"gzip_compress_level": self.gzip_compress_level,
|
|
100
|
+
"brotli_quality": self.brotli_quality,
|
|
101
|
+
"brotli_lgwin": self.brotli_lgwin,
|
|
102
|
+
"zstd_level": self.zstd_level,
|
|
103
|
+
"gzip_fallback": self.gzip_fallback,
|
|
104
|
+
}
|