GeneralManager 0.16.1__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 +897 -147
- general_manager/api/graphql_subscription_consumer.py +432 -0
- general_manager/api/mutation.py +85 -23
- general_manager/api/property.py +39 -13
- general_manager/apps.py +336 -40
- 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 +303 -53
- 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 +21 -7
- 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 +49 -42
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/METADATA +3 -1
- generalmanager-0.18.0.dist-info/RECORD +77 -0
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
- generalmanager-0.16.1.dist-info/RECORD +0 -76
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from channels.generic.websocket import AsyncJsonWebsocketConsumer # type: ignore[import-untyped]
|
|
9
|
+
from graphql import (
|
|
10
|
+
ExecutionResult,
|
|
11
|
+
GraphQLError,
|
|
12
|
+
GraphQLSchema,
|
|
13
|
+
parse,
|
|
14
|
+
subscribe,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from general_manager.api.graphql import GraphQL
|
|
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
|
+
|
|
31
|
+
class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
32
|
+
"""
|
|
33
|
+
Websocket consumer implementing the ``graphql-transport-ws`` protocol for GraphQL subscriptions.
|
|
34
|
+
|
|
35
|
+
The consumer streams results produced by the dynamically generated GeneralManager GraphQL schema so
|
|
36
|
+
clients such as GraphiQL can subscribe to live updates.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
connection_acknowledged: bool
|
|
40
|
+
connection_params: dict[str, Any]
|
|
41
|
+
|
|
42
|
+
async def connect(self) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialize connection state and accept the WebSocket, preferring the "graphql-transport-ws" subprotocol when offered.
|
|
45
|
+
|
|
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.
|
|
47
|
+
"""
|
|
48
|
+
self.connection_acknowledged = False
|
|
49
|
+
self.connection_params = {}
|
|
50
|
+
self.active_subscriptions: dict[str, asyncio.Task[None]] = {}
|
|
51
|
+
subprotocols = self.scope.get("subprotocols", [])
|
|
52
|
+
selected_subprotocol = (
|
|
53
|
+
"graphql-transport-ws" if "graphql-transport-ws" in subprotocols else None
|
|
54
|
+
)
|
|
55
|
+
await self.accept(subprotocol=selected_subprotocol)
|
|
56
|
+
|
|
57
|
+
async def disconnect(self, code: int) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Perform cleanup on WebSocket disconnect by cancelling and awaiting active subscription tasks and clearing the subscription registry.
|
|
60
|
+
|
|
61
|
+
Parameters:
|
|
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.
|
|
66
|
+
"""
|
|
67
|
+
tasks = list(self.active_subscriptions.values())
|
|
68
|
+
for task in tasks:
|
|
69
|
+
task.cancel()
|
|
70
|
+
for task in tasks:
|
|
71
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
72
|
+
await task
|
|
73
|
+
self.active_subscriptions.clear()
|
|
74
|
+
|
|
75
|
+
async def receive_json(self, content: dict[str, Any], **_: Any) -> None:
|
|
76
|
+
"""
|
|
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
|
+
|
|
81
|
+
Parameters:
|
|
82
|
+
content (dict[str, Any]): The received JSON message; expected to include a "type" key indicating the protocol action.
|
|
83
|
+
"""
|
|
84
|
+
message_type = content.get("type")
|
|
85
|
+
if message_type == "connection_init":
|
|
86
|
+
await self._handle_connection_init(content)
|
|
87
|
+
elif message_type == "ping":
|
|
88
|
+
await self._handle_ping(content)
|
|
89
|
+
elif message_type == "subscribe":
|
|
90
|
+
await self._handle_subscribe(content)
|
|
91
|
+
elif message_type == "complete":
|
|
92
|
+
await self._handle_complete(content)
|
|
93
|
+
else:
|
|
94
|
+
await self.close(code=4400)
|
|
95
|
+
|
|
96
|
+
async def _handle_connection_init(self, content: dict[str, Any]) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Handle a client's "connection_init" message and send a protocol acknowledgment.
|
|
99
|
+
|
|
100
|
+
If the connection has already been acknowledged, closes the WebSocket with code 4429.
|
|
101
|
+
If the incoming message contains a "payload" that is a dict, stores it on
|
|
102
|
+
self.connection_params; otherwise clears connection_params. Marks the connection
|
|
103
|
+
as acknowledged and sends a "connection_ack" protocol message.
|
|
104
|
+
|
|
105
|
+
Parameters:
|
|
106
|
+
content (dict[str, Any]): The received WebSocket message for "connection_init".
|
|
107
|
+
"""
|
|
108
|
+
if self.connection_acknowledged:
|
|
109
|
+
await self.close(code=4429)
|
|
110
|
+
return
|
|
111
|
+
payload = content.get("payload")
|
|
112
|
+
if isinstance(payload, dict):
|
|
113
|
+
self.connection_params = payload
|
|
114
|
+
else:
|
|
115
|
+
self.connection_params = {}
|
|
116
|
+
self.connection_acknowledged = True
|
|
117
|
+
await self._send_protocol_message({"type": "connection_ack"})
|
|
118
|
+
|
|
119
|
+
async def _handle_ping(self, content: dict[str, Any]) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Responds to an incoming ping by sending a pong protocol message.
|
|
122
|
+
|
|
123
|
+
If the incoming `content` contains a `"payload"` key, its value is included in the sent pong message under the same key.
|
|
124
|
+
|
|
125
|
+
Parameters:
|
|
126
|
+
content (dict[str, Any]): The received message object; may include an optional `"payload"` to echo.
|
|
127
|
+
"""
|
|
128
|
+
payload = content.get("payload")
|
|
129
|
+
response: dict[str, Any] = {"type": "pong"}
|
|
130
|
+
if payload is not None:
|
|
131
|
+
response["payload"] = payload
|
|
132
|
+
await self._send_protocol_message(response)
|
|
133
|
+
|
|
134
|
+
async def _handle_subscribe(self, content: dict[str, Any]) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Handle an incoming GraphQL "subscribe" protocol message and initiate or deliver the corresponding subscription results.
|
|
137
|
+
|
|
138
|
+
Parameters:
|
|
139
|
+
content (dict[str, Any]): The incoming protocol message. Expected keys:
|
|
140
|
+
- "id" (str): Operation identifier.
|
|
141
|
+
- "payload" (dict): Operation payload containing:
|
|
142
|
+
- "query" (str): GraphQL query string (required).
|
|
143
|
+
- "variables" (dict, optional): Operation variables.
|
|
144
|
+
- "operationName" (str, optional): Named operation to execute.
|
|
145
|
+
"""
|
|
146
|
+
if not self.connection_acknowledged:
|
|
147
|
+
await self.close(code=4401)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
operation_id = content.get("id")
|
|
151
|
+
payload = content.get("payload", {})
|
|
152
|
+
if not isinstance(operation_id, str) or not isinstance(payload, dict):
|
|
153
|
+
await self.close(code=4403)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
schema = GraphQL.get_schema()
|
|
157
|
+
if schema is None or self._schema_has_no_subscription(schema.graphql_schema):
|
|
158
|
+
await self._send_protocol_message(
|
|
159
|
+
{
|
|
160
|
+
"type": "error",
|
|
161
|
+
"id": operation_id,
|
|
162
|
+
"payload": [
|
|
163
|
+
{"message": "GraphQL subscriptions are not configured."}
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
query = payload.get("query")
|
|
171
|
+
if not isinstance(query, str):
|
|
172
|
+
await self._send_protocol_message(
|
|
173
|
+
{
|
|
174
|
+
"type": "error",
|
|
175
|
+
"id": operation_id,
|
|
176
|
+
"payload": [{"message": "A GraphQL query string is required."}],
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
variables = payload.get("variables")
|
|
183
|
+
if variables is not None and not isinstance(variables, dict):
|
|
184
|
+
await self._send_protocol_message(
|
|
185
|
+
{
|
|
186
|
+
"type": "error",
|
|
187
|
+
"id": operation_id,
|
|
188
|
+
"payload": [
|
|
189
|
+
{"message": "Variables must be provided as an object."}
|
|
190
|
+
],
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
operation_name = payload.get("operationName")
|
|
197
|
+
if operation_name is not None and not isinstance(operation_name, str):
|
|
198
|
+
await self._send_protocol_message(
|
|
199
|
+
{
|
|
200
|
+
"type": "error",
|
|
201
|
+
"id": operation_id,
|
|
202
|
+
"payload": [
|
|
203
|
+
{
|
|
204
|
+
"message": "The operation name must be a string when provided."
|
|
205
|
+
}
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
document = parse(query)
|
|
214
|
+
except GraphQLError as error:
|
|
215
|
+
await self._send_protocol_message(
|
|
216
|
+
{
|
|
217
|
+
"type": "error",
|
|
218
|
+
"id": operation_id,
|
|
219
|
+
"payload": [error.formatted],
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
context = self._build_context()
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
subscription = await subscribe(
|
|
229
|
+
schema.graphql_schema,
|
|
230
|
+
document,
|
|
231
|
+
variable_values=variables,
|
|
232
|
+
operation_name=operation_name,
|
|
233
|
+
context_value=context,
|
|
234
|
+
)
|
|
235
|
+
except GraphQLError as error:
|
|
236
|
+
await self._send_protocol_message(
|
|
237
|
+
{
|
|
238
|
+
"type": "error",
|
|
239
|
+
"id": operation_id,
|
|
240
|
+
"payload": [error.formatted],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
244
|
+
return
|
|
245
|
+
except (
|
|
246
|
+
RECOVERABLE_SUBSCRIPTION_ERRORS
|
|
247
|
+
) as error: # pragma: no cover - defensive safeguard
|
|
248
|
+
await self._send_protocol_message(
|
|
249
|
+
{
|
|
250
|
+
"type": "error",
|
|
251
|
+
"id": operation_id,
|
|
252
|
+
"payload": [{"message": str(error)}],
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
if isinstance(subscription, ExecutionResult):
|
|
259
|
+
await self._send_execution_result(operation_id, subscription)
|
|
260
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if operation_id in self.active_subscriptions:
|
|
264
|
+
await self._stop_subscription(operation_id)
|
|
265
|
+
|
|
266
|
+
self.active_subscriptions[operation_id] = asyncio.create_task(
|
|
267
|
+
self._stream_subscription(operation_id, subscription)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
async def _handle_complete(self, content: dict[str, Any]) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Handle an incoming "complete" protocol message by stopping the subscription for the specified operation.
|
|
273
|
+
|
|
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.
|
|
275
|
+
|
|
276
|
+
Parameters:
|
|
277
|
+
content (dict[str, Any]): The received protocol message payload. Expected to contain an "id" key with the operation identifier.
|
|
278
|
+
"""
|
|
279
|
+
operation_id = content.get("id")
|
|
280
|
+
if isinstance(operation_id, str):
|
|
281
|
+
await self._stop_subscription(operation_id)
|
|
282
|
+
|
|
283
|
+
async def _stream_subscription(
|
|
284
|
+
self, operation_id: str, async_iterator: Any
|
|
285
|
+
) -> None:
|
|
286
|
+
"""
|
|
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
|
+
|
|
291
|
+
Parameters:
|
|
292
|
+
operation_id (str): The subscription operation identifier used in protocol messages.
|
|
293
|
+
async_iterator (Any): An asynchronous iterator that yields execution result objects to be sent to the client.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
asyncio.CancelledError: Propagated when the surrounding subscription task is cancelled.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
async for result in async_iterator:
|
|
300
|
+
await self._send_execution_result(operation_id, result)
|
|
301
|
+
except asyncio.CancelledError:
|
|
302
|
+
raise
|
|
303
|
+
except (
|
|
304
|
+
RECOVERABLE_SUBSCRIPTION_ERRORS
|
|
305
|
+
) as error: # pragma: no cover - defensive safeguard
|
|
306
|
+
await self._send_protocol_message(
|
|
307
|
+
{
|
|
308
|
+
"type": "error",
|
|
309
|
+
"id": operation_id,
|
|
310
|
+
"payload": [{"message": str(error)}],
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
finally:
|
|
314
|
+
await self._close_iterator(async_iterator)
|
|
315
|
+
await self._send_protocol_message({"type": "complete", "id": operation_id})
|
|
316
|
+
self.active_subscriptions.pop(operation_id, None)
|
|
317
|
+
|
|
318
|
+
async def _stop_subscription(self, operation_id: str) -> None:
|
|
319
|
+
"""
|
|
320
|
+
Cancel and await the active subscription task for the given operation id, if one exists.
|
|
321
|
+
|
|
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.
|
|
323
|
+
"""
|
|
324
|
+
task = self.active_subscriptions.pop(operation_id, None)
|
|
325
|
+
if task is None:
|
|
326
|
+
return
|
|
327
|
+
task.cancel()
|
|
328
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
329
|
+
await task
|
|
330
|
+
|
|
331
|
+
async def _send_execution_result(
|
|
332
|
+
self, operation_id: str, result: ExecutionResult
|
|
333
|
+
) -> None:
|
|
334
|
+
"""
|
|
335
|
+
Send a GraphQL execution result to the client as a "next" protocol message.
|
|
336
|
+
|
|
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.
|
|
338
|
+
|
|
339
|
+
Parameters:
|
|
340
|
+
operation_id (str): The operation identifier to include as the message `id`.
|
|
341
|
+
result (ExecutionResult): The GraphQL execution result to serialize and send.
|
|
342
|
+
"""
|
|
343
|
+
payload: dict[str, Any] = {}
|
|
344
|
+
if result.data is not None:
|
|
345
|
+
payload["data"] = result.data
|
|
346
|
+
if result.errors:
|
|
347
|
+
payload["errors"] = [self._format_error(error) for error in result.errors]
|
|
348
|
+
await self._send_protocol_message(
|
|
349
|
+
{"type": "next", "id": operation_id, "payload": payload}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def _send_protocol_message(self, message: dict[str, Any]) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Send a JSON-serializable GraphQL transport protocol message over the WebSocket.
|
|
355
|
+
|
|
356
|
+
Parameters:
|
|
357
|
+
message (dict[str, Any]): The protocol message to send. If the connection is already closed, the message is discarded silently.
|
|
358
|
+
"""
|
|
359
|
+
try:
|
|
360
|
+
await self.send_json(message)
|
|
361
|
+
except RuntimeError:
|
|
362
|
+
# The connection has already been closed. There is nothing else to send.
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
def _build_context(self) -> Any:
|
|
366
|
+
"""
|
|
367
|
+
Builds a request context object for GraphQL execution containing the current user, decoded headers, scope, and connection parameters.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
context (SimpleNamespace): An object with attributes:
|
|
371
|
+
- `user`: the value of `scope["user"]` (may be None).
|
|
372
|
+
- `headers`: a dict mapping header names to decoded string values.
|
|
373
|
+
- `scope`: the consumer's `scope`.
|
|
374
|
+
- `connection_params`: the connection parameters provided during `connection_init`.
|
|
375
|
+
"""
|
|
376
|
+
user = self.scope.get("user")
|
|
377
|
+
raw_headers = self.scope.get("headers") or []
|
|
378
|
+
headers = {
|
|
379
|
+
(key.decode("latin1") if isinstance(key, (bytes, bytearray)) else key): (
|
|
380
|
+
value.decode("latin1")
|
|
381
|
+
if isinstance(value, (bytes, bytearray))
|
|
382
|
+
else value
|
|
383
|
+
)
|
|
384
|
+
for key, value in raw_headers
|
|
385
|
+
}
|
|
386
|
+
return SimpleNamespace(
|
|
387
|
+
user=user,
|
|
388
|
+
headers=headers,
|
|
389
|
+
scope=self.scope,
|
|
390
|
+
connection_params=self.connection_params,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _schema_has_no_subscription(schema: GraphQLSchema) -> bool:
|
|
395
|
+
"""
|
|
396
|
+
Check whether the provided GraphQL schema defines no subscription root type.
|
|
397
|
+
|
|
398
|
+
Parameters:
|
|
399
|
+
schema (GraphQLSchema): The schema to inspect.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
bool: `True` if the schema has no subscription type, `False` otherwise.
|
|
403
|
+
"""
|
|
404
|
+
return schema.subscription_type is None
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _format_error(error: Exception) -> dict[str, Any]:
|
|
408
|
+
"""
|
|
409
|
+
Format an exception as a GraphQL-compatible error dictionary.
|
|
410
|
+
|
|
411
|
+
Parameters:
|
|
412
|
+
error (Exception): The exception to format; if a `GraphQLError`, its `.formatted` representation is used.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
dict[str, Any]: The error payload: the `GraphQLError.formatted` mapping for GraphQLError instances, otherwise `{"message": str(error)}`.
|
|
416
|
+
"""
|
|
417
|
+
if isinstance(error, GraphQLError):
|
|
418
|
+
return cast(dict[str, Any], error.formatted)
|
|
419
|
+
return {"message": str(error)}
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
async def _close_iterator(async_iterator: Any) -> None:
|
|
423
|
+
"""
|
|
424
|
+
Close an asynchronous iterator by awaiting its `aclose` coroutine if present.
|
|
425
|
+
|
|
426
|
+
Parameters:
|
|
427
|
+
async_iterator (Any): The iterator to close; if it defines an `aclose` coroutine method, that coroutine will be awaited.
|
|
428
|
+
"""
|
|
429
|
+
close = getattr(async_iterator, "aclose", None)
|
|
430
|
+
if close is None:
|
|
431
|
+
return
|
|
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),
|