GeneralManager 0.17.0__py3-none-any.whl → 0.18.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.
Potentially problematic release.
This version of GeneralManager might be problematic. Click here for more details.
- general_manager/__init__.py +11 -1
- general_manager/_types/api.py +0 -1
- general_manager/_types/bucket.py +0 -1
- general_manager/_types/cache.py +0 -1
- general_manager/_types/factory.py +0 -1
- general_manager/_types/general_manager.py +0 -1
- general_manager/_types/interface.py +0 -1
- general_manager/_types/manager.py +0 -1
- general_manager/_types/measurement.py +0 -1
- general_manager/_types/permission.py +0 -1
- general_manager/_types/rule.py +0 -1
- general_manager/_types/utils.py +0 -1
- general_manager/api/__init__.py +13 -1
- general_manager/api/graphql.py +356 -221
- general_manager/api/graphql_subscription_consumer.py +81 -78
- general_manager/api/mutation.py +85 -23
- general_manager/api/property.py +39 -13
- general_manager/apps.py +188 -47
- general_manager/bucket/__init__.py +10 -1
- general_manager/bucket/calculationBucket.py +155 -53
- general_manager/bucket/databaseBucket.py +157 -45
- general_manager/bucket/groupBucket.py +133 -44
- general_manager/cache/__init__.py +10 -1
- general_manager/cache/dependencyIndex.py +143 -45
- general_manager/cache/signals.py +9 -2
- general_manager/factory/__init__.py +10 -1
- general_manager/factory/autoFactory.py +55 -13
- general_manager/factory/factories.py +110 -40
- general_manager/factory/factoryMethods.py +122 -34
- general_manager/interface/__init__.py +10 -1
- general_manager/interface/baseInterface.py +129 -36
- general_manager/interface/calculationInterface.py +35 -18
- general_manager/interface/databaseBasedInterface.py +71 -45
- general_manager/interface/databaseInterface.py +96 -38
- general_manager/interface/models.py +5 -5
- general_manager/interface/readOnlyInterface.py +94 -20
- general_manager/manager/__init__.py +10 -1
- general_manager/manager/generalManager.py +25 -16
- general_manager/manager/groupManager.py +20 -6
- general_manager/manager/meta.py +84 -16
- general_manager/measurement/__init__.py +10 -1
- general_manager/measurement/measurement.py +289 -95
- general_manager/measurement/measurementField.py +42 -31
- general_manager/permission/__init__.py +10 -1
- general_manager/permission/basePermission.py +120 -38
- general_manager/permission/managerBasedPermission.py +72 -21
- general_manager/permission/mutationPermission.py +14 -9
- general_manager/permission/permissionChecks.py +14 -12
- general_manager/permission/permissionDataManager.py +24 -11
- general_manager/permission/utils.py +34 -6
- general_manager/public_api_registry.py +36 -10
- general_manager/rule/__init__.py +10 -1
- general_manager/rule/handler.py +133 -44
- general_manager/rule/rule.py +178 -39
- general_manager/utils/__init__.py +10 -1
- general_manager/utils/argsToKwargs.py +34 -9
- general_manager/utils/filterParser.py +22 -7
- general_manager/utils/formatString.py +1 -0
- general_manager/utils/pathMapping.py +23 -15
- general_manager/utils/public_api.py +33 -2
- general_manager/utils/testing.py +31 -33
- {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/METADATA +2 -1
- generalmanager-0.18.0.dist-info/RECORD +77 -0
- {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
- generalmanager-0.17.0.dist-info/RECORD +0 -77
- {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/top_level.txt +0 -0
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import contextlib
|
|
5
5
|
from types import SimpleNamespace
|
|
6
6
|
from typing import Any, cast
|
|
7
|
+
|
|
7
8
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer # type: ignore[import-untyped]
|
|
8
9
|
from graphql import (
|
|
9
10
|
ExecutionResult,
|
|
@@ -16,6 +17,17 @@ from graphql import (
|
|
|
16
17
|
from general_manager.api.graphql import GraphQL
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
RECOVERABLE_SUBSCRIPTION_ERRORS: tuple[type[Exception], ...] = (
|
|
21
|
+
RuntimeError,
|
|
22
|
+
ValueError,
|
|
23
|
+
TypeError,
|
|
24
|
+
LookupError,
|
|
25
|
+
ConnectionError,
|
|
26
|
+
KeyError,
|
|
27
|
+
asyncio.TimeoutError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
19
31
|
class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
20
32
|
"""
|
|
21
33
|
Websocket consumer implementing the ``graphql-transport-ws`` protocol for GraphQL subscriptions.
|
|
@@ -30,7 +42,7 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
30
42
|
async def connect(self) -> None:
|
|
31
43
|
"""
|
|
32
44
|
Initialize connection state and accept the WebSocket, preferring the "graphql-transport-ws" subprotocol when offered.
|
|
33
|
-
|
|
45
|
+
|
|
34
46
|
Sets up initial flags and containers used for subscription management (connection_acknowledged, connection_params, active_subscriptions) and accepts the WebSocket connection with the selected subprotocol.
|
|
35
47
|
"""
|
|
36
48
|
self.connection_acknowledged = False
|
|
@@ -44,10 +56,13 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
44
56
|
|
|
45
57
|
async def disconnect(self, code: int) -> None:
|
|
46
58
|
"""
|
|
47
|
-
Perform cleanup
|
|
48
|
-
|
|
59
|
+
Perform cleanup on WebSocket disconnect by cancelling and awaiting active subscription tasks and clearing the subscription registry.
|
|
60
|
+
|
|
49
61
|
Parameters:
|
|
50
|
-
code (int):
|
|
62
|
+
code (int): WebSocket close code received from the connection.
|
|
63
|
+
|
|
64
|
+
Notes:
|
|
65
|
+
Awaiting cancelled tasks suppresses asyncio.CancelledError so task cancellation completes silently.
|
|
51
66
|
"""
|
|
52
67
|
tasks = list(self.active_subscriptions.values())
|
|
53
68
|
for task in tasks:
|
|
@@ -59,13 +74,12 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
59
74
|
|
|
60
75
|
async def receive_json(self, content: dict[str, Any], **_: Any) -> None:
|
|
61
76
|
"""
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
Route an incoming graphql-transport-ws protocol message to the corresponding handler based on its "type" field.
|
|
78
|
+
|
|
79
|
+
Valid message types: "connection_init", "ping", "subscribe", and "complete". Messages with an unrecognized or missing "type" cause the connection to be closed with code 4400.
|
|
80
|
+
|
|
64
81
|
Parameters:
|
|
65
|
-
content (dict[str, Any]): The received JSON message; expected to include a "type" key
|
|
66
|
-
whose value is one of "connection_init", "ping", "subscribe", or "complete".
|
|
67
|
-
Messages with an unrecognized or missing "type" cause the connection to be closed
|
|
68
|
-
with code 4400.
|
|
82
|
+
content (dict[str, Any]): The received JSON message; expected to include a "type" key indicating the protocol action.
|
|
69
83
|
"""
|
|
70
84
|
message_type = content.get("type")
|
|
71
85
|
if message_type == "connection_init":
|
|
@@ -82,12 +96,12 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
82
96
|
async def _handle_connection_init(self, content: dict[str, Any]) -> None:
|
|
83
97
|
"""
|
|
84
98
|
Handle a client's "connection_init" message and send a protocol acknowledgment.
|
|
85
|
-
|
|
99
|
+
|
|
86
100
|
If the connection has already been acknowledged, closes the WebSocket with code 4429.
|
|
87
101
|
If the incoming message contains a "payload" that is a dict, stores it on
|
|
88
102
|
self.connection_params; otherwise clears connection_params. Marks the connection
|
|
89
103
|
as acknowledged and sends a "connection_ack" protocol message.
|
|
90
|
-
|
|
104
|
+
|
|
91
105
|
Parameters:
|
|
92
106
|
content (dict[str, Any]): The received WebSocket message for "connection_init".
|
|
93
107
|
"""
|
|
@@ -105,9 +119,9 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
105
119
|
async def _handle_ping(self, content: dict[str, Any]) -> None:
|
|
106
120
|
"""
|
|
107
121
|
Responds to an incoming ping by sending a pong protocol message.
|
|
108
|
-
|
|
122
|
+
|
|
109
123
|
If the incoming `content` contains a `"payload"` key, its value is included in the sent pong message under the same key.
|
|
110
|
-
|
|
124
|
+
|
|
111
125
|
Parameters:
|
|
112
126
|
content (dict[str, Any]): The received message object; may include an optional `"payload"` to echo.
|
|
113
127
|
"""
|
|
@@ -119,13 +133,11 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
119
133
|
|
|
120
134
|
async def _handle_subscribe(self, content: dict[str, Any]) -> None:
|
|
121
135
|
"""
|
|
122
|
-
Handle an incoming GraphQL "subscribe" message and
|
|
123
|
-
|
|
124
|
-
Validates the connection state and the message shape, checks that a subscription-capable schema is available, parses and executes the GraphQL operation, and either streams resulting events to the client or sends a single execution result. On protocol or execution errors the method sends appropriate "error" and "complete" protocol messages. If an active subscription exists for the same operation id it is stopped before the new subscription is started.
|
|
125
|
-
|
|
136
|
+
Handle an incoming GraphQL "subscribe" protocol message and initiate or deliver the corresponding subscription results.
|
|
137
|
+
|
|
126
138
|
Parameters:
|
|
127
139
|
content (dict[str, Any]): The incoming protocol message. Expected keys:
|
|
128
|
-
- "id" (str):
|
|
140
|
+
- "id" (str): Operation identifier.
|
|
129
141
|
- "payload" (dict): Operation payload containing:
|
|
130
142
|
- "query" (str): GraphQL query string (required).
|
|
131
143
|
- "variables" (dict, optional): Operation variables.
|
|
@@ -152,9 +164,7 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
152
164
|
],
|
|
153
165
|
}
|
|
154
166
|
)
|
|
155
|
-
await self._send_protocol_message(
|
|
156
|
-
{"type": "complete", "id": operation_id}
|
|
157
|
-
)
|
|
167
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
158
168
|
return
|
|
159
169
|
|
|
160
170
|
query = payload.get("query")
|
|
@@ -166,9 +176,7 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
166
176
|
"payload": [{"message": "A GraphQL query string is required."}],
|
|
167
177
|
}
|
|
168
178
|
)
|
|
169
|
-
await self._send_protocol_message(
|
|
170
|
-
{"type": "complete", "id": operation_id}
|
|
171
|
-
)
|
|
179
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
172
180
|
return
|
|
173
181
|
|
|
174
182
|
variables = payload.get("variables")
|
|
@@ -177,12 +185,12 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
177
185
|
{
|
|
178
186
|
"type": "error",
|
|
179
187
|
"id": operation_id,
|
|
180
|
-
"payload": [
|
|
188
|
+
"payload": [
|
|
189
|
+
{"message": "Variables must be provided as an object."}
|
|
190
|
+
],
|
|
181
191
|
}
|
|
182
192
|
)
|
|
183
|
-
await self._send_protocol_message(
|
|
184
|
-
{"type": "complete", "id": operation_id}
|
|
185
|
-
)
|
|
193
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
186
194
|
return
|
|
187
195
|
|
|
188
196
|
operation_name = payload.get("operationName")
|
|
@@ -192,13 +200,13 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
192
200
|
"type": "error",
|
|
193
201
|
"id": operation_id,
|
|
194
202
|
"payload": [
|
|
195
|
-
{
|
|
203
|
+
{
|
|
204
|
+
"message": "The operation name must be a string when provided."
|
|
205
|
+
}
|
|
196
206
|
],
|
|
197
207
|
}
|
|
198
208
|
)
|
|
199
|
-
await self._send_protocol_message(
|
|
200
|
-
{"type": "complete", "id": operation_id}
|
|
201
|
-
)
|
|
209
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
202
210
|
return
|
|
203
211
|
|
|
204
212
|
try:
|
|
@@ -211,9 +219,7 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
211
219
|
"payload": [error.formatted],
|
|
212
220
|
}
|
|
213
221
|
)
|
|
214
|
-
await self._send_protocol_message(
|
|
215
|
-
{"type": "complete", "id": operation_id}
|
|
216
|
-
)
|
|
222
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
217
223
|
return
|
|
218
224
|
|
|
219
225
|
context = self._build_context()
|
|
@@ -234,11 +240,11 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
234
240
|
"payload": [error.formatted],
|
|
235
241
|
}
|
|
236
242
|
)
|
|
237
|
-
await self._send_protocol_message(
|
|
238
|
-
{"type": "complete", "id": operation_id}
|
|
239
|
-
)
|
|
243
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
240
244
|
return
|
|
241
|
-
except
|
|
245
|
+
except (
|
|
246
|
+
RECOVERABLE_SUBSCRIPTION_ERRORS
|
|
247
|
+
) as error: # pragma: no cover - defensive safeguard
|
|
242
248
|
await self._send_protocol_message(
|
|
243
249
|
{
|
|
244
250
|
"type": "error",
|
|
@@ -246,16 +252,12 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
246
252
|
"payload": [{"message": str(error)}],
|
|
247
253
|
}
|
|
248
254
|
)
|
|
249
|
-
await self._send_protocol_message(
|
|
250
|
-
{"type": "complete", "id": operation_id}
|
|
251
|
-
)
|
|
255
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
252
256
|
return
|
|
253
257
|
|
|
254
258
|
if isinstance(subscription, ExecutionResult):
|
|
255
259
|
await self._send_execution_result(operation_id, subscription)
|
|
256
|
-
await self._send_protocol_message(
|
|
257
|
-
{"type": "complete", "id": operation_id}
|
|
258
|
-
)
|
|
260
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
259
261
|
return
|
|
260
262
|
|
|
261
263
|
if operation_id in self.active_subscriptions:
|
|
@@ -268,11 +270,11 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
268
270
|
async def _handle_complete(self, content: dict[str, Any]) -> None:
|
|
269
271
|
"""
|
|
270
272
|
Handle an incoming "complete" protocol message by stopping the subscription for the specified operation.
|
|
271
|
-
|
|
273
|
+
|
|
272
274
|
If the message payload contains an "id" field that is a string, the corresponding active subscription task is cancelled and cleaned up; otherwise the message is ignored.
|
|
273
|
-
|
|
275
|
+
|
|
274
276
|
Parameters:
|
|
275
|
-
|
|
277
|
+
content (dict[str, Any]): The received protocol message payload. Expected to contain an "id" key with the operation identifier.
|
|
276
278
|
"""
|
|
277
279
|
operation_id = content.get("id")
|
|
278
280
|
if isinstance(operation_id, str):
|
|
@@ -282,23 +284,25 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
282
284
|
self, operation_id: str, async_iterator: Any
|
|
283
285
|
) -> None:
|
|
284
286
|
"""
|
|
285
|
-
Stream results from an async iterator to the client for a subscription operation.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
Stream execution results from an async iterator to the client for a subscription operation.
|
|
288
|
+
|
|
289
|
+
Sends each yielded execution result for the given operation_id to the client. If a recoverable error occurs while iterating, sends an error payload for the operation. In all cases, attempts to close the iterator, sends a completion message for the operation, and removes the operation from active_subscriptions.
|
|
290
|
+
|
|
289
291
|
Parameters:
|
|
290
|
-
operation_id (str): The subscription operation identifier
|
|
292
|
+
operation_id (str): The subscription operation identifier used in protocol messages.
|
|
291
293
|
async_iterator (Any): An asynchronous iterator that yields execution result objects to be sent to the client.
|
|
292
|
-
|
|
294
|
+
|
|
293
295
|
Raises:
|
|
294
|
-
asyncio.CancelledError: Propagated
|
|
296
|
+
asyncio.CancelledError: Propagated when the surrounding subscription task is cancelled.
|
|
295
297
|
"""
|
|
296
298
|
try:
|
|
297
299
|
async for result in async_iterator:
|
|
298
300
|
await self._send_execution_result(operation_id, result)
|
|
299
301
|
except asyncio.CancelledError:
|
|
300
302
|
raise
|
|
301
|
-
except
|
|
303
|
+
except (
|
|
304
|
+
RECOVERABLE_SUBSCRIPTION_ERRORS
|
|
305
|
+
) as error: # pragma: no cover - defensive safeguard
|
|
302
306
|
await self._send_protocol_message(
|
|
303
307
|
{
|
|
304
308
|
"type": "error",
|
|
@@ -308,15 +312,13 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
308
312
|
)
|
|
309
313
|
finally:
|
|
310
314
|
await self._close_iterator(async_iterator)
|
|
311
|
-
await self._send_protocol_message(
|
|
312
|
-
{"type": "complete", "id": operation_id}
|
|
313
|
-
)
|
|
315
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
314
316
|
self.active_subscriptions.pop(operation_id, None)
|
|
315
317
|
|
|
316
318
|
async def _stop_subscription(self, operation_id: str) -> None:
|
|
317
319
|
"""
|
|
318
320
|
Cancel and await the active subscription task for the given operation id, if one exists.
|
|
319
|
-
|
|
321
|
+
|
|
320
322
|
If a task is found for operation_id it is cancelled and awaited; CancelledError raised during awaiting is suppressed. If no task exists this is a no-op.
|
|
321
323
|
"""
|
|
322
324
|
task = self.active_subscriptions.pop(operation_id, None)
|
|
@@ -331,9 +333,9 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
331
333
|
) -> None:
|
|
332
334
|
"""
|
|
333
335
|
Send a GraphQL execution result to the client as a "next" protocol message.
|
|
334
|
-
|
|
336
|
+
|
|
335
337
|
The message payload includes a "data" field when result.data is present and an "errors" field when result.errors is non-empty; errors are converted to serializable dictionaries.
|
|
336
|
-
|
|
338
|
+
|
|
337
339
|
Parameters:
|
|
338
340
|
operation_id (str): The operation identifier to include as the message `id`.
|
|
339
341
|
result (ExecutionResult): The GraphQL execution result to serialize and send.
|
|
@@ -350,7 +352,7 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
350
352
|
async def _send_protocol_message(self, message: dict[str, Any]) -> None:
|
|
351
353
|
"""
|
|
352
354
|
Send a JSON-serializable GraphQL transport protocol message over the WebSocket.
|
|
353
|
-
|
|
355
|
+
|
|
354
356
|
Parameters:
|
|
355
357
|
message (dict[str, Any]): The protocol message to send. If the connection is already closed, the message is discarded silently.
|
|
356
358
|
"""
|
|
@@ -363,7 +365,7 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
363
365
|
def _build_context(self) -> Any:
|
|
364
366
|
"""
|
|
365
367
|
Builds a request context object for GraphQL execution containing the current user, decoded headers, scope, and connection parameters.
|
|
366
|
-
|
|
368
|
+
|
|
367
369
|
Returns:
|
|
368
370
|
context (SimpleNamespace): An object with attributes:
|
|
369
371
|
- `user`: the value of `scope["user"]` (may be None).
|
|
@@ -375,7 +377,9 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
375
377
|
raw_headers = self.scope.get("headers") or []
|
|
376
378
|
headers = {
|
|
377
379
|
(key.decode("latin1") if isinstance(key, (bytes, bytearray)) else key): (
|
|
378
|
-
value.decode("latin1")
|
|
380
|
+
value.decode("latin1")
|
|
381
|
+
if isinstance(value, (bytes, bytearray))
|
|
382
|
+
else value
|
|
379
383
|
)
|
|
380
384
|
for key, value in raw_headers
|
|
381
385
|
}
|
|
@@ -390,10 +394,10 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
390
394
|
def _schema_has_no_subscription(schema: GraphQLSchema) -> bool:
|
|
391
395
|
"""
|
|
392
396
|
Check whether the provided GraphQL schema defines no subscription root type.
|
|
393
|
-
|
|
397
|
+
|
|
394
398
|
Parameters:
|
|
395
399
|
schema (GraphQLSchema): The schema to inspect.
|
|
396
|
-
|
|
400
|
+
|
|
397
401
|
Returns:
|
|
398
402
|
bool: `True` if the schema has no subscription type, `False` otherwise.
|
|
399
403
|
"""
|
|
@@ -402,14 +406,13 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
402
406
|
@staticmethod
|
|
403
407
|
def _format_error(error: Exception) -> dict[str, Any]:
|
|
404
408
|
"""
|
|
405
|
-
Format an exception
|
|
406
|
-
|
|
409
|
+
Format an exception as a GraphQL-compatible error dictionary.
|
|
410
|
+
|
|
407
411
|
Parameters:
|
|
408
|
-
error (Exception): The exception to format;
|
|
409
|
-
|
|
412
|
+
error (Exception): The exception to format; if a `GraphQLError`, its `.formatted` representation is used.
|
|
413
|
+
|
|
410
414
|
Returns:
|
|
411
|
-
dict[str, Any]:
|
|
412
|
-
Otherwise returns a dictionary with a single `"message"` key containing the exception's string.
|
|
415
|
+
dict[str, Any]: The error payload: the `GraphQLError.formatted` mapping for GraphQLError instances, otherwise `{"message": str(error)}`.
|
|
413
416
|
"""
|
|
414
417
|
if isinstance(error, GraphQLError):
|
|
415
418
|
return cast(dict[str, Any], error.formatted)
|
|
@@ -418,12 +421,12 @@ class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
|
418
421
|
@staticmethod
|
|
419
422
|
async def _close_iterator(async_iterator: Any) -> None:
|
|
420
423
|
"""
|
|
421
|
-
Close an asynchronous iterator
|
|
422
|
-
|
|
424
|
+
Close an asynchronous iterator by awaiting its `aclose` coroutine if present.
|
|
425
|
+
|
|
423
426
|
Parameters:
|
|
424
|
-
async_iterator (Any):
|
|
427
|
+
async_iterator (Any): The iterator to close; if it defines an `aclose` coroutine method, that coroutine will be awaited.
|
|
425
428
|
"""
|
|
426
429
|
close = getattr(async_iterator, "aclose", None)
|
|
427
430
|
if close is None:
|
|
428
431
|
return
|
|
429
|
-
await close()
|
|
432
|
+
await close()
|
general_manager/api/mutation.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
4
|
from typing import (
|
|
5
|
+
Any,
|
|
5
6
|
Callable,
|
|
6
7
|
Optional,
|
|
7
8
|
TypeVar,
|
|
@@ -13,21 +14,83 @@ from typing import (
|
|
|
13
14
|
Type,
|
|
14
15
|
get_type_hints,
|
|
15
16
|
cast,
|
|
17
|
+
TypeAliasType,
|
|
16
18
|
)
|
|
17
19
|
import graphene # type: ignore[import]
|
|
18
20
|
from graphql import GraphQLResolveInfo
|
|
19
21
|
|
|
20
|
-
from general_manager.api.graphql import GraphQL
|
|
22
|
+
from general_manager.api.graphql import GraphQL, HANDLED_MANAGER_ERRORS
|
|
21
23
|
from general_manager.manager.generalManager import GeneralManager
|
|
22
24
|
|
|
23
25
|
from general_manager.utils.formatString import snake_to_camel
|
|
24
|
-
from typing import TypeAliasType
|
|
25
26
|
from general_manager.permission.mutationPermission import MutationPermission
|
|
27
|
+
from types import UnionType
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
FuncT = TypeVar("FuncT", bound=Callable[..., object])
|
|
29
31
|
|
|
30
32
|
|
|
33
|
+
class MissingParameterTypeHintError(TypeError):
|
|
34
|
+
"""Raised when a mutation resolver parameter lacks a type hint."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, parameter_name: str, function_name: str) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Initialize the exception indicating a missing type hint for a function parameter.
|
|
39
|
+
|
|
40
|
+
Parameters:
|
|
41
|
+
parameter_name (str): Name of the parameter that lacks a type hint.
|
|
42
|
+
function_name (str): Name of the function containing the parameter.
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(
|
|
45
|
+
f"Missing type hint for parameter {parameter_name} in {function_name}."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MissingMutationReturnAnnotationError(TypeError):
|
|
50
|
+
"""Raised when a mutation resolver does not specify a return annotation."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, function_name: str) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Initialize the exception indicating a mutation is missing a return annotation.
|
|
55
|
+
|
|
56
|
+
Parameters:
|
|
57
|
+
function_name (str): Name of the mutation function that lacks a return annotation.
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(f"Mutation {function_name} missing return annotation.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InvalidMutationReturnTypeError(TypeError):
|
|
63
|
+
"""Raised when a mutation resolver declares a non-type return value."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, function_name: str, return_type: object) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Initialize an InvalidMutationReturnTypeError for a mutation whose return annotation is not a valid type.
|
|
68
|
+
|
|
69
|
+
Parameters:
|
|
70
|
+
function_name (str): Name of the mutation function that provided the invalid return annotation.
|
|
71
|
+
return_type (object): The invalid return annotation value that triggered the error.
|
|
72
|
+
"""
|
|
73
|
+
super().__init__(
|
|
74
|
+
f"Mutation {function_name} return type {return_type} is not a type."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DuplicateMutationOutputNameError(ValueError):
|
|
79
|
+
"""Raised when a mutation resolver would expose duplicate output field names."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, function_name: str, field_name: str) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Initialize the exception indicating duplicate output field names.
|
|
84
|
+
|
|
85
|
+
Parameters:
|
|
86
|
+
function_name (str): Name of the mutation function that produced duplicates.
|
|
87
|
+
field_name (str): The conflicting output field name.
|
|
88
|
+
"""
|
|
89
|
+
super().__init__(
|
|
90
|
+
f"Mutation {function_name} produces duplicate output field name '{field_name}'."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
31
94
|
def graphQlMutation(
|
|
32
95
|
_func: FuncT | type[MutationPermission] | None = None,
|
|
33
96
|
permission: Optional[Type[MutationPermission]] = None,
|
|
@@ -68,21 +131,19 @@ def graphQlMutation(
|
|
|
68
131
|
mutation_name = snake_to_camel(fn.__name__)
|
|
69
132
|
|
|
70
133
|
# Build Arguments inner class dynamically
|
|
71
|
-
arg_fields = {}
|
|
134
|
+
arg_fields: dict[str, Any] = {}
|
|
72
135
|
for name, param in sig.parameters.items():
|
|
73
136
|
if name == "info":
|
|
74
137
|
continue
|
|
75
138
|
ann = hints.get(name)
|
|
76
139
|
if ann is None:
|
|
77
|
-
raise
|
|
78
|
-
f"Missing type hint for parameter {name} in {fn.__name__}"
|
|
79
|
-
)
|
|
140
|
+
raise MissingParameterTypeHintError(name, fn.__name__)
|
|
80
141
|
required = True
|
|
81
142
|
default = param.default
|
|
82
143
|
has_default = default is not inspect._empty
|
|
83
144
|
|
|
84
145
|
# Prepare kwargs
|
|
85
|
-
kwargs = {}
|
|
146
|
+
kwargs: dict[str, Any] = {}
|
|
86
147
|
if required:
|
|
87
148
|
kwargs["required"] = True
|
|
88
149
|
if has_default:
|
|
@@ -90,13 +151,14 @@ def graphQlMutation(
|
|
|
90
151
|
|
|
91
152
|
# Handle Optional[...] → not required
|
|
92
153
|
origin = get_origin(ann)
|
|
93
|
-
if origin is Union and type(None) in get_args(ann):
|
|
154
|
+
if (origin is Union or origin is UnionType) and type(None) in get_args(ann):
|
|
94
155
|
required = False
|
|
95
156
|
# extract inner type
|
|
96
|
-
ann =
|
|
157
|
+
ann = next(a for a in get_args(ann) if a is not type(None))
|
|
97
158
|
kwargs["required"] = False
|
|
98
159
|
|
|
99
160
|
# Resolve list types to List scalar
|
|
161
|
+
field: Any
|
|
100
162
|
if get_origin(ann) is list or get_origin(ann) is List:
|
|
101
163
|
inner = get_args(ann)[0]
|
|
102
164
|
field = graphene.List(
|
|
@@ -114,12 +176,12 @@ def graphQlMutation(
|
|
|
114
176
|
Arguments = type("Arguments", (), arg_fields)
|
|
115
177
|
|
|
116
178
|
# Build output fields: success + fn return types
|
|
117
|
-
outputs = {
|
|
179
|
+
outputs: dict[str, Any] = {
|
|
118
180
|
"success": graphene.Boolean(required=True),
|
|
119
181
|
}
|
|
120
182
|
return_ann: type | tuple[type] | None = hints.get("return")
|
|
121
183
|
if return_ann is None:
|
|
122
|
-
raise
|
|
184
|
+
raise MissingMutationReturnAnnotationError(fn.__name__)
|
|
123
185
|
|
|
124
186
|
# Unpack tuple return or single
|
|
125
187
|
out_types = (
|
|
@@ -131,11 +193,11 @@ def graphQlMutation(
|
|
|
131
193
|
is_named_type = isinstance(out, TypeAliasType)
|
|
132
194
|
is_type = isinstance(out, type)
|
|
133
195
|
if not is_type and not is_named_type:
|
|
134
|
-
raise
|
|
135
|
-
f"Mutation {fn.__name__} return type {out} is not a type"
|
|
136
|
-
)
|
|
196
|
+
raise InvalidMutationReturnTypeError(fn.__name__, out)
|
|
137
197
|
name = out.__name__
|
|
138
198
|
field_name = name[0].lower() + name[1:]
|
|
199
|
+
if field_name in outputs:
|
|
200
|
+
raise DuplicateMutationOutputNameError(fn.__name__, field_name)
|
|
139
201
|
|
|
140
202
|
basis_type = out.__value__ if is_named_type else out
|
|
141
203
|
|
|
@@ -150,26 +212,27 @@ def graphQlMutation(
|
|
|
150
212
|
**kwargs: object,
|
|
151
213
|
) -> graphene.Mutation:
|
|
152
214
|
"""
|
|
153
|
-
Execute the mutation resolver,
|
|
215
|
+
Execute the mutation resolver, enforce an optional permission check, and convert the resolver result into the mutation's output fields.
|
|
154
216
|
|
|
155
217
|
Parameters:
|
|
156
218
|
root: Graphene root object (unused).
|
|
157
|
-
info: GraphQL execution info
|
|
219
|
+
info: GraphQL execution info provided by Graphene.
|
|
158
220
|
**kwargs: Mutation arguments provided by the client.
|
|
159
221
|
|
|
160
222
|
Returns:
|
|
161
|
-
mutation_class: Instance
|
|
223
|
+
mutation_class: Instance of the mutation with output fields populated; `success` is `True` on successful execution and `False` if a handled manager error occurred (after being forwarded to GraphQL._handleGraphQLError).
|
|
162
224
|
"""
|
|
163
225
|
if permission:
|
|
164
226
|
permission.check(kwargs, info.context.user)
|
|
165
227
|
try:
|
|
166
228
|
result = fn(info, **kwargs)
|
|
167
|
-
data = {}
|
|
229
|
+
data: dict[str, Any] = {}
|
|
168
230
|
if isinstance(result, tuple):
|
|
169
231
|
# unpack according to outputs ordering after success
|
|
170
232
|
for (field, _), val in zip(
|
|
171
233
|
outputs.items(),
|
|
172
|
-
[None, *list(result)],
|
|
234
|
+
[None, *list(result)],
|
|
235
|
+
strict=False, # None for success field to be set later
|
|
173
236
|
):
|
|
174
237
|
# skip success
|
|
175
238
|
if field == "success":
|
|
@@ -180,12 +243,11 @@ def graphQlMutation(
|
|
|
180
243
|
data[only] = result
|
|
181
244
|
data["success"] = True
|
|
182
245
|
return mutation_class(**data)
|
|
183
|
-
except
|
|
184
|
-
GraphQL._handleGraphQLError(
|
|
185
|
-
return mutation_class(**{"success": False})
|
|
246
|
+
except HANDLED_MANAGER_ERRORS as error:
|
|
247
|
+
raise GraphQL._handleGraphQLError(error) from error
|
|
186
248
|
|
|
187
249
|
# Assemble class dict
|
|
188
|
-
class_dict = {
|
|
250
|
+
class_dict: dict[str, Any] = {
|
|
189
251
|
"Arguments": Arguments,
|
|
190
252
|
"__doc__": fn.__doc__,
|
|
191
253
|
"mutate": staticmethod(_mutate),
|