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,13 +1,28 @@
1
1
  """GraphQL-aware property descriptor used by GeneralManager classes."""
2
2
 
3
- from typing import Any, Callable, get_type_hints, overload, TypeVar
4
3
  import sys
4
+ from typing import Any, Callable, TypeVar, get_type_hints, overload
5
5
 
6
6
  T = TypeVar("T", bound=Callable[..., Any])
7
7
 
8
8
 
9
+ class GraphQLPropertyReturnAnnotationError(TypeError):
10
+ """Raised when a GraphQLProperty is defined without a return type annotation."""
11
+
12
+ def __init__(self) -> None:
13
+ """
14
+ Indicates a GraphQLProperty-decorated function is missing a return type annotation.
15
+
16
+ This exception is raised to signal that a property resolver intended for use with GraphQLProperty must have an explicit return type hint. The exception message is: "GraphQLProperty requires a return type hint for the property function."
17
+ """
18
+ super().__init__(
19
+ "GraphQLProperty requires a return type hint for the property function."
20
+ )
21
+
22
+
9
23
  class GraphQLProperty(property):
10
24
  """Descriptor that exposes a property with GraphQL metadata and type hints."""
25
+
11
26
  sortable: bool
12
27
  filterable: bool
13
28
  query_annotation: Any | None
@@ -22,14 +37,17 @@ class GraphQLProperty(property):
22
37
  query_annotation: Any | None = None,
23
38
  ) -> None:
24
39
  """
25
- Initialise the descriptor with GraphQL-specific configuration.
40
+ Initialize the GraphQLProperty descriptor with GraphQL-specific metadata.
26
41
 
27
42
  Parameters:
28
- fget (Callable): Underlying resolver function.
29
- doc (str | None): Optional documentation string.
30
- sortable (bool): Whether the property participates in sorting.
31
- filterable (bool): Whether the property participates in filtering.
32
- query_annotation (Any | None): Optional annotation applied to querysets.
43
+ fget (Callable[..., Any]): The resolver function to wrap; its unwrapped form must include a return type annotation.
44
+ doc (str | None): Optional documentation string exposed on the descriptor.
45
+ sortable (bool): Whether the property should be considered for sorting.
46
+ filterable (bool): Whether the property should be considered for filtering.
47
+ query_annotation (Any | None): Optional annotation to apply when querying/queryset construction.
48
+
49
+ Raises:
50
+ GraphQLPropertyReturnAnnotationError: If the underlying resolver function does not declare a return type annotation.
33
51
  """
34
52
  super().__init__(fget, doc=doc)
35
53
  self.is_graphql_resolver = True
@@ -46,17 +64,25 @@ class GraphQLProperty(property):
46
64
  ) # falls decorator Annotations durchreicht
47
65
  ann = getattr(orig, "__annotations__", {}) or {}
48
66
  if "return" not in ann:
49
- raise TypeError(
50
- "GraphQLProperty requires a return type hint for the property function."
51
- )
67
+ raise GraphQLPropertyReturnAnnotationError()
52
68
 
53
69
  def __set_name__(self, owner: type, name: str) -> None:
54
- """Store the owning class and attribute name for later introspection."""
70
+ """
71
+ Record the owner class and attribute name for the descriptor to support later introspection.
72
+
73
+ Parameters:
74
+ owner (type): The class that owns this descriptor.
75
+ name (str): The attribute name under which this descriptor is assigned.
76
+ """
55
77
  self._owner = owner
56
78
  self._name = name
57
79
 
58
80
  def _try_resolve_type_hint(self) -> None:
59
- """Resolve the return type hint of the wrapped resolver, if available."""
81
+ """
82
+ Resolve and cache the wrapped resolver's return type hint.
83
+
84
+ When successful, stores the resolved return annotation on self._graphql_type_hint; if resolution fails or cannot be determined, sets self._graphql_type_hint to None.
85
+ """
60
86
  if self._graphql_type_hint is not None:
61
87
  return
62
88
 
@@ -71,7 +97,7 @@ class GraphQLProperty(property):
71
97
 
72
98
  hints = get_type_hints(self.fget, globalns=globalns, localns=localns)
73
99
  self._graphql_type_hint = hints.get("return", None)
74
- except Exception:
100
+ except (AttributeError, KeyError, NameError, TypeError, ValueError):
75
101
  self._graphql_type_hint = None
76
102
 
77
103
  @property
general_manager/apps.py CHANGED
@@ -3,20 +3,45 @@ from django.apps import AppConfig
3
3
  import graphene # type: ignore[import]
4
4
  import os
5
5
  from django.conf import settings
6
- from django.urls import path
6
+ from django.urls import path, re_path
7
7
  from graphene_django.views import GraphQLView # type: ignore[import]
8
- from importlib import import_module
8
+ from importlib import import_module, util
9
+ import importlib.abc
10
+ import sys
9
11
  from general_manager.manager.generalManager import GeneralManager
10
12
  from general_manager.manager.meta import GeneralManagerMeta
11
13
  from general_manager.manager.input import Input
12
14
  from general_manager.api.property import graphQlProperty
13
15
  from general_manager.api.graphql import GraphQL
14
- from typing import TYPE_CHECKING, Type, cast
16
+ from typing import TYPE_CHECKING, Any, Callable, Type, cast
15
17
  from django.core.checks import register
16
18
  import logging
17
19
  from django.core.management.base import BaseCommand
18
20
 
19
21
 
22
+ class MissingRootUrlconfError(RuntimeError):
23
+ """Raised when Django settings do not define ROOT_URLCONF."""
24
+
25
+ def __init__(self) -> None:
26
+ """
27
+ Initialize the MissingRootUrlconfError with a default message indicating ROOT_URLCONF is missing from Django settings.
28
+ """
29
+ super().__init__("ROOT_URLCONF not found in settings.")
30
+
31
+
32
+ class InvalidPermissionClassError(TypeError):
33
+ """Raised when a GeneralManager Permission attribute is not a BasePermission subclass."""
34
+
35
+ def __init__(self, permission_name: str) -> None:
36
+ """
37
+ Create an InvalidPermissionClassError indicating a permission is not a subclass of BasePermission.
38
+
39
+ Parameters:
40
+ permission_name (str): The name of the permission (typically the class or attribute name) that failed validation. The exception message will be "`{permission_name} must be a subclass of BasePermission.`"
41
+ """
42
+ super().__init__(f"{permission_name} must be a subclass of BasePermission.")
43
+
44
+
20
45
  if TYPE_CHECKING:
21
46
  from general_manager.interface.readOnlyInterface import ReadOnlyInterface
22
47
 
@@ -46,24 +71,45 @@ class GeneralmanagerConfig(AppConfig):
46
71
  read_only_classes: list[Type[GeneralManager]],
47
72
  ) -> None:
48
73
  """
49
- Configures synchronization and schema validation for the given read-only GeneralManager classes.
74
+ Configure synchronization and register schema checks for read-only GeneralManager classes.
50
75
 
51
- For each provided class, ensures that its data is synchronized before any Django management command executes, and registers a system check to verify that the associated schema remains up to date.
76
+ Parameters:
77
+ read_only_classes (list[Type[GeneralManager]]): GeneralManager subclasses whose Interface implements a ReadOnlyInterface; each class will have its read-only data synchronized before management commands and a Django system check registered to verify the Interface schema is up to date.
52
78
  """
53
79
  GeneralmanagerConfig.patchReadOnlyInterfaceSync(read_only_classes)
54
80
  from general_manager.interface.readOnlyInterface import ReadOnlyInterface
55
81
 
56
82
  logger.debug("starting to register ReadOnlyInterface schema warnings...")
83
+
84
+ def _build_schema_check(
85
+ manager_cls: Type[GeneralManager], model: Any
86
+ ) -> Callable[..., list[Any]]:
87
+ """
88
+ Builds a Django system check callable that verifies the read-only interface schema for a manager against a model is current.
89
+
90
+ Parameters:
91
+ manager_cls (Type[GeneralManager]): The GeneralManager class whose ReadOnlyInterface schema will be validated.
92
+ model (Any): The model (or model-like descriptor) to check the schema against.
93
+
94
+ Returns:
95
+ Callable[..., list[Any]]: A callable suitable for Django's system checks framework that returns a list of check messages.
96
+ """
97
+
98
+ def schema_check(*_: Any, **__: Any) -> list[Any]:
99
+ return ReadOnlyInterface.ensureSchemaIsUpToDate(manager_cls, model)
100
+
101
+ return schema_check
102
+
57
103
  for general_manager_class in read_only_classes:
58
104
  read_only_interface = cast(
59
105
  Type[ReadOnlyInterface], general_manager_class.Interface
60
106
  )
61
107
 
62
- register(
63
- lambda app_configs, model=read_only_interface._model, manager_class=general_manager_class, **kwargs: ReadOnlyInterface.ensureSchemaIsUpToDate(
64
- manager_class, model
65
- ),
66
- "general_manager",
108
+ register("general_manager")(
109
+ _build_schema_check(
110
+ general_manager_class,
111
+ read_only_interface._model,
112
+ )
67
113
  )
68
114
 
69
115
  @staticmethod
@@ -71,9 +117,21 @@ class GeneralmanagerConfig(AppConfig):
71
117
  general_manager_classes: list[Type[GeneralManager]],
72
118
  ) -> None:
73
119
  """
74
- Monkey-patches Django's management command runner to synchronize all provided read-only interfaces before executing any management command, except during autoreload subprocesses of 'runserver'.
120
+ Ensure the provided GeneralManager classes' ReadOnlyInterfaces synchronize their data before any Django management command is executed.
121
+
122
+ For each class in `general_manager_classes`, calls the class's `Interface.syncData()` to keep read-only data consistent. Skips synchronization when running the autoreload subprocess of `runserver`.
123
+ Parameters:
124
+ general_manager_classes (list[Type[GeneralManager]]): GeneralManager subclasses whose `Interface` implements `syncData`.
125
+ """
126
+ """
127
+ Wrap BaseCommand.run_from_argv to call `syncData()` on registered ReadOnlyInterfaces before executing the original command.
75
128
 
76
- For each class in `general_manager_classes`, the associated read-only interface's `syncData` method is called prior to command execution, ensuring data consistency before management operations.
129
+ Skips synchronization when the command is `runserver` and the process is the autoreload subprocess.
130
+ Parameters:
131
+ self (BaseCommand): The management command instance.
132
+ argv (list[str]): Command-line arguments for the management command.
133
+ Returns:
134
+ The result returned by the original `BaseCommand.run_from_argv` call.
77
135
  """
78
136
  from general_manager.interface.readOnlyInterface import ReadOnlyInterface
79
137
 
@@ -85,13 +143,13 @@ class GeneralmanagerConfig(AppConfig):
85
143
  ) -> None:
86
144
  # Ensure syncData is only called at real run of runserver
87
145
  """
88
- Executes a Django management command, synchronizing all registered read-only interfaces before execution unless running in an autoreload subprocess of 'runserver'.
146
+ Synchronizes all registered ReadOnlyInterface data before running a Django management command, except when running the autoreload subprocess of `runserver`.
89
147
 
90
148
  Parameters:
91
- argv (list): Command-line arguments for the management command.
149
+ argv (list[str]): The management command `argv`, including the program name and command.
92
150
 
93
151
  Returns:
94
- The result of the original management command execution.
152
+ The value returned by the original `BaseCommand.run_from_argv` invocation.
95
153
  """
96
154
  run_main = os.environ.get("RUN_MAIN") == "true"
97
155
  command = argv[1] if len(argv) > 1 else None
@@ -108,7 +166,7 @@ class GeneralmanagerConfig(AppConfig):
108
166
  result = original_run_from_argv(self, argv)
109
167
  return result
110
168
 
111
- setattr(BaseCommand, "run_from_argv", run_from_argv_with_sync)
169
+ BaseCommand.run_from_argv = run_from_argv_with_sync # type: ignore[assignment]
112
170
 
113
171
  @staticmethod
114
172
  def initializeGeneralManagerClasses(
@@ -116,16 +174,40 @@ class GeneralmanagerConfig(AppConfig):
116
174
  all_classes: list[Type[GeneralManager]],
117
175
  ) -> None:
118
176
  """
119
- Initializes attributes and establishes dynamic relationships for GeneralManager classes.
177
+ Initialize GeneralManager classes' interface attributes, create attribute-based accessors, wire GraphQL connection properties between related managers, and validate each class's permission configuration.
120
178
 
121
- For each class pending attribute initialization, assigns interface attributes and creates property accessors. Then, for all registered GeneralManager classes, connects input fields referencing other GeneralManager subclasses by adding GraphQL properties to enable filtered access to related objects.
179
+ For each class in `pending_attribute_initialization` this assigns the class's Interface attributes to its internal `_attributes` and creates property accessors for those attributes. For each class in `all_classes` this scans its Interface `input_fields` for inputs whose type is another GeneralManager subclass and adds a GraphQL property on the connected manager that resolves related objects filtered by the input attribute. Finally, validate and normalize the Permission attribute on every class via GeneralmanagerConfig.checkPermissionClass.
180
+
181
+ Parameters:
182
+ pending_attribute_initialization (list[type[GeneralManager]]): GeneralManager classes whose Interface attributes need to be initialized and whose attribute properties should be created.
183
+ all_classes (list[type[GeneralManager]]): All registered GeneralManager classes to inspect for input-field connections and to validate permissions.
122
184
  """
123
185
  logger.debug("Initializing GeneralManager classes...")
124
186
 
187
+ def _build_connection_resolver(
188
+ attribute_key: str, manager_cls: Type[GeneralManager]
189
+ ) -> Callable[[object], Any]:
190
+ """
191
+ Create a resolver that queries the given GeneralManager class by matching an attribute to a provided value.
192
+
193
+ Parameters:
194
+ attribute_key (str): Name of the attribute to filter on.
195
+ manager_cls (Type[GeneralManager]): GeneralManager subclass to query.
196
+
197
+ Returns:
198
+ Callable[[object], Any]: A callable that accepts a value and returns the result of filtering `manager_cls` where `attribute_key` equals that value.
199
+ """
200
+
201
+ def resolver(value: object) -> Any:
202
+ return manager_cls.filter(**{attribute_key: value})
203
+
204
+ resolver.__annotations__ = {"return": manager_cls}
205
+ return resolver
206
+
125
207
  logger.debug("starting to create attributes for GeneralManager classes...")
126
208
  for general_manager_class in pending_attribute_initialization:
127
209
  attributes = general_manager_class.Interface.getAttributes()
128
- setattr(general_manager_class, "_attributes", attributes)
210
+ general_manager_class._attributes = attributes
129
211
  GeneralManagerMeta.createAtPropertiesForAttributes(
130
212
  attributes.keys(), general_manager_class
131
213
  )
@@ -138,15 +220,13 @@ class GeneralmanagerConfig(AppConfig):
138
220
  attribute.type, GeneralManager
139
221
  ):
140
222
  connected_manager = attribute.type
141
- func = lambda x, attribute_name=attribute_name: general_manager_class.filter(
142
- **{attribute_name: x}
223
+ resolver = _build_connection_resolver(
224
+ attribute_name, general_manager_class
143
225
  )
144
-
145
- func.__annotations__ = {"return": general_manager_class}
146
226
  setattr(
147
227
  connected_manager,
148
228
  f"{general_manager_class.__name__.lower()}_list",
149
- graphQlProperty(func),
229
+ graphQlProperty(resolver),
150
230
  )
151
231
  for general_manager_class in all_classes:
152
232
  GeneralmanagerConfig.checkPermissionClass(general_manager_class)
@@ -156,7 +236,10 @@ class GeneralmanagerConfig(AppConfig):
156
236
  pending_graphql_interfaces: list[Type[GeneralManager]],
157
237
  ) -> None:
158
238
  """
159
- Creates GraphQL interfaces and mutations for the provided general manager classes, builds the GraphQL schema, and registers the GraphQL endpoint in the Django URL configuration.
239
+ Generate GraphQL types, assemble the GraphQL schema for the given manager classes, and expose the GraphQL endpoint in the project's URL configuration.
240
+
241
+ Parameters:
242
+ pending_graphql_interfaces (list[Type[GeneralManager]]): GeneralManager classes for which GraphQL interfaces, mutations, and optional subscriptions should be created and included in the assembled schema.
160
243
  """
161
244
  logger.debug("Starting to create GraphQL interfaces and mutations...")
162
245
  for general_manager_class in pending_graphql_interfaces:
@@ -176,31 +259,44 @@ class GeneralmanagerConfig(AppConfig):
176
259
  },
177
260
  )
178
261
  GraphQL._mutation_class = mutation_class
179
- schema = graphene.Schema(
180
- query=GraphQL._query_class,
181
- mutation=mutation_class,
182
- )
183
262
  else:
184
263
  GraphQL._mutation_class = None
185
- schema = graphene.Schema(query=GraphQL._query_class)
264
+
265
+ if GraphQL._subscription_fields:
266
+ subscription_class = type(
267
+ "Subscription",
268
+ (graphene.ObjectType,),
269
+ GraphQL._subscription_fields,
270
+ )
271
+ GraphQL._subscription_class = subscription_class
272
+ else:
273
+ GraphQL._subscription_class = None
274
+
275
+ schema_kwargs: dict[str, Any] = {"query": GraphQL._query_class}
276
+ if GraphQL._mutation_class is not None:
277
+ schema_kwargs["mutation"] = GraphQL._mutation_class
278
+ if GraphQL._subscription_class is not None:
279
+ schema_kwargs["subscription"] = GraphQL._subscription_class
280
+ schema = graphene.Schema(**schema_kwargs)
281
+ GraphQL._schema = schema
186
282
  GeneralmanagerConfig.addGraphqlUrl(schema)
187
283
 
188
284
  @staticmethod
189
285
  def addGraphqlUrl(schema: graphene.Schema) -> None:
190
286
  """
191
- Adds a GraphQL endpoint to the Django URL configuration using the provided schema.
287
+ Add a GraphQL endpoint to the project's URL configuration and ensure the ASGI subscription route is configured.
192
288
 
193
289
  Parameters:
194
- schema: The GraphQL schema to use for the endpoint.
290
+ schema (graphene.Schema): GraphQL schema to serve at the configured GRAPHQL_URL.
195
291
 
196
292
  Raises:
197
- Exception: If the ROOT_URLCONF setting is not defined in Django settings.
293
+ MissingRootUrlconfError: If ROOT_URLCONF is not defined in Django settings.
198
294
  """
199
295
  logging.debug("Adding GraphQL URL to Django settings...")
200
296
  root_url_conf_path = getattr(settings, "ROOT_URLCONF", None)
201
297
  graph_ql_url = getattr(settings, "GRAPHQL_URL", "graphql")
202
298
  if not root_url_conf_path:
203
- raise Exception("ROOT_URLCONF not found in settings")
299
+ raise MissingRootUrlconfError()
204
300
  urlconf = import_module(root_url_conf_path)
205
301
  urlconf.urlpatterns.append(
206
302
  path(
@@ -208,12 +304,211 @@ class GeneralmanagerConfig(AppConfig):
208
304
  GraphQLView.as_view(graphiql=True, schema=schema),
209
305
  )
210
306
  )
307
+ GeneralmanagerConfig._ensure_asgi_subscription_route(graph_ql_url)
308
+
309
+ @staticmethod
310
+ def _ensure_asgi_subscription_route(graphql_url: str) -> None:
311
+ """
312
+ Ensure GraphQL websocket subscription route is integrated into the project's ASGI application when ASGI is configured.
313
+
314
+ If settings.ASGI_APPLICATION is absent or invalid, the function logs and returns without making changes. When the ASGI application module can be imported, delegates to GeneralmanagerConfig._finalize_asgi_module to add or wire WebSocket routes for GraphQL subscriptions at the given URL. If the ASGI module cannot be imported during Django's application population phase, the function arranges a deferred finalization so the ASGI module will be finalized once it is loaded; other import failures are logged and cause a no-op return.
315
+ Parameters:
316
+ graphql_url (str): URL path at which the GraphQL websocket subscription endpoint should be exposed (e.g., "/graphql/").
317
+ """
318
+ asgi_path = getattr(settings, "ASGI_APPLICATION", None)
319
+ if not asgi_path:
320
+ logger.debug("ASGI_APPLICATION not configured; skipping websocket setup.")
321
+ return
322
+
323
+ try:
324
+ module_path, attr_name = asgi_path.rsplit(".", 1)
325
+ except ValueError:
326
+ logger.warning(
327
+ "ASGI_APPLICATION '%s' is not a valid module path; skipping websocket setup.",
328
+ asgi_path,
329
+ )
330
+ return
331
+
332
+ try:
333
+ asgi_module = import_module(module_path)
334
+ except RuntimeError as exc:
335
+ if "populate() isn't reentrant" not in str(exc):
336
+ logger.warning(
337
+ "Unable to import ASGI module '%s': %s",
338
+ module_path,
339
+ exc,
340
+ exc_info=True,
341
+ )
342
+ return
343
+
344
+ spec = util.find_spec(module_path)
345
+ if spec is None or spec.loader is None:
346
+ logger.warning(
347
+ "Could not locate loader for ASGI module '%s'; skipping websocket setup.",
348
+ module_path,
349
+ )
350
+ return
351
+
352
+ def finalize(module: Any) -> None:
353
+ """
354
+ Finalize integration of GraphQL subscription routing into the given ASGI module using the surrounding attribute name and GraphQL URL.
355
+
356
+ Ensure the ASGI module exposes websocket_urlpatterns and add a websocket route for the GraphQL subscription endpoint if missing; modify or wrap the module's application mapping or ASGI application as needed to include an authenticated websocket route for subscriptions.
357
+
358
+ Parameters:
359
+ module (Any): The imported ASGI module object to be modified in-place to support GraphQL websocket subscriptions.
360
+ """
361
+ GeneralmanagerConfig._finalize_asgi_module(
362
+ module, attr_name, graphql_url
363
+ )
364
+
365
+ class _Loader(importlib.abc.Loader):
366
+ def __init__(self, original_loader: importlib.abc.Loader) -> None:
367
+ """
368
+ Initialize the wrapper with the given original importlib loader.
369
+
370
+ Parameters:
371
+ original_loader (importlib.abc.Loader): The underlying loader that this wrapper will delegate to.
372
+ """
373
+ self._original_loader = original_loader
374
+
375
+ def create_module(self, spec): # type: ignore[override]
376
+ if hasattr(self._original_loader, "create_module"):
377
+ return self._original_loader.create_module(spec) # type: ignore[attr-defined]
378
+ return None
379
+
380
+ def exec_module(self, module): # type: ignore[override]
381
+ self._original_loader.exec_module(module)
382
+ finalize(module)
383
+
384
+ wrapped_loader = _Loader(spec.loader)
385
+
386
+ class _Finder(importlib.abc.MetaPathFinder):
387
+ def __init__(self) -> None:
388
+ self._processed = False
389
+
390
+ def find_spec(self, fullname, path, target=None): # type: ignore[override]
391
+ """
392
+ Return a ModuleSpec for the wrapped loader the first time the specified module is requested.
393
+
394
+ If `fullname` matches the loader's target module name and this loader has not yet produced a spec, mark the loader as processed, create a new spec using the wrapped loader, copy `submodule_search_locations` from the original `spec` if present, and return the new spec; otherwise return `None`.
395
+
396
+ Parameters:
397
+ fullname (str): Fully-qualified name of the module being imported; matched against the loader's target module name.
398
+ path (Sequence[str] | None): Import path for package search (not used by this finder).
399
+ target (ModuleSpec | None): Optional existing spec suggested by the import machinery (ignored).
400
+
401
+ Returns:
402
+ ModuleSpec | None: The created ModuleSpec when `fullname` matches and has not been processed, `None` otherwise.
403
+ """
404
+ if fullname != module_path or self._processed:
405
+ return None
406
+ self._processed = True
407
+ new_spec = util.spec_from_loader(fullname, wrapped_loader)
408
+ if new_spec is not None:
409
+ new_spec.submodule_search_locations = (
410
+ spec.submodule_search_locations
411
+ )
412
+ return new_spec
413
+
414
+ finder = _Finder()
415
+ sys.meta_path.insert(0, finder)
416
+ return
417
+ except ImportError as exc: # pragma: no cover - defensive
418
+ logger.warning(
419
+ "Unable to import ASGI module '%s': %s", module_path, exc, exc_info=True
420
+ )
421
+ return
422
+
423
+ GeneralmanagerConfig._finalize_asgi_module(asgi_module, attr_name, graphql_url)
424
+
425
+ @staticmethod
426
+ def _finalize_asgi_module(
427
+ asgi_module: Any, attr_name: str, graphql_url: str
428
+ ) -> None:
429
+ """
430
+ Ensure the ASGI module exposes a websocket route for GraphQL subscriptions and integrate it into the ASGI application.
431
+
432
+ If Channels and the GraphQL subscription consumer are available, this function appends a websocket URL pattern for the GraphQL subscriptions to asgi_module.websocket_urlpatterns (creating the list if needed) and then wires that websocket route into the module's ASGI application. If the module exposes an application mapping (application.application_mapping is a dict) the websocket entry is added there; otherwise the existing application is wrapped in a ProtocolTypeRouter that routes websocket traffic through Channels' AuthMiddlewareStack. If optional dependencies are missing, or websocket_urlpatterns cannot be appended, the function returns without modifying the ASGI module.
433
+
434
+ Parameters:
435
+ asgi_module (module): The imported ASGI module object referenced by ASGI_APPLICATION in settings.
436
+ attr_name (str): Attribute name on asgi_module that holds the ASGI application (e.g., "application").
437
+ graphql_url (str): URL path (relative, may include leading/trailing slashes) at which the GraphQL HTTP endpoint is mounted; used to build the websocket route for subscriptions.
438
+ """
439
+ try:
440
+ from channels.auth import AuthMiddlewareStack # type: ignore[import-untyped]
441
+ from channels.routing import ProtocolTypeRouter, URLRouter # type: ignore[import-untyped]
442
+ from general_manager.api.graphql_subscription_consumer import (
443
+ GraphQLSubscriptionConsumer,
444
+ )
445
+ except (
446
+ ImportError,
447
+ RuntimeError,
448
+ ) as exc: # pragma: no cover - optional dependency
449
+ logger.debug(
450
+ "Channels or GraphQL subscription consumer unavailable (%s); skipping websocket setup.",
451
+ exc,
452
+ )
453
+ return
454
+
455
+ websocket_patterns = getattr(asgi_module, "websocket_urlpatterns", None)
456
+ if websocket_patterns is None:
457
+ websocket_patterns = []
458
+ asgi_module.websocket_urlpatterns = websocket_patterns
459
+
460
+ if not hasattr(websocket_patterns, "append"):
461
+ logger.warning(
462
+ "websocket_urlpatterns in '%s' does not support appending; skipping websocket setup.",
463
+ asgi_module.__name__,
464
+ )
465
+ return
466
+
467
+ normalized = graphql_url.strip("/")
468
+ pattern = rf"^{normalized}/?$" if normalized else r"^$"
469
+
470
+ route_exists = any(
471
+ getattr(route, "_general_manager_graphql_ws", False)
472
+ for route in websocket_patterns
473
+ )
474
+ if not route_exists:
475
+ websocket_route = re_path(pattern, GraphQLSubscriptionConsumer.as_asgi()) # type: ignore[arg-type]
476
+ websocket_route._general_manager_graphql_ws = True
477
+ websocket_patterns.append(websocket_route)
478
+
479
+ application = getattr(asgi_module, attr_name, None)
480
+ if application is None:
481
+ return
482
+
483
+ if hasattr(application, "application_mapping") and isinstance(
484
+ application.application_mapping, dict
485
+ ):
486
+ application.application_mapping["websocket"] = AuthMiddlewareStack(
487
+ URLRouter(list(websocket_patterns))
488
+ )
489
+ else:
490
+ wrapped_application = ProtocolTypeRouter(
491
+ {
492
+ "http": application,
493
+ "websocket": AuthMiddlewareStack(
494
+ URLRouter(list(websocket_patterns))
495
+ ),
496
+ }
497
+ )
498
+ setattr(asgi_module, attr_name, wrapped_application)
211
499
 
212
500
  @staticmethod
213
501
  def checkPermissionClass(general_manager_class: Type[GeneralManager]) -> None:
214
502
  """
215
- Checks if the class has a Permission attribute and if it is a subclass of BasePermission.
216
- If so, it sets the Permission attribute on the class.
503
+ Validate and normalize a GeneralManager class's Permission attribute.
504
+
505
+ If the class defines a Permission attribute, ensure it is a subclass of BasePermission and leave it assigned; if it is not a subclass, raise InvalidPermissionClassError. If the class does not define Permission, assign ManagerBasedPermission as the default.
506
+
507
+ Parameters:
508
+ general_manager_class (Type[GeneralManager]): GeneralManager subclass whose Permission attribute will be validated or initialized.
509
+
510
+ Raises:
511
+ InvalidPermissionClassError: If the existing Permission attribute is not a subclass of BasePermission.
217
512
  """
218
513
  from general_manager.permission.basePermission import BasePermission
219
514
  from general_manager.permission.managerBasedPermission import (
@@ -222,10 +517,11 @@ class GeneralmanagerConfig(AppConfig):
222
517
 
223
518
  if hasattr(general_manager_class, "Permission"):
224
519
  permission = general_manager_class.Permission
225
- if not issubclass(permission, BasePermission):
226
- raise TypeError(
227
- f"{permission.__name__} must be a subclass of BasePermission"
228
- )
520
+ if not (
521
+ isinstance(permission, type) and issubclass(permission, BasePermission)
522
+ ):
523
+ permission_name = getattr(permission, "__name__", repr(permission))
524
+ raise InvalidPermissionClassError(permission_name)
229
525
  general_manager_class.Permission = permission
230
526
  else:
231
527
  general_manager_class.Permission = ManagerBasedPermission
@@ -12,10 +12,19 @@ __all__ = list(BUCKET_EXPORTS)
12
12
  _MODULE_MAP = BUCKET_EXPORTS
13
13
 
14
14
  if TYPE_CHECKING:
15
- from general_manager._types.bucket import * # noqa: F401,F403
15
+ from general_manager._types.bucket import * # noqa: F403
16
16
 
17
17
 
18
18
  def __getattr__(name: str) -> Any:
19
+ """
20
+ Dynamically resolve and return a named bucket export from this module's public API.
21
+
22
+ Parameters:
23
+ name (str): The attribute name to resolve from the module's exports.
24
+
25
+ Returns:
26
+ Any: The resolved export object for `name`.
27
+ """
19
28
  return resolve_export(
20
29
  name,
21
30
  module_all=__all__,