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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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,2 @@
1
+ from .celery_viewset import celery_viewset, celery_viewset_client, celery_viewset_server, set_is_celery_worker
2
+ from .route_viewset import route_viewset
@@ -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
+ ]