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.

Files changed (67) hide show
  1. general_manager/__init__.py +11 -1
  2. general_manager/_types/api.py +0 -1
  3. general_manager/_types/bucket.py +0 -1
  4. general_manager/_types/cache.py +0 -1
  5. general_manager/_types/factory.py +0 -1
  6. general_manager/_types/general_manager.py +0 -1
  7. general_manager/_types/interface.py +0 -1
  8. general_manager/_types/manager.py +0 -1
  9. general_manager/_types/measurement.py +0 -1
  10. general_manager/_types/permission.py +0 -1
  11. general_manager/_types/rule.py +0 -1
  12. general_manager/_types/utils.py +0 -1
  13. general_manager/api/__init__.py +13 -1
  14. general_manager/api/graphql.py +897 -147
  15. general_manager/api/graphql_subscription_consumer.py +432 -0
  16. general_manager/api/mutation.py +85 -23
  17. general_manager/api/property.py +39 -13
  18. general_manager/apps.py +336 -40
  19. general_manager/bucket/__init__.py +10 -1
  20. general_manager/bucket/calculationBucket.py +155 -53
  21. general_manager/bucket/databaseBucket.py +157 -45
  22. general_manager/bucket/groupBucket.py +133 -44
  23. general_manager/cache/__init__.py +10 -1
  24. general_manager/cache/dependencyIndex.py +303 -53
  25. general_manager/cache/signals.py +9 -2
  26. general_manager/factory/__init__.py +10 -1
  27. general_manager/factory/autoFactory.py +55 -13
  28. general_manager/factory/factories.py +110 -40
  29. general_manager/factory/factoryMethods.py +122 -34
  30. general_manager/interface/__init__.py +10 -1
  31. general_manager/interface/baseInterface.py +129 -36
  32. general_manager/interface/calculationInterface.py +35 -18
  33. general_manager/interface/databaseBasedInterface.py +71 -45
  34. general_manager/interface/databaseInterface.py +96 -38
  35. general_manager/interface/models.py +5 -5
  36. general_manager/interface/readOnlyInterface.py +94 -20
  37. general_manager/manager/__init__.py +10 -1
  38. general_manager/manager/generalManager.py +25 -16
  39. general_manager/manager/groupManager.py +21 -7
  40. general_manager/manager/meta.py +84 -16
  41. general_manager/measurement/__init__.py +10 -1
  42. general_manager/measurement/measurement.py +289 -95
  43. general_manager/measurement/measurementField.py +42 -31
  44. general_manager/permission/__init__.py +10 -1
  45. general_manager/permission/basePermission.py +120 -38
  46. general_manager/permission/managerBasedPermission.py +72 -21
  47. general_manager/permission/mutationPermission.py +14 -9
  48. general_manager/permission/permissionChecks.py +14 -12
  49. general_manager/permission/permissionDataManager.py +24 -11
  50. general_manager/permission/utils.py +34 -6
  51. general_manager/public_api_registry.py +36 -10
  52. general_manager/rule/__init__.py +10 -1
  53. general_manager/rule/handler.py +133 -44
  54. general_manager/rule/rule.py +178 -39
  55. general_manager/utils/__init__.py +10 -1
  56. general_manager/utils/argsToKwargs.py +34 -9
  57. general_manager/utils/filterParser.py +22 -7
  58. general_manager/utils/formatString.py +1 -0
  59. general_manager/utils/pathMapping.py +23 -15
  60. general_manager/utils/public_api.py +33 -2
  61. general_manager/utils/testing.py +49 -42
  62. {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/METADATA +3 -1
  63. generalmanager-0.18.0.dist-info/RECORD +77 -0
  64. {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
  65. generalmanager-0.16.1.dist-info/RECORD +0 -76
  66. {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
  67. {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()
@@ -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 TypeError(
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 = [a for a in get_args(ann) if a is not type(None)][0]
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 TypeError(f"Mutation {fn.__name__} missing return annotation")
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 TypeError(
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, enforcing permissions and formatting output.
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 passed by Graphene.
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 populated with resolver results and a success flag.
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)], # None for success field to be set later
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 Exception as e:
184
- GraphQL._handleGraphQLError(e)
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),