dynamicforms-fastapi-viewsets 0.13.0__tar.gz → 0.13.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/PKG-INFO +1 -1
  2. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/__init__.py +1 -1
  3. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/route_viewset.py +7 -3
  4. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/redis.py +3 -4
  5. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mixins.py +54 -3
  6. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/.gitignore +0 -0
  7. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/LICENSE +0 -0
  8. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/README.md +0 -0
  9. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/action_configuration.py +0 -0
  10. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/backends/__init__.py +0 -0
  11. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/backends/django_orm.py +0 -0
  12. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/collection_viewset.py +0 -0
  13. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/conf.py +0 -0
  14. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/__init__.py +0 -0
  15. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/__init__.py +0 -0
  16. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/django.py +0 -0
  17. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/jwt.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/context/auth/static.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/cursor.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/__init__.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/build_schema.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/conftest.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/endpoint_docs.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/error_namespace.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/error_responses.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/__init__.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/authorizers.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/changes.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/conftest.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/delivery.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/event.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/lifecycle.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/scopes.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/sources.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/store.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/topics.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/events/viewset.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/exceptions.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/__init__.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/base.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/declaration.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/operators.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/filters/registry.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/list_query.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/list_shapes.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/__init__.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/idempotency.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/registry.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/server.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/mux_ws/transports.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/py.typed +0 -0
  65. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/response_classes.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/routing.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/__init__.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/save_state.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.13.0 → dynamicforms_fastapi_viewsets-0.13.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.13.0
3
+ Version: 0.13.1
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -1,4 +1,4 @@
1
- __version__ = "0.13.0"
1
+ __version__ = "0.13.1"
2
2
 
3
3
  from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
4
 
@@ -216,12 +216,14 @@ def route_viewset(
216
216
  on a shared mixin cannot know which viewset it will end up on. Here it can: parameters
217
217
  no allowed shape uses are dropped so they never reach the schema, the `X-List-Shape`
218
218
  header is kept only when there is a choice to make, and the return annotation becomes
219
- the union of exactly the models this viewset can produce.
219
+ the union of exactly the models this viewset can produce. A declared `default_sort` becomes
220
+ the `sort` parameter's default.
220
221
  """
221
222
  from fastapi_viewsets.list_shapes import response_model, shape_examples, SHAPE_HEADER
222
- from fastapi_viewsets.mixins import T as ItemVar
223
+ from fastapi_viewsets.mixins import check_declared_sort, T as ItemVar
223
224
 
224
225
  default, allowed = cls.resolve_shapes()
226
+ item_type = type_map.get(ItemVar, ItemVar)
225
227
  uses = {
226
228
  "offset": "paginated" in allowed,
227
229
  "cursor": "cursor" in allowed,
@@ -243,9 +245,11 @@ def route_viewset(
243
245
  ),
244
246
  ]
245
247
  )
248
+ if name == "sort" and cls.default_sort is not None:
249
+ check_declared_sort(cls.default_sort, item_type)
250
+ parameter = parameter.replace(default=cls.default_sort)
246
251
  params.append(parameter)
247
252
 
248
- item_type = type_map.get(ItemVar, ItemVar)
249
253
  nonlocal shape_openapi_extra
250
254
  shape_openapi_extra = shape_examples(allowed, item_type)
251
255
  return sig.replace(parameters=params, return_annotation=response_model(allowed, item_type))
@@ -102,16 +102,15 @@ class PerLoopRedis:
102
102
  return client
103
103
 
104
104
  async def _close_with_loop(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
105
+ # A task destroyed with its closed loop is finalised by the garbage collector, which can run
106
+ # inside `client()` while it holds `_lock`: taking the lock there deadlocks, so that loop's
107
+ # client is left to the next `client()` call.
105
108
  try:
106
109
  await loop.create_future()
107
110
  except asyncio.CancelledError:
108
111
  self._forget(loop, client)
109
112
  await client.aclose()
110
113
  raise
111
- except GeneratorExit:
112
- # The task is destroyed with its loop: there is no loop to close the client on.
113
- self._forget(loop, client)
114
- raise
115
114
 
116
115
  def _forget(self, loop: asyncio.AbstractEventLoop, client: "Redis") -> None:
117
116
  with self._lock:
@@ -201,6 +201,15 @@ class SortStateColumn(BaseModel):
201
201
  SortState = list[SortStateColumn]
202
202
 
203
203
 
204
+ SORT_DESCRIPTION = (
205
+ "Ordering: comma-separated `column:direction` entries in priority order (`name:asc,score:desc`)."
206
+ " The direction is `asc` or `desc`, `asc` when omitted; an entry with any other direction is"
207
+ " ignored. Without the parameter the list is ordered by its default value, where the endpoint"
208
+ " declares one; an empty value leaves the list unordered."
209
+ )
210
+ """The `sort` parameter's description on every list endpoint."""
211
+
212
+
204
213
  def parse_sort_param(sort_csv: str | None) -> SortState:
205
214
  """
206
215
  Parse the 'sort' query parameter (comma-separated) into a SortState.
@@ -227,6 +236,31 @@ def parse_sort_param(sort_csv: str | None) -> SortState:
227
236
  return result
228
237
 
229
238
 
239
+ def check_declared_sort(sort_csv: str, model: Any = None) -> SortState:
240
+ """
241
+ Parses a sort a viewset declares (`ListMixin.default_sort`) in the `sort` parameter's syntax.
242
+
243
+ Raises ValueError for an empty entry and an unrecognised direction, which `parse_sort_param`
244
+ skips in a request, and, when `model` is a pydantic model, for a column that is neither one of
245
+ its fields nor one of its computed fields.
246
+ """
247
+ entries = [entry.strip() for entry in sort_csv.split(",")]
248
+ if not all(entries):
249
+ raise ValueError(f"default_sort {sort_csv!r} has an empty entry")
250
+ result: SortState = []
251
+ for entry in entries:
252
+ column, _, raw_direction = entry.rpartition(":")
253
+ if not column:
254
+ column, raw_direction = entry, SortDirection.asc.value
255
+ if raw_direction not in SortDirection.__members__:
256
+ raise ValueError(f"default_sort {sort_csv!r}: direction of {column!r} is not 'asc' or 'desc'")
257
+ fields = getattr(model, "model_fields", None)
258
+ if isinstance(fields, dict) and column not in fields and column not in model.model_computed_fields:
259
+ raise ValueError(f"default_sort {sort_csv!r}: {model.__name__} has no field {column!r}")
260
+ result.append(SortStateColumn(column_name=column, direction=SortDirection(raw_direction)))
261
+ return result
262
+
263
+
230
264
  ###################################################################################################
231
265
  # LIST
232
266
  ###################################################################################################
@@ -284,6 +318,17 @@ class ListMixin(Generic[T, TFilter], ABC):
284
318
  what stops a cursor walking straight past the NULL rows.
285
319
  """
286
320
 
321
+ default_sort: str | None = None
322
+ """
323
+ The ordering of a list request that sends no `sort` parameter, in that parameter's syntax:
324
+ `"name:asc"`, `"category:asc,score:desc"`. None leaves such a request unordered; so does an empty
325
+ `sort=` on a viewset that declares one.
326
+
327
+ `route_viewset` publishes it as the `sort` parameter's default in the schema, where the TS
328
+ client's `defaultSort()` reads it, and raises ValueError for an empty entry, an unrecognised
329
+ direction or a column the response model has neither a field nor a computed field for.
330
+ """
331
+
287
332
  @property
288
333
  def nulls_first(self) -> bool:
289
334
  return self.nulls != "last"
@@ -298,7 +343,7 @@ class ListMixin(Generic[T, TFilter], ABC):
298
343
  self: "ImplMixin[Any, T] | ListMixin[T]",
299
344
  context: Context,
300
345
  fltr: Annotated[TFilter, Query()] = None,
301
- sort: str | None = None,
346
+ sort: Annotated[str | None, Query(description=SORT_DESCRIPTION)] = None,
302
347
  offset: Annotated[int, Query(ge=0)] = 0,
303
348
  limit: Annotated[int | None, Query(ge=1)] = None,
304
349
  cursor: str | None = None,
@@ -320,7 +365,7 @@ class ListMixin(Generic[T, TFilter], ABC):
320
365
 
321
366
  query = build_list_query(
322
367
  fltr,
323
- sort,
368
+ self.default_sort if sort is None else sort,
324
369
  offset=offset,
325
370
  limit=(
326
371
  None
@@ -797,6 +842,11 @@ class LookupMixin(Generic[TLookupFilter, TLookupItem], ABC):
797
842
 
798
843
  The filter travels in `query.fltr`, never on the viewset instance, which serves concurrent
799
844
  requests under the default `singleton` lifecycle.
845
+
846
+ The endpoint answers in the order of the source; filtering and conversion keep it. The source
847
+ orders the items by group, then by their text, in its own collation - `order_by` in the
848
+ database - because a select shows each group's items together and only the source knows which
849
+ field the text comes from and how its language orders it.
800
850
  """
801
851
 
802
852
  __router = APIRouter()
@@ -804,7 +854,8 @@ class LookupMixin(Generic[TLookupFilter, TLookupItem], ABC):
804
854
  @abstractmethod
805
855
  async def perform_lookup(self, context: Context) -> ListRecords:
806
856
  """
807
- Subclasses implement this to return the lookup source.
857
+ Subclasses implement this to return the lookup source, in the order the endpoint answers
858
+ in: by group, then by text.
808
859
 
809
860
  A lazy source - a generator, an unevaluated QuerySet - is what an overridden
810
861
  `apply_lookup_filter` pushes the filter into. Rows are TLookupItem once `to_lookup_item` has