dynamicforms-fastapi-viewsets 0.10.0__tar.gz → 0.11.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.
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/PKG-INFO +2 -1
- dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/__init__.py +1 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/action_configuration.py +26 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +5 -5
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/client.py +48 -16
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +64 -22
- dynamicforms_fastapi_viewsets-0.11.0/fastapi_viewsets/decorators/celery_viewset/server.py +718 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/lifecycle_runner.py +13 -1
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/exceptions.py +129 -6
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/__init__.py +127 -10
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/idempotency.py +16 -28
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/pyproject.toml +4 -0
- dynamicforms_fastapi_viewsets-0.10.0/fastapi_viewsets/__init__.py +0 -1
- dynamicforms_fastapi_viewsets-0.10.0/fastapi_viewsets/decorators/celery_viewset/server.py +0 -271
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/.gitignore +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/LICENSE +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/README.md +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/backends/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/backends/django_orm.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/collection_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/conf.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/django.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/context/auth/static.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/cursor.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/celery_viewset/payload.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/endpoint_docs.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/authorizers.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/changes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/conftest.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/delivery.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/event.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/lifecycle.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/redis.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/sources.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/store.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/topics.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/events/viewset.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/base.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/declaration.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/operators.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/filters/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/list_query.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/list_shapes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/auth/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/auth/authorization.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/middleware/rate_limiter.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mixins.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/protocol.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/server.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/py.typed +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/response_classes.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/routing.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/save_state/__init__.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/save_state/save_state.py +0 -0
- {dynamicforms_fastapi_viewsets-0.10.0 → dynamicforms_fastapi_viewsets-0.11.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: dynamicforms-fastapi-viewsets
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
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
|
|
@@ -33,6 +33,7 @@ Requires-Dist: asgiref; extra == 'dev'
|
|
|
33
33
|
Requires-Dist: celery; extra == 'dev'
|
|
34
34
|
Requires-Dist: coverage; extra == 'dev'
|
|
35
35
|
Requires-Dist: django; extra == 'dev'
|
|
36
|
+
Requires-Dist: gevent; extra == 'dev'
|
|
36
37
|
Requires-Dist: httpx2; extra == 'dev'
|
|
37
38
|
Requires-Dist: muxws[starlette,websockets]>=0.4.1; extra == 'dev'
|
|
38
39
|
Requires-Dist: pyjwt; extra == 'dev'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.11.0"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
3
4
|
from .conf import settings
|
|
@@ -47,6 +48,31 @@ def action_configuration(config: dict[Any, Any]):
|
|
|
47
48
|
return decorator
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class TimeLimits:
|
|
53
|
+
"""
|
|
54
|
+
The time limits, in seconds, of an execution of a deferred action, configured under the
|
|
55
|
+
`TimeLimits` key:
|
|
56
|
+
|
|
57
|
+
@action_configuration({TimeLimits: TimeLimits(soft=30, hard=60)})
|
|
58
|
+
class ReportViewSet(...): ...
|
|
59
|
+
|
|
60
|
+
`soft` interrupts the execution from inside: `Middleware.execute()` sees `ActionInterruptedError`
|
|
61
|
+
from `call_next()`, `finally` blocks and `save_state()` run, and the caller receives the error.
|
|
62
|
+
`hard` ends the execution's process: the execution unwinds as `SystemExit` only as far as it
|
|
63
|
+
gets before the process is killed, the caller receives `ActionInterruptedError` with reason
|
|
64
|
+
`time_limit`, and `Middleware.on_transition()` receives `interrupted`. None leaves a limit to
|
|
65
|
+
the executor's own configuration.
|
|
66
|
+
|
|
67
|
+
The executor enforces them (`celery_viewset` sends them with each call); an in-process action
|
|
68
|
+
has no time limit. A value in `settings.default_action_configuration` applies to every action,
|
|
69
|
+
and `ByAction` varies it per action.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
soft: float | None = None
|
|
73
|
+
hard: float | None = None
|
|
74
|
+
|
|
75
|
+
|
|
50
76
|
def _candidate_method_names(action_name: str) -> list[str]:
|
|
51
77
|
names = [action_name]
|
|
52
78
|
perform_name = _PERFORM_METHOD_BY_ACTION.get(action_name)
|
|
@@ -49,14 +49,14 @@ def celery_viewset(
|
|
|
49
49
|
- In a Celery worker (detected via sys.argv or set_is_celery_worker): uses celery_viewset_server
|
|
50
50
|
- In a FastAPI app: uses celery_viewset_client
|
|
51
51
|
|
|
52
|
-
For explicit control, use celery_viewset_client or celery_viewset_server directly.
|
|
52
|
+
For explicit control, use celery_viewset_client or celery_viewset_server directly. Both modes
|
|
53
|
+
require redis_client.
|
|
53
54
|
"""
|
|
55
|
+
if redis_client is None:
|
|
56
|
+
raise ValueError("redis_client is required for celery_viewset")
|
|
54
57
|
if _detect_is_celery_worker():
|
|
55
58
|
return celery_viewset_server(celery_app, task_prefix, lifecycle=lifecycle, redis_client=redis_client)
|
|
56
|
-
|
|
57
|
-
if redis_client is None:
|
|
58
|
-
raise ValueError("redis_client is required for celery_viewset in client (FastAPI) mode")
|
|
59
|
-
return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client, result_timeout=result_timeout)
|
|
59
|
+
return celery_viewset_client(celery_app, task_prefix, redis_client=redis_client, result_timeout=result_timeout)
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def check_result_readers() -> frozenset[str]:
|
|
@@ -8,10 +8,12 @@ from typing import TYPE_CHECKING, TypeVar
|
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from ...action_configuration import resolve_action_configuration, TimeLimits
|
|
11
12
|
from ...context import Context, SerializableObject, serialize_context, to_jsonable
|
|
12
13
|
from ...exceptions import ActionDispatchError, ResultTimeoutError
|
|
14
|
+
from ...middleware import ActionTransition, run_transition_listeners
|
|
13
15
|
from ..build_schema import build_schema
|
|
14
|
-
from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE
|
|
16
|
+
from ..lifecycle_runner import current_call, DEFERRED_ACTIONS_ATTRIBUTE, effective_middlewares
|
|
15
17
|
from ..primary_key_model_helper import PARTIAL_UPDATE_ACTIONS
|
|
16
18
|
from . import result_reader
|
|
17
19
|
from .payload import CONTEXT_KWARG, PARENT_KWARG, REQUEST_KWARG, request_to_jsonable
|
|
@@ -96,14 +98,30 @@ async def _serialize_value(value, partial: bool = False):
|
|
|
96
98
|
return to_jsonable(value)
|
|
97
99
|
|
|
98
100
|
|
|
101
|
+
def _time_limit_options(context: Context) -> dict:
|
|
102
|
+
"""The `send_task` time limit options of the call's `TimeLimits` configuration."""
|
|
103
|
+
limits = context.configuration_for(TimeLimits)
|
|
104
|
+
if limits is None:
|
|
105
|
+
return {}
|
|
106
|
+
options = {}
|
|
107
|
+
if limits.soft is not None:
|
|
108
|
+
options["soft_time_limit"] = limits.soft
|
|
109
|
+
if limits.hard is not None:
|
|
110
|
+
options["time_limit"] = limits.hard
|
|
111
|
+
return options
|
|
112
|
+
|
|
113
|
+
|
|
99
114
|
def _patch_method(
|
|
100
115
|
cls: type, original_endpoint, task_name: str, celery_app, queue_key: str, result_timeout: float | None
|
|
101
116
|
):
|
|
102
117
|
"""Replace the method on cls with an async version that sends a Celery task and awaits the result.
|
|
103
118
|
|
|
104
119
|
The task carries the call's context, request and parent execution (see `payload`) from
|
|
105
|
-
`current_call()`; a `context` the caller passes replaces the call's.
|
|
106
|
-
|
|
120
|
+
`current_call()`; a `context` the caller passes replaces the call's. The action's `TimeLimits`
|
|
121
|
+
configuration is sent as the task's time limits. Arguments that cannot be serialized raise
|
|
122
|
+
`ActionDispatchError` (reason `serialization`), a send that fails raises it with reason `send`.
|
|
123
|
+
Once the task is sent, `Middleware.on_transition()` of the action's effective middlewares
|
|
124
|
+
receives `dispatched`. The action's name joins the class's `DEFERRED_ACTIONS_ATTRIBUTE`."""
|
|
107
125
|
method_name = original_endpoint.__name__
|
|
108
126
|
declares_request = "request" in inspect.signature(original_endpoint).parameters
|
|
109
127
|
|
|
@@ -122,10 +140,26 @@ def _patch_method(
|
|
|
122
140
|
if declares_request:
|
|
123
141
|
kwargs.pop("request", None)
|
|
124
142
|
parent = call.parent_of_dispatch() if call is not None else None
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
action_configuration = resolve_action_configuration(cls, method_name)
|
|
144
|
+
bound_context = Context(context.raw(), action_configuration=action_configuration, action_name=method_name)
|
|
145
|
+
try:
|
|
146
|
+
serializable_args = [await _serialize_value(v) for v in args]
|
|
147
|
+
serializable_kwargs = {
|
|
148
|
+
k: await _serialize_value(v, method_name in PARTIAL_UPDATE_ACTIONS) for k, v in kwargs.items()
|
|
149
|
+
}
|
|
150
|
+
task_kwargs = {
|
|
151
|
+
**serializable_kwargs,
|
|
152
|
+
CONTEXT_KWARG: await serialize_context(context.raw()),
|
|
153
|
+
REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
|
|
154
|
+
PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
|
|
155
|
+
"_correlation_id": correlation_id,
|
|
156
|
+
"_result_queue_key": get_process_queue_key(queue_key),
|
|
157
|
+
}
|
|
158
|
+
except Exception as error:
|
|
159
|
+
logger.error(
|
|
160
|
+
"Celery task arguments not serializable: %s (correlation_id=%s)", task_name, correlation_id
|
|
161
|
+
)
|
|
162
|
+
raise ActionDispatchError(task_name, "serialization") from error
|
|
129
163
|
logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
|
|
130
164
|
try:
|
|
131
165
|
# send_task() blocks while it connects and publishes to the broker.
|
|
@@ -133,23 +167,21 @@ def _patch_method(
|
|
|
133
167
|
celery_app.send_task,
|
|
134
168
|
task_name,
|
|
135
169
|
args=serializable_args,
|
|
136
|
-
kwargs=
|
|
137
|
-
**serializable_kwargs,
|
|
138
|
-
CONTEXT_KWARG: await serialize_context(context.raw()),
|
|
139
|
-
REQUEST_KWARG: request_to_jsonable(call.request if call is not None else None),
|
|
140
|
-
PARENT_KWARG: parent.to_jsonable() if parent is not None else None,
|
|
141
|
-
"_correlation_id": correlation_id,
|
|
142
|
-
"_result_queue_key": get_process_queue_key(queue_key),
|
|
143
|
-
},
|
|
170
|
+
kwargs=task_kwargs,
|
|
144
171
|
# send_task() defaults ignore_result to False regardless of what the task was
|
|
145
172
|
# registered with (celery_viewset_server sets ignore_result=True) - Celery's
|
|
146
173
|
# per-request value overrides the task's own, so it must be repeated here or the
|
|
147
174
|
# worker still tries to JSON-encode the raw return value into its result backend.
|
|
148
175
|
ignore_result=True,
|
|
176
|
+
**_time_limit_options(bound_context),
|
|
149
177
|
)
|
|
150
178
|
except Exception as error:
|
|
151
179
|
logger.error("Celery task not sent: %s (correlation_id=%s)", task_name, correlation_id)
|
|
152
|
-
raise ActionDispatchError(task_name) from error
|
|
180
|
+
raise ActionDispatchError(task_name, "send") from error
|
|
181
|
+
await run_transition_listeners(
|
|
182
|
+
effective_middlewares(action_configuration),
|
|
183
|
+
ActionTransition("dispatched", cls, method_name, bound_context, parent, None),
|
|
184
|
+
)
|
|
153
185
|
try:
|
|
154
186
|
return await asyncio.wait_for(future, result_timeout)
|
|
155
187
|
except asyncio.TimeoutError:
|
|
@@ -7,9 +7,10 @@ import socket
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from fastapi import HTTPException
|
|
10
|
+
from fastapi.exceptions import RequestValidationError
|
|
10
11
|
|
|
11
12
|
from ...context import from_jsonable
|
|
12
|
-
from ...exceptions import ResultReaderStoppedError
|
|
13
|
+
from ...exceptions import _error_from_jsonable, _error_to_jsonable, ActionFailedError, ResultReaderStoppedError
|
|
13
14
|
|
|
14
15
|
if TYPE_CHECKING:
|
|
15
16
|
import redis
|
|
@@ -88,27 +89,12 @@ async def _read_results(redis_client: "redis.Redis", queue_key: str, poll_interv
|
|
|
88
89
|
|
|
89
90
|
data = json.loads(item)
|
|
90
91
|
correlation_id = data.get("correlation_id")
|
|
91
|
-
result = data.get("result")
|
|
92
|
-
error = data.get("error")
|
|
93
92
|
|
|
94
93
|
pending = _pending_futures.pop(correlation_id, None)
|
|
95
94
|
if pending is None:
|
|
96
95
|
logger.warning("No pending future for correlation_id=%s", correlation_id)
|
|
97
96
|
continue
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if not future.done():
|
|
101
|
-
if error is not None:
|
|
102
|
-
http_status_code = data.get("http_status_code")
|
|
103
|
-
http_detail = data.get("http_detail")
|
|
104
|
-
if http_status_code is not None:
|
|
105
|
-
future.set_exception(HTTPException(status_code=http_status_code, detail=http_detail))
|
|
106
|
-
else:
|
|
107
|
-
future.set_exception(Exception(error))
|
|
108
|
-
else:
|
|
109
|
-
# Undoes _to_jsonable's SerializableObject tagging (see server.py) at any depth -
|
|
110
|
-
# an untagged value passes through unchanged.
|
|
111
|
-
future.set_result(from_jsonable(result))
|
|
97
|
+
_resolve(pending[0], data)
|
|
112
98
|
|
|
113
99
|
except asyncio.CancelledError:
|
|
114
100
|
break
|
|
@@ -117,22 +103,78 @@ async def _read_results(redis_client: "redis.Redis", queue_key: str, poll_interv
|
|
|
117
103
|
await asyncio.sleep(poll_interval)
|
|
118
104
|
|
|
119
105
|
|
|
106
|
+
def _resolve(future: asyncio.Future, data: dict) -> None:
|
|
107
|
+
"""Resolves `future` with the result or the error of one payload."""
|
|
108
|
+
if future.done():
|
|
109
|
+
return
|
|
110
|
+
task = data.get("task")
|
|
111
|
+
try:
|
|
112
|
+
if data.get("error") is not None:
|
|
113
|
+
future.set_exception(_payload_error(data))
|
|
114
|
+
else:
|
|
115
|
+
# Undoes _to_jsonable's SerializableObject tagging (see server.py) at any depth - an
|
|
116
|
+
# untagged value passes through unchanged.
|
|
117
|
+
future.set_result(from_jsonable(data.get("result")))
|
|
118
|
+
except Exception as error:
|
|
119
|
+
logger.exception("Result of %s (correlation_id=%s) cannot be read", task, data.get("correlation_id"))
|
|
120
|
+
failure = ActionFailedError(task, "result")
|
|
121
|
+
failure.__cause__ = error
|
|
122
|
+
future.set_exception(failure)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _payload_error(data: dict) -> Exception:
|
|
126
|
+
"""The exception a payload's `error` carries. An `error` that is not encoded fields is an
|
|
127
|
+
`HTTPException` when the payload has `http_status_code`, else an `ActionFailedError`."""
|
|
128
|
+
error = data["error"]
|
|
129
|
+
if isinstance(error, dict):
|
|
130
|
+
return _error_from_jsonable(error)
|
|
131
|
+
if data.get("http_status_code") is not None:
|
|
132
|
+
return HTTPException(status_code=data["http_status_code"], detail=data.get("http_detail"))
|
|
133
|
+
return ActionFailedError(data.get("task"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def error_payload(correlation_id: str, task: str | None, error: BaseException) -> dict:
|
|
137
|
+
"""
|
|
138
|
+
The payload that fails one call with `error`: an `HTTPException` (a `DfViewSetError` with its
|
|
139
|
+
class), any other error as `ActionFailedError`. `http_status_code` and `http_detail` repeat the
|
|
140
|
+
status and detail for a reader that does not decode `error`.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(error, (HTTPException, RequestValidationError)):
|
|
143
|
+
error = ActionFailedError(task)
|
|
144
|
+
encoded = _error_to_jsonable(error)
|
|
145
|
+
return {
|
|
146
|
+
"correlation_id": correlation_id,
|
|
147
|
+
"task": task,
|
|
148
|
+
"error": encoded,
|
|
149
|
+
"http_status_code": encoded["status_code"],
|
|
150
|
+
"http_detail": encoded["detail"],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
120
154
|
def push_payload(redis_client: "redis.Redis", queue_key: str, data: dict) -> None:
|
|
121
155
|
"""Push one result payload to queue_key and renew the list's expiry to RESULT_QUEUE_TTL."""
|
|
122
156
|
redis_client.rpush(queue_key, json.dumps(data))
|
|
123
157
|
redis_client.expire(queue_key, RESULT_QUEUE_TTL)
|
|
124
158
|
|
|
125
159
|
|
|
126
|
-
def push_result(
|
|
160
|
+
def push_result(
|
|
161
|
+
redis_client: "redis.Redis",
|
|
162
|
+
queue_key: str,
|
|
163
|
+
correlation_id: str,
|
|
164
|
+
result=None,
|
|
165
|
+
error: BaseException | None = None,
|
|
166
|
+
task: str | None = None,
|
|
167
|
+
) -> None:
|
|
127
168
|
"""
|
|
128
169
|
Push a result or an error for one call. queue_key is the `_result_queue_key` the client sent with
|
|
129
|
-
the task.
|
|
170
|
+
the task. An `error` that is an `HTTPException` reaches the caller as it is (a `DfViewSetError`
|
|
171
|
+
of a class `fastapi_viewsets.exceptions` defines keeps its class); any other error reaches it
|
|
172
|
+
as `ActionFailedError`. `task` names the call's task in that error.
|
|
130
173
|
"""
|
|
131
|
-
data = {"correlation_id": correlation_id}
|
|
132
174
|
if error is not None:
|
|
133
|
-
data
|
|
175
|
+
data = error_payload(correlation_id, task, error)
|
|
134
176
|
else:
|
|
135
|
-
data
|
|
177
|
+
data = {"correlation_id": correlation_id, "task": task, "result": result}
|
|
136
178
|
push_payload(redis_client, queue_key, data)
|
|
137
179
|
|
|
138
180
|
|