dynamicforms-fastapi-viewsets 0.14.1__tar.gz → 0.15.0__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 (75) hide show
  1. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/PKG-INFO +17 -33
  2. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/__init__.py +3 -1
  3. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/backends/django_orm.py +2 -5
  4. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/collection_viewset.py +1 -8
  5. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/changes.py +10 -3
  6. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mixins.py +42 -12
  7. dynamicforms_fastapi_viewsets-0.15.0/pyproject.toml +169 -0
  8. dynamicforms_fastapi_viewsets-0.14.1/pyproject.toml → dynamicforms_fastapi_viewsets-0.15.0/pyproject.toml.orig +14 -22
  9. dynamicforms_fastapi_viewsets-0.14.1/.gitignore +0 -21
  10. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/LICENSE +0 -0
  11. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/README.md +0 -0
  12. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/action_configuration.py +0 -0
  13. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/backends/__init__.py +0 -0
  14. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/conf.py +0 -0
  15. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/context/__init__.py +0 -0
  16. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  17. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/context/auth/django.py +0 -0
  18. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/context/auth/static.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/cursor.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/celery_viewset/transport.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/query_model.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/decorators/sub_viewsets.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/error_namespace.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/error_responses.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/__init__.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/authorizers.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/conftest.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/delivery.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/event.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/lifecycle.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/redis.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/scopes.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/sources.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/store.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/topics.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/events/viewset.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/exceptions.py +0 -0
  51. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/filters/__init__.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/filters/base.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/filters/declaration.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/filters/operators.py +0 -0
  55. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/filters/registry.py +0 -0
  56. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/list_query.py +0 -0
  57. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/list_shapes.py +0 -0
  58. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/middleware/__init__.py +0 -0
  59. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
  60. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
  61. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/middleware/cookies.py +0 -0
  62. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/middleware/idempotency.py +0 -0
  63. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
  64. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  65. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  66. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
  67. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  68. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mux_ws/server.py +0 -0
  69. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  70. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/py.typed +0 -0
  71. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/response_classes.py +0 -0
  72. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/routing.py +0 -0
  73. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  74. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  75. {dynamicforms_fastapi_viewsets-0.14.1 → dynamicforms_fastapi_viewsets-0.15.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
@@ -1,18 +1,14 @@
1
- Metadata-Version: 2.5
1
+ Metadata-Version: 2.4
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.14.1
3
+ Version: 0.15.0
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
- Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
- Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
7
- Project-URL: Issues, https://github.com/dynamicforms/fastapi-viewsets/issues
8
- Project-URL: Documentation, https://docs.velis.si/dynamicforms/fastapi-viewsets/
9
- Author-email: Jure Erznožnik <jure.erznoznik@gmail.com>
10
- License: MIT
5
+ Keywords: fastapi,viewsets,rest,dynamicforms,celery,django-rest-framework
6
+ Author: Jure Erznožnik
7
+ Author-email: Jure Erznožnik <jure.erznoznik@velis.si>
8
+ License-Expression: MIT
11
9
  License-File: LICENSE
12
- Keywords: celery,django-rest-framework,dynamicforms,fastapi,rest,viewsets
13
10
  Classifier: Development Status :: 3 - Alpha
14
11
  Classifier: Intended Audience :: Developers
15
- Classifier: License :: OSI Approved :: MIT License
16
12
  Classifier: Operating System :: OS Independent
17
13
  Classifier: Programming Language :: Python :: 3
18
14
  Classifier: Programming Language :: Python :: 3.10
@@ -21,36 +17,24 @@ Classifier: Programming Language :: Python :: 3.12
21
17
  Classifier: Programming Language :: Python :: 3.13
22
18
  Classifier: Topic :: Internet :: WWW/HTTP
23
19
  Classifier: Typing :: Typed
24
- Requires-Python: >=3.10
25
20
  Requires-Dist: fastapi>=0.100
26
21
  Requires-Dist: pydantic>=2.11
27
22
  Requires-Dist: typing-extensions>=4.10
23
+ Requires-Dist: celery>=5.3 ; extra == 'celery'
24
+ Requires-Dist: redis>=5.0 ; extra == 'celery'
25
+ Requires-Dist: django>=4.2 ; extra == 'django'
26
+ Requires-Dist: asgiref>=3.6 ; extra == 'django'
27
+ Requires-Dist: pyjwt>=2.8 ; extra == 'jwt'
28
+ Requires-Dist: muxws[starlette]>=0.4.2 ; extra == 'muxws'
29
+ Requires-Python: >=3.10
30
+ Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
31
+ Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
32
+ Project-URL: Issues, https://github.com/dynamicforms/fastapi-viewsets/issues
33
+ Project-URL: Documentation, https://docs.velis.si/dynamicforms/fastapi-viewsets/
28
34
  Provides-Extra: celery
29
- Requires-Dist: celery>=5.3; extra == 'celery'
30
- Requires-Dist: redis>=5.0; extra == 'celery'
31
- Provides-Extra: dev
32
- Requires-Dist: asgiref; extra == 'dev'
33
- Requires-Dist: celery; extra == 'dev'
34
- Requires-Dist: coverage; extra == 'dev'
35
- Requires-Dist: django; extra == 'dev'
36
- Requires-Dist: gevent; extra == 'dev'
37
- Requires-Dist: httpx2; extra == 'dev'
38
- Requires-Dist: muxws[starlette,websockets]>=0.4.2; extra == 'dev'
39
- Requires-Dist: pyjwt; extra == 'dev'
40
- Requires-Dist: pytest; extra == 'dev'
41
- Requires-Dist: pytest-asyncio; extra == 'dev'
42
- Requires-Dist: pytest-cov; extra == 'dev'
43
- Requires-Dist: python-multipart; extra == 'dev'
44
- Requires-Dist: redis; extra == 'dev'
45
- Requires-Dist: ruff; extra == 'dev'
46
- Requires-Dist: uvicorn; extra == 'dev'
47
35
  Provides-Extra: django
48
- Requires-Dist: asgiref>=3.6; extra == 'django'
49
- Requires-Dist: django>=4.2; extra == 'django'
50
36
  Provides-Extra: jwt
51
- Requires-Dist: pyjwt>=2.8; extra == 'jwt'
52
37
  Provides-Extra: muxws
53
- Requires-Dist: muxws[starlette]>=0.4.2; extra == 'muxws'
54
38
  Description-Content-Type: text/markdown
55
39
 
56
40
  # DynamicForms FastAPI Viewsets
@@ -1,5 +1,7 @@
1
- __version__ = "0.14.1"
1
+ from importlib.metadata import version
2
2
 
3
3
  from .error_namespace import ERROR_NAMESPACE, ERROR_NAMESPACE_PYDANTIC
4
4
 
5
+ __version__ = version("dynamicforms-fastapi-viewsets")
6
+
5
7
  __all__ = ["ERROR_NAMESPACE", "ERROR_NAMESPACE_PYDANTIC", "__version__"]
@@ -286,13 +286,10 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
286
286
  instance.save()
287
287
  return [self.to_record(rows[pk]) for pk in records]
288
288
 
289
- async def perform_destroy(self, context: Context, pk: K) -> dict:
289
+ async def perform_destroy(self, context: Context, pk: K) -> dict[K, None]:
290
290
  instance = await self._get_or_404(context, pk)
291
291
  await instance.adelete()
292
- return {"id": pk}
293
-
294
- async def perform_bulk_destroy(self, context: Context, pk: list[K]) -> list[dict]:
295
- return [await self.perform_destroy(context, one) for one in pk]
292
+ return {pk: None}
296
293
 
297
294
  # -----------------------------------------------------------------------
298
295
 
@@ -66,17 +66,10 @@ class AsyncCollectionViewSet(
66
66
  results.append(await self.container_update(pk, data, partial))
67
67
  return results
68
68
 
69
- async def perform_destroy(self, _context: Context, pk: K) -> dict[K, Any]:
69
+ async def perform_destroy(self, _context: Context, pk: K) -> dict[K, None]:
70
70
  await self.container_delete(pk)
71
71
  return {pk: None}
72
72
 
73
- async def perform_bulk_destroy(self, _context: Context, pk: list[K]) -> list[dict[K, Any]]:
74
- results = []
75
- for p in pk:
76
- await self.container_delete(p)
77
- results.append({p: None})
78
- return results
79
-
80
73
 
81
74
  class CollectionViewSet(AsyncCollectionViewSet[K, T]):
82
75
  def __init__(self, container: ReadableCollection[T] | MutableCollection[K, T], pk_field: str = "id"):
@@ -241,12 +241,19 @@ class ChangeEvents(Middleware):
241
241
  return name
242
242
 
243
243
  def changes_for(self, execution: ActionExecution, op: str, result: Any) -> list[Change]:
244
- """One change per pk of the `pk` argument for `deleted`, else per returned record (`ViewSetResult.body`)."""
244
+ """
245
+ For `deleted`, one change per pk of the `pk` argument; of a bulk, only the pks the result maps to None
246
+ (destroyed). Otherwise one per returned record. A `ViewSetResult` contributes its `body`.
247
+ """
245
248
  bulk = execution.action in _BULK_ACTIONS
249
+ body = result.body if isinstance(result, ViewSetResult) else result
246
250
  if op == "deleted":
247
251
  pk = _argument(execution, "pk")
248
- return [Change(pk=one) for one in (pk if bulk else [pk])]
249
- body = result.body if isinstance(result, ViewSetResult) else result
252
+ if not bulk:
253
+ return [Change(pk=pk)]
254
+ if isinstance(body, Mapping):
255
+ return [Change(pk=one) for one in dict.fromkeys(pk) if one in body and body[one] is None]
256
+ return [Change(pk=one) for one in pk]
250
257
  field = self.pk_field_for(execution)
251
258
  return [Change(pk=_read_pk(execution, record, field)) for record in (body if bulk else [body])]
252
259
 
@@ -1,10 +1,14 @@
1
+ import functools
2
+
1
3
  from abc import ABC, abstractmethod
2
4
  from enum import Enum
3
5
  from typing import Annotated, Any, final, Generic, get_args, get_origin, Union
4
6
 
5
7
  from fastapi import APIRouter, Header, Query
8
+ from fastapi.exceptions import RequestValidationError
6
9
  from pydantic import BaseModel, ConfigDict, create_model
7
10
  from pydantic.alias_generators import to_camel
11
+ from starlette.exceptions import HTTPException
8
12
  from typing_extensions import TypeVar
9
13
 
10
14
  from fastapi_viewsets.conf import settings
@@ -21,6 +25,7 @@ from fastapi_viewsets.cursor import (
21
25
  make_predicate,
22
26
  position_of,
23
27
  )
28
+ from fastapi_viewsets.error_responses import ApiErrorBody, error_body
24
29
  from fastapi_viewsets.exceptions import CursorRequestError, UnsupportedListShapeError
25
30
  from fastapi_viewsets.filters import filters_from, FilterSet
26
31
  from fastapi_viewsets.list_query import (
@@ -39,6 +44,15 @@ K = TypeVar("K")
39
44
  TFilter = TypeVar("TFilter", default=None)
40
45
 
41
46
 
47
+ @functools.cache
48
+ def _item_type(cls: type) -> Any:
49
+ """The viewset's list item type `T`; `Any` when unbound. A page of it encodes as the declared result."""
50
+ from fastapi_viewsets.decorators.route_viewset import build_type_map
51
+
52
+ item = build_type_map(cls).get(T, Any)
53
+ return Any if isinstance(item, TypeVar) else item
54
+
55
+
42
56
  class ImplMixin(Generic[K, T], ABC):
43
57
  @abstractmethod
44
58
  async def perform_create(self, context: Context, data: T) -> T:
@@ -84,18 +98,29 @@ class ImplMixin(Generic[K, T], ABC):
84
98
  raise NotImplementedError("Method 'perform_bulk_update' must be implemented.")
85
99
 
86
100
  @abstractmethod
87
- async def perform_destroy(self, context: Context, pk: K) -> dict[K, Any]:
101
+ async def perform_destroy(self, context: Context, pk: K) -> dict[K, None]:
88
102
  """
89
- Subclasses should implement this method to perform the actual destruction.
103
+ Destroys record `pk` and returns `{pk: None}`; raises `NotFoundError` for a missing record.
90
104
  """
91
105
  raise NotImplementedError("Method 'perform_destroy' must be implemented.")
92
106
 
93
- @abstractmethod
94
- async def perform_bulk_destroy(self, context: Context, pk: list[K]) -> list[dict[K, Any]]:
107
+ async def perform_bulk_destroy(self, context: Context, pk: list[K]) -> dict[K, ApiErrorBody | None]:
95
108
  """
96
- Subclasses should implement this method to perform the actual bulk destruction.
109
+ Destroys each key with `perform_destroy`, independently of the others: None for a destroyed key, the
110
+ `ApiErrorBody` of an `HTTPException` or `RequestValidationError` its destruction raised. Any other
111
+ exception propagates. A repeated key is destroyed once.
97
112
  """
98
- raise NotImplementedError("Method 'perform_bulk_destroy' must be implemented.")
113
+ results: dict[K, ApiErrorBody | None] = {}
114
+ for one in pk:
115
+ if one in results:
116
+ continue
117
+ try:
118
+ await self.perform_destroy(context, one)
119
+ except (HTTPException, RequestValidationError) as error:
120
+ results[one] = ApiErrorBody.model_validate(error_body(error))
121
+ else:
122
+ results[one] = None
123
+ return results
99
124
 
100
125
 
101
126
  ###################################################################################################
@@ -456,7 +481,7 @@ class ListMixin(Generic[T, TFilter], ABC):
456
481
  has_more_forward = has_more if not backwards else bool(query.cursor)
457
482
  has_previous = bool(query.cursor) if not backwards else has_more
458
483
 
459
- return CursorPage[Any](
484
+ return CursorPage[_item_type(type(self))](
460
485
  results=results,
461
486
  limit=query.limit,
462
487
  has_more=has_more_forward,
@@ -487,7 +512,7 @@ class ListMixin(Generic[T, TFilter], ABC):
487
512
  page, has_more = await self.take_page(query, records)
488
513
  # Already converted if an in-memory stage had to land the source earlier; see land().
489
514
  results = page if not query.needs("conversion") else self._to_records(page)
490
- return PaginatedList[Any](
515
+ return PaginatedList[_item_type(type(self))](
491
516
  results=results,
492
517
  offset=query.offset,
493
518
  limit=query.limit,
@@ -540,7 +565,6 @@ class ListMixin(Generic[T, TFilter], ABC):
540
565
  In-memory sort hook. The default is a stable multi-key sort using the cursor's comparison
541
566
  (`compare_in_order`), so NULL placement matches the cursor's.
542
567
  """
543
- import functools
544
568
 
545
569
  def compare(a: T, b: T) -> int:
546
570
  for col in sort:
@@ -639,14 +663,15 @@ class BulkUpdateMixin(UpdateMixin[K, T], BulkOnlyUpdateMixin[K, T]):
639
663
  ###################################################################################################
640
664
  class DestroyMixin(Generic[K, T], ABC):
641
665
  """
642
- Destroy a model instance. Return the destroyed key and any additional data about its destruction
666
+ Destroy a model instance.
643
667
  """
644
668
 
645
669
  __router = APIRouter()
646
670
 
647
671
  @final
648
672
  @__router.delete("/{pk}", responses=NOT_FOUND_RESPONSE)
649
- async def destroy(self: "ImplMixin[K, T] | DestroyMixin[K, T]", context: Context, pk: K) -> dict[K, Any]:
673
+ async def destroy(self: "ImplMixin[K, T] | DestroyMixin[K, T]", context: Context, pk: K) -> dict[K, None]:
674
+ """Destroy one record. Answers `{pk: null}`; 404 `fastapi-viewsets.not_found` for a missing record."""
650
675
  return await self.perform_destroy(context, pk)
651
676
 
652
677
 
@@ -661,7 +686,12 @@ class BulkOnlyDestroyMixin(Generic[K, T], ABC):
661
686
  @__router.delete("bulk")
662
687
  async def bulk_destroy(
663
688
  self: "ImplMixin[K, T] | BulkOnlyDestroyMixin[K, T]", context: Context, pk: list[K]
664
- ) -> list[dict[K, Any]]:
689
+ ) -> dict[K, ApiErrorBody | None]:
690
+ """
691
+ Destroy the records of the keys in the body, each independently of the others. Answers 200 with an
692
+ object keyed by pk: `null` for a destroyed record, the `ApiErrorBody` of the failure otherwise
693
+ (`fastapi-viewsets.not_found` for a missing record). A failed key does not stop the others.
694
+ """
665
695
  return await self.perform_bulk_destroy(context, pk)
666
696
 
667
697
 
@@ -0,0 +1,169 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.13.6,<0.14"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "dynamicforms-fastapi-viewsets"
7
+ version = "0.15.0"
8
+ description = "Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ keywords = [
14
+ "fastapi",
15
+ "viewsets",
16
+ "rest",
17
+ "dynamicforms",
18
+ "celery",
19
+ "django-rest-framework",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 3 - Alpha",
23
+ "Intended Audience :: Developers",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Internet :: WWW/HTTP",
31
+ "Typing :: Typed",
32
+ ]
33
+ dependencies = [
34
+ "fastapi>=0.100",
35
+ "pydantic>=2.11",
36
+ "typing_extensions>=4.10",
37
+ ]
38
+
39
+ [[project.authors]]
40
+ name = "Jure Erznožnik"
41
+ email = "jure.erznoznik@velis.si"
42
+
43
+ [project.optional-dependencies]
44
+ celery = [
45
+ "celery>=5.3",
46
+ "redis>=5.0",
47
+ ]
48
+ muxws = ["muxws[starlette]>=0.4.2"]
49
+ django = [
50
+ "django>=4.2",
51
+ "asgiref>=3.6",
52
+ ]
53
+ jwt = ["PyJWT>=2.8"]
54
+
55
+ [project.urls]
56
+ Homepage = "https://github.com/dynamicforms/fastapi-viewsets"
57
+ Repository = "https://github.com/dynamicforms/fastapi-viewsets"
58
+ Issues = "https://github.com/dynamicforms/fastapi-viewsets/issues"
59
+ Documentation = "https://docs.velis.si/dynamicforms/fastapi-viewsets/"
60
+
61
+ [dependency-groups]
62
+ dev = [
63
+ "coverage",
64
+ "pytest",
65
+ "pytest-asyncio",
66
+ "pytest-cov",
67
+ "httpx2",
68
+ "python-multipart",
69
+ "redis",
70
+ "celery",
71
+ "gevent",
72
+ "django",
73
+ "asgiref",
74
+ "PyJWT",
75
+ "muxws[starlette,websockets]>=0.4.2",
76
+ "ruff",
77
+ "uvicorn",
78
+ ]
79
+
80
+ [tool.uv.build-backend]
81
+ module-name = "fastapi_viewsets"
82
+ module-root = ""
83
+ source-include = ["LICENSE"]
84
+ source-exclude = ["**/*_test.py"]
85
+
86
+ [tool.publish]
87
+ tests = "pytest"
88
+
89
+ [tool.ruff]
90
+ exclude = [
91
+ "dockertest",
92
+ "tests",
93
+ "docs",
94
+ "examples",
95
+ "scripts",
96
+ "setup.py",
97
+ "setup.cfg",
98
+ "README.md",
99
+ "LICENSE",
100
+ "MANIFEST.in",
101
+ "pyproject.toml",
102
+ "Pipfile",
103
+ "Pipfile.lock",
104
+ "ruff.egg-info",
105
+ "dist",
106
+ "build",
107
+ ".gitignore",
108
+ ".git",
109
+ ".idea",
110
+ ".vscode",
111
+ ".pytest_cache",
112
+ ".coverage",
113
+ ".ruff",
114
+ ".venv",
115
+ "*/migrations/*",
116
+ ]
117
+ line-length = 120
118
+
119
+ [tool.ruff.lint]
120
+ select = [
121
+ "E",
122
+ "W",
123
+ "F",
124
+ "I",
125
+ "Q",
126
+ "B",
127
+ "UP",
128
+ "N",
129
+ "PT",
130
+ "S",
131
+ "C4",
132
+ "ARG",
133
+ ]
134
+ ignore = [
135
+ "E731",
136
+ "E722",
137
+ "C408",
138
+ ]
139
+ fixable = ["ALL"]
140
+ unfixable = []
141
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
142
+
143
+ [tool.ruff.lint.per-file-ignores]
144
+ "*_test.py" = ["S101"]
145
+
146
+ [tool.ruff.lint.isort]
147
+ case-sensitive = false
148
+ lines-between-types = 1
149
+ combine-as-imports = true
150
+ order-by-type = false
151
+
152
+ [tool.ruff.format]
153
+ quote-style = "double"
154
+ indent-style = "space"
155
+ skip-magic-trailing-comma = false
156
+ line-ending = "auto"
157
+
158
+ [tool.pytest.ini_options]
159
+ markers = ["celery_worker: runs a real Celery worker against the Redis named by FASTAPI_VIEWSETS_TEST_REDIS_URL"]
160
+ filterwarnings = ["ignore:.*endpoints are unreachable:UserWarning"]
161
+
162
+ [tool.coverage.run]
163
+ omit = [
164
+ "demo/*",
165
+ "demo/**/*",
166
+ "demo.py",
167
+ "test_*.py",
168
+ "*_test.py",
169
+ ]
@@ -1,22 +1,22 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
2
+ requires = ["uv_build>=0.13.6,<0.14"]
3
+ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "dynamicforms-fastapi-viewsets"
7
- dynamic = ["version"]
7
+ version = "0.15.0"
8
8
  description = "Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart."
9
9
  readme = "README.md"
10
- license = { text = "MIT" }
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
11
12
  authors = [
12
- { name = "Jure Erznožnik", email = "jure.erznoznik@gmail.com" },
13
+ { name = "Jure Erznožnik", email = "jure.erznoznik@velis.si" },
13
14
  ]
14
15
  requires-python = ">=3.10"
15
16
  keywords = ["fastapi", "viewsets", "rest", "dynamicforms", "celery", "django-rest-framework"]
16
17
  classifiers = [
17
18
  "Development Status :: 3 - Alpha",
18
19
  "Intended Audience :: Developers",
19
- "License :: OSI Approved :: MIT License",
20
20
  "Operating System :: OS Independent",
21
21
  "Programming Language :: Python :: 3",
22
22
  "Programming Language :: Python :: 3.10",
@@ -37,6 +37,8 @@ celery = ["celery>=5.3", "redis>=5.0"]
37
37
  muxws = ["muxws[starlette]>=0.4.2"]
38
38
  django = ["django>=4.2", "asgiref>=3.6"]
39
39
  jwt = ["PyJWT>=2.8"]
40
+
41
+ [dependency-groups]
40
42
  dev = [
41
43
  "coverage",
42
44
  "pytest",
@@ -61,21 +63,11 @@ Repository = "https://github.com/dynamicforms/fastapi-viewsets"
61
63
  Issues = "https://github.com/dynamicforms/fastapi-viewsets/issues"
62
64
  Documentation = "https://docs.velis.si/dynamicforms/fastapi-viewsets/"
63
65
 
64
- [tool.hatch.version]
65
- path = "fastapi_viewsets/__init__.py"
66
-
67
- [tool.hatch.build.targets.wheel]
68
- packages = ["fastapi_viewsets"]
69
-
70
- [tool.hatch.build.targets.sdist]
71
- include = [
72
- "/fastapi_viewsets",
73
- "/README.md",
74
- "/LICENSE",
75
- ]
76
- exclude = [
77
- "**/*_test.py",
78
- ]
66
+ [tool.uv.build-backend]
67
+ module-name = "fastapi_viewsets"
68
+ module-root = ""
69
+ source-include = ["LICENSE"]
70
+ source-exclude = ["**/*_test.py"]
79
71
 
80
72
  [tool.publish]
81
73
  tests = "pytest"
@@ -84,7 +76,7 @@ tests = "pytest"
84
76
  exclude = ['dockertest',
85
77
  'tests', 'docs', 'examples', 'scripts', 'setup.py', 'setup.cfg', 'README.md', 'LICENSE', 'MANIFEST.in',
86
78
  'pyproject.toml', 'Pipfile', 'Pipfile.lock', 'ruff.egg-info', 'dist', 'build', '.gitignore', '.git', '.idea',
87
- '.vscode', '.pytest_cache', '.coverage', '.ruff', '*/migrations/*']
79
+ '.vscode', '.pytest_cache', '.coverage', '.ruff', '.venv', '*/migrations/*']
88
80
  line-length = 120
89
81
 
90
82
  [tool.ruff.lint]
@@ -1,21 +0,0 @@
1
- .idea
2
- node_modules
3
- coverage
4
- dist
5
- /demo/test-results
6
- /demo/backend/demo.sqlite3
7
-
8
- /celerybeat-schedule.db
9
- /demo/backend/demo.sqlite3
10
-
11
- /docs/.vitepress/cache
12
-
13
- __pycache__/
14
- *.py[cod]
15
- *.egg-info/
16
- .eggs/
17
- build/
18
- .pytest_cache/
19
- .ruff_cache/
20
- .coverage
21
- coverage.json