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.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.abi3.so +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
@@ -0,0 +1,669 @@
1
+ """
2
+ Pagination utilities for Django-Bolt.
3
+
4
+ Provides Django Paginator-based pagination that works with both functional
5
+ and class-based views, leveraging Django's built-in pagination while integrating
6
+ with Bolt's parameter extraction and serialization systems.
7
+
8
+ Example (Functional View):
9
+ @api.get("/users")
10
+ @paginate(PageNumberPagination)
11
+ async def list_users():
12
+ return User.objects.all()
13
+
14
+ Example (Class-Based View):
15
+ @api.viewset("/articles")
16
+ class ArticleViewSet(ModelViewSet):
17
+ queryset = Article.objects.all()
18
+ serializer_class = ArticleSchema
19
+ pagination_class = PageNumberPagination
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import inspect
25
+ import msgspec
26
+ from abc import ABC, abstractmethod
27
+ from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic, get_origin, get_args
28
+ from functools import wraps
29
+ from django.core.paginator import Paginator, Page, EmptyPage, PageNotAnInteger
30
+ from asgiref.sync import sync_to_async
31
+
32
+ from .params import Query
33
+ from .typing import is_optional
34
+
35
+ __all__ = [
36
+ "PaginationBase",
37
+ "PageNumberPagination",
38
+ "LimitOffsetPagination",
39
+ "CursorPagination",
40
+ "PaginatedResponse",
41
+ "paginate",
42
+ ]
43
+
44
+ T = TypeVar("T")
45
+
46
+
47
+ class PaginatedResponse(msgspec.Struct, Generic[T]):
48
+ """
49
+ Standard paginated response structure.
50
+
51
+ Attributes:
52
+ items: List of paginated items
53
+ total: Total number of items across all pages
54
+ page: Current page number (for PageNumber pagination)
55
+ page_size: Number of items per page
56
+ total_pages: Total number of pages
57
+ has_next: Whether there is a next page
58
+ has_previous: Whether there is a previous page
59
+ next_page: Next page number (None if no next page)
60
+ previous_page: Previous page number (None if no previous page)
61
+ """
62
+ items: List[T]
63
+ total: int
64
+ page: Optional[int] = None
65
+ page_size: Optional[int] = None
66
+ total_pages: Optional[int] = None
67
+ has_next: bool = False
68
+ has_previous: bool = False
69
+ next_page: Optional[int] = None
70
+ previous_page: Optional[int] = None
71
+
72
+ # For LimitOffset pagination
73
+ limit: Optional[int] = None
74
+ offset: Optional[int] = None
75
+
76
+ # For Cursor pagination
77
+ next_cursor: Optional[str] = None
78
+ previous_cursor: Optional[str] = None
79
+
80
+
81
+ class PaginationBase(ABC):
82
+ """
83
+ Base class for all pagination schemes.
84
+
85
+ Subclasses must implement:
86
+ - get_page_params(): Extract pagination params from request
87
+ - paginate_queryset(): Apply pagination to queryset
88
+ """
89
+
90
+ # Default page size
91
+ page_size: int = 100
92
+
93
+ # Maximum allowed page size (prevents abuse)
94
+ max_page_size: int = 1000
95
+
96
+ # Name of the page size query parameter
97
+ page_size_query_param: Optional[str] = None
98
+
99
+ @abstractmethod
100
+ async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
101
+ """
102
+ Extract pagination parameters from request.
103
+
104
+ Args:
105
+ request: Request dictionary
106
+
107
+ Returns:
108
+ Dictionary of pagination parameters
109
+ """
110
+ raise NotImplementedError
111
+
112
+ @abstractmethod
113
+ async def paginate_queryset(
114
+ self,
115
+ queryset: Any,
116
+ request: Dict[str, Any],
117
+ **params: Any
118
+ ) -> PaginatedResponse:
119
+ """
120
+ Apply pagination to a queryset and return paginated response.
121
+
122
+ Args:
123
+ queryset: Django QuerySet to paginate
124
+ request: Request dictionary
125
+ **params: Additional parameters
126
+
127
+ Returns:
128
+ PaginatedResponse with items and metadata
129
+ """
130
+ raise NotImplementedError
131
+
132
+ async def _get_queryset_count(self, queryset: Any) -> int:
133
+ """
134
+ Get total count of queryset items.
135
+
136
+ Handles both sync and async querysets.
137
+
138
+ Args:
139
+ queryset: Django QuerySet
140
+
141
+ Returns:
142
+ Total count
143
+ """
144
+ # Check if queryset has acount (async count)
145
+ if hasattr(queryset, 'acount'):
146
+ return await queryset.acount()
147
+ # Fallback to sync count wrapped in sync_to_async
148
+ elif hasattr(queryset, 'count'):
149
+ return await sync_to_async(queryset.count)()
150
+ # For lists or other iterables
151
+ else:
152
+ return len(queryset)
153
+
154
+ async def _evaluate_queryset_slice(self, queryset: Any) -> List[Any]:
155
+ """
156
+ Evaluate a queryset slice to a list.
157
+
158
+ Handles both sync and async querysets.
159
+ Converts Django model instances to dicts for serialization.
160
+
161
+ Args:
162
+ queryset: Django QuerySet or iterable
163
+
164
+ Returns:
165
+ List of items (Django models converted to dicts)
166
+ """
167
+ items = []
168
+
169
+ # Check if it's an async iterable (has __aiter__)
170
+ if hasattr(queryset, '__aiter__'):
171
+ async for item in queryset:
172
+ items.append(self._model_to_dict(item))
173
+ return items
174
+ # Check if it's a Django QuerySet with async support
175
+ elif hasattr(queryset, '_iterable_class') and hasattr(queryset, 'model'):
176
+ # It's a QuerySet - check if we can iterate async
177
+ try:
178
+ async for item in queryset:
179
+ items.append(self._model_to_dict(item))
180
+ return items
181
+ except TypeError:
182
+ # Not async iterable, use sync_to_async
183
+ raw_items = await sync_to_async(list)(queryset)
184
+ return [self._model_to_dict(item) for item in raw_items]
185
+ # Regular iterable or list
186
+ else:
187
+ result = list(queryset)
188
+ return [self._model_to_dict(item) for item in result]
189
+
190
+ def _model_to_dict(self, item: Any) -> Any:
191
+ """
192
+ Convert Django model instance to dict for serialization.
193
+
194
+ Args:
195
+ item: Django model instance or any object
196
+
197
+ Returns:
198
+ Dict if item is a Django model, otherwise returns item unchanged
199
+ """
200
+ # Check if it's a Django model instance
201
+ if hasattr(item, '_meta') and hasattr(item, '_state'):
202
+ # It's a Django model - convert to dict
203
+ # Get all field values - use __dict__ which is safe in async context
204
+ data = {}
205
+ # Use model's __dict__ to avoid accessing _meta in async context
206
+ for key, value in item.__dict__.items():
207
+ # Skip private attributes and Django internal state
208
+ if not key.startswith('_'):
209
+ data[key] = value
210
+ return data
211
+ # Not a model, return as-is
212
+ return item
213
+
214
+ def _get_page_size(self, request: Dict[str, Any]) -> int:
215
+ """
216
+ Get page size from request, with validation.
217
+
218
+ Args:
219
+ request: Request dictionary
220
+
221
+ Returns:
222
+ Validated page size
223
+ """
224
+ if self.page_size_query_param:
225
+ query = request.get('query', {})
226
+ page_size_str = query.get(self.page_size_query_param)
227
+ if page_size_str:
228
+ try:
229
+ page_size = int(page_size_str)
230
+ # Enforce max_page_size limit
231
+ if page_size > self.max_page_size:
232
+ return self.max_page_size
233
+ if page_size < 1:
234
+ return self.page_size
235
+ return page_size
236
+ except (ValueError, TypeError):
237
+ pass
238
+
239
+ return self.page_size
240
+
241
+
242
+ class PageNumberPagination(PaginationBase):
243
+ """
244
+ Page number-based pagination.
245
+
246
+ Query parameters:
247
+ - page: Page number (default: 1)
248
+ - page_size: Items per page (optional, default: 100)
249
+
250
+ Example:
251
+ /api/users?page=2&page_size=20
252
+
253
+ Attributes:
254
+ page_size: Default number of items per page (default: 100)
255
+ max_page_size: Maximum allowed page size (default: 1000)
256
+ page_size_query_param: Query param name for page size (default: "page_size")
257
+ """
258
+
259
+ page_size: int = 100
260
+ max_page_size: int = 1000
261
+ page_size_query_param: str = "page_size"
262
+
263
+ async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
264
+ """Extract page number and page_size from request."""
265
+ query = request.get('query', {})
266
+
267
+ # Get page number (default to 1)
268
+ page_str = query.get('page', '1')
269
+ try:
270
+ page = int(page_str)
271
+ if page < 1:
272
+ page = 1
273
+ except (ValueError, TypeError):
274
+ page = 1
275
+
276
+ # Get page size
277
+ page_size = self._get_page_size(request)
278
+
279
+ return {'page': page, 'page_size': page_size}
280
+
281
+ async def paginate_queryset(
282
+ self,
283
+ queryset: Any,
284
+ request: Dict[str, Any],
285
+ **params: Any
286
+ ) -> PaginatedResponse:
287
+ """
288
+ Paginate queryset using page numbers.
289
+
290
+ Args:
291
+ queryset: Django QuerySet to paginate
292
+ request: Request dictionary
293
+ **params: Additional parameters (unused)
294
+
295
+ Returns:
296
+ PaginatedResponse with pagination metadata
297
+ """
298
+ page_params = await self.get_page_params(request)
299
+ page_number = page_params['page']
300
+ page_size = page_params['page_size']
301
+
302
+ # Get total count
303
+ total = await self._get_queryset_count(queryset)
304
+
305
+ # Calculate total pages
306
+ total_pages = (total + page_size - 1) // page_size if total > 0 else 0
307
+
308
+ # Validate page number
309
+ if page_number > total_pages and total_pages > 0:
310
+ page_number = total_pages
311
+
312
+ # Calculate offset
313
+ offset = (page_number - 1) * page_size
314
+
315
+ # Slice queryset
316
+ items = await self._evaluate_queryset_slice(queryset[offset:offset + page_size])
317
+
318
+ # Build response
319
+ return PaginatedResponse(
320
+ items=items,
321
+ total=total,
322
+ page=page_number,
323
+ page_size=page_size,
324
+ total_pages=total_pages,
325
+ has_next=page_number < total_pages,
326
+ has_previous=page_number > 1,
327
+ next_page=page_number + 1 if page_number < total_pages else None,
328
+ previous_page=page_number - 1 if page_number > 1 else None,
329
+ )
330
+
331
+
332
+ class LimitOffsetPagination(PaginationBase):
333
+ """
334
+ Limit-offset based pagination.
335
+
336
+ Query parameters:
337
+ - limit: Number of items to return (default: 100)
338
+ - offset: Starting position (default: 0)
339
+
340
+ Example:
341
+ /api/users?limit=20&offset=40
342
+
343
+ Attributes:
344
+ page_size: Default limit when not specified (default: 100)
345
+ max_page_size: Maximum allowed limit (default: 1000)
346
+ """
347
+
348
+ page_size: int = 100
349
+ max_page_size: int = 1000
350
+
351
+ async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
352
+ """Extract limit and offset from request."""
353
+ query = request.get('query', {})
354
+
355
+ # Get limit (default to page_size)
356
+ limit_str = query.get('limit', None)
357
+ if limit_str is None:
358
+ # No limit specified, use default
359
+ limit = self.page_size
360
+ else:
361
+ try:
362
+ limit = int(limit_str)
363
+ if limit < 1:
364
+ limit = self.page_size
365
+ if limit > self.max_page_size:
366
+ limit = self.max_page_size
367
+ except (ValueError, TypeError):
368
+ limit = self.page_size
369
+
370
+ # Get offset (default to 0)
371
+ offset_str = query.get('offset', '0')
372
+ try:
373
+ offset = int(offset_str)
374
+ if offset < 0:
375
+ offset = 0
376
+ except (ValueError, TypeError):
377
+ offset = 0
378
+
379
+ return {'limit': limit, 'offset': offset}
380
+
381
+ async def paginate_queryset(
382
+ self,
383
+ queryset: Any,
384
+ request: Dict[str, Any],
385
+ **params: Any
386
+ ) -> PaginatedResponse:
387
+ """
388
+ Paginate queryset using limit/offset.
389
+
390
+ Args:
391
+ queryset: Django QuerySet to paginate
392
+ request: Request dictionary
393
+ **params: Additional parameters (unused)
394
+
395
+ Returns:
396
+ PaginatedResponse with pagination metadata
397
+ """
398
+ page_params = await self.get_page_params(request)
399
+ limit = page_params['limit']
400
+ offset = page_params['offset']
401
+
402
+ # Get total count
403
+ total = await self._get_queryset_count(queryset)
404
+
405
+ # Slice queryset
406
+ items = await self._evaluate_queryset_slice(queryset[offset:offset + limit])
407
+
408
+ # Calculate page info
409
+ has_next = (offset + limit) < total
410
+ has_previous = offset > 0
411
+
412
+ return PaginatedResponse(
413
+ items=items,
414
+ total=total,
415
+ limit=limit,
416
+ offset=offset,
417
+ has_next=has_next,
418
+ has_previous=has_previous,
419
+ )
420
+
421
+
422
+ class CursorPagination(PaginationBase):
423
+ """
424
+ Cursor-based pagination for large datasets.
425
+
426
+ More efficient than offset-based pagination for large datasets
427
+ as it doesn't require counting all records or scanning through skipped records.
428
+
429
+ Query parameters:
430
+ - cursor: Opaque cursor string (optional)
431
+ - page_size: Items per page (optional)
432
+
433
+ Example:
434
+ /api/users?cursor=eyJpZCI6MTAwfQ==&page_size=20
435
+
436
+ Attributes:
437
+ page_size: Default number of items per page (default: 100)
438
+ max_page_size: Maximum allowed page size (default: 1000)
439
+ page_size_query_param: Query param name for page size (default: "page_size")
440
+ ordering: Field to order by (default: "-id" for descending ID)
441
+ """
442
+
443
+ page_size: int = 100
444
+ max_page_size: int = 1000
445
+ page_size_query_param: str = "page_size"
446
+ ordering: str = "-id" # Default ordering field
447
+
448
+ def _encode_cursor(self, value: Any) -> str:
449
+ """
450
+ Encode cursor value to base64 string.
451
+
452
+ Args:
453
+ value: Cursor value (typically an ID)
454
+
455
+ Returns:
456
+ Base64-encoded cursor string
457
+ """
458
+ cursor_data = msgspec.json.encode({"v": value})
459
+ return base64.b64encode(cursor_data).decode('utf-8')
460
+
461
+ def _decode_cursor(self, cursor: str) -> Any:
462
+ """
463
+ Decode cursor string to value.
464
+
465
+ Args:
466
+ cursor: Base64-encoded cursor string
467
+
468
+ Returns:
469
+ Decoded cursor value
470
+ """
471
+ try:
472
+ cursor_data = base64.b64decode(cursor.encode('utf-8'))
473
+ data = msgspec.json.decode(cursor_data)
474
+ return data.get("v")
475
+ except Exception:
476
+ return None
477
+
478
+ async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
479
+ """Extract cursor and page_size from request."""
480
+ query = request.get('query', {})
481
+
482
+ # Get cursor (optional)
483
+ cursor_str = query.get('cursor')
484
+ cursor_value = self._decode_cursor(cursor_str) if cursor_str else None
485
+
486
+ # Get page size
487
+ page_size = self._get_page_size(request)
488
+
489
+ return {'cursor': cursor_value, 'page_size': page_size}
490
+
491
+ async def paginate_queryset(
492
+ self,
493
+ queryset: Any,
494
+ request: Dict[str, Any],
495
+ **params: Any
496
+ ) -> PaginatedResponse:
497
+ """
498
+ Paginate queryset using cursor-based pagination.
499
+
500
+ Args:
501
+ queryset: Django QuerySet to paginate
502
+ request: Request dictionary
503
+ **params: Additional parameters (unused)
504
+
505
+ Returns:
506
+ PaginatedResponse with cursor metadata
507
+ """
508
+ page_params = await self.get_page_params(request)
509
+ cursor_value = page_params['cursor']
510
+ page_size = page_params['page_size']
511
+
512
+ # Apply ordering
513
+ ordering = self.ordering
514
+ is_descending = ordering.startswith('-')
515
+ ordering_field = ordering.lstrip('-')
516
+
517
+ # Apply ordering to queryset
518
+ ordered_qs = queryset.order_by(ordering)
519
+
520
+ # Apply cursor filter if present
521
+ if cursor_value is not None:
522
+ if is_descending:
523
+ # For descending order, we want items less than cursor
524
+ filter_kwargs = {f"{ordering_field}__lt": cursor_value}
525
+ else:
526
+ # For ascending order, we want items greater than cursor
527
+ filter_kwargs = {f"{ordering_field}__gt": cursor_value}
528
+
529
+ ordered_qs = ordered_qs.filter(**filter_kwargs)
530
+
531
+ # Fetch page_size + 1 items to determine if there's a next page
532
+ items = await self._evaluate_queryset_slice(ordered_qs[:page_size + 1])
533
+
534
+ # Check if there are more items
535
+ has_next = len(items) > page_size
536
+ if has_next:
537
+ items = items[:page_size] # Trim to page_size
538
+
539
+ # Generate next cursor from last item
540
+ next_cursor = None
541
+ if has_next and items:
542
+ last_item = items[-1]
543
+ # Items are now dicts (converted from models), so use dict access
544
+ if isinstance(last_item, dict):
545
+ last_value = last_item.get(ordering_field)
546
+ else:
547
+ last_value = getattr(last_item, ordering_field, None)
548
+
549
+ if last_value is not None:
550
+ next_cursor = self._encode_cursor(last_value)
551
+
552
+ return PaginatedResponse(
553
+ items=items,
554
+ total=0, # Cursor pagination doesn't provide total count for efficiency
555
+ page_size=page_size,
556
+ has_next=has_next,
557
+ has_previous=cursor_value is not None,
558
+ next_cursor=next_cursor,
559
+ )
560
+
561
+
562
+ def paginate(pagination_class: type[PaginationBase] = PageNumberPagination):
563
+ """
564
+ Decorator to apply pagination to a route handler.
565
+
566
+ The decorated handler should return a Django QuerySet or list.
567
+ The decorator will automatically apply pagination and return a PaginatedResponse.
568
+
569
+ Args:
570
+ pagination_class: Pagination class to use (default: PageNumberPagination)
571
+
572
+ Example:
573
+ @api.get("/users")
574
+ @paginate(PageNumberPagination)
575
+ async def list_users():
576
+ return User.objects.all()
577
+
578
+ @api.get("/articles")
579
+ @paginate(LimitOffsetPagination)
580
+ async def list_articles(status: str = "published"):
581
+ return Article.objects.filter(status=status)
582
+
583
+ Returns:
584
+ Decorated handler function
585
+ """
586
+ def decorator(handler: Callable) -> Callable:
587
+ # Create pagination instance
588
+ paginator = pagination_class()
589
+
590
+ # Store original handler for introspection
591
+ original_handler = handler
592
+
593
+ @wraps(handler)
594
+ async def wrapper(*args, **kwargs):
595
+ # Extract request from args/kwargs
596
+ # Request can be in:
597
+ # 1. kwargs['request'] - most common
598
+ # 2. args[0] - for single-param handlers
599
+ # 3. args[1] - for ViewSet methods (args[0] is self)
600
+ request = None
601
+
602
+ # Try kwargs first (most reliable)
603
+ if 'request' in kwargs:
604
+ request = kwargs['request']
605
+ # Check if this is a method with self as first arg
606
+ elif len(args) >= 2:
607
+ # Could be (self, request, ...) or (request, other_params...)
608
+ # If args[0] looks like a view instance, args[1] is request
609
+ first_arg = args[0]
610
+ if (hasattr(first_arg, '__class__') and
611
+ hasattr(first_arg.__class__, '__mro__') and
612
+ any('View' in cls.__name__ for cls in first_arg.__class__.__mro__)):
613
+ request = args[1]
614
+ else:
615
+ # args[0] is request, args[1] is another parameter
616
+ request = args[0]
617
+ elif len(args) == 1:
618
+ # Single arg - should be request
619
+ request = args[0]
620
+
621
+ if request is None:
622
+ raise ValueError(
623
+ f"Pagination decorator on {handler.__name__} could not find request. "
624
+ f"Args: {[type(a).__name__ for a in args]}, kwargs: {list(kwargs.keys())}"
625
+ )
626
+
627
+ # Convert PyRequest to dict if needed for pagination
628
+ # PyRequest objects from Rust layer behave like dicts
629
+ if not isinstance(request, dict) and hasattr(request, '__getitem__'):
630
+ # It's a PyRequest object - convert to dict for pagination methods
631
+ request_dict = {
632
+ 'method': request.get('method', 'GET'),
633
+ 'query': request.get('query', {}),
634
+ 'params': request.get('params', {}),
635
+ 'headers': request.get('headers', {}),
636
+ 'cookies': request.get('cookies', {}),
637
+ }
638
+ elif isinstance(request, dict):
639
+ request_dict = request
640
+ else:
641
+ raise ValueError(
642
+ f"Unexpected request type: {type(request)}. "
643
+ f"Args: {[type(a).__name__ for a in args]}, kwargs: {list(kwargs.keys())}"
644
+ )
645
+
646
+ # Call original handler to get queryset
647
+ # Pass all args along (including self for methods)
648
+ queryset = await handler(*args, **kwargs)
649
+
650
+ # Apply pagination using dict version
651
+ paginated = await paginator.paginate_queryset(
652
+ queryset, request_dict
653
+ )
654
+
655
+ return paginated
656
+
657
+ # Preserve signature so framework knows to pass request
658
+ wrapper.__signature__ = inspect.signature(original_handler)
659
+ wrapper.__name__ = original_handler.__name__
660
+ wrapper.__doc__ = original_handler.__doc__
661
+ wrapper.__module__ = original_handler.__module__
662
+
663
+ # Mark that this handler returns PaginatedResponse for serialization
664
+ wrapper.__paginated__ = True
665
+ wrapper.__pagination_class__ = pagination_class
666
+
667
+ return wrapper
668
+
669
+ return decorator
@@ -0,0 +1,49 @@
1
+ from typing import Any
2
+
3
+ from .params import (
4
+ Query as _Query,
5
+ Path as _Path,
6
+ Body as _Body,
7
+ Header as _Header,
8
+ Cookie as _Cookie,
9
+ Depends as _Depends,
10
+ Form as _Form,
11
+ File as _File,
12
+ )
13
+
14
+
15
+ def Query(*args: Any, **kwargs: Any) -> Any: # noqa: N802
16
+ return _Query(*args, **kwargs)
17
+
18
+
19
+ def Path(*args: Any, **kwargs: Any) -> Any: # noqa: N802
20
+ return _Path(*args, **kwargs)
21
+
22
+
23
+ def Body(*args: Any, **kwargs: Any) -> Any: # noqa: N802
24
+ return _Body(*args, **kwargs)
25
+
26
+
27
+ def Header(*args: Any, **kwargs: Any) -> Any: # noqa: N802
28
+ return _Header(*args, **kwargs)
29
+
30
+
31
+ def Cookie(*args: Any, **kwargs: Any) -> Any: # noqa: N802
32
+ return _Cookie(*args, **kwargs)
33
+
34
+
35
+ def Depends(*args: Any, **kwargs: Any) -> Any: # noqa: N802
36
+ return _Depends(*args, **kwargs)
37
+
38
+
39
+ def Form(*args: Any, **kwargs: Any) -> Any: # noqa: N802
40
+ return _Form(*args, **kwargs)
41
+
42
+
43
+ def File(*args: Any, **kwargs: Any) -> Any: # noqa: N802
44
+ return _File(*args, **kwargs)
45
+
46
+
47
+ __all__ = ["Query", "Path", "Body", "Header", "Cookie", "Depends", "Form", "File"]
48
+
49
+