dynamicforms-fastapi-viewsets 0.1.0__tar.gz → 0.1.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.
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/PKG-INFO +13 -4
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/README.md +4 -3
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/__init__.py +1 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/action_configuration.py +111 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/collection_viewset.py +11 -8
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/conf.py +45 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/context/__init__.py +395 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/context/auth/__init__.py +55 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/context/auth/django.py +126 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/context/auth/jwt.py +86 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/context/auth/static.py +87 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/build_schema.py +6 -5
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/celery_viewset/client.py +9 -4
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/celery_viewset/server.py +18 -4
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/decorators/lifecycle_runner.py +108 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/route_viewset.py +75 -15
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/middleware/__init__.py +122 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/middleware/auth/__init__.py +53 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/middleware/auth/authorization.py +67 -0
- dynamicforms_fastapi_viewsets-0.1.1/fastapi_viewsets/middleware/rate_limiter.py +101 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/mixins.py +42 -29
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/pyproject.toml +5 -0
- dynamicforms_fastapi_viewsets-0.1.0/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.1.0/fastapi_viewsets/celery_viewset.py +0 -50
- dynamicforms_fastapi_viewsets-0.1.0/fastapi_viewsets/decorators/lifecycle_runner.py +0 -64
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.1.0 → dynamicforms_fastapi_viewsets-0.1.1}/fastapi_viewsets/save_state/serialize_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
|
@@ -29,13 +29,21 @@ Provides-Extra: celery
|
|
|
29
29
|
Requires-Dist: celery>=5.3; extra == 'celery'
|
|
30
30
|
Requires-Dist: redis>=5.0; extra == 'celery'
|
|
31
31
|
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: asgiref; extra == 'dev'
|
|
32
33
|
Requires-Dist: celery; extra == 'dev'
|
|
33
34
|
Requires-Dist: coverage; extra == 'dev'
|
|
35
|
+
Requires-Dist: django; extra == 'dev'
|
|
34
36
|
Requires-Dist: httpx; extra == 'dev'
|
|
37
|
+
Requires-Dist: pyjwt; extra == 'dev'
|
|
35
38
|
Requires-Dist: pytest; extra == 'dev'
|
|
36
39
|
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
37
40
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
38
41
|
Requires-Dist: redis; extra == 'dev'
|
|
42
|
+
Provides-Extra: django
|
|
43
|
+
Requires-Dist: asgiref>=3.6; extra == 'django'
|
|
44
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
45
|
+
Provides-Extra: jwt
|
|
46
|
+
Requires-Dist: pyjwt>=2.8; extra == 'jwt'
|
|
39
47
|
Description-Content-Type: text/markdown
|
|
40
48
|
|
|
41
49
|
# DynamicForms FastAPI Viewsets
|
|
@@ -48,8 +56,9 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
48
56
|
Handles type resolution, lifecycle management and OpenAPI schema automatically.
|
|
49
57
|
- **`CollectionViewSet`** — zero-boilerplate in-memory viewset backed by any Python list, set or dict.
|
|
50
58
|
Great for prototyping and testing.
|
|
51
|
-
- **`
|
|
52
|
-
processing scenarios (requires the `celery`
|
|
59
|
+
- **`celery_viewset` decorator** — move a viewset's execution to a Celery worker with no code changes
|
|
60
|
+
to the viewset itself, for long-running or background processing scenarios (requires the `celery`
|
|
61
|
+
extra).
|
|
53
62
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
54
63
|
- **Vue / TypeScript counterpart** — mirror mixin classes and a `route_rest` factory give you a fully
|
|
55
64
|
typed HTTP client that matches your backend viewset exactly (published separately as
|
|
@@ -98,7 +107,7 @@ app.include_router(router)
|
|
|
98
107
|
```
|
|
99
108
|
|
|
100
109
|
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
101
|
-
the mixin system, `route_viewset`, `CollectionViewSet`, `
|
|
110
|
+
the mixin system, `route_viewset`, `CollectionViewSet`, `celery_viewset`, and the Vue client.
|
|
102
111
|
|
|
103
112
|
## License
|
|
104
113
|
|
|
@@ -8,8 +8,9 @@ Celery-backed async execution and a matching Vue/TypeScript client counterpart.
|
|
|
8
8
|
Handles type resolution, lifecycle management and OpenAPI schema automatically.
|
|
9
9
|
- **`CollectionViewSet`** — zero-boilerplate in-memory viewset backed by any Python list, set or dict.
|
|
10
10
|
Great for prototyping and testing.
|
|
11
|
-
- **`
|
|
12
|
-
processing scenarios (requires the `celery`
|
|
11
|
+
- **`celery_viewset` decorator** — move a viewset's execution to a Celery worker with no code changes
|
|
12
|
+
to the viewset itself, for long-running or background processing scenarios (requires the `celery`
|
|
13
|
+
extra).
|
|
13
14
|
- **Bulk operations** — first-class support for bulk create, update, partial update and destroy.
|
|
14
15
|
- **Vue / TypeScript counterpart** — mirror mixin classes and a `route_rest` factory give you a fully
|
|
15
16
|
typed HTTP client that matches your backend viewset exactly (published separately as
|
|
@@ -58,7 +59,7 @@ app.include_router(router)
|
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
See the [full documentation](https://docs.velis.si/dynamicforms/fastapi-viewsets/) for guides on
|
|
61
|
-
the mixin system, `route_viewset`, `CollectionViewSet`, `
|
|
62
|
+
the mixin system, `route_viewset`, `CollectionViewSet`, `celery_viewset`, and the Vue client.
|
|
62
63
|
|
|
63
64
|
## License
|
|
64
65
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .conf import settings
|
|
4
|
+
from .middleware import Middleware
|
|
5
|
+
|
|
6
|
+
_PERFORM_METHOD_BY_ACTION = {
|
|
7
|
+
"create": "perform_create",
|
|
8
|
+
"bulk_create": "perform_bulk_create",
|
|
9
|
+
"list_items": "perform_list",
|
|
10
|
+
"retrieve": "perform_retrieve",
|
|
11
|
+
"update": "perform_update",
|
|
12
|
+
"partial_update": "perform_update",
|
|
13
|
+
"bulk_update": "perform_bulk_update",
|
|
14
|
+
"bulk_partial_update": "perform_bulk_update",
|
|
15
|
+
"destroy": "perform_destroy",
|
|
16
|
+
"bulk_destroy": "perform_bulk_destroy",
|
|
17
|
+
"lookup": "perform_lookup",
|
|
18
|
+
}
|
|
19
|
+
"""Mirrors the action-method -> perform_* wiring already in mixins.py, so @action_configuration on
|
|
20
|
+
a perform_* method (where CRUD logic actually lives) is found when resolving its action's config,
|
|
21
|
+
not just on the framework's own @final action methods (update, list_items, ...)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def action_configuration(config: dict[Any, Any]):
|
|
25
|
+
"""
|
|
26
|
+
Attaches per-viewset/per-action configuration for context processors and command middleware to
|
|
27
|
+
consult - works identically whether applied to a viewset class or to an individual method
|
|
28
|
+
(typically a perform_* method). Keys are identifiers (a context processor callable, a
|
|
29
|
+
Middleware subclass already registered in settings.viewsets_command_middleware, or a fresh
|
|
30
|
+
Middleware instance to inject just for this call - see extra_middlewares_for); values are
|
|
31
|
+
opaque, interpreted only by whichever processor/middleware reads them (see
|
|
32
|
+
fastapi_viewsets/context/__init__.py's Context.configuration_for and build_context). See
|
|
33
|
+
docs/guide/action-configuration.md.
|
|
34
|
+
|
|
35
|
+
@action_configuration({Session: False})
|
|
36
|
+
class LoginViewSet(...): ...
|
|
37
|
+
|
|
38
|
+
class ItemViewSet(...):
|
|
39
|
+
@action_configuration({Session: False})
|
|
40
|
+
async def perform_list(self, context): ...
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def decorator(target):
|
|
44
|
+
target.__action_configuration__ = config
|
|
45
|
+
return target
|
|
46
|
+
|
|
47
|
+
return decorator
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _candidate_method_names(action_name: str) -> list[str]:
|
|
51
|
+
names = [action_name]
|
|
52
|
+
perform_name = _PERFORM_METHOD_BY_ACTION.get(action_name)
|
|
53
|
+
if perform_name:
|
|
54
|
+
names.append(perform_name)
|
|
55
|
+
return names
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_action_configuration(cls: type, action_name: str) -> dict[Any, Any]:
|
|
59
|
+
"""
|
|
60
|
+
Merges settings.default_action_configuration (lowest priority) -> class-level
|
|
61
|
+
@action_configuration -> method-level @action_configuration (highest priority), per identifier
|
|
62
|
+
(a method-level entry for an identifier fully replaces whatever a lower layer had for that same
|
|
63
|
+
identifier; identifiers a method doesn't mention still fall through from the class/default).
|
|
64
|
+
|
|
65
|
+
Returns the raw merged dict - ByAction values are left unresolved (see
|
|
66
|
+
fastapi_viewsets/context/__init__.py's Context.configuration_for, which resolves them at read
|
|
67
|
+
time against the current action, so a Context reused across actions - e.g. via
|
|
68
|
+
clone_for_command - never resolves stale).
|
|
69
|
+
|
|
70
|
+
Takes the viewset *class* (not an instance) - method-level lookup via `getattr(cls, candidate)`
|
|
71
|
+
returns the same underlying function (and thus the same `__action_configuration__` attribute)
|
|
72
|
+
as `getattr(instance, candidate)` would, so no instance is ever needed; this also lets it be
|
|
73
|
+
called before a viewset instance exists yet (e.g. from an early Middleware.depends() call - see
|
|
74
|
+
route_viewset.py - for per-request/instance-key lifecycles).
|
|
75
|
+
|
|
76
|
+
Method-level lookup checks both the actual action method name (for hand-written __router
|
|
77
|
+
endpoints, which have no separate perform_* counterpart) and its mapped perform_* method (where
|
|
78
|
+
CRUD logic actually lives - see _PERFORM_METHOD_BY_ACTION), in that order, so a perform_*-level
|
|
79
|
+
override wins if both happen to be decorated.
|
|
80
|
+
"""
|
|
81
|
+
merged: dict[Any, Any] = dict(settings.default_action_configuration)
|
|
82
|
+
merged.update(getattr(cls, "__action_configuration__", {}))
|
|
83
|
+
for candidate in _candidate_method_names(action_name):
|
|
84
|
+
method_config = getattr(getattr(cls, candidate, None), "__action_configuration__", None)
|
|
85
|
+
if method_config:
|
|
86
|
+
merged.update(method_config)
|
|
87
|
+
return merged
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extra_middlewares_for(action_configuration: dict[Any, Any], global_middlewares: list) -> list:
|
|
91
|
+
"""
|
|
92
|
+
Keys in `action_configuration` that are a Middleware *instance* are appended to the chain,
|
|
93
|
+
just for this call, in dict-iteration order - this is how @action_configuration injects a
|
|
94
|
+
brand-new middleware for one viewset/method without registering it globally. Keys that are a
|
|
95
|
+
Middleware *class* configure an already-globally-registered instance of that type (read via
|
|
96
|
+
Middleware.config_from/Context.configuration_for) - a class with no matching instance in
|
|
97
|
+
`global_middlewares` raises ValueError, fail-fast, since it's almost certainly a typo or a
|
|
98
|
+
forgotten global registration rather than an intentional injection (use an instance for that).
|
|
99
|
+
"""
|
|
100
|
+
global_types = {type(mw) for mw in global_middlewares}
|
|
101
|
+
extras = []
|
|
102
|
+
for identifier in action_configuration:
|
|
103
|
+
if isinstance(identifier, Middleware):
|
|
104
|
+
extras.append(identifier)
|
|
105
|
+
elif isinstance(identifier, type) and issubclass(identifier, Middleware) and identifier not in global_types:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"{identifier.__name__} is configured via @action_configuration but has no instance "
|
|
108
|
+
f"in settings.viewsets_command_middleware - register one globally, or pass a "
|
|
109
|
+
f"Middleware instance (not the class) to inject a new one just for this call."
|
|
110
|
+
)
|
|
111
|
+
return extras
|
|
@@ -4,6 +4,7 @@ from typing import Any, Generic, TypeVar
|
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
6
|
|
|
7
|
+
from .context import Context
|
|
7
8
|
from .mixins import ImplMixin
|
|
8
9
|
from .response_classes import NotFoundError
|
|
9
10
|
|
|
@@ -39,35 +40,37 @@ class AsyncCollectionViewSet(
|
|
|
39
40
|
async def container_delete(self, pk: K) -> None:
|
|
40
41
|
raise NotImplementedError
|
|
41
42
|
|
|
42
|
-
async def perform_list(self) -> list[T]:
|
|
43
|
+
async def perform_list(self, _context: Context) -> list[T]:
|
|
43
44
|
return await self.container_list()
|
|
44
45
|
|
|
45
|
-
async def perform_retrieve(self, pk: K) -> T:
|
|
46
|
+
async def perform_retrieve(self, _context: Context, pk: K) -> T:
|
|
46
47
|
return await self.container_retrieve(pk)
|
|
47
48
|
|
|
48
|
-
async def perform_create(self, data: T) -> T:
|
|
49
|
+
async def perform_create(self, _context: Context, data: T) -> T:
|
|
49
50
|
return await self.container_add(data)
|
|
50
51
|
|
|
51
|
-
async def perform_bulk_create(self, data: list[T]) -> list[T]:
|
|
52
|
+
async def perform_bulk_create(self, _context: Context, data: list[T]) -> list[T]:
|
|
52
53
|
results = []
|
|
53
54
|
for item in data:
|
|
54
55
|
results.append(await self.container_add(item))
|
|
55
56
|
return results
|
|
56
57
|
|
|
57
|
-
async def perform_update(self, pk: K, data: T, partial: bool = True) -> T:
|
|
58
|
+
async def perform_update(self, _context: Context, pk: K, data: T, partial: bool = True) -> T:
|
|
58
59
|
return await self.container_update(pk, data, partial)
|
|
59
60
|
|
|
60
|
-
async def perform_bulk_update(
|
|
61
|
+
async def perform_bulk_update(
|
|
62
|
+
self, _context: Context, records: dict[K, T], partial: bool = True
|
|
63
|
+
) -> list[T]:
|
|
61
64
|
results = []
|
|
62
65
|
for pk, data in records.items():
|
|
63
66
|
results.append(await self.container_update(pk, data, partial))
|
|
64
67
|
return results
|
|
65
68
|
|
|
66
|
-
async def perform_destroy(self, pk: K) -> dict[K, Any]:
|
|
69
|
+
async def perform_destroy(self, _context: Context, pk: K) -> dict[K, Any]:
|
|
67
70
|
await self.container_delete(pk)
|
|
68
71
|
return {pk: None}
|
|
69
72
|
|
|
70
|
-
async def perform_bulk_destroy(self, pk: list[K]) -> list[dict[K, Any]]:
|
|
73
|
+
async def perform_bulk_destroy(self, _context: Context, pk: list[K]) -> list[dict[K, Any]]:
|
|
71
74
|
results = []
|
|
72
75
|
for p in pk:
|
|
73
76
|
await self.container_delete(p)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi.security.base import SecurityBase
|
|
9
|
+
|
|
10
|
+
from .context.auth import AuthBackend
|
|
11
|
+
from .middleware import CommandMiddleware
|
|
12
|
+
|
|
13
|
+
ContextProcessor = Callable[["Request", Any], Awaitable[dict[str, Any]]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings:
|
|
17
|
+
"""
|
|
18
|
+
Global, mutable settings for fastapi_viewsets - configured once by the application, e.g.:
|
|
19
|
+
|
|
20
|
+
from fastapi_viewsets.conf import settings
|
|
21
|
+
|
|
22
|
+
async def auth_processor(request, viewset) -> dict:
|
|
23
|
+
return {"user": await authenticate(request)}
|
|
24
|
+
|
|
25
|
+
settings.viewsets_context_processors = [auth_processor]
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.viewsets_context_processors: list[ContextProcessor] = []
|
|
30
|
+
self.viewsets_context_json_encoder: type[json.JSONEncoder] | None = None
|
|
31
|
+
self.viewsets_context_json_decoder: type[json.JSONDecoder] | None = None
|
|
32
|
+
self.viewsets_command_middleware: list[CommandMiddleware] = []
|
|
33
|
+
self.viewsets_auth_processors: list[AuthBackend] = []
|
|
34
|
+
self.default_action_configuration: dict[Any, Any] = {}
|
|
35
|
+
self.viewsets_security_scheme: SecurityBase | None = None
|
|
36
|
+
"""
|
|
37
|
+
Purely for OpenAPI/Swagger discoverability (a lock icon + testable Authorize flow) - attached
|
|
38
|
+
as an extra sub-dependency alongside the per-route Middleware.depends() bridge (see
|
|
39
|
+
route_viewset.py), not otherwise consumed by this library. Must be set before any viewset
|
|
40
|
+
class is decorated with route_viewset (same existing constraint as
|
|
41
|
+
disable_response_model=bool(settings.viewsets_command_middleware) already has).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
settings = Settings()
|