GeneralManager 0.16.0__py3-none-any.whl → 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of GeneralManager might be problematic. Click here for more details.

@@ -1,33 +1,47 @@
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
+ Generator,
20
+ Iterable,
10
21
  TYPE_CHECKING,
11
- cast,
12
22
  Type,
13
- Generator,
14
23
  Union,
24
+ cast,
25
+ get_args,
26
+ get_origin,
15
27
  )
16
- from types import UnionType
17
28
 
18
- from decimal import Decimal
19
- from datetime import date, datetime
20
- import json
29
+ import graphene # type: ignore[import]
21
30
  from graphql.language import ast
31
+ from graphql.language.ast import FieldNode, FragmentSpreadNode, InlineFragmentNode, SelectionSetNode
32
+ from asgiref.sync import async_to_sync
33
+ from channels.layers import BaseChannelLayer, get_channel_layer
22
34
 
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
35
+ from general_manager.cache.cacheTracker import DependencyTracker
36
+ from general_manager.cache.dependencyIndex import Dependency
37
+ from general_manager.cache.signals import post_data_change
26
38
  from general_manager.bucket.baseBucket import Bucket
27
39
  from general_manager.interface.baseInterface import InterfaceBase
28
- from django.db.models import NOT_PROVIDED
29
- from django.core.exceptions import ValidationError
40
+ from general_manager.manager.generalManager import GeneralManager
41
+ from general_manager.measurement.measurement import Measurement
30
42
 
43
+ from django.core.exceptions import ValidationError
44
+ from django.db.models import NOT_PROVIDED
31
45
  from graphql import GraphQLError
32
46
 
33
47
 
@@ -36,6 +50,14 @@ if TYPE_CHECKING:
36
50
  from graphene import ResolveInfo as GraphQLResolveInfo
37
51
 
38
52
 
53
+ @dataclass(slots=True)
54
+ class SubscriptionEvent:
55
+ """Payload delivered to GraphQL subscription resolvers."""
56
+
57
+ item: Any | None
58
+ action: str
59
+
60
+
39
61
  class MeasurementType(graphene.ObjectType):
40
62
  value = graphene.Float()
41
63
  unit = graphene.String()
@@ -71,18 +93,18 @@ class PageInfo(graphene.ObjectType):
71
93
 
72
94
 
73
95
  def getReadPermissionFilter(
74
- generalManagerClass: GeneralManagerMeta,
96
+ generalManagerClass: Type[GeneralManager],
75
97
  info: GraphQLResolveInfo,
76
98
  ) -> list[tuple[dict[str, Any], dict[str, Any]]]:
77
99
  """
78
- Return permission-derived filter and exclude pairs for the given manager class.
79
-
100
+ Produce a list of permission-derived filter and exclude mappings for queries against a manager class.
101
+
80
102
  Parameters:
81
- generalManagerClass (GeneralManagerMeta): Manager class being queried.
82
- info (GraphQLResolveInfo): GraphQL resolver info containing the request user.
83
-
103
+ generalManagerClass (Type[GeneralManager]): Manager class to derive permission filters for.
104
+ info (GraphQLResolveInfo): GraphQL resolver info whose context provides the current user.
105
+
84
106
  Returns:
85
- list[tuple[dict[str, Any], dict[str, Any]]]: List of ``(filter, exclude)`` mappings.
107
+ 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
108
  """
87
109
  filters = []
88
110
  PermissionClass: type[BasePermission] | None = getattr(
@@ -104,19 +126,106 @@ class GraphQL:
104
126
 
105
127
  _query_class: type[graphene.ObjectType] | None = None
106
128
  _mutation_class: type[graphene.ObjectType] | None = None
129
+ _subscription_class: type[graphene.ObjectType] | None = None
107
130
  _mutations: dict[str, Any] = {}
108
131
  _query_fields: dict[str, Any] = {}
132
+ _subscription_fields: dict[str, Any] = {}
109
133
  _page_type_registry: dict[str, type[graphene.ObjectType]] = {}
134
+ _subscription_payload_registry: dict[str, type[graphene.ObjectType]] = {}
110
135
  graphql_type_registry: dict[str, type] = {}
111
136
  graphql_filter_type_registry: dict[str, type] = {}
137
+ manager_registry: dict[str, type[GeneralManager]] = {}
138
+ _schema: graphene.Schema | None = None
139
+
140
+ @staticmethod
141
+ def _get_channel_layer(strict: bool = False) -> BaseChannelLayer | None:
142
+ """
143
+ Return the configured channel layer used for GraphQL subscriptions.
144
+
145
+ Parameters:
146
+ strict (bool): If True, raise an error when no channel layer is configured.
147
+
148
+ Returns:
149
+ BaseChannelLayer | None: The channel layer instance if configured, otherwise `None`.
150
+
151
+ Raises:
152
+ RuntimeError: If `strict` is True and no channel layer is configured.
153
+ """
154
+ layer = cast(BaseChannelLayer | None, get_channel_layer())
155
+ if layer is None and strict:
156
+ raise RuntimeError(
157
+ "No channel layer configured. Configure CHANNEL_LAYERS to enable GraphQL subscriptions."
158
+ )
159
+ return layer
112
160
 
113
161
  @classmethod
114
- def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
162
+ def get_schema(cls) -> graphene.Schema | None:
163
+ """
164
+ Get the currently configured Graphene schema for the GraphQL registry.
165
+
166
+ Returns:
167
+ The active `graphene.Schema` instance, or `None` if no schema has been created.
168
+ """
169
+ return cls._schema
170
+
171
+ @staticmethod
172
+ def _group_name(
173
+ manager_class: type[GeneralManager], identification: dict[str, Any]
174
+ ) -> str:
175
+ """
176
+ Build a consistent channel group name for subscriptions tied to a manager and its identification.
177
+
178
+ Parameters:
179
+ manager_class (type[GeneralManager]): Manager class used to namespace the group.
180
+ identification (dict[str, Any]): Mapping of identifying fields for a manager instance; will be JSON-normalized and included in the group name.
181
+
182
+ Returns:
183
+ group_name (str): A deterministic string usable as a channel group identifier for subscription events.
184
+ """
185
+ normalized = json.dumps(identification, sort_keys=True, default=str)
186
+ digest = hashlib.sha256(
187
+ f"{manager_class.__module__}.{manager_class.__name__}:{normalized}".encode(
188
+ "utf-8"
189
+ )
190
+ ).hexdigest()[:32]
191
+ return f"gm_subscriptions.{manager_class.__name__}.{digest}"
192
+
193
+ @staticmethod
194
+ async def _channel_listener(
195
+ channel_layer: BaseChannelLayer,
196
+ channel_name: str,
197
+ queue: asyncio.Queue[str],
198
+ ) -> None:
199
+ """
200
+ Listen to a channel layer for "gm.subscription.event" messages and enqueue their `action` values.
201
+
202
+ 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.
203
+
204
+ Parameters:
205
+ channel_layer (BaseChannelLayer): Channel layer to receive messages from.
206
+ channel_name (str): Name of the channel to listen on.
207
+ queue (asyncio.Queue[str]): Async queue to which received action strings will be enqueued.
115
208
  """
116
- Register create, update, and delete mutations for ``generalManagerClass``.
209
+ try:
210
+ while True:
211
+ message = cast(dict[str, Any], await channel_layer.receive(channel_name))
212
+ if message.get("type") != "gm.subscription.event":
213
+ continue
214
+ action = cast(str | None, message.get("action"))
215
+ if action is not None:
216
+ await queue.put(action)
217
+ except asyncio.CancelledError:
218
+ pass
117
219
 
220
+ @classmethod
221
+ def createGraphqlMutation(cls, generalManagerClass: type[GeneralManager]) -> None:
222
+ """
223
+ Register create, update, and delete GraphQL mutations for a manager class when its Interface overrides the default CRUD methods.
224
+
225
+ For each of `create`, `update`, and `deactivate` that is implemented on the manager's `Interface` (i.e., differs from InterfaceBase), this method generates a corresponding mutation class and stores it on the class-level mutation registry (`cls._mutations`) under the names `create<ManagerName>`, `update<ManagerName>`, and `delete<ManagerName>`. The generated mutations return a `success` flag and the created/updated/deactivated object field keyed by the manager class name.
226
+
118
227
  Parameters:
119
- generalManagerClass (type[GeneralManager]): Manager class whose interface drives mutation generation.
228
+ generalManagerClass (type[GeneralManager]): Manager class whose `Interface` drives which mutations are generated and registered.
120
229
  """
121
230
 
122
231
  interface_cls: InterfaceBase | None = getattr(
@@ -150,12 +259,14 @@ class GraphQL:
150
259
  )
151
260
 
152
261
  @classmethod
153
- def createGraphqlInterface(cls, generalManagerClass: GeneralManagerMeta) -> None:
262
+ def createGraphqlInterface(cls, generalManagerClass: Type[GeneralManager]) -> None:
154
263
  """
155
- Build and register a Graphene ``ObjectType`` for the supplied manager class.
156
-
264
+ Create and register a Graphene ObjectType for a GeneralManager class and expose its queries and subscription.
265
+
266
+ 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.
267
+
157
268
  Parameters:
158
- generalManagerClass (GeneralManagerMeta): Manager class whose attributes drive field generation.
269
+ generalManagerClass (Type[GeneralManager]): The manager class whose Interface and GraphQLProperties are used to generate Graphene fields and resolvers.
159
270
  """
160
271
  interface_cls: InterfaceBase | None = getattr(
161
272
  generalManagerClass, "Interface", None
@@ -216,20 +327,19 @@ class GraphQL:
216
327
 
217
328
  graphene_type = type(graphene_type_name, (graphene.ObjectType,), fields)
218
329
  cls.graphql_type_registry[generalManagerClass.__name__] = graphene_type
330
+ cls.manager_registry[generalManagerClass.__name__] = generalManagerClass
219
331
  cls._addQueriesToSchema(graphene_type, generalManagerClass)
332
+ cls._addSubscriptionField(graphene_type, generalManagerClass)
220
333
 
221
334
  @staticmethod
222
335
  def _sortByOptions(
223
- generalManagerClass: GeneralManagerMeta,
336
+ generalManagerClass: Type[GeneralManager],
224
337
  ) -> type[graphene.Enum] | None:
225
338
  """
226
- Build an enum of sortable fields for the provided manager class.
227
-
228
- Parameters:
229
- generalManagerClass (GeneralManagerMeta): Manager class being inspected.
230
-
339
+ Builds an enum of sortable field names for the given manager class.
340
+
231
341
  Returns:
232
- type[graphene.Enum] | None: Enum of sortable fields, or ``None`` when no options exist.
342
+ An Enum type whose members are the sortable field names for the manager, or `None` if no sortable fields exist.
233
343
  """
234
344
  sort_options = []
235
345
  for (
@@ -322,16 +432,18 @@ class GraphQL:
322
432
 
323
433
  @staticmethod
324
434
  def _createFilterOptions(
325
- field_type: GeneralManagerMeta,
435
+ field_type: Type[GeneralManager],
326
436
  ) -> type[graphene.InputObjectType] | None:
327
437
  """
328
- Create a Graphene ``InputObjectType`` for filters on ``field_type``.
329
-
438
+ Create a Graphene InputObjectType that exposes available filter fields for a GeneralManager subclass.
439
+
440
+ Builds filter fields from the manager's Interface attributes and any GraphQLProperty marked filterable. Returns None when no filterable fields are found.
441
+
330
442
  Parameters:
331
- field_type (GeneralManagerMeta): Manager class whose attributes drive filter generation.
332
-
443
+ field_type (Type[GeneralManager]): Manager class whose Interface and GraphQLProperties determine the filter fields.
444
+
333
445
  Returns:
334
- type[graphene.InputObjectType] | None: Input type containing filter fields, or ``None`` if not applicable.
446
+ type[graphene.InputObjectType] | None: Generated InputObjectType class for filters, or `None` if no filter fields are applicable.
335
447
  """
336
448
 
337
449
  graphene_filter_type_name = f"{field_type.__name__}FilterType"
@@ -713,9 +825,19 @@ class GraphQL:
713
825
  item_type: type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]],
714
826
  ) -> type[graphene.ObjectType]:
715
827
  """
716
- Returns a paginated GraphQL ObjectType for the specified item type, creating and caching it if it does not already exist.
717
-
718
- The returned ObjectType includes an `items` field (a required list of the item type) and a `pageInfo` field (pagination metadata).
828
+ Provide or retrieve a GraphQL ObjectType that represents a paginated page for the given item type.
829
+
830
+ Creates and caches a GraphQL ObjectType with two fields:
831
+ - `items`: a required list of the provided item type.
832
+ - `pageInfo`: a required PageInfo object containing pagination metadata.
833
+
834
+ Parameters:
835
+ page_type_name (str): The name to use for the generated GraphQL ObjectType.
836
+ item_type (type[graphene.ObjectType] | Callable[[], type[graphene.ObjectType]]):
837
+ The Graphene ObjectType used for items, or a zero-argument callable that returns it (to support forward references).
838
+
839
+ Returns:
840
+ type[graphene.ObjectType]: A Graphene ObjectType with `items` and `pageInfo` fields.
719
841
  """
720
842
  if page_type_name not in cls._page_type_registry:
721
843
  cls._page_type_registry[page_type_name] = type(
@@ -728,16 +850,50 @@ class GraphQL:
728
850
  )
729
851
  return cls._page_type_registry[page_type_name]
730
852
 
853
+ @classmethod
854
+ def _buildIdentificationArguments(
855
+ cls, generalManagerClass: Type[GeneralManager]
856
+ ) -> dict[str, Any]:
857
+ """
858
+ Builds GraphQL arguments required to uniquely identify an instance of the given manager class.
859
+
860
+ Parameters:
861
+ generalManagerClass (Type[GeneralManager]): Manager class whose Interface.input_fields are used to derive identification arguments.
862
+
863
+ Returns:
864
+ dict[str, Any]: Mapping from argument name to a Graphene argument/field. Fields for related manager inputs use "<name>_id" as an ID, the "id" input uses an ID, and other inputs are mapped to appropriate Graphene read fields with `required=True`.
865
+ """
866
+ identification_fields: dict[str, graphene.Argument] = {}
867
+ for input_field_name, input_field in generalManagerClass.Interface.input_fields.items():
868
+ if issubclass(input_field.type, GeneralManager):
869
+ key = f"{input_field_name}_id"
870
+ identification_fields[key] = graphene.Argument(
871
+ graphene.ID, required=True
872
+ )
873
+ elif input_field_name == "id":
874
+ identification_fields[input_field_name] = graphene.Argument(
875
+ graphene.ID, required=True
876
+ )
877
+ else:
878
+ base_type = cls._mapFieldToGrapheneBaseType(input_field.type)
879
+ identification_fields[input_field_name] = graphene.Argument(
880
+ base_type, required=True
881
+ )
882
+ return identification_fields
883
+
731
884
  @classmethod
732
885
  def _addQueriesToSchema(
733
- cls, graphene_type: type, generalManagerClass: GeneralManagerMeta
886
+ cls, graphene_type: type, generalManagerClass: Type[GeneralManager]
734
887
  ) -> None:
735
888
  """
736
- Register list and detail query fields for ``generalManagerClass``.
737
-
889
+ Registers list and detail GraphQL query fields for the given manager type into the class query registry.
890
+
738
891
  Parameters:
739
- graphene_type (type): Graphene ``ObjectType`` representing the manager.
740
- generalManagerClass (GeneralManagerMeta): Manager class being exposed.
892
+ graphene_type (type): The Graphene ObjectType that represents the manager's GraphQL type.
893
+ generalManagerClass (Type[GeneralManager]): The GeneralManager subclass to expose via queries.
894
+
895
+ Raises:
896
+ TypeError: If `generalManagerClass` is not a subclass of GeneralManager.
741
897
  """
742
898
  if not issubclass(generalManagerClass, GeneralManager):
743
899
  raise TypeError(
@@ -776,42 +932,448 @@ class GraphQL:
776
932
 
777
933
  # resolver and field for the single item query
778
934
  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
-
935
+ identification_fields = cls._buildIdentificationArguments(generalManagerClass)
795
936
  item_field = graphene.Field(graphene_type, **identification_fields)
796
937
 
797
938
  def resolver(
798
939
  self: GeneralManager, info: GraphQLResolveInfo, **identification: dict
799
940
  ) -> GeneralManager:
941
+ """
942
+ Resolve a single manager instance from provided identification arguments.
943
+
944
+ Parameters:
945
+ identification (dict): Mapping of identification argument names to their values; used as keyword arguments when instantiating the manager.
946
+
947
+ Returns:
948
+ GeneralManager: The instantiated manager corresponding to the given identification.
949
+ """
800
950
  return generalManagerClass(**identification)
801
951
 
802
952
  cls._query_fields[item_field_name] = item_field
803
953
  cls._query_fields[f"resolve_{item_field_name}"] = resolver
804
954
 
955
+ @staticmethod
956
+ def _prime_graphql_properties(
957
+ instance: GeneralManager, property_names: Iterable[str] | None = None
958
+ ) -> None:
959
+ """
960
+ Eagerly evaluate GraphQLProperty attributes on a manager instance to capture dependency metadata.
961
+
962
+ 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.
963
+
964
+ Parameters:
965
+ instance (GeneralManager): The manager instance whose GraphQLProperty attributes should be evaluated.
966
+ property_names (Iterable[str] | None): Optional iterable of property names to evaluate. Names not present in the Interface's GraphQLProperties are ignored.
967
+ """
968
+ interface_cls = getattr(instance.__class__, "Interface", None)
969
+ if interface_cls is None:
970
+ return
971
+ available_properties = interface_cls.getGraphQLProperties()
972
+ if property_names is None:
973
+ names = available_properties.keys()
974
+ else:
975
+ names = [name for name in property_names if name in available_properties]
976
+ for prop_name in names:
977
+ getattr(instance, prop_name)
978
+
805
979
  @classmethod
806
- def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
980
+ def _dependencies_from_tracker(
981
+ cls, dependency_records: Iterable[Dependency]
982
+ ) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
983
+ """
984
+ Convert DependencyTracker records into tuples of (manager class, identification dict).
985
+
986
+ Filters input records to those whose operation is "identification", whose manager name maps to a registered GeneralManager class, and whose identifier can be parsed to a dict; returns one tuple per successfully resolved record.
987
+
988
+ Parameters:
989
+ dependency_records (Iterable[Dependency]): Iterable of dependency records as (manager_name, operation, identifier).
990
+
991
+ Returns:
992
+ list[tuple[type[GeneralManager], dict[str, Any]]]: A list of (manager_class, identification_dict) tuples for each resolved record.
993
+ """
994
+ resolved: list[tuple[type[GeneralManager], dict[str, Any]]] = []
995
+ for manager_name, operation, identifier in dependency_records:
996
+ if operation != "identification":
997
+ continue
998
+ manager_class = cls.manager_registry.get(manager_name)
999
+ if manager_class is None:
1000
+ continue
1001
+ try:
1002
+ parsed = py_ast.literal_eval(identifier)
1003
+ except (ValueError, SyntaxError):
1004
+ continue
1005
+ if not isinstance(parsed, dict):
1006
+ continue
1007
+ resolved.append((manager_class, parsed))
1008
+ return resolved
1009
+
1010
+ @classmethod
1011
+ def _subscription_property_names(
1012
+ cls,
1013
+ info: GraphQLResolveInfo,
1014
+ manager_class: type[GeneralManager],
1015
+ ) -> set[str]:
1016
+ """
1017
+ Identify GraphQLProperty names selected within the subscription payload's `item` field.
1018
+
1019
+ Parameters:
1020
+ info (GraphQLResolveInfo): Resolve info containing the parsed selection set and fragments.
1021
+ manager_class (type[GeneralManager]): Manager class whose Interface defines available GraphQLProperty names.
1022
+
1023
+ Returns:
1024
+ property_names (set[str]): Set of GraphQLProperty names referenced under the `item` selection in the subscription request; empty if none or if the manager has no Interface.
807
1025
  """
808
- Generate Graphene input fields for writable interface attributes.
1026
+ interface_cls = getattr(manager_class, "Interface", None)
1027
+ if interface_cls is None:
1028
+ return set()
1029
+ available_properties = set(interface_cls.getGraphQLProperties().keys())
1030
+ if not available_properties:
1031
+ return set()
809
1032
 
1033
+ property_names: set[str] = set()
1034
+
1035
+ def collect_from_selection(selection_set: SelectionSetNode | None) -> None:
1036
+ """
1037
+ Recursively collect selected GraphQL property names from a SelectionSetNode into the enclosing `property_names` set.
1038
+
1039
+ Processes each selection in the provided selection_set:
1040
+ - Adds a field's name to `property_names` when the name is present in the surrounding `available_properties` set.
1041
+ - For fragment spreads, resolves the fragment via `info.fragments` (from the enclosing scope) and recurses into its selection set.
1042
+ - For inline fragments, recurses into the fragment's selection set.
1043
+
1044
+ Parameters:
1045
+ selection_set (SelectionSetNode | None): GraphQL selection set to traverse; no action is taken if None.
1046
+ """
1047
+ if selection_set is None:
1048
+ return
1049
+ for selection in selection_set.selections:
1050
+ if isinstance(selection, FieldNode):
1051
+ name = selection.name.value
1052
+ if name in available_properties:
1053
+ property_names.add(name)
1054
+ elif isinstance(selection, FragmentSpreadNode):
1055
+ fragment = info.fragments.get(selection.name.value)
1056
+ if fragment is not None:
1057
+ collect_from_selection(fragment.selection_set)
1058
+ elif isinstance(selection, InlineFragmentNode):
1059
+ collect_from_selection(selection.selection_set)
1060
+
1061
+ def inspect_selection_set(selection_set: SelectionSetNode | None) -> None:
1062
+ """
1063
+ Recursively traverse a GraphQL SelectionSet and delegate the subselection of any field named "item" to collect_from_selection.
1064
+
1065
+ Parameters:
1066
+ selection_set (SelectionSetNode | None): The AST selection set to inspect. If None, the function does nothing.
1067
+
1068
+ Description:
1069
+ Visits FieldNode, FragmentSpreadNode, and InlineFragmentNode entries:
1070
+ - For FieldNode named "item", calls collect_from_selection with that field's subselection.
1071
+ - For other FieldNode entries and inline fragments, continues recursion into their subselections.
1072
+ - For fragment spreads, resolves the fragment from `info.fragments` (if present) and inspects its subselection.
1073
+ """
1074
+ if selection_set is None:
1075
+ return
1076
+ for selection in selection_set.selections:
1077
+ if isinstance(selection, FieldNode):
1078
+ if selection.name.value == "item":
1079
+ collect_from_selection(selection.selection_set)
1080
+ else:
1081
+ inspect_selection_set(selection.selection_set)
1082
+ elif isinstance(selection, FragmentSpreadNode):
1083
+ fragment = info.fragments.get(selection.name.value)
1084
+ if fragment is not None:
1085
+ inspect_selection_set(fragment.selection_set)
1086
+ elif isinstance(selection, InlineFragmentNode):
1087
+ inspect_selection_set(selection.selection_set)
1088
+
1089
+ for node in info.field_nodes:
1090
+ inspect_selection_set(node.selection_set)
1091
+ return property_names
1092
+
1093
+ @classmethod
1094
+ def _resolve_subscription_dependencies(
1095
+ cls,
1096
+ manager_class: type[GeneralManager],
1097
+ instance: GeneralManager,
1098
+ dependency_records: Iterable[Dependency] | None = None,
1099
+ ) -> list[tuple[type[GeneralManager], dict[str, Any]]]:
1100
+ """
1101
+ Builds a list of dependency pairs (manager class, identification) for subscription wiring from an instance and optional dependency records.
1102
+
1103
+ Given a manager class and its instantiated item, returns deduplicated dependency definitions derived from:
1104
+ - any Dependency records produced by a dependency tracker, and
1105
+ - the manager Interface's input fields that reference other GeneralManager types and are populated on the instance.
1106
+
810
1107
  Parameters:
811
- interface_cls (InterfaceBase): Interface whose attributes drive the input field map.
1108
+ manager_class (type[GeneralManager]): The manager type whose subscription dependencies are being resolved.
1109
+ instance (GeneralManager): The instantiated manager item whose inputs and identification are inspected.
1110
+ dependency_records (Iterable[Dependency] | None): Optional dependency-tracker records to include.
1111
+
1112
+ Returns:
1113
+ list[tuple[type[GeneralManager], dict[str, Any]]]: A list of (dependent_manager_class, identification) pairs.
1114
+ Each identification is a dict of identification fields. The list excludes the (manager_class, instance.identification) pair and contains no duplicates.
1115
+ """
1116
+ dependencies: list[tuple[type[GeneralManager], dict[str, Any]]] = []
1117
+ seen: set[tuple[str, str]] = set()
1118
+ if dependency_records:
1119
+ for dependency_class, dependency_identification in cls._dependencies_from_tracker(
1120
+ dependency_records
1121
+ ):
1122
+ if (
1123
+ dependency_class is manager_class
1124
+ and dependency_identification == instance.identification
1125
+ ):
1126
+ continue
1127
+ key = (dependency_class.__name__, repr(dependency_identification))
1128
+ if key in seen:
1129
+ continue
1130
+ seen.add(key)
1131
+ dependencies.append((dependency_class, dependency_identification))
1132
+ interface_cls = manager_class.Interface
1133
+
1134
+ for (
1135
+ input_name,
1136
+ input_field,
1137
+ ) in interface_cls.input_fields.items():
1138
+ if not issubclass(input_field.type, GeneralManager):
1139
+ continue
1140
+
1141
+ raw_value = instance._interface.identification.get(input_name)
1142
+ if raw_value is None:
1143
+ continue
1144
+
1145
+ values = raw_value if isinstance(raw_value, list) else [raw_value]
1146
+ for value in values:
1147
+ if isinstance(value, GeneralManager):
1148
+ identification = deepcopy(value.identification)
1149
+ key = (input_field.type.__name__, repr(identification))
1150
+ if key in seen:
1151
+ continue
1152
+ seen.add(key)
1153
+ dependencies.append(
1154
+ (
1155
+ cast(type[GeneralManager], input_field.type),
1156
+ identification,
1157
+ )
1158
+ )
1159
+ elif isinstance(value, dict):
1160
+ identification_dict = deepcopy(cast(dict[str, Any], value))
1161
+ key = (input_field.type.__name__, repr(identification_dict))
1162
+ if key in seen:
1163
+ continue
1164
+ seen.add(key)
1165
+ dependencies.append(
1166
+ (
1167
+ cast(type[GeneralManager], input_field.type),
1168
+ identification_dict,
1169
+ )
1170
+ )
1171
+
1172
+ return dependencies
1173
+
1174
+ @staticmethod
1175
+ def _instantiate_manager(
1176
+ manager_class: type[GeneralManager],
1177
+ identification: dict[str, Any],
1178
+ *,
1179
+ collect_dependencies: bool = False,
1180
+ property_names: Iterable[str] | None = None,
1181
+ ) -> tuple[GeneralManager, set[Dependency]]:
1182
+ """
1183
+ Create a GeneralManager instance for the given identification and optionally prime its GraphQL properties to capture dependency records.
1184
+
1185
+ Parameters:
1186
+ manager_class (type[GeneralManager]): Manager class to instantiate.
1187
+ identification (dict[str, Any]): Mapping of identification field names to values used to construct the instance.
1188
+ collect_dependencies (bool): If True, prime GraphQL properties while tracking and return the captured Dependency records.
1189
+ property_names (Iterable[str] | None): Specific GraphQLProperty names to prime; if None, all relevant properties are primed.
1190
+
1191
+ Returns:
1192
+ tuple[GeneralManager, set[Dependency]]: The instantiated manager and a set of captured Dependency objects (empty if collect_dependencies is False).
1193
+ """
1194
+ if collect_dependencies:
1195
+ with DependencyTracker() as captured_dependencies:
1196
+ instance = manager_class(**identification)
1197
+ GraphQL._prime_graphql_properties(instance, property_names)
1198
+ return instance, captured_dependencies
1199
+
1200
+ instance = manager_class(**identification)
1201
+ return instance, set()
1202
+
1203
+ @classmethod
1204
+ def _addSubscriptionField(
1205
+ cls, graphene_type: type[graphene.ObjectType], generalManagerClass: Type[GeneralManager]
1206
+ ) -> None:
1207
+ """
1208
+ Register a GraphQL subscription field that publishes change events for the given manager type.
1209
+
1210
+ Creates (or reuses) a SubscriptionEvent payload GraphQL type and adds three entries to the class subscription registry:
1211
+ - a field exposing the subscription with identification arguments,
1212
+ - an async subscribe function that yields an initial "snapshot" event and subsequent change events for the identified instance and its dependencies,
1213
+ - and a resolve function that returns the delivered payload.
1214
+
1215
+ Parameters:
1216
+ graphene_type (type[graphene.ObjectType]): GraphQL ObjectType representing the manager's item type used as the payload `item` field.
1217
+ generalManagerClass (Type[GeneralManager]): The GeneralManager subclass whose changes the subscription will publish.
1218
+
1219
+ Notes:
1220
+ - The subscribe function requires an available channel layer and subscribes the caller to channel groups derived from the instance identification and its resolved dependencies.
1221
+ - 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).
1222
+ - On termination the subscription cleans up listener tasks and unsubscribes from channel groups.
1223
+ """
1224
+ field_name = f"on_{generalManagerClass.__name__.lower()}_change"
1225
+ if field_name in cls._subscription_fields:
1226
+ return
1227
+
1228
+ payload_type = cls._subscription_payload_registry.get(
1229
+ generalManagerClass.__name__
1230
+ )
1231
+ if payload_type is None:
1232
+ payload_type = type(
1233
+ f"{generalManagerClass.__name__}SubscriptionEvent",
1234
+ (graphene.ObjectType,),
1235
+ {
1236
+ "item": graphene.Field(graphene_type),
1237
+ "action": graphene.String(required=True),
1238
+ },
1239
+ )
1240
+ cls._subscription_payload_registry[
1241
+ generalManagerClass.__name__
1242
+ ] = payload_type
1243
+
1244
+ identification_args = cls._buildIdentificationArguments(generalManagerClass)
1245
+ subscription_field = graphene.Field(payload_type, **identification_args)
1246
+
1247
+ async def subscribe(
1248
+ _root: Any,
1249
+ info: GraphQLResolveInfo,
1250
+ **identification: Any,
1251
+ ) -> AsyncIterator[SubscriptionEvent]:
1252
+ """
1253
+ Open a subscription stream for a specific manager instance identified by the provided arguments.
1254
+
1255
+ The iterator first yields a `SubscriptionEvent` with action `"snapshot"` containing the current item, then yields subsequent `SubscriptionEvent`s for each received action. If an item cannot be re-instantiated while producing an update event, the yielded `SubscriptionEvent.item` will be `None`. The subscription registers the caller on the manager's channel groups and automatically unsubscribes and cancels background listeners when the iterator is closed or cancelled.
1256
+
1257
+ Parameters:
1258
+ identification: Identification fields required to locate the manager instance (maps to the manager's identification signature).
1259
+
1260
+ Returns:
1261
+ AsyncIterator[SubscriptionEvent]: An asynchronous iterator that produces subscription events (initial snapshot then updates).
1262
+ """
1263
+ identification_copy = deepcopy(identification)
1264
+ property_names = cls._subscription_property_names(
1265
+ info, cast(type[GeneralManager], generalManagerClass)
1266
+ )
1267
+ try:
1268
+ instance, dependency_records = await asyncio.to_thread(
1269
+ cls._instantiate_manager,
1270
+ cast(type[GeneralManager], generalManagerClass),
1271
+ identification_copy,
1272
+ collect_dependencies=True,
1273
+ property_names=property_names,
1274
+ )
1275
+ except Exception as exc: # pragma: no cover - bubbled to GraphQL
1276
+ raise GraphQLError(str(exc)) from exc
812
1277
 
1278
+ try:
1279
+ channel_layer = cast(BaseChannelLayer, cls._get_channel_layer(strict=True))
1280
+ except RuntimeError as exc:
1281
+ raise GraphQLError(str(exc)) from exc
1282
+ channel_name = cast(str, await channel_layer.new_channel())
1283
+ queue: asyncio.Queue[str] = asyncio.Queue[str]()
1284
+
1285
+ group_names = {
1286
+ cls._group_name(
1287
+ cast(type[GeneralManager], generalManagerClass),
1288
+ instance.identification,
1289
+ )
1290
+ }
1291
+ dependencies = cls._resolve_subscription_dependencies(
1292
+ cast(type[GeneralManager], generalManagerClass),
1293
+ instance,
1294
+ dependency_records,
1295
+ )
1296
+ for dependency_class, dependency_identification in dependencies:
1297
+ group_names.add(
1298
+ cls._group_name(dependency_class, dependency_identification)
1299
+ )
1300
+
1301
+ for group in group_names:
1302
+ await channel_layer.group_add(group, channel_name)
1303
+
1304
+ listener_task = asyncio.create_task(
1305
+ cls._channel_listener(channel_layer, channel_name, queue)
1306
+ )
1307
+
1308
+ async def event_stream() -> AsyncIterator[SubscriptionEvent]:
1309
+ """
1310
+ Asynchronously yields subscription events for a manager instance: an initial "snapshot" event followed by events for subsequent actions.
1311
+
1312
+ Yields:
1313
+ SubscriptionEvent: First yields a `SubscriptionEvent` with `action` set to "snapshot" and `item` containing the current manager instance (or `None` if instantiation failed). Afterwards yields `SubscriptionEvent` values for each action received, where `action` is the action string and `item` is the (re-)instantiated manager or `None` if instantiation failed.
1314
+
1315
+ Notes:
1316
+ On exit the function cancels the background listener task and discards the channel subscriptions for all groups associated with the subscription.
1317
+ """
1318
+ try:
1319
+ yield SubscriptionEvent(item=instance, action="snapshot")
1320
+ while True:
1321
+ action = await queue.get()
1322
+ try:
1323
+ item, _ = await asyncio.to_thread(
1324
+ cls._instantiate_manager,
1325
+ cast(type[GeneralManager], generalManagerClass),
1326
+ identification_copy,
1327
+ property_names=property_names,
1328
+ )
1329
+ except Exception:
1330
+ item = None
1331
+ yield SubscriptionEvent(item=item, action=action)
1332
+ finally:
1333
+ listener_task.cancel()
1334
+ with suppress(asyncio.CancelledError):
1335
+ await listener_task
1336
+ for group in group_names:
1337
+ await channel_layer.group_discard(group, channel_name)
1338
+
1339
+ return event_stream()
1340
+
1341
+ def resolve(
1342
+ payload: SubscriptionEvent,
1343
+ info: GraphQLResolveInfo,
1344
+ **_: Any,
1345
+ ) -> SubscriptionEvent:
1346
+ """
1347
+ Passes a subscription payload through unchanged.
1348
+
1349
+ Parameters:
1350
+ payload (SubscriptionEvent): The subscription event payload to deliver to the client.
1351
+ info (GraphQLResolveInfo): GraphQL resolver info (unused).
1352
+
1353
+ Returns:
1354
+ SubscriptionEvent: The same payload instance provided as input.
1355
+ """
1356
+ return payload
1357
+
1358
+ cls._subscription_fields[field_name] = subscription_field
1359
+ cls._subscription_fields[f"subscribe_{field_name}"] = subscribe
1360
+ cls._subscription_fields[f"resolve_{field_name}"] = resolve
1361
+
1362
+ @classmethod
1363
+ def createWriteFields(cls, interface_cls: InterfaceBase) -> dict[str, Any]:
1364
+ """
1365
+ Generate Graphene input fields for writable attributes defined by an Interface.
1366
+
1367
+ Skips system fields ("changed_by", "created_at", "updated_at") and derived attributes.
1368
+ For relation attributes referencing another GeneralManager, produces ID or list-of-ID fields.
1369
+ Each generated field is annotated with an `editable` attribute reflecting the interface metadata.
1370
+ Always includes an optional "history_comment" String field marked editable.
1371
+
1372
+ Parameters:
1373
+ interface_cls (InterfaceBase): Interface whose attribute metadata (types, required, default, editable, derived) is used to build input fields.
1374
+
813
1375
  Returns:
814
- dict[str, Any]: Mapping of attribute names to Graphene field definitions.
1376
+ dict[str, Any]: Mapping from attribute name to a Graphene input field instance.
815
1377
  """
816
1378
  fields: dict[str, Any] = {}
817
1379
 
@@ -1075,13 +1637,18 @@ class GraphQL:
1075
1637
  @staticmethod
1076
1638
  def _handleGraphQLError(error: Exception) -> None:
1077
1639
  """
1078
- Raise a ``GraphQLError`` with a code based on the exception type.
1079
-
1640
+ Convert an exception into a GraphQL error with an appropriate extensions['code'].
1641
+
1642
+ Maps:
1643
+ PermissionError -> "PERMISSION_DENIED"
1644
+ ValueError, ValidationError, TypeError -> "BAD_USER_INPUT"
1645
+ other exceptions -> "INTERNAL_SERVER_ERROR"
1646
+
1080
1647
  Parameters:
1081
- error (Exception): Exception raised during mutation execution.
1082
-
1648
+ error (Exception): The original exception to convert.
1649
+
1083
1650
  Raises:
1084
- GraphQLError: Error with an appropriate ``extensions['code']`` value.
1651
+ GraphQLError: GraphQL error containing the original message and an `extensions['code']` indicating the error category.
1085
1652
  """
1086
1653
  if isinstance(error, PermissionError):
1087
1654
  raise GraphQLError(
@@ -1104,3 +1671,51 @@ class GraphQL:
1104
1671
  "code": "INTERNAL_SERVER_ERROR",
1105
1672
  },
1106
1673
  )
1674
+
1675
+ @classmethod
1676
+ def _handle_data_change(
1677
+ cls,
1678
+ sender: type[GeneralManager] | GeneralManager,
1679
+ instance: GeneralManager | None,
1680
+ action: str,
1681
+ **_: Any,
1682
+ ) -> None:
1683
+ """
1684
+ Send a subscription event to the channel group for a changed GeneralManager instance.
1685
+
1686
+ If `instance` is a GeneralManager of a registered manager type and a channel layer is available,
1687
+ publish a "gm.subscription.event" message containing the provided `action` to the group identified
1688
+ by the manager class and the instance's identification. If `instance` is missing, the manager is
1689
+ not registered, or no channel layer is available, the function returns without side effects.
1690
+
1691
+ Parameters:
1692
+ sender: The signal sender; either a GeneralManager subclass or an instance.
1693
+ instance: The GeneralManager instance that changed.
1694
+ action: A string describing the change action (e.g., "created", "updated", "deleted").
1695
+ """
1696
+ if instance is None or not isinstance(instance, GeneralManager):
1697
+ return
1698
+
1699
+ if isinstance(sender, type) and issubclass(sender, GeneralManager):
1700
+ manager_class: type[GeneralManager] = sender
1701
+ else:
1702
+ manager_class = instance.__class__
1703
+
1704
+ if manager_class.__name__ not in cls.manager_registry:
1705
+ return
1706
+
1707
+ channel_layer = cls._get_channel_layer()
1708
+ if channel_layer is None:
1709
+ return
1710
+
1711
+ group_name = cls._group_name(manager_class, instance.identification)
1712
+ async_to_sync(channel_layer.group_send)(
1713
+ group_name,
1714
+ {
1715
+ "type": "gm.subscription.event",
1716
+ "action": action,
1717
+ },
1718
+ )
1719
+
1720
+
1721
+ post_data_change.connect(GraphQL._handle_data_change, weak=False)