GeneralManager 0.16.0__py3-none-any.whl → 0.17.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/api/graphql.py +689 -74
- general_manager/api/graphql_subscription_consumer.py +429 -0
- general_manager/apps.py +164 -9
- general_manager/cache/dependencyIndex.py +169 -17
- general_manager/manager/groupManager.py +1 -1
- general_manager/py.typed +0 -0
- general_manager/utils/testing.py +26 -17
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/METADATA +10 -3
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/RECORD +12 -10
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/licenses/LICENSE +0 -0
- {generalmanager-0.16.0.dist-info → generalmanager-0.17.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
from channels.generic.websocket import AsyncJsonWebsocketConsumer # type: ignore[import-untyped]
|
|
8
|
+
from graphql import (
|
|
9
|
+
ExecutionResult,
|
|
10
|
+
GraphQLError,
|
|
11
|
+
GraphQLSchema,
|
|
12
|
+
parse,
|
|
13
|
+
subscribe,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from general_manager.api.graphql import GraphQL
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GraphQLSubscriptionConsumer(AsyncJsonWebsocketConsumer):
|
|
20
|
+
"""
|
|
21
|
+
Websocket consumer implementing the ``graphql-transport-ws`` protocol for GraphQL subscriptions.
|
|
22
|
+
|
|
23
|
+
The consumer streams results produced by the dynamically generated GeneralManager GraphQL schema so
|
|
24
|
+
clients such as GraphiQL can subscribe to live updates.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
connection_acknowledged: bool
|
|
28
|
+
connection_params: dict[str, Any]
|
|
29
|
+
|
|
30
|
+
async def connect(self) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initialize connection state and accept the WebSocket, preferring the "graphql-transport-ws" subprotocol when offered.
|
|
33
|
+
|
|
34
|
+
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
|
+
"""
|
|
36
|
+
self.connection_acknowledged = False
|
|
37
|
+
self.connection_params = {}
|
|
38
|
+
self.active_subscriptions: dict[str, asyncio.Task[None]] = {}
|
|
39
|
+
subprotocols = self.scope.get("subprotocols", [])
|
|
40
|
+
selected_subprotocol = (
|
|
41
|
+
"graphql-transport-ws" if "graphql-transport-ws" in subprotocols else None
|
|
42
|
+
)
|
|
43
|
+
await self.accept(subprotocol=selected_subprotocol)
|
|
44
|
+
|
|
45
|
+
async def disconnect(self, code: int) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Perform cleanup when the WebSocket disconnects by cancelling and awaiting any active subscription tasks and clearing the subscription registry.
|
|
48
|
+
|
|
49
|
+
Parameters:
|
|
50
|
+
code (int): The WebSocket close code received from the connection.
|
|
51
|
+
"""
|
|
52
|
+
tasks = list(self.active_subscriptions.values())
|
|
53
|
+
for task in tasks:
|
|
54
|
+
task.cancel()
|
|
55
|
+
for task in tasks:
|
|
56
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
57
|
+
await task
|
|
58
|
+
self.active_subscriptions.clear()
|
|
59
|
+
|
|
60
|
+
async def receive_json(self, content: dict[str, Any], **_: Any) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Dispatch incoming graphql-transport-ws protocol messages to the appropriate handler.
|
|
63
|
+
|
|
64
|
+
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.
|
|
69
|
+
"""
|
|
70
|
+
message_type = content.get("type")
|
|
71
|
+
if message_type == "connection_init":
|
|
72
|
+
await self._handle_connection_init(content)
|
|
73
|
+
elif message_type == "ping":
|
|
74
|
+
await self._handle_ping(content)
|
|
75
|
+
elif message_type == "subscribe":
|
|
76
|
+
await self._handle_subscribe(content)
|
|
77
|
+
elif message_type == "complete":
|
|
78
|
+
await self._handle_complete(content)
|
|
79
|
+
else:
|
|
80
|
+
await self.close(code=4400)
|
|
81
|
+
|
|
82
|
+
async def _handle_connection_init(self, content: dict[str, Any]) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Handle a client's "connection_init" message and send a protocol acknowledgment.
|
|
85
|
+
|
|
86
|
+
If the connection has already been acknowledged, closes the WebSocket with code 4429.
|
|
87
|
+
If the incoming message contains a "payload" that is a dict, stores it on
|
|
88
|
+
self.connection_params; otherwise clears connection_params. Marks the connection
|
|
89
|
+
as acknowledged and sends a "connection_ack" protocol message.
|
|
90
|
+
|
|
91
|
+
Parameters:
|
|
92
|
+
content (dict[str, Any]): The received WebSocket message for "connection_init".
|
|
93
|
+
"""
|
|
94
|
+
if self.connection_acknowledged:
|
|
95
|
+
await self.close(code=4429)
|
|
96
|
+
return
|
|
97
|
+
payload = content.get("payload")
|
|
98
|
+
if isinstance(payload, dict):
|
|
99
|
+
self.connection_params = payload
|
|
100
|
+
else:
|
|
101
|
+
self.connection_params = {}
|
|
102
|
+
self.connection_acknowledged = True
|
|
103
|
+
await self._send_protocol_message({"type": "connection_ack"})
|
|
104
|
+
|
|
105
|
+
async def _handle_ping(self, content: dict[str, Any]) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Responds to an incoming ping by sending a pong protocol message.
|
|
108
|
+
|
|
109
|
+
If the incoming `content` contains a `"payload"` key, its value is included in the sent pong message under the same key.
|
|
110
|
+
|
|
111
|
+
Parameters:
|
|
112
|
+
content (dict[str, Any]): The received message object; may include an optional `"payload"` to echo.
|
|
113
|
+
"""
|
|
114
|
+
payload = content.get("payload")
|
|
115
|
+
response: dict[str, Any] = {"type": "pong"}
|
|
116
|
+
if payload is not None:
|
|
117
|
+
response["payload"] = payload
|
|
118
|
+
await self._send_protocol_message(response)
|
|
119
|
+
|
|
120
|
+
async def _handle_subscribe(self, content: dict[str, Any]) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Handle an incoming GraphQL "subscribe" message and start or send the corresponding subscription results.
|
|
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
|
+
|
|
126
|
+
Parameters:
|
|
127
|
+
content (dict[str, Any]): The incoming protocol message. Expected keys:
|
|
128
|
+
- "id" (str): The operation identifier.
|
|
129
|
+
- "payload" (dict): Operation payload containing:
|
|
130
|
+
- "query" (str): GraphQL query string (required).
|
|
131
|
+
- "variables" (dict, optional): Operation variables.
|
|
132
|
+
- "operationName" (str, optional): Named operation to execute.
|
|
133
|
+
"""
|
|
134
|
+
if not self.connection_acknowledged:
|
|
135
|
+
await self.close(code=4401)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
operation_id = content.get("id")
|
|
139
|
+
payload = content.get("payload", {})
|
|
140
|
+
if not isinstance(operation_id, str) or not isinstance(payload, dict):
|
|
141
|
+
await self.close(code=4403)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
schema = GraphQL.get_schema()
|
|
145
|
+
if schema is None or self._schema_has_no_subscription(schema.graphql_schema):
|
|
146
|
+
await self._send_protocol_message(
|
|
147
|
+
{
|
|
148
|
+
"type": "error",
|
|
149
|
+
"id": operation_id,
|
|
150
|
+
"payload": [
|
|
151
|
+
{"message": "GraphQL subscriptions are not configured."}
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
await self._send_protocol_message(
|
|
156
|
+
{"type": "complete", "id": operation_id}
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
query = payload.get("query")
|
|
161
|
+
if not isinstance(query, str):
|
|
162
|
+
await self._send_protocol_message(
|
|
163
|
+
{
|
|
164
|
+
"type": "error",
|
|
165
|
+
"id": operation_id,
|
|
166
|
+
"payload": [{"message": "A GraphQL query string is required."}],
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
await self._send_protocol_message(
|
|
170
|
+
{"type": "complete", "id": operation_id}
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
variables = payload.get("variables")
|
|
175
|
+
if variables is not None and not isinstance(variables, dict):
|
|
176
|
+
await self._send_protocol_message(
|
|
177
|
+
{
|
|
178
|
+
"type": "error",
|
|
179
|
+
"id": operation_id,
|
|
180
|
+
"payload": [{"message": "Variables must be provided as an object."}],
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
await self._send_protocol_message(
|
|
184
|
+
{"type": "complete", "id": operation_id}
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
operation_name = payload.get("operationName")
|
|
189
|
+
if operation_name is not None and not isinstance(operation_name, str):
|
|
190
|
+
await self._send_protocol_message(
|
|
191
|
+
{
|
|
192
|
+
"type": "error",
|
|
193
|
+
"id": operation_id,
|
|
194
|
+
"payload": [
|
|
195
|
+
{"message": "The operation name must be a string when provided."}
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
await self._send_protocol_message(
|
|
200
|
+
{"type": "complete", "id": operation_id}
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
document = parse(query)
|
|
206
|
+
except GraphQLError as error:
|
|
207
|
+
await self._send_protocol_message(
|
|
208
|
+
{
|
|
209
|
+
"type": "error",
|
|
210
|
+
"id": operation_id,
|
|
211
|
+
"payload": [error.formatted],
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
await self._send_protocol_message(
|
|
215
|
+
{"type": "complete", "id": operation_id}
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
context = self._build_context()
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
subscription = await subscribe(
|
|
223
|
+
schema.graphql_schema,
|
|
224
|
+
document,
|
|
225
|
+
variable_values=variables,
|
|
226
|
+
operation_name=operation_name,
|
|
227
|
+
context_value=context,
|
|
228
|
+
)
|
|
229
|
+
except GraphQLError as error:
|
|
230
|
+
await self._send_protocol_message(
|
|
231
|
+
{
|
|
232
|
+
"type": "error",
|
|
233
|
+
"id": operation_id,
|
|
234
|
+
"payload": [error.formatted],
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
await self._send_protocol_message(
|
|
238
|
+
{"type": "complete", "id": operation_id}
|
|
239
|
+
)
|
|
240
|
+
return
|
|
241
|
+
except Exception as error: # pragma: no cover - defensive safeguard
|
|
242
|
+
await self._send_protocol_message(
|
|
243
|
+
{
|
|
244
|
+
"type": "error",
|
|
245
|
+
"id": operation_id,
|
|
246
|
+
"payload": [{"message": str(error)}],
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
await self._send_protocol_message(
|
|
250
|
+
{"type": "complete", "id": operation_id}
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
if isinstance(subscription, ExecutionResult):
|
|
255
|
+
await self._send_execution_result(operation_id, subscription)
|
|
256
|
+
await self._send_protocol_message(
|
|
257
|
+
{"type": "complete", "id": operation_id}
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if operation_id in self.active_subscriptions:
|
|
262
|
+
await self._stop_subscription(operation_id)
|
|
263
|
+
|
|
264
|
+
self.active_subscriptions[operation_id] = asyncio.create_task(
|
|
265
|
+
self._stream_subscription(operation_id, subscription)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def _handle_complete(self, content: dict[str, Any]) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Handle an incoming "complete" protocol message by stopping the subscription for the specified operation.
|
|
271
|
+
|
|
272
|
+
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
|
+
|
|
274
|
+
Parameters:
|
|
275
|
+
content (dict[str, Any]): The received protocol message payload. Expected to contain an "id" key with the operation identifier.
|
|
276
|
+
"""
|
|
277
|
+
operation_id = content.get("id")
|
|
278
|
+
if isinstance(operation_id, str):
|
|
279
|
+
await self._stop_subscription(operation_id)
|
|
280
|
+
|
|
281
|
+
async def _stream_subscription(
|
|
282
|
+
self, operation_id: str, async_iterator: Any
|
|
283
|
+
) -> None:
|
|
284
|
+
"""
|
|
285
|
+
Stream results from an async iterator to the client for a subscription operation.
|
|
286
|
+
|
|
287
|
+
Iterates the provided async_iterator and sends each yielded execution result to the client for the given operation_id. If an unexpected exception 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_id, and removes the operation from active_subscriptions.
|
|
288
|
+
|
|
289
|
+
Parameters:
|
|
290
|
+
operation_id (str): The subscription operation identifier to use in protocol messages.
|
|
291
|
+
async_iterator (Any): An asynchronous iterator that yields execution result objects to be sent to the client.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
asyncio.CancelledError: Propagated if the surrounding subscription task is cancelled.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
async for result in async_iterator:
|
|
298
|
+
await self._send_execution_result(operation_id, result)
|
|
299
|
+
except asyncio.CancelledError:
|
|
300
|
+
raise
|
|
301
|
+
except Exception as error: # pragma: no cover - defensive safeguard
|
|
302
|
+
await self._send_protocol_message(
|
|
303
|
+
{
|
|
304
|
+
"type": "error",
|
|
305
|
+
"id": operation_id,
|
|
306
|
+
"payload": [{"message": str(error)}],
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
finally:
|
|
310
|
+
await self._close_iterator(async_iterator)
|
|
311
|
+
await self._send_protocol_message(
|
|
312
|
+
{"type": "complete", "id": operation_id}
|
|
313
|
+
)
|
|
314
|
+
self.active_subscriptions.pop(operation_id, None)
|
|
315
|
+
|
|
316
|
+
async def _stop_subscription(self, operation_id: str) -> None:
|
|
317
|
+
"""
|
|
318
|
+
Cancel and await the active subscription task for the given operation id, if one exists.
|
|
319
|
+
|
|
320
|
+
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
|
+
"""
|
|
322
|
+
task = self.active_subscriptions.pop(operation_id, None)
|
|
323
|
+
if task is None:
|
|
324
|
+
return
|
|
325
|
+
task.cancel()
|
|
326
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
327
|
+
await task
|
|
328
|
+
|
|
329
|
+
async def _send_execution_result(
|
|
330
|
+
self, operation_id: str, result: ExecutionResult
|
|
331
|
+
) -> None:
|
|
332
|
+
"""
|
|
333
|
+
Send a GraphQL execution result to the client as a "next" protocol message.
|
|
334
|
+
|
|
335
|
+
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
|
+
|
|
337
|
+
Parameters:
|
|
338
|
+
operation_id (str): The operation identifier to include as the message `id`.
|
|
339
|
+
result (ExecutionResult): The GraphQL execution result to serialize and send.
|
|
340
|
+
"""
|
|
341
|
+
payload: dict[str, Any] = {}
|
|
342
|
+
if result.data is not None:
|
|
343
|
+
payload["data"] = result.data
|
|
344
|
+
if result.errors:
|
|
345
|
+
payload["errors"] = [self._format_error(error) for error in result.errors]
|
|
346
|
+
await self._send_protocol_message(
|
|
347
|
+
{"type": "next", "id": operation_id, "payload": payload}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def _send_protocol_message(self, message: dict[str, Any]) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Send a JSON-serializable GraphQL transport protocol message over the WebSocket.
|
|
353
|
+
|
|
354
|
+
Parameters:
|
|
355
|
+
message (dict[str, Any]): The protocol message to send. If the connection is already closed, the message is discarded silently.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
await self.send_json(message)
|
|
359
|
+
except RuntimeError:
|
|
360
|
+
# The connection has already been closed. There is nothing else to send.
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
def _build_context(self) -> Any:
|
|
364
|
+
"""
|
|
365
|
+
Builds a request context object for GraphQL execution containing the current user, decoded headers, scope, and connection parameters.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
context (SimpleNamespace): An object with attributes:
|
|
369
|
+
- `user`: the value of `scope["user"]` (may be None).
|
|
370
|
+
- `headers`: a dict mapping header names to decoded string values.
|
|
371
|
+
- `scope`: the consumer's `scope`.
|
|
372
|
+
- `connection_params`: the connection parameters provided during `connection_init`.
|
|
373
|
+
"""
|
|
374
|
+
user = self.scope.get("user")
|
|
375
|
+
raw_headers = self.scope.get("headers") or []
|
|
376
|
+
headers = {
|
|
377
|
+
(key.decode("latin1") if isinstance(key, (bytes, bytearray)) else key): (
|
|
378
|
+
value.decode("latin1") if isinstance(value, (bytes, bytearray)) else value
|
|
379
|
+
)
|
|
380
|
+
for key, value in raw_headers
|
|
381
|
+
}
|
|
382
|
+
return SimpleNamespace(
|
|
383
|
+
user=user,
|
|
384
|
+
headers=headers,
|
|
385
|
+
scope=self.scope,
|
|
386
|
+
connection_params=self.connection_params,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _schema_has_no_subscription(schema: GraphQLSchema) -> bool:
|
|
391
|
+
"""
|
|
392
|
+
Check whether the provided GraphQL schema defines no subscription root type.
|
|
393
|
+
|
|
394
|
+
Parameters:
|
|
395
|
+
schema (GraphQLSchema): The schema to inspect.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
bool: `True` if the schema has no subscription type, `False` otherwise.
|
|
399
|
+
"""
|
|
400
|
+
return schema.subscription_type is None
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _format_error(error: Exception) -> dict[str, Any]:
|
|
404
|
+
"""
|
|
405
|
+
Format an exception into a GraphQL-compatible error dictionary.
|
|
406
|
+
|
|
407
|
+
Parameters:
|
|
408
|
+
error (Exception): The exception to format; may be a GraphQLError.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
dict[str, Any]: If `error` is a `GraphQLError`, returns its `formatted` representation.
|
|
412
|
+
Otherwise returns a dictionary with a single `"message"` key containing the exception's string.
|
|
413
|
+
"""
|
|
414
|
+
if isinstance(error, GraphQLError):
|
|
415
|
+
return cast(dict[str, Any], error.formatted)
|
|
416
|
+
return {"message": str(error)}
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
async def _close_iterator(async_iterator: Any) -> None:
|
|
420
|
+
"""
|
|
421
|
+
Close an asynchronous iterator if it exposes an `aclose` coroutine.
|
|
422
|
+
|
|
423
|
+
Parameters:
|
|
424
|
+
async_iterator (Any): An asynchronous iterator; if it has an `aclose` coroutine method, that coroutine will be awaited to close the iterator.
|
|
425
|
+
"""
|
|
426
|
+
close = getattr(async_iterator, "aclose", None)
|
|
427
|
+
if close is None:
|
|
428
|
+
return
|
|
429
|
+
await close()
|
general_manager/apps.py
CHANGED
|
@@ -3,15 +3,17 @@ from django.apps import AppConfig
|
|
|
3
3
|
import graphene # type: ignore[import]
|
|
4
4
|
import os
|
|
5
5
|
from django.conf import settings
|
|
6
|
-
from django.urls import path
|
|
6
|
+
from django.urls import path, re_path
|
|
7
7
|
from graphene_django.views import GraphQLView # type: ignore[import]
|
|
8
|
-
from importlib import import_module
|
|
8
|
+
from importlib import import_module, util
|
|
9
|
+
import importlib.abc
|
|
10
|
+
import sys
|
|
9
11
|
from general_manager.manager.generalManager import GeneralManager
|
|
10
12
|
from general_manager.manager.meta import GeneralManagerMeta
|
|
11
13
|
from general_manager.manager.input import Input
|
|
12
14
|
from general_manager.api.property import graphQlProperty
|
|
13
15
|
from general_manager.api.graphql import GraphQL
|
|
14
|
-
from typing import TYPE_CHECKING, Type, cast
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Type, cast
|
|
15
17
|
from django.core.checks import register
|
|
16
18
|
import logging
|
|
17
19
|
from django.core.management.base import BaseCommand
|
|
@@ -156,7 +158,10 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
156
158
|
pending_graphql_interfaces: list[Type[GeneralManager]],
|
|
157
159
|
) -> None:
|
|
158
160
|
"""
|
|
159
|
-
|
|
161
|
+
Create GraphQL interfaces and mutations for the given manager classes, build the GraphQL schema, and add the GraphQL endpoint to the URL configuration.
|
|
162
|
+
|
|
163
|
+
Parameters:
|
|
164
|
+
pending_graphql_interfaces (list[Type[GeneralManager]]): GeneralManager classes that require GraphQL interface and mutation generation.
|
|
160
165
|
"""
|
|
161
166
|
logger.debug("Starting to create GraphQL interfaces and mutations...")
|
|
162
167
|
for general_manager_class in pending_graphql_interfaces:
|
|
@@ -176,13 +181,26 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
176
181
|
},
|
|
177
182
|
)
|
|
178
183
|
GraphQL._mutation_class = mutation_class
|
|
179
|
-
schema = graphene.Schema(
|
|
180
|
-
query=GraphQL._query_class,
|
|
181
|
-
mutation=mutation_class,
|
|
182
|
-
)
|
|
183
184
|
else:
|
|
184
185
|
GraphQL._mutation_class = None
|
|
185
|
-
|
|
186
|
+
|
|
187
|
+
if GraphQL._subscription_fields:
|
|
188
|
+
subscription_class = type(
|
|
189
|
+
"Subscription",
|
|
190
|
+
(graphene.ObjectType,),
|
|
191
|
+
GraphQL._subscription_fields,
|
|
192
|
+
)
|
|
193
|
+
GraphQL._subscription_class = subscription_class
|
|
194
|
+
else:
|
|
195
|
+
GraphQL._subscription_class = None
|
|
196
|
+
|
|
197
|
+
schema_kwargs: dict[str, Any] = {"query": GraphQL._query_class}
|
|
198
|
+
if GraphQL._mutation_class is not None:
|
|
199
|
+
schema_kwargs["mutation"] = GraphQL._mutation_class
|
|
200
|
+
if GraphQL._subscription_class is not None:
|
|
201
|
+
schema_kwargs["subscription"] = GraphQL._subscription_class
|
|
202
|
+
schema = graphene.Schema(**schema_kwargs)
|
|
203
|
+
GraphQL._schema = schema
|
|
186
204
|
GeneralmanagerConfig.addGraphqlUrl(schema)
|
|
187
205
|
|
|
188
206
|
@staticmethod
|
|
@@ -208,6 +226,143 @@ class GeneralmanagerConfig(AppConfig):
|
|
|
208
226
|
GraphQLView.as_view(graphiql=True, schema=schema),
|
|
209
227
|
)
|
|
210
228
|
)
|
|
229
|
+
GeneralmanagerConfig._ensure_asgi_subscription_route(graph_ql_url)
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _ensure_asgi_subscription_route(graphql_url: str) -> None:
|
|
233
|
+
asgi_path = getattr(settings, "ASGI_APPLICATION", None)
|
|
234
|
+
if not asgi_path:
|
|
235
|
+
logger.debug("ASGI_APPLICATION not configured; skipping websocket setup.")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
module_path, attr_name = asgi_path.rsplit(".", 1)
|
|
240
|
+
except ValueError:
|
|
241
|
+
logger.warning(
|
|
242
|
+
"ASGI_APPLICATION '%s' is not a valid module path; skipping websocket setup.",
|
|
243
|
+
asgi_path,
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
asgi_module = import_module(module_path)
|
|
249
|
+
except RuntimeError as exc:
|
|
250
|
+
if "populate() isn't reentrant" not in str(exc):
|
|
251
|
+
logger.warning(
|
|
252
|
+
"Unable to import ASGI module '%s': %s", module_path, exc, exc_info=True
|
|
253
|
+
)
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
spec = util.find_spec(module_path)
|
|
257
|
+
if spec is None or spec.loader is None:
|
|
258
|
+
logger.warning(
|
|
259
|
+
"Could not locate loader for ASGI module '%s'; skipping websocket setup.",
|
|
260
|
+
module_path,
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
def finalize(module: Any) -> None:
|
|
265
|
+
GeneralmanagerConfig._finalize_asgi_module(module, attr_name, graphql_url)
|
|
266
|
+
|
|
267
|
+
class _Loader(importlib.abc.Loader):
|
|
268
|
+
def __init__(self, original_loader: importlib.abc.Loader) -> None:
|
|
269
|
+
self._original_loader = original_loader
|
|
270
|
+
|
|
271
|
+
def create_module(self, spec): # type: ignore[override]
|
|
272
|
+
if hasattr(self._original_loader, "create_module"):
|
|
273
|
+
return self._original_loader.create_module(spec) # type: ignore[attr-defined]
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def exec_module(self, module): # type: ignore[override]
|
|
277
|
+
self._original_loader.exec_module(module)
|
|
278
|
+
finalize(module)
|
|
279
|
+
|
|
280
|
+
wrapped_loader = _Loader(spec.loader)
|
|
281
|
+
|
|
282
|
+
class _Finder(importlib.abc.MetaPathFinder):
|
|
283
|
+
def __init__(self) -> None:
|
|
284
|
+
self._processed = False
|
|
285
|
+
|
|
286
|
+
def find_spec(self, fullname, path, target=None): # type: ignore[override]
|
|
287
|
+
if fullname != module_path or self._processed:
|
|
288
|
+
return None
|
|
289
|
+
self._processed = True
|
|
290
|
+
new_spec = util.spec_from_loader(fullname, wrapped_loader)
|
|
291
|
+
if new_spec is not None:
|
|
292
|
+
new_spec.submodule_search_locations = spec.submodule_search_locations
|
|
293
|
+
return new_spec
|
|
294
|
+
|
|
295
|
+
finder = _Finder()
|
|
296
|
+
sys.meta_path.insert(0, finder)
|
|
297
|
+
return
|
|
298
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Unable to import ASGI module '%s': %s", module_path, exc, exc_info=True
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
GeneralmanagerConfig._finalize_asgi_module(asgi_module, attr_name, graphql_url)
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _finalize_asgi_module(asgi_module: Any, attr_name: str, graphql_url: str) -> None:
|
|
308
|
+
try:
|
|
309
|
+
from channels.auth import AuthMiddlewareStack # type: ignore[import-untyped]
|
|
310
|
+
from channels.routing import ProtocolTypeRouter, URLRouter # type: ignore[import-untyped]
|
|
311
|
+
from general_manager.api.graphql_subscription_consumer import (
|
|
312
|
+
GraphQLSubscriptionConsumer,
|
|
313
|
+
)
|
|
314
|
+
except Exception as exc: # pragma: no cover - optional dependency
|
|
315
|
+
logger.debug(
|
|
316
|
+
"Channels or GraphQL subscription consumer unavailable (%s); skipping websocket setup.",
|
|
317
|
+
exc,
|
|
318
|
+
)
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
websocket_patterns = getattr(asgi_module, "websocket_urlpatterns", None)
|
|
322
|
+
if websocket_patterns is None:
|
|
323
|
+
websocket_patterns = []
|
|
324
|
+
setattr(asgi_module, "websocket_urlpatterns", websocket_patterns)
|
|
325
|
+
|
|
326
|
+
if not hasattr(websocket_patterns, "append"):
|
|
327
|
+
logger.warning(
|
|
328
|
+
"websocket_urlpatterns in '%s' does not support appending; skipping websocket setup.",
|
|
329
|
+
asgi_module.__name__,
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
normalized = graphql_url.strip("/")
|
|
334
|
+
pattern = rf"^{normalized}/?$" if normalized else r"^$"
|
|
335
|
+
|
|
336
|
+
route_exists = any(
|
|
337
|
+
getattr(route, "_general_manager_graphql_ws", False)
|
|
338
|
+
for route in websocket_patterns
|
|
339
|
+
)
|
|
340
|
+
if not route_exists:
|
|
341
|
+
websocket_route = re_path(pattern, GraphQLSubscriptionConsumer.as_asgi()) # type: ignore[arg-type]
|
|
342
|
+
setattr(websocket_route, "_general_manager_graphql_ws", True)
|
|
343
|
+
websocket_patterns.append(websocket_route)
|
|
344
|
+
|
|
345
|
+
application = getattr(asgi_module, attr_name, None)
|
|
346
|
+
if application is None:
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
if (
|
|
350
|
+
hasattr(application, "application_mapping")
|
|
351
|
+
and isinstance(application.application_mapping, dict)
|
|
352
|
+
):
|
|
353
|
+
application.application_mapping["websocket"] = AuthMiddlewareStack(
|
|
354
|
+
URLRouter(list(websocket_patterns))
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
357
|
+
wrapped_application = ProtocolTypeRouter(
|
|
358
|
+
{
|
|
359
|
+
"http": application,
|
|
360
|
+
"websocket": AuthMiddlewareStack(
|
|
361
|
+
URLRouter(list(websocket_patterns))
|
|
362
|
+
),
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
setattr(asgi_module, attr_name, wrapped_application)
|
|
211
366
|
|
|
212
367
|
@staticmethod
|
|
213
368
|
def checkPermissionClass(general_manager_class: Type[GeneralManager]) -> None:
|