GeneralManager 0.17.0__py3-none-any.whl → 0.19.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.
Files changed (68) 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 +356 -221
  15. general_manager/api/graphql_subscription_consumer.py +81 -78
  16. general_manager/api/mutation.py +85 -23
  17. general_manager/api/property.py +39 -13
  18. general_manager/apps.py +188 -47
  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/cacheDecorator.py +3 -0
  25. general_manager/cache/dependencyIndex.py +143 -45
  26. general_manager/cache/signals.py +9 -2
  27. general_manager/factory/__init__.py +10 -1
  28. general_manager/factory/autoFactory.py +55 -13
  29. general_manager/factory/factories.py +110 -40
  30. general_manager/factory/factoryMethods.py +122 -34
  31. general_manager/interface/__init__.py +10 -1
  32. general_manager/interface/baseInterface.py +129 -36
  33. general_manager/interface/calculationInterface.py +35 -18
  34. general_manager/interface/databaseBasedInterface.py +71 -45
  35. general_manager/interface/databaseInterface.py +96 -38
  36. general_manager/interface/models.py +5 -5
  37. general_manager/interface/readOnlyInterface.py +94 -20
  38. general_manager/manager/__init__.py +10 -1
  39. general_manager/manager/generalManager.py +25 -16
  40. general_manager/manager/groupManager.py +20 -6
  41. general_manager/manager/meta.py +84 -16
  42. general_manager/measurement/__init__.py +10 -1
  43. general_manager/measurement/measurement.py +289 -95
  44. general_manager/measurement/measurementField.py +42 -31
  45. general_manager/permission/__init__.py +10 -1
  46. general_manager/permission/basePermission.py +120 -38
  47. general_manager/permission/managerBasedPermission.py +72 -21
  48. general_manager/permission/mutationPermission.py +14 -9
  49. general_manager/permission/permissionChecks.py +14 -12
  50. general_manager/permission/permissionDataManager.py +24 -11
  51. general_manager/permission/utils.py +34 -6
  52. general_manager/public_api_registry.py +36 -10
  53. general_manager/rule/__init__.py +10 -1
  54. general_manager/rule/handler.py +133 -44
  55. general_manager/rule/rule.py +178 -39
  56. general_manager/utils/__init__.py +10 -1
  57. general_manager/utils/argsToKwargs.py +34 -9
  58. general_manager/utils/filterParser.py +22 -7
  59. general_manager/utils/formatString.py +1 -0
  60. general_manager/utils/pathMapping.py +23 -15
  61. general_manager/utils/public_api.py +33 -2
  62. general_manager/utils/testing.py +31 -33
  63. {generalmanager-0.17.0.dist-info → generalmanager-0.19.0.dist-info}/METADATA +3 -1
  64. generalmanager-0.19.0.dist-info/RECORD +77 -0
  65. {generalmanager-0.17.0.dist-info → generalmanager-0.19.0.dist-info}/licenses/LICENSE +1 -1
  66. generalmanager-0.17.0.dist-info/RECORD +0 -77
  67. {generalmanager-0.17.0.dist-info → generalmanager-0.19.0.dist-info}/WHEEL +0 -0
  68. {generalmanager-0.17.0.dist-info → generalmanager-0.19.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 when the WebSocket disconnects by cancelling and awaiting any active subscription tasks and clearing the subscription registry.
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): The WebSocket close code received from the connection.
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
- Dispatch incoming graphql-transport-ws protocol messages to the appropriate handler.
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 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
-
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): The operation identifier.
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": [{"message": "Variables must be provided as an object."}],
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
- {"message": "The operation name must be a string when provided."}
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 Exception as error: # pragma: no cover - defensive safeguard
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
- content (dict[str, Any]): The received protocol message payload. Expected to contain an "id" key with the operation identifier.
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
- 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
-
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 to use in protocol messages.
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 if the surrounding subscription task is cancelled.
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 Exception as error: # pragma: no cover - defensive safeguard
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") if isinstance(value, (bytes, bytearray)) else value
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 into a GraphQL-compatible error dictionary.
406
-
409
+ Format an exception as a GraphQL-compatible error dictionary.
410
+
407
411
  Parameters:
408
- error (Exception): The exception to format; may be a GraphQLError.
409
-
412
+ error (Exception): The exception to format; if a `GraphQLError`, its `.formatted` representation is used.
413
+
410
414
  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.
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 if it exposes an `aclose` coroutine.
422
-
424
+ Close an asynchronous iterator by awaiting its `aclose` coroutine if present.
425
+
423
426
  Parameters:
424
- async_iterator (Any): An asynchronous iterator; if it has an `aclose` coroutine method, that coroutine will be awaited to close the iterator.
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()
@@ -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),