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
@@ -1,33 +1,53 @@
1
1
  """GraphQL schema utilities for exposing GeneralManager models via Graphene."""
2
2
 
3
3
  from __future__ import annotations
4
- import graphene # type: ignore[import]
4
+
5
+ import ast as py_ast
6
+ import asyncio
7
+ from contextlib import suppress
8
+ import json
9
+ from dataclasses import dataclass
10
+ from copy import deepcopy
11
+ from datetime import date, datetime
12
+ from decimal import Decimal
13
+ import hashlib
14
+ from types import UnionType
5
15
  from typing import (
6
16
  Any,
17
+ AsyncIterator,
7
18
  Callable,
8
- get_args,
9
- get_origin,
19
+ ClassVar,
20
+ Generator,
21
+ Iterable,
10
22
  TYPE_CHECKING,
11
- cast,
12
23
  Type,
13
- Generator,
14
24
  Union,
25
+ cast,
26
+ get_args,
27
+ get_origin,
15
28
  )
16
- from types import UnionType
17
29
 
18
- from decimal import Decimal
19
- from datetime import date, datetime
20
- import json
30
+ import graphene # type: ignore[import]
21
31
  from graphql.language import ast
32
+ from graphql.language.ast import (
33
+ FieldNode,
34
+ FragmentSpreadNode,
35
+ InlineFragmentNode,
36
+ SelectionSetNode,
37
+ )
38
+ from asgiref.sync import async_to_sync
39
+ from channels.layers import BaseChannelLayer, get_channel_layer
22
40
 
23
- from general_manager.measurement.measurement import Measurement
24
- from general_manager.manager.generalManager import GeneralManagerMeta, GeneralManager
25
- from general_manager.api.property import GraphQLProperty
41
+ from general_manager.cache.cacheTracker import DependencyTracker
42
+ from general_manager.cache.dependencyIndex import Dependency
43
+ from general_manager.cache.signals import post_data_change
26
44
  from general_manager.bucket.baseBucket import Bucket
27
45
  from general_manager.interface.baseInterface import InterfaceBase
28
- from django.db.models import NOT_PROVIDED
29
- from django.core.exceptions import ValidationError
46
+ from general_manager.manager.generalManager import GeneralManager
47
+ from general_manager.measurement.measurement import Measurement
30
48
 
49
+ from django.core.exceptions import ValidationError
50
+ from django.db.models import NOT_PROVIDED
31
51
  from graphql import GraphQLError
32
52
 
33
53
 
@@ -36,6 +56,95 @@ if TYPE_CHECKING:
36
56
  from graphene import ResolveInfo as GraphQLResolveInfo
37
57
 
38
58
 
59
+ @dataclass(slots=True)
60
+ class SubscriptionEvent:
61
+ """Payload delivered to GraphQL subscription resolvers."""
62
+
63
+ item: Any | None
64
+ action: str
65
+
66
+
67
+ class InvalidMeasurementValueError(TypeError):
68
+ """Raised when a scalar receives a value that is not a Measurement instance."""
69
+
70
+ def __init__(self, value: object) -> None:
71
+ """
72
+ Initialize the error raised when a scalar value is not a Measurement instance.
73
+
74
+ Parameters:
75
+ value (object): The value that failed validation; its type name is included in the exception message.
76
+ """
77
+ super().__init__(f"Expected Measurement, got {type(value).__name__}.")
78
+
79
+
80
+ class MissingChannelLayerError(RuntimeError):
81
+ """Raised when GraphQL subscriptions run without a configured channel layer."""
82
+
83
+ def __init__(self) -> None:
84
+ """
85
+ Indicates that Django Channels channel layer is not configured for GraphQL subscriptions.
86
+
87
+ Raised when subscription functionality requires a configured channel layer (e.g., CHANNEL_LAYERS) but none is present.
88
+ """
89
+ super().__init__(
90
+ "No channel layer configured. Configure CHANNEL_LAYERS to enable GraphQL subscriptions."
91
+ )
92
+
93
+
94
+ class UnsupportedGraphQLFieldTypeError(TypeError):
95
+ """Raised when attempting to map an unsupported Python type to GraphQL."""
96
+
97
+ def __init__(self, field_type: type) -> None:
98
+ """
99
+ Exception raised when a Python `dict` type is encountered while mapping a field to GraphQL, which is not supported.
100
+
101
+ Parameters:
102
+ field_type (type): The offending Python type that was provided for the field. The exception message includes this type's `__name__`.
103
+ """
104
+ super().__init__(
105
+ f"GraphQL does not support dict fields (received {field_type.__name__})."
106
+ )
107
+
108
+
109
+ class InvalidGeneralManagerClassError(TypeError):
110
+ """Raised when a non-GeneralManager subclass is used in the GraphQL registry."""
111
+
112
+ def __init__(self, received_class: type) -> None:
113
+ """
114
+ Initialize the exception indicating the provided class is not a GeneralManager subclass.
115
+
116
+ Parameters:
117
+ received_class (type): The class that was supplied but is not a subclass of GeneralManager.
118
+ """
119
+ super().__init__(
120
+ f"{received_class.__name__} must be a subclass of GeneralManager to create a GraphQL interface."
121
+ )
122
+
123
+
124
+ class MissingManagerIdentifierError(ValueError):
125
+ """Raised when a GraphQL mutation is missing the required manager identifier."""
126
+
127
+ def __init__(self) -> None:
128
+ """
129
+ Initialize the exception indicating a required manager identifier is missing.
130
+
131
+ This exception instance carries the default message "id is required.".
132
+ """
133
+ super().__init__("id is required.")
134
+
135
+
136
+ HANDLED_MANAGER_ERRORS: tuple[type[Exception], ...] = (
137
+ PermissionError,
138
+ ValidationError,
139
+ ValueError,
140
+ LookupError,
141
+ TypeError,
142
+ AttributeError,
143
+ GraphQLError,
144
+ RuntimeError,
145
+ )
146
+
147
+
39
148
  class MeasurementType(graphene.ObjectType):
40
149
  value = graphene.Float()
41
150
  unit = graphene.String()
@@ -48,8 +157,20 @@ class MeasurementScalar(graphene.Scalar):
48
157
 
49
158
  @staticmethod
50
159
  def serialize(value: Measurement) -> str:
160
+ """
161
+ Convert a Measurement to its string representation.
162
+
163
+ Parameters:
164
+ value (Measurement): Measurement to serialize.
165
+
166
+ Returns:
167
+ str: String representation of the given Measurement.
168
+
169
+ Raises:
170
+ InvalidMeasurementValueError: If `value` is not a Measurement instance.
171
+ """
51
172
  if not isinstance(value, Measurement):
52
- raise TypeError(f"Expected Measurement, got {type(value)}")
173
+ raise InvalidMeasurementValueError(value)
53
174
  return str(value)
54
175
 
55
176
  @staticmethod
@@ -71,18 +192,18 @@ class PageInfo(graphene.ObjectType):
71
192
 
72
193
 
73
194
  def getReadPermissionFilter(
74
- generalManagerClass: GeneralManagerMeta,
195
+ generalManagerClass: Type[GeneralManager],
75
196
  info: GraphQLResolveInfo,
76
197
  ) -> list[tuple[dict[str, Any], dict[str, Any]]]:
77
198
  """
78
- Return permission-derived filter and exclude pairs for the given manager class.
199
+ Produce a list of permission-derived filter and exclude mappings for queries against a manager class.
79
200
 
80
201
  Parameters:
81
- generalManagerClass (GeneralManagerMeta): Manager class being queried.
82
- info (GraphQLResolveInfo): GraphQL resolver info containing the request user.
202
+ generalManagerClass (Type[GeneralManager]): Manager class to derive permission filters for.
203
+ info (GraphQLResolveInfo): GraphQL resolver info whose context provides the current user.
83
204
 
84
205
  Returns:
85
- list[tuple[dict[str, Any], dict[str, Any]]]: List of ``(filter, exclude)`` mappings.
206
+ list[tuple[dict[str, Any], dict[str, Any]]]: A list of `(filter, exclude)` tuples where each `filter` and `exclude` is a mapping of query constraints produced by the manager's Permission class.
86
207
  """
87
208
  filters = []
88
209
  PermissionClass: type[BasePermission] | None = getattr(
@@ -102,21 +223,108 @@ def getReadPermissionFilter(
102
223
  class GraphQL:
103
224
  """Static helper that builds GraphQL types, queries, and mutations for managers."""
104
225
 
105
- _query_class: type[graphene.ObjectType] | None = None
106
- _mutation_class: type[graphene.ObjectType] | None = None
107
- _mutations: dict[str, Any] = {}
108
- _query_fields: dict[str, Any] = {}
109
- _page_type_registry: dict[str, type[graphene.ObjectType]] = {}
110
- graphql_type_registry: dict[str, type] = {}
111
- graphql_filter_type_registry: dict[str, type] = {}
226
+ _query_class: ClassVar[type[graphene.ObjectType] | None] = None
227
+ _mutation_class: ClassVar[type[graphene.ObjectType] | None] = None
228
+ _subscription_class: ClassVar[type[graphene.ObjectType] | None] = None
229
+ _mutations: ClassVar[dict[str, Any]] = {}
230
+ _query_fields: ClassVar[dict[str, Any]] = {}
231
+ _subscription_fields: ClassVar[dict[str, Any]] = {}
232
+ _page_type_registry: ClassVar[dict[str, type[graphene.ObjectType]]] = {}
233
+ _subscription_payload_registry: ClassVar[dict[str, type[graphene.ObjectType]]] = {}
234
+ graphql_type_registry: ClassVar[dict[str, type]] = {}
235
+ graphql_filter_type_registry: ClassVar[dict[str, type]] = {}
236
+ manager_registry: ClassVar[dict[str, type[GeneralManager]]] = {}
237
+ _schema: ClassVar[graphene.Schema | None] = None
238
+
239
+ @staticmethod
240
+ def _get_channel_layer(strict: bool = False) -> BaseChannelLayer | None:
241
+ """
242
+ Retrieve the configured channel layer for GraphQL subscriptions.
243
+
244
+ Parameters:
245
+ strict (bool): When True, raise MissingChannelLayerError if no channel layer is configured.
246
+
247
+ Returns:
248
+ BaseChannelLayer | None: The configured channel layer instance if available, otherwise None.
249
+
250
+ Raises:
251
+ MissingChannelLayerError: If `strict` is True and no channel layer is configured.
252
+ """
253
+ layer = cast(BaseChannelLayer | None, get_channel_layer())
254
+ if layer is None and strict:
255
+ raise MissingChannelLayerError()
256
+ return layer
257
+
258
+ @classmethod
259
+ def get_schema(cls) -> graphene.Schema | None:
260
+ """
261
+ Get the currently configured Graphene schema for the GraphQL registry.
262
+
263
+ Returns:
264
+ The active `graphene.Schema` instance, or `None` if no schema has been created.
265
+ """
266
+ return cls._schema
267
+
268
+ @staticmethod
269
+ def _group_name(
270
+ manager_class: type[GeneralManager], identification: dict[str, Any]
271
+ ) -> str:
272
+ """
273
+ Builds a deterministic channel group name for subscription events for a specific manager instance.
274
+
275
+ Parameters:
276
+ manager_class (type[GeneralManager]): GeneralManager subclass used to namespace the group.
277
+ identification (dict[str, Any]): Identifying fields for the manager instance; the mapping is JSON-normalized (sorted keys) before being incorporated.
278
+
279
+ Returns:
280
+ group_name (str): A deterministic channel group identifier derived from the manager class and normalized identification.
281
+ """
282
+ normalized = json.dumps(identification, sort_keys=True, default=str)
283
+ digest = hashlib.sha256(
284
+ f"{manager_class.__module__}.{manager_class.__name__}:{normalized}".encode(
285
+ "utf-8"
286
+ )
287
+ ).hexdigest()[:32]
288
+ return f"gm_subscriptions.{manager_class.__name__}.{digest}"
289
+
290
+ @staticmethod
291
+ async def _channel_listener(
292
+ channel_layer: BaseChannelLayer,
293
+ channel_name: str,
294
+ queue: asyncio.Queue[str],
295
+ ) -> None:
296
+ """
297
+ Listen to a channel layer for "gm.subscription.event" messages and enqueue their `action` values.
298
+
299
+ Continuously receives messages from the given channel_name on the channel_layer, and when a message of type "gm.subscription.event" contains an `action`, that action string is put into the provided asyncio queue. Ignores messages of other types. The loop exits silently when the task is cancelled.
300
+
301
+ Parameters:
302
+ channel_layer (BaseChannelLayer): Channel layer to receive messages from.
303
+ channel_name (str): Name of the channel to listen on.
304
+ queue (asyncio.Queue[str]): Async queue to which received action strings will be enqueued.
305
+ """
306
+ try:
307
+ while True:
308
+ message = cast(
309
+ dict[str, Any], await channel_layer.receive(channel_name)
310
+ )
311
+ if message.get("type") != "gm.subscription.event":
312
+ continue
313
+ action = cast(str | None, message.get("action"))
314
+ if action is not None:
315
+ await queue.put(action)
316
+ except asyncio.CancelledError:
317
+ pass
112
318
 
113
319
  @classmethod
114
320
  def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
115
321
  """
116
- Register create, update, and delete mutations for ``generalManagerClass``.
322
+ Register GraphQL mutation classes for a GeneralManager based on its Interface.
323
+
324
+ If the manager's Interface overrides the default `create`, `update`, or `deactivate` methods, corresponding mutation classes are generated and stored in the class-level mutation registry under the names `create<ManagerName>`, `update<ManagerName>`, and `delete<ManagerName>`. Each generated mutation exposes a `success` flag and a field named for the manager class that returns the created/updated/deactivated object.
117
325
 
118
326
  Parameters:
119
- generalManagerClass (type[GeneralManager]): Manager class whose interface drives mutation generation.
327
+ generalManagerClass (type[GeneralManager]): Manager class whose `Interface` determines which mutations are generated and registered.
120
328
  """
121
329
 
122
330
  interface_cls: InterfaceBase | None = getattr(
@@ -150,12 +358,14 @@ class GraphQL:
150
358
  )
151
359
 
152
360
  @classmethod
153
- def createGraphqlInterface(cls, generalManagerClass: GeneralManagerMeta) -> None:
361
+ def createGraphqlInterface(cls, generalManagerClass: Type[GeneralManager]) -> None:
154
362
  """
155
- Build and register a Graphene ``ObjectType`` for the supplied manager class.
363
+ Create and register a Graphene ObjectType for a GeneralManager class and expose its queries and subscription.
364
+
365
+ Builds a Graphene type by mapping the manager's Interface attributes and GraphQLProperties to Graphene fields and resolvers, registers the resulting type and manager in the GraphQL registries, and adds corresponding query and subscription fields to the schema.
156
366
 
157
367
  Parameters:
158
- generalManagerClass (GeneralManagerMeta): Manager class whose attributes drive field generation.
368
+ generalManagerClass (Type[GeneralManager]): The manager class whose Interface and GraphQLProperties are used to generate Graphene fields and resolvers.
159
369
  """
160
370
  interface_cls: InterfaceBase | None = getattr(
161
371
  generalManagerClass, "Interface", None
@@ -216,20 +426,19 @@ class GraphQL:
216
426
 
217
427
  graphene_type = type(graphene_type_name, (graphene.ObjectType,), fields)
218
428
  cls.graphql_type_registry[generalManagerClass.__name__] = graphene_type
429
+ cls.manager_registry[generalManagerClass.__name__] = generalManagerClass
219
430
  cls._addQueriesToSchema(graphene_type, generalManagerClass)
431
+ cls._addSubscriptionField(graphene_type, generalManagerClass)
220
432
 
221
433
  @staticmethod
222
434
  def _sortByOptions(
223
- generalManagerClass: GeneralManagerMeta,
435
+ generalManagerClass: Type[GeneralManager],
224
436
  ) -> type[graphene.Enum] | None:
225
437
  """
226
- Build an enum of sortable fields for the provided manager class.
227
-
228
- Parameters:
229
- generalManagerClass (GeneralManagerMeta): Manager class being inspected.
438
+ Builds an enum of sortable field names for the given manager class.
230
439
 
231
440
  Returns:
232
- type[graphene.Enum] | None: Enum of sortable fields, or ``None`` when no options exist.
441
+ An Enum type whose members are the sortable field names for the manager, or `None` if no sortable fields exist.
233
442
  """
234
443
  sort_options = []
235
444
  for (
@@ -266,7 +475,9 @@ class GraphQL:
266
475
  )
267
476
 
268
477
  @staticmethod
269
- def _getFilterOptions(attribute_type: type, attribute_name: str) -> Generator[
478
+ def _getFilterOptions(
479
+ attribute_type: type, attribute_name: str
480
+ ) -> Generator[
270
481
  tuple[
271
482
  str, type[graphene.ObjectType] | MeasurementScalar | graphene.List | None
272
483
  ],
@@ -274,14 +485,18 @@ class GraphQL:
274
485
  None,
275
486
  ]:
276
487
  """
277
- Yield filter field names and Graphene types for a given attribute.
488
+ Produce filter field names and corresponding Graphene input types for a given attribute.
278
489
 
279
490
  Parameters:
280
- attribute_type (type): Python type declared for the attribute.
281
- attribute_name (str): Name of the attribute.
491
+ attribute_type (type): The Python type declared for the attribute.
492
+ attribute_name (str): The attribute's name used as the base for generated filter field names.
282
493
 
283
494
  Yields:
284
- tuple[str, Graphene type | None]: Filter name and corresponding Graphene type.
495
+ tuple[str, type | MeasurementScalar | graphene.List | None]:
496
+ A pair where the first element is a filter field name (e.g., "age", "price__gte",
497
+ "name__icontains") and the second element is the Graphene input type for that filter
498
+ or `None` when no Graphene input type should be exposed (for example, nested
499
+ GeneralManager references).
285
500
  """
286
501
  number_options = ["exact", "gt", "gte", "lt", "lte"]
287
502
  string_options = [
@@ -300,13 +515,19 @@ class GraphQL:
300
515
  for option in number_options:
301
516
  yield f"{attribute_name}__{option}", MeasurementScalar()
302
517
  else:
303
- yield attribute_name, GraphQL._mapFieldToGrapheneRead(
304
- attribute_type, attribute_name
518
+ yield (
519
+ attribute_name,
520
+ GraphQL._mapFieldToGrapheneRead(attribute_type, attribute_name),
305
521
  )
306
522
  if issubclass(attribute_type, (int, float, Decimal, date, datetime)):
307
523
  for option in number_options:
308
- yield f"{attribute_name}__{option}", (
309
- GraphQL._mapFieldToGrapheneRead(attribute_type, attribute_name)
524
+ yield (
525
+ f"{attribute_name}__{option}",
526
+ (
527
+ GraphQL._mapFieldToGrapheneRead(
528
+ attribute_type, attribute_name
529
+ )
530
+ ),
310
531
  )
311
532
  elif issubclass(attribute_type, str):
312
533
  base_type = GraphQL._mapFieldToGrapheneBaseType(attribute_type)
@@ -314,24 +535,29 @@ class GraphQL:
314
535
  if option == "in":
315
536
  yield f"{attribute_name}__in", graphene.List(base_type)
316
537
  else:
317
- yield f"{attribute_name}__{option}", (
318
- GraphQL._mapFieldToGrapheneRead(
319
- attribute_type, attribute_name
320
- )
538
+ yield (
539
+ f"{attribute_name}__{option}",
540
+ (
541
+ GraphQL._mapFieldToGrapheneRead(
542
+ attribute_type, attribute_name
543
+ )
544
+ ),
321
545
  )
322
546
 
323
547
  @staticmethod
324
548
  def _createFilterOptions(
325
- field_type: GeneralManagerMeta,
549
+ field_type: Type[GeneralManager],
326
550
  ) -> type[graphene.InputObjectType] | None:
327
551
  """
328
- Create a Graphene ``InputObjectType`` for filters on ``field_type``.
552
+ Create a Graphene InputObjectType that exposes available filter fields for a GeneralManager subclass.
553
+
554
+ Builds filter fields from the manager's Interface attributes and any GraphQLProperty marked filterable. Returns None when no filterable fields are found.
329
555
 
330
556
  Parameters:
331
- field_type (GeneralManagerMeta): Manager class whose attributes drive filter generation.
557
+ field_type (Type[GeneralManager]): Manager class whose Interface and GraphQLProperties determine the filter fields.
332
558
 
333
559
  Returns:
334
- type[graphene.InputObjectType] | None: Input type containing filter fields, or ``None`` if not applicable.
560
+ type[graphene.InputObjectType] | None: Generated InputObjectType class for filters, or `None` if no filter fields are applicable.
335
561
  """
336
562
 
337
563
  graphene_filter_type_name = f"{field_type.__name__}FilterType"
@@ -390,7 +616,7 @@ class GraphQL:
390
616
  return graphene.Field(MeasurementType, target_unit=graphene.String())
391
617
  elif issubclass(field_type, GeneralManager):
392
618
  if field_name.endswith("_list"):
393
- attributes = {
619
+ attributes: dict[str, Any] = {
394
620
  "reverse": graphene.Boolean(),
395
621
  "page": graphene.Int(),
396
622
  "page_size": graphene.Int(),
@@ -420,29 +646,37 @@ class GraphQL:
420
646
  @staticmethod
421
647
  def _mapFieldToGrapheneBaseType(field_type: type) -> Type[Any]:
422
648
  """
423
- Map a Python type to the corresponding Graphene scalar/class.
649
+ Map a Python interface type to the corresponding Graphene scalar or custom scalar.
424
650
 
425
651
  Parameters:
426
- field_type (type): Python type declared on the interface.
652
+ field_type (type): Python type from the interface to map.
427
653
 
428
654
  Returns:
429
- Type[Any]: Graphene scalar or type implementing the field.
655
+ Type[Any]: The Graphene scalar or custom scalar type used to represent the input type (e.g., graphene.String, graphene.Int, MeasurementScalar).
656
+
657
+ Raises:
658
+ UnsupportedGraphQLFieldTypeError: If `field_type` is `dict`, which is not supported for GraphQL mapping.
430
659
  """
431
- if issubclass(field_type, dict):
432
- raise TypeError("GraphQL does not support dict fields")
433
- if issubclass(field_type, str):
660
+ base_type = (
661
+ get_origin(field_type) or field_type
662
+ ) # Handle typing generics safely.
663
+ if not isinstance(base_type, type):
434
664
  return graphene.String
435
- elif issubclass(field_type, bool):
665
+ if issubclass(base_type, dict):
666
+ raise UnsupportedGraphQLFieldTypeError(field_type)
667
+ if issubclass(base_type, str):
668
+ return graphene.String
669
+ elif issubclass(base_type, bool):
436
670
  return graphene.Boolean
437
- elif issubclass(field_type, int):
671
+ elif issubclass(base_type, int):
438
672
  return graphene.Int
439
- elif issubclass(field_type, (float, Decimal)):
673
+ elif issubclass(base_type, (float, Decimal)):
440
674
  return graphene.Float
441
- elif issubclass(field_type, datetime):
675
+ elif issubclass(base_type, datetime):
442
676
  return graphene.DateTime
443
- elif issubclass(field_type, date):
677
+ elif issubclass(base_type, date):
444
678
  return graphene.Date
445
- elif issubclass(field_type, Measurement):
679
+ elif issubclass(base_type, Measurement):
446
680
  return MeasurementScalar
447
681
  else:
448
682
  return graphene.String
@@ -450,20 +684,22 @@ class GraphQL:
450
684
  @staticmethod
451
685
  def _parseInput(input_val: dict[str, Any] | str | None) -> dict[str, Any]:
452
686
  """
453
- Normalise filter/exclude input into a dictionary.
687
+ Normalize a filter or exclude input into a dictionary.
688
+
689
+ Accepts a dict, a JSON-encoded string, or None. If given None or an invalid JSON string, returns an empty dict.
454
690
 
455
691
  Parameters:
456
- input_val (dict[str, Any] | str | None): Raw filter/exclude value.
692
+ input_val: Filter or exclude input provided as a dict, a JSON-encoded string, or None.
457
693
 
458
694
  Returns:
459
- dict[str, Any]: Parsed dictionary suitable for queryset filtering.
695
+ dict: Mapping of filter keys to values; empty dict if input is None or invalid JSON.
460
696
  """
461
697
  if input_val is None:
462
698
  return {}
463
699
  if isinstance(input_val, str):
464
700
  try:
465
701
  return json.loads(input_val)
466
- except Exception:
702
+ except (json.JSONDecodeError, ValueError):
467
703
  return {}
468
704
  return input_val
469
705
 
@@ -585,6 +821,8 @@ class GraphQL:
585
821
  A dictionary containing the paginated items under "items" and pagination metadata under "pageInfo".
586
822
  """
587
823
  base_queryset = base_getter(self)
824
+ if base_queryset is None:
825
+ base_queryset = fallback_manager_class.all()
588
826
  # use _manager_class from the attribute if available, otherwise fallback
589
827
  manager_class = getattr(
590
828
  base_queryset, "_manager_class", fallback_manager_class
@@ -713,9 +951,19 @@ class GraphQL:
713
951
  item_type: type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]],
714
952
  ) -> type[graphene.ObjectType]:
715
953
  """
716
- Returns a paginated GraphQL ObjectType for the specified item type, creating and caching it if it does not already exist.
954
+ Provide or retrieve a GraphQL ObjectType that represents a paginated page for the given item type.
717
955
 
718
- The returned ObjectType includes an `items` field (a required list of the item type) and a `pageInfo` field (pagination metadata).
956
+ Creates and caches a GraphQL ObjectType with two fields:
957
+ - `items`: a required list of the provided item type.
958
+ - `pageInfo`: a required PageInfo object containing pagination metadata.
959
+
960
+ Parameters:
961
+ page_type_name (str): The name to use for the generated GraphQL ObjectType.
962
+ item_type (type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]]):
963
+ The Graphene ObjectType used for items, or a zero-argument callable that returns it (to support forward references).
964
+
965
+ Returns:
966
+ type[graphene.ObjectType]: A Graphene ObjectType with `items` and `pageInfo` fields.
719
967
  """
720
968
  if page_type_name not in cls._page_type_registry:
721
969
  cls._page_type_registry[page_type_name] = type(
@@ -728,28 +976,65 @@ class GraphQL:
728
976
  )
729
977
  return cls._page_type_registry[page_type_name]
730
978
 
979
+ @classmethod
980
+ def _buildIdentificationArguments(
981
+ cls, generalManagerClass: Type[GeneralManager]
982
+ ) -> dict[str, Any]:
983
+ """
984
+ Build the GraphQL arguments required to uniquely identify an instance of the given manager class.
985
+
986
+ For each input field defined on the manager's Interface: use "<name>_id" as a required ID argument for fields that reference another GeneralManager, use "id" as a required ID argument when present, and map other fields to their corresponding Graphene base type marked required.
987
+
988
+ Parameters:
989
+ generalManagerClass: GeneralManager subclass whose Interface.input_fields are used to derive identification arguments.
990
+
991
+ Returns:
992
+ dict[str, Any]: Mapping of argument name to a Graphene Argument suitable for identifying a single manager instance.
993
+ """
994
+ identification_fields: dict[str, Any] = {}
995
+ for (
996
+ input_field_name,
997
+ input_field,
998
+ ) in generalManagerClass.Interface.input_fields.items():
999
+ if issubclass(input_field.type, GeneralManager):
1000
+ key = f"{input_field_name}_id"
1001
+ identification_fields[key] = graphene.Argument(
1002
+ graphene.ID, required=True
1003
+ )
1004
+ elif input_field_name == "id":
1005
+ identification_fields[input_field_name] = graphene.Argument(
1006
+ graphene.ID, required=True
1007
+ )
1008
+ else:
1009
+ base_type = cls._mapFieldToGrapheneBaseType(input_field.type)
1010
+ identification_fields[input_field_name] = graphene.Argument(
1011
+ base_type, required=True
1012
+ )
1013
+ return identification_fields
1014
+
731
1015
  @classmethod
732
1016
  def _addQueriesToSchema(
733
- cls, graphene_type: type, generalManagerClass: GeneralManagerMeta
1017
+ cls, graphene_type: type, generalManagerClass: Type[GeneralManager]
734
1018
  ) -> None:
735
1019
  """
736
- Register list and detail query fields for ``generalManagerClass``.
1020
+ Registers list and detail GraphQL query fields for the given manager type into the class query registry.
737
1021
 
738
1022
  Parameters:
739
- graphene_type (type): Graphene ``ObjectType`` representing the manager.
740
- generalManagerClass (GeneralManagerMeta): Manager class being exposed.
1023
+ graphene_type (type): The Graphene ObjectType that represents the manager's GraphQL type.
1024
+ generalManagerClass (Type[GeneralManager]): The GeneralManager subclass to expose via queries.
1025
+
1026
+ Raises:
1027
+ TypeError: If `generalManagerClass` is not a subclass of GeneralManager.
741
1028
  """
742
1029
  if not issubclass(generalManagerClass, GeneralManager):
743
- raise TypeError(
744
- "generalManagerClass must be a subclass of GeneralManager to create a GraphQL interface"
745
- )
1030
+ raise InvalidGeneralManagerClassError(generalManagerClass)
746
1031
 
747
1032
  if not hasattr(cls, "_query_fields"):
748
1033
  cls._query_fields = cast(dict[str, Any], {})
749
1034
 
750
1035
  # resolver and field for the list query
751
1036
  list_field_name = f"{generalManagerClass.__name__.lower()}_list"
752
- attributes = {
1037
+ attributes: dict[str, Any] = {
753
1038
  "reverse": graphene.Boolean(),
754
1039
  "page": graphene.Int(),
755
1040
  "page_size": graphene.Int(),
@@ -768,50 +1053,465 @@ class GraphQL:
768
1053
  )
769
1054
  list_field = graphene.Field(page_type, **attributes)
770
1055
 
771
- list_resolver = cls._createListResolver(
772
- lambda self: generalManagerClass.all(), generalManagerClass
773
- )
1056
+ def _all_items(_: Any) -> Any:
1057
+ """
1058
+ Return all instances for the associated GeneralManager class.
1059
+
1060
+ Returns:
1061
+ All instances for the associated GeneralManager class, typically provided as a Bucket/QuerySet-like iterable.
1062
+ """
1063
+ return generalManagerClass.all()
1064
+
1065
+ list_resolver = cls._createListResolver(_all_items, generalManagerClass)
774
1066
  cls._query_fields[list_field_name] = list_field
775
1067
  cls._query_fields[f"resolve_{list_field_name}"] = list_resolver
776
1068
 
777
1069
  # resolver and field for the single item query
778
1070
  item_field_name = generalManagerClass.__name__.lower()
779
- identification_fields = {}
780
- for (
781
- input_field_name,
782
- input_field,
783
- ) in generalManagerClass.Interface.input_fields.items():
784
- if issubclass(input_field.type, GeneralManager):
785
- key = f"{input_field_name}_id"
786
- identification_fields[key] = graphene.Int(required=True)
787
- elif input_field_name == "id":
788
- identification_fields[input_field_name] = graphene.ID(required=True)
789
- else:
790
- identification_fields[input_field_name] = cls._mapFieldToGrapheneRead(
791
- input_field.type, input_field_name
792
- )
793
- identification_fields[input_field_name].required = True
794
-
1071
+ identification_fields = cls._buildIdentificationArguments(generalManagerClass)
795
1072
  item_field = graphene.Field(graphene_type, **identification_fields)
796
1073
 
797
1074
  def resolver(
798
1075
  self: GeneralManager, info: GraphQLResolveInfo, **identification: dict
799
1076
  ) -> GeneralManager:
1077
+ """
1078
+ Instantiate and return a GeneralManager for the provided identification arguments.
1079
+
1080
+ Parameters:
1081
+ identification (dict): Mapping of identification argument names to values passed to the manager constructor.
1082
+
1083
+ Returns:
1084
+ GeneralManager: The manager instance identified by the provided arguments.
1085
+ """
800
1086
  return generalManagerClass(**identification)
801
1087
 
802
1088
  cls._query_fields[item_field_name] = item_field
803
1089
  cls._query_fields[f"resolve_{item_field_name}"] = resolver
804
1090
 
1091
+ @staticmethod
1092
+ def _prime_graphql_properties(
1093
+ instance: GeneralManager, property_names: Iterable[str] | None = None
1094
+ ) -> None:
1095
+ """
1096
+ Eagerly evaluate GraphQLProperty attributes on a manager instance to capture dependency metadata.
1097
+
1098
+ When GraphQLProperty descriptors are accessed they may record dependency information on the instance; this function forces those properties to be read so their dependency tracking is populated. If `property_names` is provided, only those property names that exist on the Interface are evaluated; otherwise all GraphQLProperties from the Interface are evaluated.
1099
+
1100
+ Parameters:
1101
+ instance (GeneralManager): The manager instance whose GraphQLProperty attributes should be evaluated.
1102
+ property_names (Iterable[str] | None): Optional iterable of property names to evaluate. Names not present in the Interface's GraphQLProperties are ignored.
1103
+ """
1104
+ interface_cls = getattr(instance.__class__, "Interface", None)
1105
+ if interface_cls is None:
1106
+ return
1107
+ available_properties = interface_cls.getGraphQLProperties()
1108
+ if property_names is None:
1109
+ names = available_properties.keys()
1110
+ else:
1111
+ names = [name for name in property_names if name in available_properties]
1112
+ for prop_name in names:
1113
+ getattr(instance, prop_name)
1114
+
1115
+ @classmethod
1116
+ def _dependencies_from_tracker(
1117
+ cls, dependency_records: Iterable[Dependency]
1118
+ ) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
1119
+ """
1120
+ Convert dependency tracker records into resolved (manager class, identification dict) pairs.
1121
+
1122
+ Parses records whose operation is "identification", looks up the corresponding GeneralManager class by name in the registry, and parses the identifier into a dict. Records that do not meet these criteria are skipped.
1123
+
1124
+ Parameters:
1125
+ dependency_records (Iterable[Dependency]): Iterable of dependency records as (manager_name, operation, identifier).
1126
+
1127
+ Returns:
1128
+ list[tuple[type[GeneralManager], dict[str, Any]]]: List of (manager_class, identification_dict) tuples for each successfully resolved record.
1129
+ """
1130
+ resolved: list[tuple[type[GeneralManager], dict[str, Any]]] = []
1131
+ for manager_name, operation, identifier in dependency_records:
1132
+ if operation != "identification":
1133
+ continue
1134
+ manager_class = cls.manager_registry.get(manager_name)
1135
+ if manager_class is None:
1136
+ continue
1137
+ try:
1138
+ parsed = py_ast.literal_eval(identifier)
1139
+ except (ValueError, SyntaxError):
1140
+ continue
1141
+ if not isinstance(parsed, dict):
1142
+ continue
1143
+ resolved.append((manager_class, parsed))
1144
+ return resolved
1145
+
1146
+ @classmethod
1147
+ def _subscription_property_names(
1148
+ cls,
1149
+ info: GraphQLResolveInfo,
1150
+ manager_class: type[GeneralManager],
1151
+ ) -> set[str]:
1152
+ """
1153
+ Determine which GraphQLProperty names are selected under the subscription payload's `item` field.
1154
+
1155
+ Parameters:
1156
+ info (GraphQLResolveInfo): Resolve info containing the parsed selection set and fragments.
1157
+ manager_class (type[GeneralManager]): GeneralManager subclass whose Interface defines available GraphQLProperty names.
1158
+
1159
+ Returns:
1160
+ property_names (set[str]): Set of GraphQLProperty names referenced under the `item` selection in the subscription request; empty set if none or if the manager has no Interface.
1161
+ """
1162
+ interface_cls = getattr(manager_class, "Interface", None)
1163
+ if interface_cls is None:
1164
+ return set()
1165
+ available_properties = set(interface_cls.getGraphQLProperties().keys())
1166
+ if not available_properties:
1167
+ return set()
1168
+
1169
+ property_names: set[str] = set()
1170
+
1171
+ def collect_from_selection(selection_set: SelectionSetNode | None) -> None:
1172
+ """
1173
+ Recursively collect selected GraphQL property names from a SelectionSetNode into the enclosing `property_names` set.
1174
+
1175
+ Processes each selection in the provided selection_set:
1176
+ - Adds a field's name to `property_names` when the name is present in the surrounding `available_properties` set.
1177
+ - For fragment spreads, resolves the fragment via `info.fragments` (from the enclosing scope) and recurses into its selection set.
1178
+ - For inline fragments, recurses into the fragment's selection set.
1179
+
1180
+ Parameters:
1181
+ selection_set (SelectionSetNode | None): GraphQL selection set to traverse; no action is taken if None.
1182
+ """
1183
+ if selection_set is None:
1184
+ return
1185
+ for selection in selection_set.selections:
1186
+ if isinstance(selection, FieldNode):
1187
+ name = selection.name.value
1188
+ if name in available_properties:
1189
+ property_names.add(name)
1190
+ elif isinstance(selection, FragmentSpreadNode):
1191
+ fragment = info.fragments.get(selection.name.value)
1192
+ if fragment is not None:
1193
+ collect_from_selection(fragment.selection_set)
1194
+ elif isinstance(selection, InlineFragmentNode):
1195
+ collect_from_selection(selection.selection_set)
1196
+
1197
+ def inspect_selection_set(selection_set: SelectionSetNode | None) -> None:
1198
+ """
1199
+ Traverse a GraphQL SelectionSet and collect subselections of any field named "item".
1200
+
1201
+ Parameters:
1202
+ selection_set (SelectionSetNode | None): The AST selection set to inspect. If None, the function returns without action.
1203
+
1204
+ Description:
1205
+ - Visits FieldNode, FragmentSpreadNode, and InlineFragmentNode entries.
1206
+ - For a FieldNode named "item", delegates its subselection to collect_from_selection.
1207
+ - For other FieldNode entries and InlineFragmentNode entries, recurses into their subselections.
1208
+ - For FragmentSpreadNode entries, attempts to resolve the referenced fragment (via the surrounding `info.fragments` if available) and inspects that fragment's subselection.
1209
+ """
1210
+ if selection_set is None:
1211
+ return
1212
+ for selection in selection_set.selections:
1213
+ if isinstance(selection, FieldNode):
1214
+ if selection.name.value == "item":
1215
+ collect_from_selection(selection.selection_set)
1216
+ else:
1217
+ inspect_selection_set(selection.selection_set)
1218
+ elif isinstance(selection, FragmentSpreadNode):
1219
+ fragment = info.fragments.get(selection.name.value)
1220
+ if fragment is not None:
1221
+ inspect_selection_set(fragment.selection_set)
1222
+ elif isinstance(selection, InlineFragmentNode):
1223
+ inspect_selection_set(selection.selection_set)
1224
+
1225
+ for node in info.field_nodes:
1226
+ inspect_selection_set(node.selection_set)
1227
+ return property_names
1228
+
1229
+ @classmethod
1230
+ def _resolve_subscription_dependencies(
1231
+ cls,
1232
+ manager_class: type[GeneralManager],
1233
+ instance: GeneralManager,
1234
+ dependency_records: Iterable[Dependency] | None = None,
1235
+ ) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
1236
+ """
1237
+ Builds a list of dependency pairs (manager class, identification) for subscription wiring from an instance and optional dependency records.
1238
+
1239
+ Given a manager class and its instantiated item, returns deduplicated dependency definitions derived from:
1240
+ - any Dependency records produced by a dependency tracker, and
1241
+ - the manager Interface's input fields that reference other GeneralManager types and are populated on the instance.
1242
+
1243
+ Parameters:
1244
+ manager_class (type[GeneralManager]): The manager type whose subscription dependencies are being resolved.
1245
+ instance (GeneralManager): The instantiated manager item whose inputs and identification are inspected.
1246
+ dependency_records (Iterable[Dependency] | None): Optional dependency-tracker records to include.
1247
+
1248
+ Returns:
1249
+ list[tuple[type[GeneralManager], dict[str, Any]]]: A list of (dependent_manager_class, identification) pairs.
1250
+ Each identification is a dict of identification fields. The list excludes the (manager_class, instance.identification) pair and contains no duplicates.
1251
+ """
1252
+ dependencies: list[tuple[type[GeneralManager], dict[str, Any]]] = []
1253
+ seen: set[tuple[str, str]] = set()
1254
+ if dependency_records:
1255
+ for (
1256
+ dependency_class,
1257
+ dependency_identification,
1258
+ ) in cls._dependencies_from_tracker(dependency_records):
1259
+ if (
1260
+ dependency_class is manager_class
1261
+ and dependency_identification == instance.identification
1262
+ ):
1263
+ continue
1264
+ key = (dependency_class.__name__, repr(dependency_identification))
1265
+ if key in seen:
1266
+ continue
1267
+ seen.add(key)
1268
+ dependencies.append((dependency_class, dependency_identification))
1269
+ interface_cls = manager_class.Interface
1270
+
1271
+ for (
1272
+ input_name,
1273
+ input_field,
1274
+ ) in interface_cls.input_fields.items():
1275
+ if not issubclass(input_field.type, GeneralManager):
1276
+ continue
1277
+
1278
+ raw_value = instance._interface.identification.get(input_name)
1279
+ if raw_value is None:
1280
+ continue
1281
+
1282
+ values = raw_value if isinstance(raw_value, list) else [raw_value]
1283
+ for value in values:
1284
+ if isinstance(value, GeneralManager):
1285
+ identification = deepcopy(value.identification)
1286
+ key = (input_field.type.__name__, repr(identification))
1287
+ if key in seen:
1288
+ continue
1289
+ seen.add(key)
1290
+ dependencies.append(
1291
+ (
1292
+ cast(type[GeneralManager], input_field.type),
1293
+ identification,
1294
+ )
1295
+ )
1296
+ elif isinstance(value, dict):
1297
+ identification_dict = deepcopy(cast(dict[str, Any], value))
1298
+ key = (input_field.type.__name__, repr(identification_dict))
1299
+ if key in seen:
1300
+ continue
1301
+ seen.add(key)
1302
+ dependencies.append(
1303
+ (
1304
+ cast(type[GeneralManager], input_field.type),
1305
+ identification_dict,
1306
+ )
1307
+ )
1308
+
1309
+ return dependencies
1310
+
1311
+ @staticmethod
1312
+ def _instantiate_manager(
1313
+ manager_class: type[GeneralManager],
1314
+ identification: dict[str, Any],
1315
+ *,
1316
+ collect_dependencies: bool = False,
1317
+ property_names: Iterable[str] | None = None,
1318
+ ) -> tuple[GeneralManager, set[Dependency]]:
1319
+ """
1320
+ Create a GeneralManager instance for the given identification and optionally prime its GraphQL properties to capture dependency records.
1321
+
1322
+ Parameters:
1323
+ manager_class (type[GeneralManager]): Manager class to instantiate.
1324
+ identification (dict[str, Any]): Mapping of identification field names to values used to construct the instance.
1325
+ collect_dependencies (bool): If True, prime GraphQL properties while tracking and return the captured Dependency records.
1326
+ property_names (Iterable[str] | None): Specific GraphQLProperty names to prime; if None, all relevant properties are primed.
1327
+
1328
+ Returns:
1329
+ tuple[GeneralManager, set[Dependency]]: The instantiated manager and a set of captured Dependency objects (empty if collect_dependencies is False).
1330
+ """
1331
+ if collect_dependencies:
1332
+ with DependencyTracker() as captured_dependencies:
1333
+ instance = manager_class(**identification)
1334
+ GraphQL._prime_graphql_properties(instance, property_names)
1335
+ return instance, captured_dependencies
1336
+
1337
+ instance = manager_class(**identification)
1338
+ return instance, set()
1339
+
1340
+ @classmethod
1341
+ def _addSubscriptionField(
1342
+ cls,
1343
+ graphene_type: type[graphene.ObjectType],
1344
+ generalManagerClass: Type[GeneralManager],
1345
+ ) -> None:
1346
+ """
1347
+ Register a GraphQL subscription field that publishes change events for the given manager type.
1348
+
1349
+ Creates (or reuses) a SubscriptionEvent payload GraphQL type and adds three entries to the class subscription registry:
1350
+ - a field exposing the subscription with identification arguments,
1351
+ - an async subscribe function that yields an initial "snapshot" event and subsequent change events for the identified instance and its dependencies,
1352
+ - and a resolve function that returns the delivered payload.
1353
+
1354
+ Parameters:
1355
+ graphene_type (type[graphene.ObjectType]): GraphQL ObjectType representing the manager's item type used as the payload `item` field.
1356
+ generalManagerClass (Type[GeneralManager]): The GeneralManager subclass whose changes the subscription will publish.
1357
+
1358
+ Notes:
1359
+ - The subscribe function requires an available channel layer and subscribes the caller to channel groups derived from the instance identification and its resolved dependencies.
1360
+ - The subscribe coroutine yields SubscriptionEvent objects with fields `item` (the current instance or None if it cannot be instantiated) and `action` (a string such as `"snapshot"` or other change actions).
1361
+ - On termination the subscription cleans up listener tasks and unsubscribes from channel groups.
1362
+ """
1363
+ field_name = f"on_{generalManagerClass.__name__.lower()}_change"
1364
+ if field_name in cls._subscription_fields:
1365
+ return
1366
+
1367
+ payload_type = cls._subscription_payload_registry.get(
1368
+ generalManagerClass.__name__
1369
+ )
1370
+ if payload_type is None:
1371
+ payload_type = type(
1372
+ f"{generalManagerClass.__name__}SubscriptionEvent",
1373
+ (graphene.ObjectType,),
1374
+ {
1375
+ "item": graphene.Field(graphene_type),
1376
+ "action": graphene.String(required=True),
1377
+ },
1378
+ )
1379
+ cls._subscription_payload_registry[generalManagerClass.__name__] = (
1380
+ payload_type
1381
+ )
1382
+
1383
+ identification_args = cls._buildIdentificationArguments(generalManagerClass)
1384
+ subscription_field = graphene.Field(payload_type, **identification_args)
1385
+
1386
+ async def subscribe(
1387
+ _root: Any,
1388
+ info: GraphQLResolveInfo,
1389
+ **identification: Any,
1390
+ ) -> AsyncIterator[SubscriptionEvent]:
1391
+ """
1392
+ Stream subscription events for a specific manager instance identified by the provided arguments.
1393
+
1394
+ Yields an initial `SubscriptionEvent` with `action` set to `"snapshot"` containing the current manager instance, then yields `SubscriptionEvent`s for each subsequent action. For update events, `item` will be the re-instantiated manager instance or `None` if instantiation fails. The subscriber is registered on the manager's channel groups (including dependent managers' groups) and the channel subscriptions and background listener are cleaned up when the iterator is closed or cancelled.
1395
+
1396
+ Parameters:
1397
+ identification: Identification fields required to locate the manager instance (maps to the manager's identification signature).
1398
+
1399
+ Returns:
1400
+ AsyncIterator[SubscriptionEvent]: An asynchronous iterator that first yields a snapshot event and then yields update events; each event's `item` is the manager instance or `None` if it could not be instantiated.
1401
+ """
1402
+ identification_copy = deepcopy(identification)
1403
+ property_names = cls._subscription_property_names(
1404
+ info, cast(type[GeneralManager], generalManagerClass)
1405
+ )
1406
+ try:
1407
+ instance, dependency_records = await asyncio.to_thread(
1408
+ cls._instantiate_manager,
1409
+ cast(type[GeneralManager], generalManagerClass),
1410
+ identification_copy,
1411
+ collect_dependencies=True,
1412
+ property_names=property_names,
1413
+ )
1414
+ except Exception as exc: # pragma: no cover - bubbled to GraphQL
1415
+ raise GraphQLError(str(exc)) from exc
1416
+
1417
+ try:
1418
+ channel_layer = cast(
1419
+ BaseChannelLayer, cls._get_channel_layer(strict=True)
1420
+ )
1421
+ except RuntimeError as exc:
1422
+ raise GraphQLError(str(exc)) from exc
1423
+ channel_name = cast(str, await channel_layer.new_channel())
1424
+ queue: asyncio.Queue[str] = asyncio.Queue[str]()
1425
+
1426
+ group_names = {
1427
+ cls._group_name(
1428
+ cast(type[GeneralManager], generalManagerClass),
1429
+ instance.identification,
1430
+ )
1431
+ }
1432
+ dependencies = cls._resolve_subscription_dependencies(
1433
+ cast(type[GeneralManager], generalManagerClass),
1434
+ instance,
1435
+ dependency_records,
1436
+ )
1437
+ for dependency_class, dependency_identification in dependencies:
1438
+ group_names.add(
1439
+ cls._group_name(dependency_class, dependency_identification)
1440
+ )
1441
+
1442
+ for group in group_names:
1443
+ await channel_layer.group_add(group, channel_name)
1444
+
1445
+ listener_task = asyncio.create_task(
1446
+ cls._channel_listener(channel_layer, channel_name, queue)
1447
+ )
1448
+
1449
+ async def event_stream() -> AsyncIterator[SubscriptionEvent]:
1450
+ """
1451
+ Yield subscription events for a manager instance, starting with an initial snapshot followed by subsequent updates.
1452
+
1453
+ Returns:
1454
+ AsyncIterator[SubscriptionEvent]: An asynchronous iterator that first yields a `SubscriptionEvent` with `action` set to `"snapshot"` and `item` containing the current manager instance (or `None` if instantiation failed). Subsequent yields provide `SubscriptionEvent` values for each received action, where `action` is the action string and `item` is the (re-)instantiated manager or `None` if instantiation failed.
1455
+
1456
+ Notes:
1457
+ When the iterator is closed or exits, the background listener task is cancelled and the subscription's channel group memberships are discarded.
1458
+ """
1459
+ try:
1460
+ yield SubscriptionEvent(item=instance, action="snapshot")
1461
+ while True:
1462
+ action = await queue.get()
1463
+ try:
1464
+ item, _ = await asyncio.to_thread(
1465
+ cls._instantiate_manager,
1466
+ cast(type[GeneralManager], generalManagerClass),
1467
+ identification_copy,
1468
+ property_names=property_names,
1469
+ )
1470
+ except HANDLED_MANAGER_ERRORS:
1471
+ item = None
1472
+ yield SubscriptionEvent(item=item, action=action)
1473
+ finally:
1474
+ listener_task.cancel()
1475
+ with suppress(asyncio.CancelledError):
1476
+ await listener_task
1477
+ for group in group_names:
1478
+ await channel_layer.group_discard(group, channel_name)
1479
+
1480
+ return event_stream()
1481
+
1482
+ def resolve(
1483
+ payload: SubscriptionEvent,
1484
+ info: GraphQLResolveInfo,
1485
+ **_: Any,
1486
+ ) -> SubscriptionEvent:
1487
+ """
1488
+ Passes a subscription payload through unchanged.
1489
+
1490
+ Parameters:
1491
+ payload (SubscriptionEvent): The subscription event payload to deliver to the client.
1492
+ info (GraphQLResolveInfo): GraphQL resolver info (unused).
1493
+
1494
+ Returns:
1495
+ SubscriptionEvent: The same payload instance provided as input.
1496
+ """
1497
+ return payload
1498
+
1499
+ cls._subscription_fields[field_name] = subscription_field
1500
+ cls._subscription_fields[f"subscribe_{field_name}"] = subscribe
1501
+ cls._subscription_fields[f"resolve_{field_name}"] = resolve
1502
+
805
1503
  @classmethod
806
1504
  def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
807
1505
  """
808
- Generate Graphene input fields for writable interface attributes.
1506
+ Create Graphene input fields for writable attributes defined by an Interface.
1507
+
1508
+ Skips system fields ("changed_by", "created_at", "updated_at") and attributes marked as derived. For attributes whose type is a GeneralManager, produces an ID field or a list of ID fields for names ending with "_list". Each generated field is annotated with an `editable` attribute reflecting the interface metadata. Always includes an optional `history_comment` string field marked editable.
809
1509
 
810
1510
  Parameters:
811
- interface_cls (InterfaceBase): Interface whose attributes drive the input field map.
1511
+ interface_cls (InterfaceBase): Interface providing attribute metadata (type, required, default, editable, derived) used to build the input fields.
812
1512
 
813
1513
  Returns:
814
- dict[str, Any]: Mapping of attribute names to Graphene field definitions.
1514
+ dict[str, Any]: Mapping from attribute name to a Graphene input field instance.
815
1515
  """
816
1516
  fields: dict[str, Any] = {}
817
1517
 
@@ -825,6 +1525,7 @@ class GraphQL:
825
1525
  req = info["is_required"]
826
1526
  default = info["default"]
827
1527
 
1528
+ fld: Any
828
1529
  if issubclass(typ, GeneralManager):
829
1530
  if name.endswith("_list"):
830
1531
  fld = graphene.List(
@@ -845,12 +1546,13 @@ class GraphQL:
845
1546
  )
846
1547
 
847
1548
  # mark for generate* code to know what is editable
848
- setattr(fld, "editable", info["is_editable"])
1549
+ cast(Any, fld).editable = info["is_editable"]
849
1550
  fields[name] = fld
850
1551
 
851
1552
  # history_comment is always optional without a default value
852
- fields["history_comment"] = graphene.String()
853
- setattr(fields["history_comment"], "editable", True)
1553
+ history_field = graphene.String()
1554
+ cast(Any, history_field).editable = True
1555
+ fields["history_comment"] = history_field
854
1556
 
855
1557
  return fields
856
1558
 
@@ -861,12 +1563,14 @@ class GraphQL:
861
1563
  default_return_values: dict[str, Any],
862
1564
  ) -> type[graphene.Mutation] | None:
863
1565
  """
864
- Dynamically generates a Graphene mutation class for creating an instance of a specified GeneralManager subclass.
1566
+ Generate a Graphene Mutation class that creates instances of the given GeneralManager subclass.
865
1567
 
866
- The generated mutation class uses the manager's interface to define input arguments, filters out fields with `NOT_PROVIDED` values, and invokes the manager's `create` method with the provided data and the current user's ID. On success, it returns a dictionary with a success flag and the created instance; on failure, it raises a GraphQL error. Returns `None` if the manager class does not define an interface.
1568
+ Parameters:
1569
+ generalManagerClass (type[GeneralManager]): The GeneralManager subclass to expose a create mutation for.
1570
+ default_return_values (dict[str, Any]): Base mutation return fields to include on the generated class.
867
1571
 
868
1572
  Returns:
869
- The generated Graphene mutation class, or `None` if the manager class does not define an interface.
1573
+ type[graphene.Mutation] | None: A Mutation class named `Create<ManagerName>` that implements creation for the manager (exposes appropriate Arguments and a `mutate` implementation), or `None` if the manager class does not define an `Interface`.
870
1574
  """
871
1575
  interface_cls: InterfaceBase | None = getattr(
872
1576
  generalManagerClass, "Interface", None
@@ -880,9 +1584,14 @@ class GraphQL:
880
1584
  **kwargs: Any,
881
1585
  ) -> dict:
882
1586
  """
883
- Creates a new instance of the manager class using the provided arguments.
1587
+ Create a new instance of the manager using the provided input fields.
884
1588
 
885
- Filters out any fields set to `NOT_PROVIDED` before invoking the creation method. Returns a dictionary with a success flag and the created instance keyed by the manager class name. If creation fails, raises a GraphQL error and returns a dictionary with `success` set to `False`.
1589
+ Parameters:
1590
+ info (GraphQLResolveInfo): GraphQL resolve info whose context user ID is used as the creator_id.
1591
+ kwargs (Any): Input fields for the new instance; fields equal to `NOT_PROVIDED` are ignored.
1592
+
1593
+ Returns:
1594
+ result (dict): A dictionary containing `success` (`True` if creation succeeded, `False` otherwise). On success the dictionary also includes the created instance under a key matching the manager class name.
886
1595
  """
887
1596
  try:
888
1597
  kwargs = {
@@ -893,11 +1602,8 @@ class GraphQL:
893
1602
  instance = generalManagerClass.create(
894
1603
  **kwargs, creator_id=info.context.user.id
895
1604
  )
896
- except Exception as e:
897
- GraphQL._handleGraphQLError(e)
898
- return {
899
- "success": False,
900
- }
1605
+ except HANDLED_MANAGER_ERRORS as error:
1606
+ raise GraphQL._handleGraphQLError(error) from error
901
1607
 
902
1608
  return {
903
1609
  "success": True,
@@ -951,27 +1657,24 @@ class GraphQL:
951
1657
  **kwargs: Any,
952
1658
  ) -> dict:
953
1659
  """
954
- Updates an instance of a GeneralManager subclass with the specified field values.
1660
+ Update a GeneralManager instance identified by its `id` with the provided field values.
955
1661
 
956
1662
  Parameters:
957
- info (GraphQLResolveInfo): The GraphQL resolver context, including user and request data.
958
- **kwargs: Field values to update, including the required 'id' of the instance.
1663
+ info (GraphQLResolveInfo): GraphQL resolver context (contains the requesting user and request data).
1664
+ **kwargs: Field values to update. Must include `id` to identify the target instance; other keys are treated as fields to update.
959
1665
 
960
1666
  Returns:
961
- dict: A dictionary with 'success' (bool) and the updated instance keyed by its class name.
1667
+ dict: Contains `success` (`True` if the update succeeded, `False` otherwise`) and, on success, the updated instance under a key matching the manager class name.
962
1668
  """
1669
+ manager_id = kwargs.pop("id", None)
1670
+ if manager_id is None:
1671
+ raise GraphQL._handleGraphQLError(MissingManagerIdentifierError())
963
1672
  try:
964
- manager_id = kwargs.pop("id", None)
965
- if manager_id is None:
966
- raise ValueError("id is required")
967
1673
  instance = generalManagerClass(id=manager_id).update(
968
1674
  creator_id=info.context.user.id, **kwargs
969
1675
  )
970
- except Exception as e:
971
- GraphQL._handleGraphQLError(e)
972
- return {
973
- "success": False,
974
- }
1676
+ except HANDLED_MANAGER_ERRORS as error:
1677
+ raise GraphQL._handleGraphQLError(error) from error
975
1678
 
976
1679
  return {
977
1680
  "success": True,
@@ -1028,23 +1731,20 @@ class GraphQL:
1028
1731
  **kwargs: Any,
1029
1732
  ) -> dict:
1030
1733
  """
1031
- Deactivates an instance of a GeneralManager subclass and returns the operation result.
1734
+ Deactivates the identified GeneralManager instance.
1032
1735
 
1033
1736
  Returns:
1034
- dict: A dictionary with a "success" boolean and the deactivated instance keyed by its class name.
1737
+ dict: `success` key with `True` if deactivation succeeded, `False` otherwise. On success, includes an additional key equal to the manager class name containing the deactivated instance.
1035
1738
  """
1739
+ manager_id = kwargs.pop("id", None)
1740
+ if manager_id is None:
1741
+ raise GraphQL._handleGraphQLError(MissingManagerIdentifierError())
1036
1742
  try:
1037
- manager_id = kwargs.pop("id", None)
1038
- if manager_id is None:
1039
- raise ValueError("id is required")
1040
1743
  instance = generalManagerClass(id=manager_id).deactivate(
1041
1744
  creator_id=info.context.user.id
1042
1745
  )
1043
- except Exception as e:
1044
- GraphQL._handleGraphQLError(e)
1045
- return {
1046
- "success": False,
1047
- }
1746
+ except HANDLED_MANAGER_ERRORS as error:
1747
+ raise GraphQL._handleGraphQLError(error) from error
1048
1748
 
1049
1749
  return {
1050
1750
  "success": True,
@@ -1073,34 +1773,84 @@ class GraphQL:
1073
1773
  )
1074
1774
 
1075
1775
  @staticmethod
1076
- def _handleGraphQLError(error: Exception) -> None:
1776
+ def _handleGraphQLError(error: Exception) -> GraphQLError:
1077
1777
  """
1078
- Raise a ``GraphQLError`` with a code based on the exception type.
1778
+ Convert an exception into a GraphQL error with an appropriate extensions['code'].
1779
+
1780
+ Maps:
1781
+ PermissionError -> "PERMISSION_DENIED"
1782
+ ValueError, ValidationError, TypeError -> "BAD_USER_INPUT"
1783
+ other exceptions -> "INTERNAL_SERVER_ERROR"
1079
1784
 
1080
1785
  Parameters:
1081
- error (Exception): Exception raised during mutation execution.
1786
+ error (Exception): The original exception to convert.
1082
1787
 
1083
- Raises:
1084
- GraphQLError: Error with an appropriate ``extensions['code']`` value.
1788
+ Returns:
1789
+ GraphQLError: GraphQL error containing the original message and an `extensions['code']` indicating the error category.
1085
1790
  """
1086
1791
  if isinstance(error, PermissionError):
1087
- raise GraphQLError(
1792
+ return GraphQLError(
1088
1793
  str(error),
1089
1794
  extensions={
1090
1795
  "code": "PERMISSION_DENIED",
1091
1796
  },
1092
1797
  )
1093
1798
  elif isinstance(error, (ValueError, ValidationError, TypeError)):
1094
- raise GraphQLError(
1799
+ return GraphQLError(
1095
1800
  str(error),
1096
1801
  extensions={
1097
1802
  "code": "BAD_USER_INPUT",
1098
1803
  },
1099
1804
  )
1100
1805
  else:
1101
- raise GraphQLError(
1806
+ return GraphQLError(
1102
1807
  str(error),
1103
1808
  extensions={
1104
1809
  "code": "INTERNAL_SERVER_ERROR",
1105
1810
  },
1106
1811
  )
1812
+
1813
+ @classmethod
1814
+ def _handle_data_change(
1815
+ cls,
1816
+ sender: type[GeneralManager] | GeneralManager,
1817
+ instance: GeneralManager | None,
1818
+ action: str,
1819
+ **_: Any,
1820
+ ) -> None:
1821
+ """
1822
+ Send a "gm.subscription.event" message to the channel group corresponding to a changed GeneralManager instance.
1823
+
1824
+ If the provided instance is a registered GeneralManager and a channel layer is configured, publish a message containing the given action to the channel group derived from the manager class and the instance's identification. If the instance is None, the manager type is not registered, or no channel layer is available, the function returns without side effects.
1825
+
1826
+ Parameters:
1827
+ sender (type[GeneralManager] | GeneralManager): The signal sender; either a GeneralManager subclass or an instance.
1828
+ instance (GeneralManager | None): The GeneralManager instance that changed.
1829
+ action (str): A string describing the change action (e.g., "created", "updated", "deleted").
1830
+ """
1831
+ if instance is None or not isinstance(instance, GeneralManager):
1832
+ return
1833
+
1834
+ if isinstance(sender, type) and issubclass(sender, GeneralManager):
1835
+ manager_class: type[GeneralManager] = sender
1836
+ else:
1837
+ manager_class = instance.__class__
1838
+
1839
+ if manager_class.__name__ not in cls.manager_registry:
1840
+ return
1841
+
1842
+ channel_layer = cls._get_channel_layer()
1843
+ if channel_layer is None:
1844
+ return
1845
+
1846
+ group_name = cls._group_name(manager_class, instance.identification)
1847
+ async_to_sync(channel_layer.group_send)(
1848
+ group_name,
1849
+ {
1850
+ "type": "gm.subscription.event",
1851
+ "action": action,
1852
+ },
1853
+ )
1854
+
1855
+
1856
+ post_data_change.connect(GraphQL._handle_data_change, weak=False)