dynamicforms-fastapi-viewsets 0.1.0__py3-none-any.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.
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/METADATA +105 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/RECORD +22 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/WHEEL +4 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_viewsets/__init__.py +1 -0
- fastapi_viewsets/celery_viewset.py +50 -0
- fastapi_viewsets/collection_viewset.py +216 -0
- fastapi_viewsets/decorators/__init__.py +2 -0
- fastapi_viewsets/decorators/build_schema.py +161 -0
- fastapi_viewsets/decorators/celery_viewset/__init__.py +51 -0
- fastapi_viewsets/decorators/celery_viewset/client.py +88 -0
- fastapi_viewsets/decorators/celery_viewset/result_reader.py +99 -0
- fastapi_viewsets/decorators/celery_viewset/server.py +138 -0
- fastapi_viewsets/decorators/lifecycle_runner.py +64 -0
- fastapi_viewsets/decorators/primary_key_model_helper.py +33 -0
- fastapi_viewsets/decorators/route_viewset.py +216 -0
- fastapi_viewsets/mixins.py +476 -0
- fastapi_viewsets/py.typed +0 -0
- fastapi_viewsets/response_classes.py +15 -0
- fastapi_viewsets/save_state/__init__.py +0 -0
- fastapi_viewsets/save_state/save_state.py +36 -0
- fastapi_viewsets/save_state/serialize_state.py +30 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dynamicforms-fastapi-viewsets
|
|
3
|
+
Version: 0.1.0
|
|
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
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: celery,django-rest-framework,dynamicforms,fastapi,rest,viewsets
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: fastapi>=0.100
|
|
26
|
+
Requires-Dist: pydantic>=2.1
|
|
27
|
+
Requires-Dist: typing-extensions>=4.10
|
|
28
|
+
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: celery; extra == 'dev'
|
|
33
|
+
Requires-Dist: coverage; extra == 'dev'
|
|
34
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
38
|
+
Requires-Dist: redis; extra == 'dev'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# DynamicForms FastAPI Viewsets
|
|
42
|
+
|
|
43
|
+
Django REST Framework-style viewsets for [FastAPI](https://fastapi.tiangolo.com/), with optional
|
|
44
|
+
Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
45
|
+
|
|
46
|
+
- **Python mixins for FastAPI** — compose CRUD and bulk endpoints from small, focused mixin classes.
|
|
47
|
+
- **`route_viewset` decorator** — register a viewset on a FastAPI router with a single decorator call.
|
|
48
|
+
Handles type resolution, lifecycle management and OpenAPI schema automatically.
|
|
49
|
+
- **`CollectionViewSet`** — zero-boilerplate in-memory viewset backed by any Python list, set or dict.
|
|
50
|
+
Great for prototyping and testing.
|
|
51
|
+
- **`CeleryViewSet`** — delegate all CRUD operations to Celery tasks, for long-running or background
|
|
52
|
+
processing scenarios (requires the `celery` extra).
|
|
53
|
+
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
54
|
+
- **Vue / TypeScript counterpart** — mirror mixin classes and a `route_rest` factory give you a fully
|
|
55
|
+
typed HTTP client that matches your backend viewset exactly (published separately as
|
|
56
|
+
[`@dynamicforms/fastapi-viewsets`](https://www.npmjs.com/package/@dynamicforms/fastapi-viewsets) on npm).
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install dynamicforms-fastapi-viewsets
|
|
62
|
+
|
|
63
|
+
# with Celery-backed viewset support
|
|
64
|
+
pip install "dynamicforms-fastapi-viewsets[celery]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires Python 3.10+, FastAPI and Pydantic v2.
|
|
68
|
+
|
|
69
|
+
## Quick example
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi import APIRouter, FastAPI
|
|
73
|
+
from pydantic import BaseModel
|
|
74
|
+
|
|
75
|
+
from fastapi_viewsets.collection_viewset import CollectionViewSet
|
|
76
|
+
from fastapi_viewsets.decorators.route_viewset import route_viewset
|
|
77
|
+
from fastapi_viewsets.mixins import BulkViewSetMixin
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Item(BaseModel):
|
|
81
|
+
id: int
|
|
82
|
+
name: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
database: dict[int, Item] = {1: Item(id=1, name="First element")}
|
|
86
|
+
|
|
87
|
+
app = FastAPI()
|
|
88
|
+
router = APIRouter()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@route_viewset(router, base_path="/items", pk_field_name="id")
|
|
92
|
+
class ItemViewSet(CollectionViewSet[int, Item], BulkViewSetMixin[int, Item]):
|
|
93
|
+
def __init__(self):
|
|
94
|
+
super().__init__(container=database, pk_field="id")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
app.include_router(router)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
101
|
+
the mixin system, `route_viewset`, `CollectionViewSet`, `CeleryViewSet`, and the Vue client.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
fastapi_viewsets/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
fastapi_viewsets/celery_viewset.py,sha256=RuUSWg7yYFXN5XMYyo1fVhGFd1B5EnlpXdkkY8fUSCA,1805
|
|
3
|
+
fastapi_viewsets/collection_viewset.py,sha256=zDZeYfKmFv0ITaZTkBcFzBrx2ogLZkhvuO_njqAym2k,7809
|
|
4
|
+
fastapi_viewsets/mixins.py,sha256=1xymX80V36KrQo1HowexxBYQBn9d3DM0sjSgOSl3mYM,17175
|
|
5
|
+
fastapi_viewsets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
fastapi_viewsets/response_classes.py,sha256=a6ijtl4fv218K04hZ2EyNWUVkhRKpZsTevdpcPwTfPI,573
|
|
7
|
+
fastapi_viewsets/decorators/__init__.py,sha256=7XVRyUuwFBIWtkTJeJfSUdM684oKlK8SSgUTSqZ7ga0,152
|
|
8
|
+
fastapi_viewsets/decorators/build_schema.py,sha256=0gsXTe6nGMOgVNVfT7RD0jA8ghvaePEZo6-KSEaqiLE,6497
|
|
9
|
+
fastapi_viewsets/decorators/lifecycle_runner.py,sha256=SK2l6C6tkEjUgTqbBQFLJ29xBkxIjcxd7A-EiAFaySU,2739
|
|
10
|
+
fastapi_viewsets/decorators/primary_key_model_helper.py,sha256=GzcNUEa1_xEWR5nefNd0i_LEBdAJCeluMvTeeGv5m4Q,1400
|
|
11
|
+
fastapi_viewsets/decorators/route_viewset.py,sha256=D8Y8yFISQUwBSL6AEyW0pF3BKcDVuFPL6K3yJz2GpvU,9409
|
|
12
|
+
fastapi_viewsets/decorators/celery_viewset/__init__.py,sha256=Dg5bhnmRU22OhDWRl8vTEm2kNUObu9ioY5380np6c5g,1844
|
|
13
|
+
fastapi_viewsets/decorators/celery_viewset/client.py,sha256=qHRM67ZmVK_e0JeZws3HditR4VqAvws12mvxg5yYZU0,2687
|
|
14
|
+
fastapi_viewsets/decorators/celery_viewset/result_reader.py,sha256=-e7Ek83zAKtaqZc8zNLEQXMb91vUxaLD5xGbzx9uVo4,3440
|
|
15
|
+
fastapi_viewsets/decorators/celery_viewset/server.py,sha256=MqLM4kfPZjV6xYO-6N2APZ3tuMYGH9hBKr0V7JKT7MA,5180
|
|
16
|
+
fastapi_viewsets/save_state/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
fastapi_viewsets/save_state/save_state.py,sha256=7MNSLlbzTHNNVwh8UCBh254rCAfgEbsb0pjWGs8CcPE,1041
|
|
18
|
+
fastapi_viewsets/save_state/serialize_state.py,sha256=B4sVAs34NxXOngXIQl539wDc6hxv6DsUL64N85ckcv4,892
|
|
19
|
+
dynamicforms_fastapi_viewsets-0.1.0.dist-info/METADATA,sha256=xqJIg-q7GvCwY40g7MrkrKLWCuq05PtW_ddCNL6kIIs,4119
|
|
20
|
+
dynamicforms_fastapi_viewsets-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
21
|
+
dynamicforms_fastapi_viewsets-0.1.0.dist-info/licenses/LICENSE,sha256=FAkqLxu7p_qANzmRKPino1JsRH7jqPAIAU6wwF22Qko,1072
|
|
22
|
+
dynamicforms_fastapi_viewsets-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jure Erznožnik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Any, Generic, TYPE_CHECKING, TypeVar
|
|
2
|
+
|
|
3
|
+
from .mixins import BulkViewSetMixin
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from celery import Celery
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
K = TypeVar("K")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CeleryViewSet(BulkViewSetMixin[K, T], Generic[K, T]):
|
|
14
|
+
def __init__(self, celery: "Celery", task_prefix: str):
|
|
15
|
+
self.celery = celery
|
|
16
|
+
self.task_prefix = task_prefix
|
|
17
|
+
|
|
18
|
+
async def perform_create(self, data):
|
|
19
|
+
result = self.celery.send_task(f"{self.task_prefix}.create", args=[data])
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
async def perform_bulk_create(self, data: list):
|
|
23
|
+
result = self.celery.send_task(f"{self.task_prefix}.bulk_create", args=[data])
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
async def perform_list(self):
|
|
27
|
+
result = self.celery.send_task(f"{self.task_prefix}.list")
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
async def perform_retrieve(self, pk):
|
|
31
|
+
result = self.celery.send_task(f"{self.task_prefix}.retrieve", args=[pk])
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
async def perform_update(self, pk, data, partial: bool = True):
|
|
35
|
+
task_name = "partial_update" if partial else "update"
|
|
36
|
+
result = self.celery.send_task(f"{self.task_prefix}.{task_name}", args=[pk, data])
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
async def perform_bulk_update(self, records: dict, partial: bool = True):
|
|
40
|
+
task_name = "bulk_partial_update" if partial else "bulk_update"
|
|
41
|
+
result = self.celery.send_task(f"{self.task_prefix}.{task_name}", args=[records])
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
async def perform_destroy(self, pk) -> dict[Any, Any]:
|
|
45
|
+
result = self.celery.send_task(f"{self.task_prefix}.destroy", args=[pk])
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
async def perform_bulk_destroy(self, pk: list) -> list[dict[Any, Any]]:
|
|
49
|
+
result = self.celery.send_task(f"{self.task_prefix}.bulk_destroy", args=[pk])
|
|
50
|
+
return result
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Callable, Iterable, MutableMapping, MutableSequence, MutableSet
|
|
3
|
+
from typing import Any, Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from .mixins import ImplMixin
|
|
8
|
+
from .response_classes import NotFoundError
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
K = TypeVar("K")
|
|
12
|
+
|
|
13
|
+
ReadableCollection = Iterable[T] # list/retrieve only
|
|
14
|
+
MutableCollection = MutableSequence[T] | MutableSet[T] | MutableMapping[K, T] # + create/update/delete
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncCollectionViewSet(
|
|
18
|
+
Generic[K, T],
|
|
19
|
+
ImplMixin[K, T],
|
|
20
|
+
ABC,
|
|
21
|
+
):
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def container_list(self) -> list[T]:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def container_retrieve(self, pk: K) -> T:
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def container_add(self, data: T) -> T:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def container_update(self, pk: K, data: T, partial: bool) -> T:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def container_delete(self, pk: K) -> None:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
async def perform_list(self) -> list[T]:
|
|
43
|
+
return await self.container_list()
|
|
44
|
+
|
|
45
|
+
async def perform_retrieve(self, pk: K) -> T:
|
|
46
|
+
return await self.container_retrieve(pk)
|
|
47
|
+
|
|
48
|
+
async def perform_create(self, data: T) -> T:
|
|
49
|
+
return await self.container_add(data)
|
|
50
|
+
|
|
51
|
+
async def perform_bulk_create(self, data: list[T]) -> list[T]:
|
|
52
|
+
results = []
|
|
53
|
+
for item in data:
|
|
54
|
+
results.append(await self.container_add(item))
|
|
55
|
+
return results
|
|
56
|
+
|
|
57
|
+
async def perform_update(self, pk: K, data: T, partial: bool = True) -> T:
|
|
58
|
+
return await self.container_update(pk, data, partial)
|
|
59
|
+
|
|
60
|
+
async def perform_bulk_update(self, records: dict[K, T], partial: bool = True) -> list[T]:
|
|
61
|
+
results = []
|
|
62
|
+
for pk, data in records.items():
|
|
63
|
+
results.append(await self.container_update(pk, data, partial))
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
async def perform_destroy(self, pk: K) -> dict[K, Any]:
|
|
67
|
+
await self.container_delete(pk)
|
|
68
|
+
return {pk: None}
|
|
69
|
+
|
|
70
|
+
async def perform_bulk_destroy(self, pk: list[K]) -> list[dict[K, Any]]:
|
|
71
|
+
results = []
|
|
72
|
+
for p in pk:
|
|
73
|
+
await self.container_delete(p)
|
|
74
|
+
results.append({p: None})
|
|
75
|
+
return results
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CollectionViewSet(AsyncCollectionViewSet[K, T]):
|
|
79
|
+
def __init__(self, container: ReadableCollection[T] | MutableCollection[K, T], pk_field: str = "id"):
|
|
80
|
+
self.container = container
|
|
81
|
+
self.pk_field = pk_field
|
|
82
|
+
self.is_mutable = isinstance(container, (MutableSequence, MutableSet, MutableMapping))
|
|
83
|
+
|
|
84
|
+
self._add_func: Callable | None = None
|
|
85
|
+
self._update_func: Callable | None = None
|
|
86
|
+
self._delete_func: Callable | None = None
|
|
87
|
+
|
|
88
|
+
if self.is_mutable:
|
|
89
|
+
if isinstance(container, MutableSequence):
|
|
90
|
+
self._add_func = container.append
|
|
91
|
+
self._update_func = self._update_sequence
|
|
92
|
+
self._delete_func = self._delete_sequence
|
|
93
|
+
elif isinstance(container, MutableSet):
|
|
94
|
+
self._add_func = container.add
|
|
95
|
+
self._update_func = self._update_set
|
|
96
|
+
self._delete_func = container.remove
|
|
97
|
+
elif isinstance(container, MutableMapping):
|
|
98
|
+
self._add_func = self._add_mapping
|
|
99
|
+
self._update_func = self._update_mapping
|
|
100
|
+
self._delete_func = container.__delitem__
|
|
101
|
+
|
|
102
|
+
def _check_mutable(self):
|
|
103
|
+
if not self.is_mutable:
|
|
104
|
+
raise Exception("Provided container is not mutable")
|
|
105
|
+
|
|
106
|
+
def _get_pk(self, item: T) -> K:
|
|
107
|
+
if isinstance(item, dict):
|
|
108
|
+
return item[self.pk_field]
|
|
109
|
+
return getattr(item, self.pk_field)
|
|
110
|
+
|
|
111
|
+
# Sequence helpers
|
|
112
|
+
def _update_sequence(self, pk: K, data: T, partial: bool):
|
|
113
|
+
for i, item in enumerate(self.container):
|
|
114
|
+
if self._get_pk(item) == pk:
|
|
115
|
+
if partial:
|
|
116
|
+
if isinstance(item, dict):
|
|
117
|
+
item.update(data)
|
|
118
|
+
else:
|
|
119
|
+
for key, value in data.items():
|
|
120
|
+
setattr(item, key, value)
|
|
121
|
+
else:
|
|
122
|
+
self.container[i] = data
|
|
123
|
+
return self.container[i]
|
|
124
|
+
raise NotFoundError(pk)
|
|
125
|
+
|
|
126
|
+
def _delete_sequence(self, pk: K):
|
|
127
|
+
for i, item in enumerate(self.container):
|
|
128
|
+
if self._get_pk(item) == pk:
|
|
129
|
+
del self.container[i]
|
|
130
|
+
return
|
|
131
|
+
raise NotFoundError(pk)
|
|
132
|
+
|
|
133
|
+
# Set helpers
|
|
134
|
+
def _update_set(self, pk: K, data: T, partial: bool):
|
|
135
|
+
for item in self.container:
|
|
136
|
+
if self._get_pk(item) == pk:
|
|
137
|
+
self.container.remove(item)
|
|
138
|
+
self.container.add(data) # Sets don't really support partial update easily without mutation
|
|
139
|
+
return data
|
|
140
|
+
raise NotFoundError(pk)
|
|
141
|
+
|
|
142
|
+
# Mapping helpers
|
|
143
|
+
def _add_mapping(self, data: T):
|
|
144
|
+
pk = self._get_pk(data)
|
|
145
|
+
self.container[pk] = data
|
|
146
|
+
|
|
147
|
+
def _update_mapping(self, pk: K, data: T, partial: bool):
|
|
148
|
+
if pk not in self.container:
|
|
149
|
+
raise NotFoundError(pk)
|
|
150
|
+
if partial:
|
|
151
|
+
item = self.container[pk]
|
|
152
|
+
if isinstance(item, dict):
|
|
153
|
+
item.update(data)
|
|
154
|
+
else:
|
|
155
|
+
for key, value in data.items():
|
|
156
|
+
setattr(item, key, value)
|
|
157
|
+
else:
|
|
158
|
+
self.container[pk] = data
|
|
159
|
+
return self.container[pk]
|
|
160
|
+
|
|
161
|
+
async def container_list(self) -> list[T]:
|
|
162
|
+
if isinstance(self.container, MutableMapping):
|
|
163
|
+
return list(self.container.values())
|
|
164
|
+
return list(self.container)
|
|
165
|
+
|
|
166
|
+
async def container_retrieve(self, pk: K) -> T:
|
|
167
|
+
if isinstance(self.container, MutableMapping):
|
|
168
|
+
if pk in self.container:
|
|
169
|
+
return self.container[pk]
|
|
170
|
+
else:
|
|
171
|
+
for item in self.container:
|
|
172
|
+
if self._get_pk(item) == pk:
|
|
173
|
+
return item
|
|
174
|
+
raise NotFoundError(pk)
|
|
175
|
+
|
|
176
|
+
async def container_add(self, data: T) -> T:
|
|
177
|
+
self._check_mutable()
|
|
178
|
+
self._handle_autoinc(data)
|
|
179
|
+
self._add_func(data)
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
def _handle_autoinc(self, data: T):
|
|
183
|
+
if not isinstance(data, BaseModel):
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
for field_name, field_info in data.__class__.model_fields.items():
|
|
187
|
+
if field_info.json_schema_extra and field_info.json_schema_extra.get("autoinc_int"):
|
|
188
|
+
# If the field is already set and is not 0 or None, we leave it (unless this is the desired behavior)
|
|
189
|
+
# However, we usually want autoinc only if the value is not yet set
|
|
190
|
+
current_val = getattr(data, field_name, None)
|
|
191
|
+
if current_val:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# We find the max value in the container
|
|
195
|
+
max_val = 0
|
|
196
|
+
items = self.container.values() if isinstance(self.container, MutableMapping) else self.container
|
|
197
|
+
for item in items:
|
|
198
|
+
try:
|
|
199
|
+
val = item[field_name] if isinstance(item, dict) else getattr(item, field_name, 0)
|
|
200
|
+
if isinstance(val, int) and val > max_val:
|
|
201
|
+
max_val = val
|
|
202
|
+
except (KeyError, AttributeError):
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
setattr(data, field_name, max_val + 1)
|
|
206
|
+
|
|
207
|
+
async def container_update(self, pk: K, data: T, partial: bool) -> T:
|
|
208
|
+
self._check_mutable()
|
|
209
|
+
return self._update_func(pk, data, partial)
|
|
210
|
+
|
|
211
|
+
async def container_delete(self, pk: K) -> None:
|
|
212
|
+
self._check_mutable()
|
|
213
|
+
try:
|
|
214
|
+
self._delete_func(pk)
|
|
215
|
+
except KeyError as e:
|
|
216
|
+
raise NotFoundError(pk) from e
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
from typing import get_args, get_origin, TypeVar
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, FastAPI
|
|
6
|
+
from fastapi.routing import APIRoute
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def route_to_add_api_route_kwargs(route: APIRoute, **kwargs) -> dict:
|
|
10
|
+
"""
|
|
11
|
+
Maps an APIRoute instance to a dict of kwargs suitable for add_api_route().
|
|
12
|
+
Any keyword arguments passed will override the corresponding route attributes.
|
|
13
|
+
"""
|
|
14
|
+
result = dict(
|
|
15
|
+
path=route.path,
|
|
16
|
+
endpoint=route.endpoint,
|
|
17
|
+
response_model=route.response_model,
|
|
18
|
+
status_code=route.status_code,
|
|
19
|
+
tags=route.tags,
|
|
20
|
+
dependencies=route.dependencies,
|
|
21
|
+
summary=route.summary,
|
|
22
|
+
description=route.description,
|
|
23
|
+
response_description=route.response_description,
|
|
24
|
+
responses=route.responses,
|
|
25
|
+
deprecated=route.deprecated,
|
|
26
|
+
methods=route.methods,
|
|
27
|
+
operation_id=route.operation_id,
|
|
28
|
+
include_in_schema=route.include_in_schema,
|
|
29
|
+
response_class=route.response_class,
|
|
30
|
+
name=route.name,
|
|
31
|
+
callbacks=route.callbacks,
|
|
32
|
+
openapi_extra=route.openapi_extra,
|
|
33
|
+
generate_unique_id_function=route.generate_unique_id_function,
|
|
34
|
+
)
|
|
35
|
+
unknown_keys = set(kwargs) - set(result)
|
|
36
|
+
if unknown_keys:
|
|
37
|
+
warnings.warn(
|
|
38
|
+
f"route_to_add_api_route_kwargs() received unknown kwargs: {unknown_keys}",
|
|
39
|
+
stacklevel=2,
|
|
40
|
+
)
|
|
41
|
+
result.update(kwargs)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_schema(cls, base_path: str = "", default_tags=None, get_wrapper=None, disable_response_model=False):
|
|
46
|
+
"""
|
|
47
|
+
Builds __router (APIRouter with all routes from mixins) and __app (FastAPI instance),
|
|
48
|
+
and attaches them to the class. Idempotent: if already called, returns immediately.
|
|
49
|
+
If get_wrapper is None, only __router is built (no FastAPI app, no endpoint wrapping).
|
|
50
|
+
|
|
51
|
+
disable_response_model: pass True when the class defines `finalize_response` (see
|
|
52
|
+
route_viewset.py) - that hook can change the shape of what's actually served (e.g. strip an
|
|
53
|
+
internal-only field), so FastAPI must not coerce/re-validate the endpoint's return value
|
|
54
|
+
against the ORIGINAL method's declared return type, which would silently undo the hook's
|
|
55
|
+
changes (missing fields reappear with their model defaults).
|
|
56
|
+
"""
|
|
57
|
+
if hasattr(cls, "__router") and (get_wrapper is None or getattr(cls, "__router_full", False)):
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
celery_mode = get_wrapper is None
|
|
61
|
+
# Use a dict so that more-specific classes (visited later in reversed MRO) override
|
|
62
|
+
# routes from less-specific base classes. This allows e.g. FilterableMixin to
|
|
63
|
+
# replace ListMixin's plain GET "" route with one that carries filter query params.
|
|
64
|
+
route_registry: dict[tuple, APIRoute] = {}
|
|
65
|
+
|
|
66
|
+
for base in reversed(cls.__mro__):
|
|
67
|
+
mangled_name = f"_{base.__name__}__router"
|
|
68
|
+
mixin_router = getattr(base, mangled_name, None)
|
|
69
|
+
if mixin_router and isinstance(mixin_router, APIRouter):
|
|
70
|
+
for route in mixin_router.routes:
|
|
71
|
+
if not isinstance(route, APIRoute):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
full_path = f"{base_path.rstrip('/')}/{route.path.lstrip('/')}".rstrip("/")
|
|
75
|
+
if not full_path:
|
|
76
|
+
full_path = "/"
|
|
77
|
+
|
|
78
|
+
route_key = (tuple(sorted(route.methods)), full_path)
|
|
79
|
+
route_registry[route_key] = route # last (most specific) wins
|
|
80
|
+
|
|
81
|
+
all_routes = list(route_registry.values())
|
|
82
|
+
|
|
83
|
+
class_router = APIRouter()
|
|
84
|
+
|
|
85
|
+
if not celery_mode:
|
|
86
|
+
app = FastAPI()
|
|
87
|
+
|
|
88
|
+
def schema():
|
|
89
|
+
return app.openapi()
|
|
90
|
+
|
|
91
|
+
schema_route_kwargs = dict(
|
|
92
|
+
path=f"{base_path.rstrip('/')}/schema",
|
|
93
|
+
endpoint=schema,
|
|
94
|
+
tags=default_tags,
|
|
95
|
+
methods=["GET"],
|
|
96
|
+
description="Returns OpenAPI schema for the ViewSet",
|
|
97
|
+
)
|
|
98
|
+
class_router.add_api_route(**schema_route_kwargs)
|
|
99
|
+
|
|
100
|
+
for route in sorted(all_routes, key=route_sort_key, reverse=True):
|
|
101
|
+
full_path = f"{base_path.rstrip('/')}/{route.path.lstrip('/')}".rstrip("/")
|
|
102
|
+
if not full_path:
|
|
103
|
+
full_path = "/"
|
|
104
|
+
|
|
105
|
+
endpoint_wrapper, resolved_response_model = get_wrapper(route.endpoint, route.path, route.methods)
|
|
106
|
+
|
|
107
|
+
if disable_response_model:
|
|
108
|
+
response_model = None
|
|
109
|
+
else:
|
|
110
|
+
use_resolved = (
|
|
111
|
+
route.response_model is None or isinstance(route.response_model, TypeVar) or
|
|
112
|
+
(get_origin(route.response_model) and
|
|
113
|
+
any(isinstance(arg, TypeVar) for arg in get_args(route.response_model)))
|
|
114
|
+
)
|
|
115
|
+
response_model = resolved_response_model if use_resolved else route.response_model
|
|
116
|
+
|
|
117
|
+
class_router.add_api_route(**route_to_add_api_route_kwargs(
|
|
118
|
+
route,
|
|
119
|
+
path=full_path,
|
|
120
|
+
endpoint=endpoint_wrapper,
|
|
121
|
+
response_model=response_model,
|
|
122
|
+
tags=route.tags or default_tags,
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
app.include_router(class_router)
|
|
126
|
+
cls.__app = app
|
|
127
|
+
else:
|
|
128
|
+
# celery mode: store raw mixin routes directly, no FastAPI wrapping
|
|
129
|
+
for route in all_routes:
|
|
130
|
+
class_router.routes.append(route)
|
|
131
|
+
|
|
132
|
+
cls.__router = class_router
|
|
133
|
+
if not celery_mode:
|
|
134
|
+
cls.__router_full = True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def route_sort_key(route: APIRoute):
|
|
138
|
+
path = route.path.strip("/")
|
|
139
|
+
methods = route.methods or set()
|
|
140
|
+
|
|
141
|
+
method_order = ["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
142
|
+
method_idx = min((method_order.index(m) for m in methods if m in method_order), default=10)
|
|
143
|
+
|
|
144
|
+
def segment_key(segment: str):
|
|
145
|
+
if segment == "":
|
|
146
|
+
return 0, ""
|
|
147
|
+
elif segment.startswith("{"):
|
|
148
|
+
return 1, segment
|
|
149
|
+
else:
|
|
150
|
+
return 2, segment
|
|
151
|
+
|
|
152
|
+
path_idx = tuple(segment_key(s) for s in path.split("/"))
|
|
153
|
+
|
|
154
|
+
# NOT flattened via `*path_idx, method_idx` - paths of different depths (e.g.
|
|
155
|
+
# "account/register" vs "account/register/verify/resend", a real case for custom,
|
|
156
|
+
# non-CRUD viewset actions) would then put `method_idx` (a bare int) at the same tuple
|
|
157
|
+
# position where a LONGER path still has a (int, str) segment tuple, and `sorted()` would
|
|
158
|
+
# raise `TypeError: '<' not supported between instances of 'int' and 'tuple'`. Keeping
|
|
159
|
+
# `path_idx` nested means every element at every depth is uniformly a 2-tuple - a shorter
|
|
160
|
+
# path_idx that is a prefix of a longer one compares as "less than" it with no type clash.
|
|
161
|
+
return path_idx, method_idx
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from ..lifecycle_runner import LifecycleType
|
|
4
|
+
from .client import celery_viewset_client
|
|
5
|
+
from .result_reader import get_result_queue_key, push_result, start_result_reader, stop_result_reader
|
|
6
|
+
from .server import celery_viewset_server
|
|
7
|
+
|
|
8
|
+
# Allow explicit override via set_is_celery_worker()
|
|
9
|
+
_is_celery_worker: bool | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def set_is_celery_worker(is_worker: bool) -> None:
|
|
13
|
+
"""Explicitly set whether we are running in a Celery worker context."""
|
|
14
|
+
global _is_celery_worker
|
|
15
|
+
_is_celery_worker = is_worker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _detect_is_celery_worker() -> bool:
|
|
19
|
+
"""Auto-detect if we are running inside a Celery worker by inspecting sys.argv."""
|
|
20
|
+
if _is_celery_worker is not None:
|
|
21
|
+
return _is_celery_worker
|
|
22
|
+
return len(sys.argv) > 0 and "celery" in sys.argv[0].lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def celery_viewset(celery_app, task_prefix: str, lifecycle: LifecycleType = "singleton", redis_client=None):
|
|
26
|
+
"""
|
|
27
|
+
Convenience decorator that auto-detects whether to use client or server mode.
|
|
28
|
+
|
|
29
|
+
- In a Celery worker (detected via sys.argv or set_is_celery_worker): uses celery_viewset_server
|
|
30
|
+
- In a FastAPI app: uses celery_viewset_client
|
|
31
|
+
|
|
32
|
+
For explicit control, use celery_viewset_client or celery_viewset_server directly.
|
|
33
|
+
"""
|
|
34
|
+
if _detect_is_celery_worker():
|
|
35
|
+
return celery_viewset_server(celery_app, task_prefix, lifecycle=lifecycle, redis_client=redis_client)
|
|
36
|
+
else:
|
|
37
|
+
if redis_client is None:
|
|
38
|
+
raise ValueError("redis_client is required for celery_viewset in client (FastAPI) mode")
|
|
39
|
+
return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"celery_viewset",
|
|
44
|
+
"celery_viewset_client",
|
|
45
|
+
"celery_viewset_server",
|
|
46
|
+
"set_is_celery_worker",
|
|
47
|
+
"start_result_reader",
|
|
48
|
+
"stop_result_reader",
|
|
49
|
+
"push_result",
|
|
50
|
+
"get_result_queue_key",
|
|
51
|
+
]
|