django-bolt 0.1.0__cp310-abi3-win_amd64.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.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.pyd +0 -0
  3. django_bolt/admin/__init__.py +25 -0
  4. django_bolt/admin/admin_detection.py +179 -0
  5. django_bolt/admin/asgi_bridge.py +267 -0
  6. django_bolt/admin/routes.py +91 -0
  7. django_bolt/admin/static.py +155 -0
  8. django_bolt/admin/static_routes.py +111 -0
  9. django_bolt/api.py +1011 -0
  10. django_bolt/apps.py +7 -0
  11. django_bolt/async_collector.py +228 -0
  12. django_bolt/auth/README.md +464 -0
  13. django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
  14. django_bolt/auth/__init__.py +84 -0
  15. django_bolt/auth/backends.py +236 -0
  16. django_bolt/auth/guards.py +224 -0
  17. django_bolt/auth/jwt_utils.py +212 -0
  18. django_bolt/auth/revocation.py +286 -0
  19. django_bolt/auth/token.py +335 -0
  20. django_bolt/binding.py +363 -0
  21. django_bolt/bootstrap.py +77 -0
  22. django_bolt/cli.py +133 -0
  23. django_bolt/compression.py +104 -0
  24. django_bolt/decorators.py +159 -0
  25. django_bolt/dependencies.py +128 -0
  26. django_bolt/error_handlers.py +305 -0
  27. django_bolt/exceptions.py +294 -0
  28. django_bolt/health.py +129 -0
  29. django_bolt/logging/__init__.py +6 -0
  30. django_bolt/logging/config.py +357 -0
  31. django_bolt/logging/middleware.py +296 -0
  32. django_bolt/management/__init__.py +1 -0
  33. django_bolt/management/commands/__init__.py +0 -0
  34. django_bolt/management/commands/runbolt.py +427 -0
  35. django_bolt/middleware/__init__.py +32 -0
  36. django_bolt/middleware/compiler.py +131 -0
  37. django_bolt/middleware/middleware.py +247 -0
  38. django_bolt/openapi/__init__.py +23 -0
  39. django_bolt/openapi/config.py +196 -0
  40. django_bolt/openapi/plugins.py +439 -0
  41. django_bolt/openapi/routes.py +152 -0
  42. django_bolt/openapi/schema_generator.py +581 -0
  43. django_bolt/openapi/spec/__init__.py +68 -0
  44. django_bolt/openapi/spec/base.py +74 -0
  45. django_bolt/openapi/spec/callback.py +24 -0
  46. django_bolt/openapi/spec/components.py +72 -0
  47. django_bolt/openapi/spec/contact.py +21 -0
  48. django_bolt/openapi/spec/discriminator.py +25 -0
  49. django_bolt/openapi/spec/encoding.py +67 -0
  50. django_bolt/openapi/spec/enums.py +41 -0
  51. django_bolt/openapi/spec/example.py +36 -0
  52. django_bolt/openapi/spec/external_documentation.py +21 -0
  53. django_bolt/openapi/spec/header.py +132 -0
  54. django_bolt/openapi/spec/info.py +50 -0
  55. django_bolt/openapi/spec/license.py +28 -0
  56. django_bolt/openapi/spec/link.py +66 -0
  57. django_bolt/openapi/spec/media_type.py +51 -0
  58. django_bolt/openapi/spec/oauth_flow.py +36 -0
  59. django_bolt/openapi/spec/oauth_flows.py +28 -0
  60. django_bolt/openapi/spec/open_api.py +87 -0
  61. django_bolt/openapi/spec/operation.py +105 -0
  62. django_bolt/openapi/spec/parameter.py +147 -0
  63. django_bolt/openapi/spec/path_item.py +78 -0
  64. django_bolt/openapi/spec/paths.py +27 -0
  65. django_bolt/openapi/spec/reference.py +38 -0
  66. django_bolt/openapi/spec/request_body.py +38 -0
  67. django_bolt/openapi/spec/response.py +48 -0
  68. django_bolt/openapi/spec/responses.py +44 -0
  69. django_bolt/openapi/spec/schema.py +678 -0
  70. django_bolt/openapi/spec/security_requirement.py +28 -0
  71. django_bolt/openapi/spec/security_scheme.py +69 -0
  72. django_bolt/openapi/spec/server.py +34 -0
  73. django_bolt/openapi/spec/server_variable.py +32 -0
  74. django_bolt/openapi/spec/tag.py +32 -0
  75. django_bolt/openapi/spec/xml.py +44 -0
  76. django_bolt/pagination.py +669 -0
  77. django_bolt/param_functions.py +49 -0
  78. django_bolt/params.py +337 -0
  79. django_bolt/request_parsing.py +128 -0
  80. django_bolt/responses.py +214 -0
  81. django_bolt/router.py +48 -0
  82. django_bolt/serialization.py +193 -0
  83. django_bolt/status_codes.py +321 -0
  84. django_bolt/testing/__init__.py +10 -0
  85. django_bolt/testing/client.py +274 -0
  86. django_bolt/testing/helpers.py +93 -0
  87. django_bolt/tests/__init__.py +0 -0
  88. django_bolt/tests/admin_tests/__init__.py +1 -0
  89. django_bolt/tests/admin_tests/conftest.py +6 -0
  90. django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
  91. django_bolt/tests/admin_tests/urls.py +9 -0
  92. django_bolt/tests/cbv/__init__.py +0 -0
  93. django_bolt/tests/cbv/test_class_views.py +570 -0
  94. django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
  95. django_bolt/tests/cbv/test_class_views_features.py +1173 -0
  96. django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
  97. django_bolt/tests/conftest.py +165 -0
  98. django_bolt/tests/test_action_decorator.py +399 -0
  99. django_bolt/tests/test_auth_secret_key.py +83 -0
  100. django_bolt/tests/test_decorator_syntax.py +159 -0
  101. django_bolt/tests/test_error_handling.py +481 -0
  102. django_bolt/tests/test_file_response.py +192 -0
  103. django_bolt/tests/test_global_cors.py +172 -0
  104. django_bolt/tests/test_guards_auth.py +441 -0
  105. django_bolt/tests/test_guards_integration.py +303 -0
  106. django_bolt/tests/test_health.py +283 -0
  107. django_bolt/tests/test_integration_validation.py +400 -0
  108. django_bolt/tests/test_json_validation.py +536 -0
  109. django_bolt/tests/test_jwt_auth.py +327 -0
  110. django_bolt/tests/test_jwt_token.py +458 -0
  111. django_bolt/tests/test_logging.py +837 -0
  112. django_bolt/tests/test_logging_merge.py +419 -0
  113. django_bolt/tests/test_middleware.py +492 -0
  114. django_bolt/tests/test_middleware_server.py +230 -0
  115. django_bolt/tests/test_model_viewset.py +323 -0
  116. django_bolt/tests/test_models.py +24 -0
  117. django_bolt/tests/test_pagination.py +1258 -0
  118. django_bolt/tests/test_parameter_validation.py +178 -0
  119. django_bolt/tests/test_syntax.py +626 -0
  120. django_bolt/tests/test_testing_utilities.py +163 -0
  121. django_bolt/tests/test_testing_utilities_simple.py +123 -0
  122. django_bolt/tests/test_viewset_unified.py +346 -0
  123. django_bolt/typing.py +273 -0
  124. django_bolt/views.py +1110 -0
  125. django_bolt-0.1.0.dist-info/METADATA +629 -0
  126. django_bolt-0.1.0.dist-info/RECORD +128 -0
  127. django_bolt-0.1.0.dist-info/WHEEL +4 -0
  128. django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
django_bolt/views.py ADDED
@@ -0,0 +1,1110 @@
1
+ """
2
+ Class-based views for Django-Bolt.
3
+
4
+ Provides Django-style class-based views that integrate seamlessly with
5
+ Bolt's routing, dependency injection, guards, and authentication.
6
+
7
+ Example:
8
+ api = BoltAPI()
9
+
10
+ @api.view("/hello")
11
+ class HelloView(APIView):
12
+ guards = [IsAuthenticated()]
13
+
14
+ async def get(self, request, current_user=Depends(get_current_user)) -> dict:
15
+ return {"user": current_user.id}
16
+ """
17
+ import inspect
18
+ from typing import Any, Callable, Dict, List, Optional, Set, Type
19
+ from .exceptions import HTTPException
20
+
21
+
22
+ class APIView:
23
+ """
24
+ Base class for class-based views in Django-Bolt.
25
+
26
+ Attributes:
27
+ http_method_names: List of supported HTTP methods (lowercase)
28
+ guards: List of guard/permission classes to apply to all methods
29
+ auth: List of authentication backends to apply to all methods
30
+ status_code: Default status code for responses (can be overridden per-method)
31
+ """
32
+
33
+ http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
34
+
35
+ # Class-level defaults (can be overridden by subclasses)
36
+ guards: Optional[List[Any]] = None
37
+ auth: Optional[List[Any]] = None
38
+ status_code: Optional[int] = None
39
+
40
+ def __init__(self, **kwargs):
41
+ """
42
+ Initialize the view instance.
43
+
44
+ Args:
45
+ **kwargs: Additional instance attributes
46
+ """
47
+ for key, value in kwargs.items():
48
+ setattr(self, key, value)
49
+
50
+ @classmethod
51
+ def as_view(cls, method: str, action: Optional[str] = None) -> Callable:
52
+ """
53
+ Create a handler callable for a specific HTTP method.
54
+
55
+ This method:
56
+ 1. Validates that the HTTP method is supported
57
+ 2. Creates a wrapper that instantiates the view and calls the method handler
58
+ 3. Preserves the method signature for parameter extraction and dependency injection
59
+ 4. Attaches class-level metadata (guards, auth) for middleware compilation
60
+ 5. Maps DRF-style action names (list, retrieve, etc.) to HTTP methods
61
+
62
+ Args:
63
+ method: HTTP method name (lowercase, e.g., "get", "post")
64
+ action: Optional action name for DRF-style methods (e.g., "list", "retrieve")
65
+
66
+ Returns:
67
+ Async handler function compatible with BoltAPI routing
68
+
69
+ Raises:
70
+ ValueError: If method is not supported by this view
71
+ """
72
+ method_lower = method.lower()
73
+
74
+ if method_lower not in cls.http_method_names:
75
+ raise ValueError(
76
+ f"Method '{method}' not allowed. "
77
+ f"Allowed methods: {cls.http_method_names}"
78
+ )
79
+
80
+ # DRF-style action mapping: try action name first, then HTTP method
81
+ # Actions: list, retrieve, create, update, partial_update, destroy
82
+ method_handler = None
83
+ action_name = None
84
+
85
+ if action:
86
+ # Try the action name first (e.g., "list", "retrieve")
87
+ method_handler = getattr(cls, action, None)
88
+ action_name = action
89
+
90
+ # Fall back to HTTP method name
91
+ if method_handler is None:
92
+ method_handler = getattr(cls, method_lower, None)
93
+ action_name = method_lower
94
+
95
+ if method_handler is None:
96
+ raise ValueError(
97
+ f"View class {cls.__name__} does not implement method '{action or method_lower}'"
98
+ )
99
+
100
+ # Validate that handler is async
101
+ if not inspect.iscoroutinefunction(method_handler):
102
+ raise TypeError(
103
+ f"Handler {cls.__name__}.{action_name} must be async. "
104
+ f"Use 'async def' instead of 'def'"
105
+ )
106
+
107
+ # Create wrapper that preserves signature for parameter extraction
108
+ # The wrapper's signature matches the method handler (excluding 'self')
109
+ sig = inspect.signature(method_handler)
110
+ params = list(sig.parameters.values())[1:] # Skip 'self' parameter
111
+
112
+ # Build new signature without 'self'
113
+ new_sig = sig.replace(parameters=params)
114
+
115
+ # Create single view instance at registration time (not per-request)
116
+ # This eliminates the per-request instantiation overhead (~40% faster)
117
+ view_instance = cls()
118
+
119
+ # Set action name once at registration time
120
+ if hasattr(view_instance, 'action'):
121
+ view_instance.action = action_name
122
+
123
+ # Bind the method once to eliminate lookup overhead
124
+ bound_method = method_handler.__get__(view_instance, cls)
125
+
126
+ # Create pure functional handler that calls bound method directly
127
+ async def view_handler(*args, **kwargs):
128
+ """Auto-generated view handler that calls bound method directly."""
129
+ # Inject request object into view instance for pagination/filtering
130
+ # Request is typically the first positional arg or named 'request'
131
+ if args and isinstance(args[0], dict) and 'method' in args[0]:
132
+ view_instance.request = args[0]
133
+ elif 'request' in kwargs:
134
+ view_instance.request = kwargs['request']
135
+
136
+ return await bound_method(*args, **kwargs)
137
+
138
+ # Attach the signature (for parameter extraction)
139
+ view_handler.__signature__ = new_sig
140
+ view_handler.__annotations__ = {
141
+ k: v for k, v in method_handler.__annotations__.items() if k != "self"
142
+ }
143
+
144
+ # Preserve docstring and name
145
+ view_handler.__name__ = f"{cls.__name__}.{action_name}"
146
+ view_handler.__doc__ = method_handler.__doc__
147
+ view_handler.__module__ = cls.__module__
148
+
149
+ # Attach class-level metadata for middleware compilation
150
+ # These will be picked up by BoltAPI._route_decorator
151
+ if cls.guards is not None:
152
+ view_handler.__bolt_guards__ = cls.guards
153
+ if cls.auth is not None:
154
+ view_handler.__bolt_auth__ = cls.auth
155
+ if cls.status_code is not None:
156
+ view_handler.__bolt_status_code__ = cls.status_code
157
+
158
+ return view_handler
159
+
160
+ def initialize(self, request: Dict[str, Any]) -> None:
161
+ """
162
+ Hook called before the method handler is invoked.
163
+
164
+ Override this to perform per-request initialization.
165
+
166
+ Args:
167
+ request: The request dictionary
168
+ """
169
+ pass
170
+
171
+ @classmethod
172
+ def get_allowed_methods(cls) -> Set[str]:
173
+ """
174
+ Get the set of HTTP methods that this view implements.
175
+
176
+ Returns:
177
+ Set of uppercase HTTP method names (e.g., {"GET", "POST"})
178
+ """
179
+ allowed = set()
180
+ for method in cls.http_method_names:
181
+ if hasattr(cls, method) and callable(getattr(cls, method)):
182
+ allowed.add(method.upper())
183
+ return allowed
184
+
185
+
186
+ class ViewSet(APIView):
187
+ """
188
+ ViewSet for CRUD operations on resources.
189
+
190
+ Provides a higher-level abstraction for common REST patterns.
191
+ Subclasses can implement standard methods: list, retrieve, create, update, partial_update, destroy.
192
+
193
+ Example:
194
+ @api.viewset("/users")
195
+ class UserViewSet(ViewSet):
196
+ queryset = User.objects.all()
197
+ serializer_class = UserSchema
198
+ list_serializer_class = UserMiniSchema # Optional: different serializer for lists
199
+ pagination_class = PageNumberPagination # Optional: enable pagination
200
+
201
+ async def list(self, request):
202
+ users = await self.get_queryset()
203
+ return [UserSchema.from_model(u) async for u in users]
204
+
205
+ async def retrieve(self, request, pk: int):
206
+ user = await self.get_object(pk)
207
+ return UserSchema.from_model(user)
208
+ """
209
+
210
+ # ViewSet configuration
211
+ queryset: Optional[Any] = None
212
+ serializer_class: Optional[Type] = None
213
+ list_serializer_class: Optional[Type] = None # Optional: override serializer for list operations
214
+ lookup_field: str = 'pk' # Field to use for object lookup (default: 'pk')
215
+ pagination_class: Optional[Any] = None # Optional: pagination class to use
216
+
217
+ # Action name for current request (set automatically)
218
+ action: Optional[str] = None
219
+
220
+ # Request object (set automatically during dispatch)
221
+ request: Optional[Dict[str, Any]] = None
222
+
223
+ def __init_subclass__(cls, **kwargs):
224
+ """
225
+ Hook called when a subclass is created.
226
+
227
+ Converts class-level queryset to instance-level _base_queryset
228
+ to enable proper cloning on each access (Litestar pattern).
229
+ """
230
+ super().__init_subclass__(**kwargs)
231
+
232
+ # If subclass defines queryset as class attribute, store it separately
233
+ if 'queryset' in cls.__dict__ and cls.__dict__['queryset'] is not None:
234
+ # Store the base queryset for cloning
235
+ cls._base_queryset = cls.__dict__['queryset']
236
+ # Remove the class attribute so property works
237
+ delattr(cls, 'queryset')
238
+
239
+ def _get_base_queryset(self):
240
+ """
241
+ Get the base queryset defined on the class.
242
+
243
+ Returns None if no queryset is defined.
244
+ """
245
+ # Check instance attribute first (for dynamic assignment)
246
+ if hasattr(self, '_instance_queryset'):
247
+ return self._instance_queryset
248
+
249
+ # Check class attribute (set via __init_subclass__)
250
+ if hasattr(self.__class__, '_base_queryset'):
251
+ return self.__class__._base_queryset
252
+
253
+ # Check if there's a class attribute 'queryset' (shouldn't happen after __init_subclass__)
254
+ return getattr(self.__class__, 'queryset', None)
255
+
256
+ def _clone_queryset(self, queryset):
257
+ """
258
+ Clone a queryset to ensure isolation between requests.
259
+
260
+ Args:
261
+ queryset: The queryset to clone
262
+
263
+ Returns:
264
+ Fresh QuerySet clone
265
+ """
266
+ if queryset is None:
267
+ return None
268
+
269
+ # Always return a fresh clone to prevent state leakage
270
+ # Django QuerySets are lazy, so .all() creates a new QuerySet instance
271
+ if hasattr(queryset, '_clone'):
272
+ # Use Django's internal _clone() for true deep copy
273
+ return queryset._clone()
274
+ elif hasattr(queryset, 'all'):
275
+ # Fallback to .all() which also creates a new QuerySet
276
+ return queryset.all()
277
+
278
+ # Not a QuerySet, return as-is
279
+ return queryset
280
+
281
+ @property
282
+ def queryset(self):
283
+ """
284
+ Property that returns a fresh queryset clone on each access.
285
+
286
+ This ensures queryset isolation between requests while maintaining
287
+ single-instance performance (Litestar pattern).
288
+
289
+ Returns:
290
+ Fresh QuerySet clone or None if not set
291
+ """
292
+ base_qs = self._get_base_queryset()
293
+ return self._clone_queryset(base_qs)
294
+
295
+ @queryset.setter
296
+ def queryset(self, value):
297
+ """
298
+ Setter for queryset attribute.
299
+
300
+ Stores the base queryset that will be cloned on each access.
301
+
302
+ Args:
303
+ value: Base queryset to store
304
+ """
305
+ self._instance_queryset = value
306
+
307
+ async def get_queryset(self):
308
+ """
309
+ Get the queryset for this viewset.
310
+
311
+ This method returns a fresh queryset clone on each call, ensuring
312
+ no state leakage between requests (following Litestar's pattern).
313
+ Override to customize queryset filtering.
314
+
315
+ Returns:
316
+ Django QuerySet
317
+ """
318
+ base_qs = self._get_base_queryset()
319
+
320
+ if base_qs is None:
321
+ raise ValueError(
322
+ f"'{self.__class__.__name__}' should either include a `queryset` attribute, "
323
+ f"or override the `get_queryset()` method."
324
+ )
325
+
326
+ # Return a fresh clone
327
+ return self._clone_queryset(base_qs)
328
+
329
+ async def filter_queryset(self, queryset):
330
+ """
331
+ Given a queryset, filter it with whichever filter backends are enabled.
332
+
333
+ This method provides a hook for filtering, searching, and ordering.
334
+ Override this method to implement custom filtering logic.
335
+
336
+ Note: Pagination is handled separately via paginate_queryset().
337
+
338
+ Example:
339
+ async def filter_queryset(self, queryset):
340
+ # Apply filters from query params
341
+ status = self.request.get('query', {}).get('status')
342
+ if status:
343
+ queryset = queryset.filter(status=status)
344
+
345
+ # Apply ordering
346
+ ordering = self.request.get('query', {}).get('ordering')
347
+ if ordering:
348
+ queryset = queryset.order_by(ordering)
349
+
350
+ # Apply search
351
+ search = self.request.get('query', {}).get('search')
352
+ if search:
353
+ queryset = queryset.filter(name__icontains=search)
354
+
355
+ return queryset
356
+
357
+ Args:
358
+ queryset: The base queryset to filter
359
+
360
+ Returns:
361
+ Filtered queryset (still lazy, not evaluated)
362
+ """
363
+ # Default implementation: return queryset unchanged
364
+ # Subclasses should override this method to add filtering logic
365
+ return queryset
366
+
367
+ async def paginate_queryset(self, queryset):
368
+ """
369
+ Paginate a queryset if pagination is enabled.
370
+
371
+ This method checks if self.pagination_class is set and applies
372
+ pagination if available. If no pagination is configured, returns
373
+ the queryset unchanged.
374
+
375
+ Args:
376
+ queryset: The queryset to paginate
377
+
378
+ Returns:
379
+ PaginatedResponse if pagination enabled, otherwise queryset
380
+ """
381
+ if self.pagination_class is None:
382
+ return queryset
383
+
384
+ if self.request is None:
385
+ raise ValueError(
386
+ f"Cannot paginate in {self.__class__.__name__}: request object not available. "
387
+ f"Ensure request parameter is passed to the handler."
388
+ )
389
+
390
+ # Create paginator instance
391
+ paginator = self.pagination_class()
392
+
393
+ # Apply pagination
394
+ return await paginator.paginate_queryset(queryset, self.request)
395
+
396
+ def get_pagination_class(self):
397
+ """
398
+ Get the pagination class for this viewset.
399
+
400
+ Override this method to dynamically select pagination class
401
+ based on action or other criteria.
402
+
403
+ Returns:
404
+ Pagination class or None
405
+ """
406
+ return self.pagination_class
407
+
408
+ async def get_object(self, pk: Any = None, **lookup_kwargs):
409
+ """
410
+ Get a single object by lookup field.
411
+
412
+ Args:
413
+ pk: Primary key value (if using default lookup_field)
414
+ **lookup_kwargs: Additional lookup parameters
415
+
416
+ Returns:
417
+ Model instance
418
+
419
+ Raises:
420
+ HTTPException: If object not found (404)
421
+ """
422
+ queryset = await self.get_queryset()
423
+
424
+ # Build lookup kwargs
425
+ if pk is not None and not lookup_kwargs:
426
+ lookup_kwargs = {self.lookup_field: pk}
427
+
428
+ try:
429
+ # Use aget for async retrieval
430
+ obj = await queryset.aget(**lookup_kwargs)
431
+ return obj
432
+ except Exception as e:
433
+ # Django raises DoesNotExist, but we convert to HTTPException
434
+ raise HTTPException(status_code=404, detail="Not found")
435
+
436
+ def get_serializer_class(self, action: Optional[str] = None):
437
+ """
438
+ Get the serializer class for this viewset.
439
+
440
+ Override to customize serializer selection based on action.
441
+
442
+ Args:
443
+ action: The action being performed ('list', 'retrieve', 'create', etc.)
444
+
445
+ Returns:
446
+ Serializer class
447
+ """
448
+ # Use instance action if not provided
449
+ if action is None:
450
+ action = self.action
451
+
452
+ # Use list_serializer_class for list actions if defined
453
+ if action == "list" and self.list_serializer_class is not None:
454
+ return self.list_serializer_class
455
+
456
+ if self.serializer_class is None:
457
+ raise ValueError(
458
+ f"'{self.__class__.__name__}' should either include a `serializer_class` attribute, "
459
+ f"or override the `get_serializer_class()` method."
460
+ )
461
+ return self.serializer_class
462
+
463
+ def get_serializer(self, instance=None, data=None, many=False):
464
+ """
465
+ Get a serializer instance.
466
+
467
+ Args:
468
+ instance: Model instance to serialize (for reading)
469
+ data: Data to validate/deserialize (for writing)
470
+ many: Whether serializing multiple instances
471
+
472
+ Returns:
473
+ Serializer instance or list of serialized data
474
+ """
475
+ serializer_class = self.get_serializer_class()
476
+
477
+ # If it's a msgspec.Struct, handle conversion
478
+ if hasattr(serializer_class, "__struct_fields__"):
479
+ if instance is not None:
480
+ if many:
481
+ # Serialize multiple instances
482
+ if hasattr(serializer_class, "from_model"):
483
+ return [serializer_class.from_model(obj) for obj in instance]
484
+ else:
485
+ # Manual mapping
486
+ import msgspec
487
+ fields = getattr(serializer_class, "__annotations__", {})
488
+ return [
489
+ msgspec.convert({name: getattr(obj, name, None) for name in fields.keys()}, serializer_class)
490
+ for obj in instance
491
+ ]
492
+ else:
493
+ # Serialize single instance
494
+ if hasattr(serializer_class, "from_model"):
495
+ return serializer_class.from_model(instance)
496
+ else:
497
+ import msgspec
498
+ fields = getattr(serializer_class, "__annotations__", {})
499
+ mapped = {name: getattr(instance, name, None) for name in fields.keys()}
500
+ return msgspec.convert(mapped, serializer_class)
501
+ elif data is not None:
502
+ # Data is already validated by msgspec at parameter binding
503
+ return data
504
+
505
+ return serializer_class
506
+
507
+
508
+ # Mixins for common CRUD operations
509
+
510
+ class ListMixin:
511
+ """
512
+ Mixin that provides a list() method for GET requests on collections.
513
+
514
+ Automatically implements:
515
+ async def get(self, request) -> list
516
+
517
+ Requires:
518
+ - queryset attribute
519
+ - serializer_class attribute (optional, returns raw queryset if not provided)
520
+ """
521
+
522
+ async def get(self, request):
523
+ """
524
+ List all objects in the queryset.
525
+
526
+ Note: This evaluates the entire queryset. For large datasets,
527
+ consider implementing pagination or filtering via filter_queryset().
528
+ """
529
+ queryset = await self.get_queryset()
530
+
531
+ # Optional: Apply filtering if filter_queryset is available
532
+ if hasattr(self, 'filter_queryset'):
533
+ queryset = await self.filter_queryset(queryset)
534
+
535
+ # Convert queryset to list (evaluates database query here)
536
+ results = []
537
+ async for obj in queryset:
538
+ # If serializer_class is defined, use it
539
+ if hasattr(self, "serializer_class") and self.serializer_class:
540
+ # Get serializer class (use method if available, otherwise direct attribute)
541
+ if hasattr(self, "get_serializer_class"):
542
+ serializer_class = self.get_serializer_class()
543
+ else:
544
+ serializer_class = self.serializer_class
545
+
546
+ if hasattr(serializer_class, "from_model"):
547
+ results.append(serializer_class.from_model(obj))
548
+ else:
549
+ # Assume it's a msgspec.Struct, use convert
550
+ import msgspec
551
+ fields = getattr(serializer_class, "__annotations__", {})
552
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
553
+ results.append(msgspec.convert(mapped, serializer_class))
554
+ else:
555
+ results.append(obj)
556
+
557
+ return results
558
+
559
+
560
+ class RetrieveMixin:
561
+ """
562
+ Mixin that provides a retrieve() method for GET requests on single objects.
563
+
564
+ Automatically implements:
565
+ async def get(self, request, pk: int) -> object
566
+
567
+ Requires:
568
+ - queryset attribute
569
+ - get_object(pk) method
570
+ - serializer_class attribute (optional, returns raw object if not provided)
571
+ """
572
+
573
+ async def get(self, request, pk: int):
574
+ """Retrieve a single object by primary key."""
575
+ obj = await self.get_object(pk)
576
+
577
+ # If serializer_class is defined, use it
578
+ if hasattr(self, "serializer_class") and self.serializer_class:
579
+ # Get serializer class (use method if available, otherwise direct attribute)
580
+ if hasattr(self, "get_serializer_class"):
581
+ serializer_class = self.get_serializer_class()
582
+ else:
583
+ serializer_class = self.serializer_class
584
+
585
+ if hasattr(serializer_class, "from_model"):
586
+ return serializer_class.from_model(obj)
587
+ else:
588
+ # Assume it's a msgspec.Struct, use convert
589
+ import msgspec
590
+ fields = getattr(serializer_class, "__annotations__", {})
591
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
592
+ return msgspec.convert(mapped, serializer_class)
593
+
594
+ return obj
595
+
596
+
597
+ class CreateMixin:
598
+ """
599
+ Mixin that provides a create() method for POST requests.
600
+
601
+ Automatically implements:
602
+ async def post(self, request, data: SerializerClass) -> object
603
+
604
+ Requires:
605
+ - queryset attribute (to determine model)
606
+ - serializer_class attribute (for input validation)
607
+ """
608
+
609
+ async def post(self, request, data):
610
+ """Create a new object."""
611
+ # Get the model class without evaluating queryset
612
+ base_qs = self._get_base_queryset()
613
+ if base_qs is None:
614
+ raise ValueError(
615
+ f"'{self.__class__.__name__}' should include a `queryset` attribute."
616
+ )
617
+ model = base_qs.model
618
+
619
+ # Extract data from msgspec.Struct to dict
620
+ if hasattr(data, "__struct_fields__"):
621
+ # It's a msgspec.Struct
622
+ fields = data.__struct_fields__
623
+ data_dict = {field: getattr(data, field) for field in fields}
624
+ elif isinstance(data, dict):
625
+ data_dict = data
626
+ else:
627
+ raise ValueError(f"Cannot extract data from {type(data)}")
628
+
629
+ # Create object using async ORM
630
+ obj = await model.objects.acreate(**data_dict)
631
+
632
+ # Serialize response
633
+ if hasattr(self, "serializer_class") and self.serializer_class:
634
+ # Get serializer class (use method if available, otherwise direct attribute)
635
+ if hasattr(self, "get_serializer_class"):
636
+ serializer_class = self.get_serializer_class()
637
+ else:
638
+ serializer_class = self.serializer_class
639
+
640
+ if hasattr(serializer_class, "from_model"):
641
+ return serializer_class.from_model(obj)
642
+ else:
643
+ import msgspec
644
+ fields = getattr(serializer_class, "__annotations__", {})
645
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
646
+ return msgspec.convert(mapped, serializer_class)
647
+
648
+ return obj
649
+
650
+
651
+ class UpdateMixin:
652
+ """
653
+ Mixin that provides an update() method for PUT requests.
654
+
655
+ Automatically implements:
656
+ async def put(self, request, pk: int, data: SerializerClass) -> object
657
+
658
+ Requires:
659
+ - queryset attribute
660
+ - get_object(pk) method
661
+ - serializer_class attribute
662
+ """
663
+
664
+ async def put(self, request, pk: int, data):
665
+ """Update an object (full update)."""
666
+ obj = await self.get_object(pk)
667
+
668
+ # Extract data from msgspec.Struct to dict
669
+ if hasattr(data, "__struct_fields__"):
670
+ fields = data.__struct_fields__
671
+ data_dict = {field: getattr(data, field) for field in fields}
672
+ elif isinstance(data, dict):
673
+ data_dict = data
674
+ else:
675
+ raise ValueError(f"Cannot extract data from {type(data)}")
676
+
677
+ # Update object fields
678
+ for key, value in data_dict.items():
679
+ setattr(obj, key, value)
680
+
681
+ # Save using async ORM
682
+ await obj.asave()
683
+
684
+ # Serialize response
685
+ if hasattr(self, "serializer_class") and self.serializer_class:
686
+ # Get serializer class (use method if available, otherwise direct attribute)
687
+ if hasattr(self, "get_serializer_class"):
688
+ serializer_class = self.get_serializer_class()
689
+ else:
690
+ serializer_class = self.serializer_class
691
+
692
+ if hasattr(serializer_class, "from_model"):
693
+ return serializer_class.from_model(obj)
694
+ else:
695
+ import msgspec
696
+ fields = getattr(serializer_class, "__annotations__", {})
697
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
698
+ return msgspec.convert(mapped, serializer_class)
699
+
700
+ return obj
701
+
702
+
703
+ class PartialUpdateMixin:
704
+ """
705
+ Mixin that provides a partial_update() method for PATCH requests.
706
+
707
+ Automatically implements:
708
+ async def patch(self, request, pk: int, data: SerializerClass) -> object
709
+
710
+ Requires:
711
+ - queryset attribute
712
+ - get_object(pk) method
713
+ - serializer_class attribute
714
+ """
715
+
716
+ async def patch(self, request, pk: int, data):
717
+ """Update an object (partial update)."""
718
+ obj = await self.get_object(pk)
719
+
720
+ # Extract data from msgspec.Struct to dict
721
+ if hasattr(data, "__struct_fields__"):
722
+ fields = data.__struct_fields__
723
+ data_dict = {field: getattr(data, field) for field in fields}
724
+ elif isinstance(data, dict):
725
+ data_dict = data
726
+ else:
727
+ raise ValueError(f"Cannot extract data from {type(data)}")
728
+
729
+ # Update only provided fields
730
+ for key, value in data_dict.items():
731
+ if value is not None: # Skip None values in PATCH
732
+ setattr(obj, key, value)
733
+
734
+ # Save using async ORM
735
+ await obj.asave()
736
+
737
+ # Serialize response
738
+ if hasattr(self, "serializer_class") and self.serializer_class:
739
+ # Get serializer class (use method if available, otherwise direct attribute)
740
+ if hasattr(self, "get_serializer_class"):
741
+ serializer_class = self.get_serializer_class()
742
+ else:
743
+ serializer_class = self.serializer_class
744
+
745
+ if hasattr(serializer_class, "from_model"):
746
+ return serializer_class.from_model(obj)
747
+ else:
748
+ import msgspec
749
+ fields = getattr(serializer_class, "__annotations__", {})
750
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
751
+ return msgspec.convert(mapped, serializer_class)
752
+
753
+ return obj
754
+
755
+
756
+ class DestroyMixin:
757
+ """
758
+ Mixin that provides a destroy() method for DELETE requests.
759
+
760
+ Automatically implements:
761
+ async def delete(self, request, pk: int) -> dict
762
+
763
+ Requires:
764
+ - queryset attribute
765
+ - get_object(pk) method
766
+ """
767
+
768
+ async def delete(self, request, pk: int):
769
+ """Delete an object."""
770
+ obj = await self.get_object(pk)
771
+
772
+ # Delete using async ORM
773
+ await obj.adelete()
774
+
775
+ # Return success response
776
+ return {"detail": "Object deleted successfully"}
777
+
778
+
779
+ # Convenience ViewSet classes (like Django REST Framework)
780
+
781
+ class ReadOnlyModelViewSet(ViewSet):
782
+ """
783
+ A viewset base class for read-only operations.
784
+
785
+ Provides `get_queryset()`, `get_object()`, and `get_serializer_class()` methods.
786
+ You implement the HTTP method handlers with proper type annotations.
787
+
788
+ Example:
789
+ @api.view("/articles")
790
+ class ArticleListViewSet(ReadOnlyModelViewSet):
791
+ queryset = Article.objects.all()
792
+ serializer_class = ArticleSchema
793
+
794
+ async def get(self, request):
795
+ \"\"\"List all articles.\"\"\"
796
+ articles = []
797
+ async for article in await self.get_queryset():
798
+ articles.append(ArticleSchema.from_model(article))
799
+ return articles
800
+
801
+ @api.view("/articles/{pk}")
802
+ class ArticleDetailViewSet(ReadOnlyModelViewSet):
803
+ queryset = Article.objects.all()
804
+ serializer_class = ArticleSchema
805
+
806
+ async def get(self, request, pk: int):
807
+ \"\"\"Retrieve a single article.\"\"\"
808
+ article = await self.get_object(pk)
809
+ return ArticleSchema.from_model(article)
810
+ """
811
+ pass
812
+
813
+
814
+ class ModelViewSet(ViewSet):
815
+ """
816
+ A viewset base class that provides helpers for full CRUD operations.
817
+
818
+ Similar to Django REST Framework's ModelViewSet, but adapted for Django-Bolt's
819
+ type-based parameter binding. You set `queryset` and `serializer_class`, then
820
+ implement DRF-style action methods (list, retrieve, create, update, etc.).
821
+
822
+ Example:
823
+ from django_bolt import BoltAPI, ModelViewSet
824
+ from myapp.models import Article
825
+ import msgspec
826
+
827
+ api = BoltAPI()
828
+
829
+ class ArticleSchema(msgspec.Struct):
830
+ id: int
831
+ title: str
832
+ content: str
833
+
834
+ @classmethod
835
+ def from_model(cls, obj):
836
+ return cls(id=obj.id, title=obj.title, content=obj.content)
837
+
838
+ class ArticleCreateSchema(msgspec.Struct):
839
+ title: str
840
+ content: str
841
+
842
+ @api.viewset("/articles")
843
+ class ArticleViewSet(ModelViewSet):
844
+ queryset = Article.objects.all()
845
+ serializer_class = ArticleSchema
846
+
847
+ async def list(self, request):
848
+ \"\"\"GET /articles - List all articles.\"\"\"
849
+ articles = []
850
+ async for article in await self.get_queryset():
851
+ articles.append(ArticleSchema.from_model(article))
852
+ return articles
853
+
854
+ async def retrieve(self, request, pk: int):
855
+ \"\"\"GET /articles/{pk} - Retrieve a single article.\"\"\"
856
+ article = await self.get_object(pk=pk)
857
+ return ArticleSchema.from_model(article)
858
+
859
+ async def create(self, request, data: ArticleCreateSchema):
860
+ \"\"\"POST /articles - Create a new article.\"\"\"
861
+ article = await Article.objects.acreate(
862
+ title=data.title,
863
+ content=data.content
864
+ )
865
+ return ArticleSchema.from_model(article)
866
+
867
+ async def update(self, request, pk: int, data: ArticleCreateSchema):
868
+ \"\"\"PUT /articles/{pk} - Update an article.\"\"\"
869
+ article = await self.get_object(pk=pk)
870
+ article.title = data.title
871
+ article.content = data.content
872
+ await article.asave()
873
+ return ArticleSchema.from_model(article)
874
+
875
+ async def partial_update(self, request, pk: int, data: ArticleCreateSchema):
876
+ \"\"\"PATCH /articles/{pk} - Partially update an article.\"\"\"
877
+ article = await self.get_object(pk=pk)
878
+ if data.title:
879
+ article.title = data.title
880
+ if data.content:
881
+ article.content = data.content
882
+ await article.asave()
883
+ return ArticleSchema.from_model(article)
884
+
885
+ async def destroy(self, request, pk: int):
886
+ \"\"\"DELETE /articles/{pk} - Delete an article.\"\"\"
887
+ article = await self.get_object(pk=pk)
888
+ await article.adelete()
889
+ return {"deleted": True}
890
+
891
+ This provides full CRUD operations with Django ORM integration, just like DRF.
892
+ The difference is that Django-Bolt requires explicit type annotations for
893
+ parameter binding and validation. Routes are automatically generated based on
894
+ implemented action methods.
895
+ """
896
+
897
+ # Optional: separate serializer for create/update operations
898
+ create_serializer_class: Optional[Type] = None
899
+ update_serializer_class: Optional[Type] = None
900
+
901
+ async def list(self, request):
902
+ """
903
+ List all objects in the queryset.
904
+
905
+ Uses list_serializer_class if defined, otherwise serializer_class.
906
+ Applies filter_queryset() for filtering, searching, ordering.
907
+ """
908
+ qs = await self.get_queryset()
909
+ qs = await self.filter_queryset(qs) # Apply filtering (still lazy)
910
+ serializer_class = self.get_serializer_class(action='list')
911
+
912
+ # Queryset is evaluated here during iteration
913
+ results = []
914
+ async for obj in qs:
915
+ if hasattr(serializer_class, 'from_model'):
916
+ results.append(serializer_class.from_model(obj))
917
+ else:
918
+ # Fallback: manual conversion
919
+ import msgspec
920
+ fields = getattr(serializer_class, '__annotations__', {})
921
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
922
+ results.append(msgspec.convert(mapped, serializer_class))
923
+
924
+ return results
925
+
926
+ async def retrieve(self, request, **kwargs):
927
+ """
928
+ Retrieve a single object by lookup field.
929
+
930
+ The lookup field value is passed as a keyword argument (e.g., pk=1, id=1).
931
+ """
932
+ # Extract lookup value from kwargs
933
+ lookup_value = kwargs.get(self.lookup_field)
934
+ if lookup_value is None:
935
+ raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
936
+
937
+ obj = await self.get_object(**{self.lookup_field: lookup_value})
938
+ serializer_class = self.get_serializer_class(action='retrieve')
939
+
940
+ if hasattr(serializer_class, 'from_model'):
941
+ return serializer_class.from_model(obj)
942
+ else:
943
+ import msgspec
944
+ fields = getattr(serializer_class, '__annotations__', {})
945
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
946
+ return msgspec.convert(mapped, serializer_class)
947
+
948
+ async def create(self, request, data):
949
+ """
950
+ Create a new object.
951
+
952
+ The `data` parameter should be a msgspec.Struct with the fields to create.
953
+ Uses create_serializer_class if defined, otherwise serializer_class.
954
+ """
955
+ # Get the model class without evaluating queryset
956
+ base_qs = self._get_base_queryset()
957
+ if base_qs is None:
958
+ raise ValueError(
959
+ f"'{self.__class__.__name__}' should include a `queryset` attribute."
960
+ )
961
+ model = base_qs.model
962
+
963
+ # Extract data from msgspec.Struct
964
+ if hasattr(data, '__struct_fields__'):
965
+ fields = data.__struct_fields__
966
+ data_dict = {field: getattr(data, field) for field in fields}
967
+ elif isinstance(data, dict):
968
+ data_dict = data
969
+ else:
970
+ raise ValueError(f"Cannot extract data from {type(data)}")
971
+
972
+ # Create object
973
+ obj = await model.objects.acreate(**data_dict)
974
+
975
+ # Serialize response
976
+ serializer_class = self.get_serializer_class(action='create')
977
+ if hasattr(serializer_class, 'from_model'):
978
+ return serializer_class.from_model(obj)
979
+ else:
980
+ import msgspec
981
+ fields = getattr(serializer_class, '__annotations__', {})
982
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
983
+ return msgspec.convert(mapped, serializer_class)
984
+
985
+ async def update(self, request, data, **kwargs):
986
+ """
987
+ Update an object (full update).
988
+
989
+ The lookup field value is passed as a keyword argument.
990
+ Uses update_serializer_class if defined, otherwise create_serializer_class or serializer_class.
991
+ """
992
+ lookup_value = kwargs.get(self.lookup_field)
993
+ if lookup_value is None:
994
+ raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
995
+
996
+ obj = await self.get_object(**{self.lookup_field: lookup_value})
997
+
998
+ # Extract data
999
+ if hasattr(data, '__struct_fields__'):
1000
+ fields = data.__struct_fields__
1001
+ data_dict = {field: getattr(data, field) for field in fields}
1002
+ elif isinstance(data, dict):
1003
+ data_dict = data
1004
+ else:
1005
+ raise ValueError(f"Cannot extract data from {type(data)}")
1006
+
1007
+ # Update all fields
1008
+ for key, value in data_dict.items():
1009
+ setattr(obj, key, value)
1010
+
1011
+ await obj.asave()
1012
+
1013
+ # Serialize response
1014
+ serializer_class = self.get_serializer_class(action='update')
1015
+ if hasattr(serializer_class, 'from_model'):
1016
+ return serializer_class.from_model(obj)
1017
+ else:
1018
+ import msgspec
1019
+ fields = getattr(serializer_class, '__annotations__', {})
1020
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
1021
+ return msgspec.convert(mapped, serializer_class)
1022
+
1023
+ async def partial_update(self, request, data, **kwargs):
1024
+ """
1025
+ Partially update an object.
1026
+
1027
+ Only updates fields that are not None in the data.
1028
+ """
1029
+ lookup_value = kwargs.get(self.lookup_field)
1030
+ if lookup_value is None:
1031
+ raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
1032
+
1033
+ obj = await self.get_object(**{self.lookup_field: lookup_value})
1034
+
1035
+ # Extract data
1036
+ if hasattr(data, '__struct_fields__'):
1037
+ fields = data.__struct_fields__
1038
+ data_dict = {field: getattr(data, field) for field in fields}
1039
+ elif isinstance(data, dict):
1040
+ data_dict = data
1041
+ else:
1042
+ raise ValueError(f"Cannot extract data from {type(data)}")
1043
+
1044
+ # Update only non-None fields
1045
+ for key, value in data_dict.items():
1046
+ if value is not None:
1047
+ setattr(obj, key, value)
1048
+
1049
+ await obj.asave()
1050
+
1051
+ # Serialize response
1052
+ serializer_class = self.get_serializer_class(action='partial_update')
1053
+ if hasattr(serializer_class, 'from_model'):
1054
+ return serializer_class.from_model(obj)
1055
+ else:
1056
+ import msgspec
1057
+ fields = getattr(serializer_class, '__annotations__', {})
1058
+ mapped = {name: getattr(obj, name, None) for name in fields.keys()}
1059
+ return msgspec.convert(mapped, serializer_class)
1060
+
1061
+ async def destroy(self, request, **kwargs):
1062
+ """
1063
+ Delete an object.
1064
+ """
1065
+ lookup_value = kwargs.get(self.lookup_field)
1066
+ if lookup_value is None:
1067
+ raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
1068
+
1069
+ obj = await self.get_object(**{self.lookup_field: lookup_value})
1070
+ await obj.adelete()
1071
+
1072
+ return {"deleted": True}
1073
+
1074
+ def get_serializer_class(self, action: Optional[str] = None):
1075
+ """
1076
+ Get the serializer class for this viewset.
1077
+
1078
+ Supports action-specific serializer classes:
1079
+ - list: list_serializer_class or serializer_class
1080
+ - create: create_serializer_class or serializer_class
1081
+ - update/partial_update: update_serializer_class or create_serializer_class or serializer_class
1082
+ - retrieve/destroy: serializer_class
1083
+
1084
+ Args:
1085
+ action: The action being performed ('list', 'retrieve', 'create', etc.)
1086
+
1087
+ Returns:
1088
+ Serializer class
1089
+ """
1090
+ if action is None:
1091
+ action = self.action
1092
+
1093
+ # Action-specific serializer classes
1094
+ if action == 'list' and self.list_serializer_class is not None:
1095
+ return self.list_serializer_class
1096
+ elif action == 'create' and self.create_serializer_class is not None:
1097
+ return self.create_serializer_class
1098
+ elif action in ('update', 'partial_update'):
1099
+ if self.update_serializer_class is not None:
1100
+ return self.update_serializer_class
1101
+ elif self.create_serializer_class is not None:
1102
+ return self.create_serializer_class
1103
+
1104
+ if self.serializer_class is None:
1105
+ raise ValueError(
1106
+ f"'{self.__class__.__name__}' should either include a `serializer_class` attribute, "
1107
+ f"or override the `get_serializer_class()` method."
1108
+ )
1109
+ return self.serializer_class
1110
+