GeneralManager 0.17.0__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 +356 -221
  15. general_manager/api/graphql_subscription_consumer.py +81 -78
  16. general_manager/api/mutation.py +85 -23
  17. general_manager/api/property.py +39 -13
  18. general_manager/apps.py +188 -47
  19. general_manager/bucket/__init__.py +10 -1
  20. general_manager/bucket/calculationBucket.py +155 -53
  21. general_manager/bucket/databaseBucket.py +157 -45
  22. general_manager/bucket/groupBucket.py +133 -44
  23. general_manager/cache/__init__.py +10 -1
  24. general_manager/cache/dependencyIndex.py +143 -45
  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 +20 -6
  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 +31 -33
  62. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/METADATA +2 -1
  63. generalmanager-0.18.0.dist-info/RECORD +77 -0
  64. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
  65. generalmanager-0.17.0.dist-info/RECORD +0 -77
  66. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
  67. {generalmanager-0.17.0.dist-info → generalmanager-0.18.0.dist-info}/top_level.txt +0 -0
@@ -5,16 +5,8 @@ import ast
5
5
  import inspect
6
6
  import re
7
7
  import textwrap
8
- from typing import (
9
- Callable,
10
- ClassVar,
11
- Dict,
12
- Generic,
13
- List,
14
- Optional,
15
- TypeVar,
16
- cast,
17
- )
8
+ from typing import Callable, Dict, Generic, List, Optional, Tuple, TypeVar
9
+ from decimal import Decimal
18
10
 
19
11
  from django.conf import settings
20
12
  from django.utils.module_loading import import_string
@@ -30,6 +22,48 @@ from general_manager.manager.generalManager import GeneralManager
30
22
 
31
23
  GeneralManagerType = TypeVar("GeneralManagerType", bound=GeneralManager)
32
24
 
25
+ NOTEXISTENT = object()
26
+
27
+
28
+ class NonexistentAttributeError(AttributeError):
29
+ """Raised when a referenced attribute does not exist on the GeneralManager instance."""
30
+
31
+ def __init__(self, attribute: str) -> None:
32
+ """
33
+ Initialize the exception indicating that a referenced attribute does not exist.
34
+
35
+ Parameters:
36
+ attribute (str): The name of the nonexistent attribute; this name will be included in the exception message.
37
+ """
38
+ super().__init__(f"The attribute '{attribute}' does not exist.")
39
+
40
+
41
+ class MissingErrorTemplateVariableError(ValueError):
42
+ """Raised when a custom error template omits required variables."""
43
+
44
+ def __init__(self, missing: List[str]) -> None:
45
+ """
46
+ Initialize the exception indicating that one or more variables referenced by a rule are missing from a custom error template.
47
+
48
+ Parameters:
49
+ missing (List[str]): Names of the variables that are required by the template but were not found; these names will be included in the exception message.
50
+ """
51
+ super().__init__(
52
+ f"The custom error message does not contain all used variables: {missing}."
53
+ )
54
+
55
+
56
+ class ErrorMessageGenerationError(ValueError):
57
+ """Raised when generating an error message before evaluating any input."""
58
+
59
+ def __init__(self) -> None:
60
+ """
61
+ Exception raised when no input is available for generating an error message.
62
+
63
+ This exception is initialized with the default message "No input provided for error message generation."
64
+ """
65
+ super().__init__("No input provided for error message generation.")
66
+
33
67
 
34
68
  class Rule(Generic[GeneralManagerType]):
35
69
  """
@@ -44,6 +78,9 @@ class Rule(Generic[GeneralManagerType]):
44
78
  _ignore_if_none: bool
45
79
  _last_result: Optional[bool]
46
80
  _last_input: Optional[GeneralManagerType]
81
+ _last_args: Dict[str, object]
82
+ _param_names: Tuple[str, ...]
83
+ _primary_param: Optional[str]
47
84
  _tree: ast.AST
48
85
  _variables: List[str]
49
86
  _handlers: Dict[str, BaseRuleHandler]
@@ -54,11 +91,24 @@ class Rule(Generic[GeneralManagerType]):
54
91
  custom_error_message: Optional[str] = None,
55
92
  ignore_if_none: bool = True,
56
93
  ) -> None:
94
+ """
95
+ Initialize a Rule that wraps a predicate function, captures its source AST to discover referenced variables, and prepares handlers for generating error messages.
96
+
97
+ Parameters:
98
+ func (Callable[[GeneralManagerType], bool]): Predicate that evaluates a GeneralManager instance.
99
+ custom_error_message (Optional[str]): Optional template used to format generated error messages; placeholders must match variables referenced by the predicate.
100
+ ignore_if_none (bool): If True, evaluation will be skipped (result recorded as None) when any referenced variable resolves to None.
101
+ """
57
102
  self._func = func
58
103
  self._custom_error_message = custom_error_message
59
104
  self._ignore_if_none = ignore_if_none
60
105
  self._last_result = None
61
106
  self._last_input = None
107
+ self._last_args = {}
108
+
109
+ parameters = inspect.signature(func).parameters
110
+ self._param_names = tuple(parameters.keys())
111
+ self._primary_param = self._param_names[0] if self._param_names else None
62
112
 
63
113
  # 1) Extract source, strip decorators, and dedent
64
114
  src = textwrap.dedent(inspect.getsource(func))
@@ -67,7 +117,7 @@ class Rule(Generic[GeneralManagerType]):
67
117
  self._tree = ast.parse(src)
68
118
  for parent in ast.walk(self._tree):
69
119
  for child in ast.iter_child_nodes(parent):
70
- setattr(child, "parent", parent)
120
+ child.parent = parent # type: ignore
71
121
 
72
122
  # 3) Extract referenced variables
73
123
  self._variables = self._extract_variables()
@@ -108,15 +158,21 @@ class Rule(Generic[GeneralManagerType]):
108
158
 
109
159
  def evaluate(self, x: GeneralManagerType) -> Optional[bool]:
110
160
  """
111
- Execute the predicate against a manager instance and record the result.
161
+ Evaluate the rule's predicate against a GeneralManager instance and record evaluation context.
162
+
163
+ Binds the primary parameter to the provided input, extracts referenced variable values, and sets the last evaluation result to the predicate outcome. If `ignore_if_none` is true and any referenced variable value is `None`, the evaluation is skipped and the last result is set to `None`.
112
164
 
113
165
  Parameters:
114
166
  x (GeneralManagerType): Manager instance supplied to the predicate.
115
167
 
116
168
  Returns:
117
- bool | None: True or False when the predicate executes; None when `ignore_if_none` is set and any referenced value is None.
169
+ `True` if the predicate evaluates to true, `False` if it evaluates to false, `None` if evaluation was skipped because a referenced value was `None` and `ignore_if_none` is enabled.
118
170
  """
119
171
  self._last_input = x
172
+ self._last_args = {}
173
+ if self._primary_param is not None:
174
+ self._last_args[self._primary_param] = x
175
+
120
176
  vals = self._extract_variable_values(x)
121
177
  if self._ignore_if_none and any(v is None for v in vals.values()):
122
178
  self._last_result = None
@@ -127,13 +183,10 @@ class Rule(Generic[GeneralManagerType]):
127
183
 
128
184
  def validateCustomErrorMessage(self) -> None:
129
185
  """
130
- Ensure the user-defined template references every extracted variable.
131
-
132
- Returns:
133
- None
186
+ Validate that a provided custom error message template includes placeholders for every variable referenced by the rule.
134
187
 
135
188
  Raises:
136
- ValueError: If the custom error message omits a required placeholder.
189
+ MissingErrorTemplateVariableError: If one or more extracted variables are not present as `{name}` placeholders in the custom template.
137
190
  """
138
191
  if not self._custom_error_message:
139
192
  return
@@ -141,24 +194,22 @@ class Rule(Generic[GeneralManagerType]):
141
194
  vars_in_msg = set(re.findall(r"{([^}]+)}", self._custom_error_message))
142
195
  missing = [v for v in self._variables if v not in vars_in_msg]
143
196
  if missing:
144
- raise ValueError(
145
- f"The custom error message does not contain all used variables: {missing}"
146
- )
197
+ raise MissingErrorTemplateVariableError(missing)
147
198
 
148
199
  def getErrorMessage(self) -> Optional[Dict[str, str]]:
149
200
  """
150
- Build a mapping of variable names to error messages for the last evaluation.
201
+ Constructs error messages for the last failed evaluation and returns them keyed by variable name.
151
202
 
152
203
  Returns:
153
- dict[str, str] | None: Mapping describing validation failures, or None when the predicate passed.
204
+ dict[str, str] | None: Mapping from each referenced variable name to its error message, or `None` if the predicate passed or was not evaluated.
154
205
 
155
206
  Raises:
156
- ValueError: If called before any input has been evaluated.
207
+ ErrorMessageGenerationError: If called before any input has been evaluated.
157
208
  """
158
209
  if self._last_result or self._last_result is None:
159
210
  return None
160
211
  if self._last_input is None:
161
- raise ValueError("No input provided for error message generation")
212
+ raise ErrorMessageGenerationError()
162
213
 
163
214
  # Validate and substitute template placeholders
164
215
  self.validateCustomErrorMessage()
@@ -176,20 +227,48 @@ class Rule(Generic[GeneralManagerType]):
176
227
  return errors or None
177
228
 
178
229
  def _extract_variables(self) -> List[str]:
230
+ """
231
+ Collects the dotted attribute names referenced on the rule's parameters.
232
+
233
+ Scans the predicate's AST and returns a sorted list of attribute access paths that originate from any of the predicate's parameter names (for example, "user.name" or "order.total"). If the predicate has no parameters, returns an empty list.
234
+
235
+ Returns:
236
+ List[str]: Sorted list of dotted variable names referenced from the predicate's parameters.
237
+ """
238
+ param_names = set(self._param_names)
239
+ if not param_names:
240
+ return []
241
+
179
242
  class VarVisitor(ast.NodeVisitor):
180
- vars: set[str] = set()
243
+ def __init__(self, params: set[str]) -> None:
244
+ """
245
+ Initialize visitor state with a set of parameter names and an empty collection for discovered variables.
246
+
247
+ Parameters:
248
+ params (set[str]): Names of function parameters to consider when extracting referenced variables.
249
+ """
250
+ self.vars: set[str] = set()
251
+ self.params = params
181
252
 
182
253
  def visit_Attribute(self, node: ast.Attribute) -> None:
254
+ """
255
+ Record dotted attribute accesses that originate from allowed parameter names.
256
+
257
+ Visits an ast.Attribute node, collects the dotted name components (e.g., "a.b.c") if the base is an ast.Name present in self.params, and adds the joined name to self.vars. Continues generic traversal after handling the node.
258
+
259
+ Parameters:
260
+ node (ast.Attribute): The attribute node being visited.
261
+ """
183
262
  parts: list[str] = []
184
263
  curr: ast.AST = node
185
264
  while isinstance(curr, ast.Attribute):
186
265
  parts.append(curr.attr)
187
266
  curr = curr.value
188
- if isinstance(curr, ast.Name) and curr.id == "x":
267
+ if isinstance(curr, ast.Name) and curr.id in self.params:
189
268
  self.vars.add(".".join(reversed(parts)))
190
269
  self.generic_visit(node)
191
270
 
192
- visitor = VarVisitor()
271
+ visitor = VarVisitor(param_names)
193
272
  visitor.visit(self._tree)
194
273
  return sorted(visitor.vars)
195
274
 
@@ -200,15 +279,27 @@ class Rule(Generic[GeneralManagerType]):
200
279
  for var in self._variables:
201
280
  obj: object = x # type: ignore
202
281
  for part in var.split("."):
203
- obj = getattr(obj, part)
282
+ obj = getattr(obj, part, NOTEXISTENT)
283
+ if obj is NOTEXISTENT:
284
+ raise NonexistentAttributeError(var)
204
285
  if obj is None:
205
286
  break
206
287
  out[var] = obj
207
288
  return out
208
289
 
209
290
  def _extract_comparisons(self) -> list[ast.Compare]:
291
+ """
292
+ Collect all comparison (ast.Compare) nodes from the predicate AST.
293
+
294
+ Searches the rule's parsed AST stored on self._tree and returns every ast.Compare node found in traversal order.
295
+
296
+ Returns:
297
+ comps (list[ast.Compare]): The list of comparison nodes present in the predicate AST.
298
+ """
299
+
210
300
  class CompVisitor(ast.NodeVisitor):
211
- comps: list[ast.Compare] = []
301
+ def __init__(self) -> None:
302
+ self.comps: list[ast.Compare] = []
212
303
 
213
304
  def visit_Compare(self, node: ast.Compare) -> None:
214
305
  self.comps.append(node)
@@ -234,6 +325,17 @@ class Rule(Generic[GeneralManagerType]):
234
325
  def _generate_error_messages(
235
326
  self, var_values: Dict[str, Optional[object]]
236
327
  ) -> Dict[str, str]:
328
+ """
329
+ Generate human-readable error messages for each referenced variable using the resolved variable values.
330
+
331
+ Given a mapping of variable names to their evaluated values, produce a dictionary mapping each variable to an explanatory error message derived from the rule's predicate. If specialized rule handlers are registered for particular function calls in the predicate, those handlers will be used to produce messages for the affected variables. When the predicate contains boolean combinations and the last evaluation failed, all referenced variables receive a combined invalid-combination message. If no explicit comparisons are present in the predicate, a generic combination-invalid message is returned for every referenced variable.
332
+
333
+ Parameters:
334
+ var_values (Dict[str, Optional[object]]): Mapping from variable names (as extracted from the predicate) to their resolved values for the last-evaluated input.
335
+
336
+ Returns:
337
+ Dict[str, str]: Mapping from variable name to its generated error message.
338
+ """
237
339
  errors: Dict[str, str] = {}
238
340
  comparisons = self._extract_comparisons()
239
341
  logical = self._contains_logical_ops()
@@ -241,7 +343,7 @@ class Rule(Generic[GeneralManagerType]):
241
343
  if comparisons:
242
344
  for cmp in comparisons:
243
345
  left, rights, ops = cmp.left, cmp.comparators, cmp.ops
244
- for right, op in zip(rights, ops):
346
+ for right, op in zip(rights, ops, strict=False):
245
347
  # Special handler?
246
348
  if isinstance(left, ast.Call):
247
349
  fn = self._get_node_name(left.func)
@@ -270,7 +372,7 @@ class Rule(Generic[GeneralManagerType]):
270
372
  combo = ", ".join(f"[{v}]" for v in self._variables)
271
373
  msg = f"{combo} combination is not valid"
272
374
  for v in self._variables:
273
- errors[v] = msg
375
+ errors.setdefault(v, msg) # keep specific messages
274
376
 
275
377
  return errors
276
378
 
@@ -295,6 +397,17 @@ class Rule(Generic[GeneralManagerType]):
295
397
  }.get(type(op), "?")
296
398
 
297
399
  def _get_node_name(self, node: ast.AST) -> str:
400
+ """
401
+ Produce a concise, human-readable name for the given AST node.
402
+
403
+ For attribute access returns the dotted attribute path (e.g., "a.b.c"); for a Name node returns its identifier; for a Call node returns "func(arg1, arg2)" using the same naming rules for subnodes; for Constant nodes or nodes that cannot be represented returns an empty string.
404
+
405
+ Parameters:
406
+ node (ast.AST): The AST node to derive a readable name from.
407
+
408
+ Returns:
409
+ str: The human-readable name or an empty string if no sensible name can be produced.
410
+ """
298
411
  if isinstance(node, ast.Attribute):
299
412
  parts: list[str] = []
300
413
  curr: ast.AST = node
@@ -313,16 +426,42 @@ class Rule(Generic[GeneralManagerType]):
313
426
  try:
314
427
  # ast.unparse returns a string representation
315
428
  return ast.unparse(node)
316
- except Exception:
429
+ except (AttributeError, ValueError, TypeError):
317
430
  return ""
318
431
 
319
432
  def _eval_node(self, node: ast.expr) -> Optional[object]:
320
- """Evaluate an AST expression in the context of the last input."""
433
+ """
434
+ Evaluate an AST expression against the Rule's last-evaluated input and bound argument context.
435
+
436
+ Parameters:
437
+ node (ast.expr): The AST expression node to evaluate.
438
+
439
+ Returns:
440
+ result (Optional[object]): The evaluated value of the expression when resolvable; `None` if the node is not an expression or cannot be evaluated in the current context.
441
+
442
+ Description:
443
+ Supported evaluations:
444
+ - Attribute access: resolves chained attributes from the last input or resolved base value.
445
+ - Name: returns a bound argument value if present, otherwise looks up the name in the predicate function's global namespace.
446
+ - Unary negative: evaluates numeric negation for integer, float, or Decimal operands.
447
+ If the expression cannot be resolved (including missing intermediate attributes or unsupported node kinds), `None` is returned.
448
+ """
321
449
  if not isinstance(node, ast.expr):
322
450
  return None
323
451
  try:
324
- expr = ast.Expression(body=node)
325
- code = compile(expr, "<ast>", "eval")
326
- return eval(code, {"x": self._last_input}, {})
327
- except Exception:
328
- return None
452
+ return ast.literal_eval(node)
453
+ except (ValueError, TypeError):
454
+ if isinstance(node, ast.Attribute):
455
+ base = self._eval_node(node.value)
456
+ if base is None:
457
+ return None
458
+ return getattr(base, node.attr, None)
459
+ if isinstance(node, ast.Name):
460
+ if node.id in self._last_args:
461
+ return self._last_args[node.id]
462
+ return self._func.__globals__.get(node.id)
463
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
464
+ operand = self._eval_node(node.operand)
465
+ if isinstance(operand, (int, float, Decimal)):
466
+ return -operand
467
+ return None
@@ -12,10 +12,19 @@ __all__ = list(UTILS_EXPORTS)
12
12
  _MODULE_MAP = UTILS_EXPORTS
13
13
 
14
14
  if TYPE_CHECKING:
15
- from general_manager._types.utils import * # noqa: F401,F403
15
+ from general_manager._types.utils import * # noqa: F403
16
16
 
17
17
 
18
18
  def __getattr__(name: str) -> Any:
19
+ """
20
+ Resolve and return a lazily exported attribute from the module's public API.
21
+
22
+ Parameters:
23
+ name (str): The attribute name being accessed on the module.
24
+
25
+ Returns:
26
+ Any: The resolved export object corresponding to `name` as defined by the module's public API mapping.
27
+ """
19
28
  return resolve_export(
20
29
  name,
21
30
  module_all=__all__,
@@ -1,32 +1,57 @@
1
1
  from typing import Iterable, Mapping
2
2
 
3
3
 
4
+ class TooManyArgumentsError(TypeError):
5
+ """Raised when more positional arguments are supplied than available keys."""
6
+
7
+ def __init__(self) -> None:
8
+ """
9
+ Initialize the TooManyArgumentsError instance.
10
+
11
+ Sets the exception message to "More positional arguments than keys provided."
12
+ """
13
+ super().__init__("More positional arguments than keys provided.")
14
+
15
+
16
+ class ConflictingKeywordError(TypeError):
17
+ """Raised when generated keyword arguments conflict with existing kwargs."""
18
+
19
+ def __init__(self) -> None:
20
+ """
21
+ Initialize ConflictingKeywordError with the standard message "Conflicts in existing kwargs."
22
+ """
23
+ super().__init__("Conflicts in existing kwargs.")
24
+
25
+
4
26
  def args_to_kwargs(
5
27
  args: tuple[object, ...],
6
28
  keys: Iterable[str],
7
29
  existing_kwargs: Mapping[str, object] | None = None,
8
30
  ) -> dict[str, object]:
9
31
  """
10
- Convert positional arguments to keyword arguments and merge them into an existing mapping.
32
+ Map positional arguments to the given keys and merge the result with an optional existing kwargs mapping.
11
33
 
12
34
  Parameters:
13
- args (tuple[Any, ...]): Positional arguments that should be mapped to keyword arguments.
14
- keys (Iterable[Any]): Keys used to map each positional argument within `args`.
15
- existing_kwargs (dict | None): Optional keyword argument mapping to merge with the generated values.
35
+ args (tuple[object, ...]): Positional values to assign to keys in order.
36
+ keys (Iterable[str]): Keys to assign the positional values to.
37
+ existing_kwargs (Mapping[str, object] | None): Optional mapping of keyword arguments to merge into the result.
16
38
 
17
39
  Returns:
18
- dict[Any, Any]: A dictionary containing the merged keyword arguments.
40
+ dict[str, object]: A dictionary containing the mapped keys for the provided positional arguments plus all entries from `existing_kwargs` (if given).
19
41
 
20
42
  Raises:
21
- TypeError: If the number of positional arguments exceeds the number of provided keys, or if any generated keyword collides with `existing_kwargs`.
43
+ TooManyArgumentsError: If more positional arguments are provided than keys.
44
+ ConflictingKeywordError: If `existing_kwargs` contains a key that was already produced from `args` and `keys`.
22
45
  """
23
46
  keys = list(keys)
24
47
  if len(args) > len(keys):
25
- raise TypeError("More positional arguments than keys provided.")
48
+ raise TooManyArgumentsError()
26
49
 
27
- kwargs: dict[str, object] = {key: value for key, value in zip(keys, args)}
50
+ kwargs: dict[str, object] = {
51
+ key: value for key, value in zip(keys, args, strict=False)
52
+ }
28
53
  if existing_kwargs and any(key in kwargs for key in existing_kwargs):
29
- raise TypeError("Conflicts in existing kwargs.")
54
+ raise ConflictingKeywordError()
30
55
  if existing_kwargs:
31
56
  kwargs.update(existing_kwargs)
32
57
 
@@ -5,21 +5,36 @@ from typing import Any, Callable
5
5
  from general_manager.manager.input import Input
6
6
 
7
7
 
8
+ class UnknownInputFieldError(ValueError):
9
+ """Raised when a filter references an unknown input field."""
10
+
11
+ def __init__(self, field_name: str) -> None:
12
+ """
13
+ Initialize the UnknownInputFieldError with a message indicating which input field was not recognized.
14
+
15
+ Parameters:
16
+ field_name (str): Name of the input field referenced in the filter that is not defined.
17
+ """
18
+ super().__init__(f"Unknown input field '{field_name}' in filter.")
19
+
20
+
8
21
  def parse_filters(
9
22
  filter_kwargs: dict[str, Any], possible_values: dict[str, Input]
10
23
  ) -> dict[str, dict]:
11
24
  """
12
- Parse raw filter keyword arguments into structured criteria for the configured input fields.
25
+ Parse raw filter keyword arguments into structured criteria aligned with configured input fields.
13
26
 
14
27
  Parameters:
15
- filter_kwargs (dict[str, Any]): Filter expressions keyed by `<field>[__lookup]` strings.
16
- possible_values (dict[str, Input]): Input definitions that validate, cast, and describe dependencies for each field.
28
+ filter_kwargs (dict[str, Any]): Mapping of filter expressions keyed by "<field>[__lookup]".
29
+ possible_values (dict[str, Input]): Mapping of field names to Input definitions used for casting and type information.
17
30
 
18
31
  Returns:
19
- dict[str, dict[str, Any]]: Mapping of input field names to dictionaries containing either `filter_kwargs` or `filter_funcs` entries used when evaluating filters.
32
+ dict[str, dict]: Mapping from input field name to a dictionary containing either:
33
+ - "filter_kwargs": dict of lookup names to values for bucket (GeneralManager) fields, or
34
+ - "filter_funcs": list of callables that evaluate non-bucket field conditions.
20
35
 
21
36
  Raises:
22
- ValueError: If a filter references an input field that is not defined in `possible_values`.
37
+ UnknownInputFieldError: If a filter references a field name not present in `possible_values`.
23
38
  """
24
39
  from general_manager.manager.generalManager import GeneralManager
25
40
 
@@ -28,7 +43,7 @@ def parse_filters(
28
43
  parts = kwarg.split("__")
29
44
  field_name = parts[0]
30
45
  if field_name not in possible_values:
31
- raise ValueError(f"Unknown input field '{field_name}' in filter")
46
+ raise UnknownInputFieldError(field_name)
32
47
  input_field = possible_values[field_name]
33
48
 
34
49
  lookup = "__".join(parts[1:]) if len(parts) > 1 else ""
@@ -131,5 +146,5 @@ def apply_lookup(value_to_check: Any, lookup: str, filter_value: Any) -> bool:
131
146
  return value_to_check in filter_value
132
147
  else:
133
148
  return False
134
- except TypeError as e:
149
+ except TypeError:
135
150
  return False
@@ -1,5 +1,6 @@
1
1
  """Utility helpers for converting between common string casing styles."""
2
2
 
3
+
3
4
  def snake_to_pascal(s: str) -> str:
4
5
  """
5
6
  Convert a snake_case string to PascalCase.
@@ -1,7 +1,7 @@
1
1
  """Utilities for tracing relationships between GeneralManager classes."""
2
2
 
3
3
  from __future__ import annotations
4
- from typing import TYPE_CHECKING, Any, cast, get_args
4
+ from typing import TYPE_CHECKING, Any, ClassVar, cast, get_args
5
5
  from general_manager.manager.meta import GeneralManagerMeta
6
6
  from general_manager.api.property import GraphQLProperty
7
7
 
@@ -13,19 +13,27 @@ type PathStart = str
13
13
  type PathDestination = str
14
14
 
15
15
 
16
+ class MissingStartInstanceError(ValueError):
17
+ """Raised when attempting to traverse a path without a starting instance."""
18
+
19
+ def __init__(self) -> None:
20
+ """
21
+ Create the MissingStartInstanceError with its default message.
22
+
23
+ This initializer constructs the exception with the message: "Cannot call goTo on a PathMap without a start instance."
24
+ """
25
+ super().__init__("Cannot call goTo on a PathMap without a start instance.")
26
+
27
+
16
28
  class PathMap:
17
29
  """Maintain cached traversal paths between GeneralManager classes."""
18
30
 
19
31
  instance: PathMap
20
- mapping: dict[tuple[PathStart, PathDestination], PathTracer] = {}
32
+ mapping: ClassVar[dict[tuple[PathStart, PathDestination], PathTracer]] = {}
21
33
 
22
- def __new__(cls, *args: object, **kwargs: object) -> PathMap:
34
+ def __new__(cls, *args: Any, **kwargs: Any) -> PathMap:
23
35
  """
24
- Create or return the singleton PathMap instance and ensure the path mapping is initialised.
25
-
26
- Parameters:
27
- args (tuple): Positional arguments ignored by the constructor.
28
- kwargs (dict): Keyword arguments ignored by the constructor.
36
+ Obtain the singleton PathMap, initializing the path mapping on first instantiation.
29
37
 
30
38
  Returns:
31
39
  PathMap: The singleton PathMap instance.
@@ -106,16 +114,16 @@ class PathMap:
106
114
  self, path_destination: PathDestination | type[GeneralManager] | str
107
115
  ) -> GeneralManager | Bucket | None:
108
116
  """
109
- Follow the cached path from the starting point to the requested destination.
117
+ Traverse the cached path from the configured start to the given destination.
110
118
 
111
119
  Parameters:
112
- path_destination (PathDestination | type[GeneralManager] | str): Target manager identifier, either as a class, instance, or class name.
120
+ path_destination (PathDestination | type[GeneralManager] | str): Destination specified as a GeneralManager class, a destination name, or a PathDestination identifier.
113
121
 
114
122
  Returns:
115
- GeneralManager | Bucket | None: The resolved manager instance, a bucket of instances, or None when no path exists.
123
+ GeneralManager | Bucket | None: The resolved GeneralManager instance, a Bucket of instances reached by the path, or `None` if no cached path exists.
116
124
 
117
125
  Raises:
118
- ValueError: If no starting instance was supplied when constructing the PathMap.
126
+ MissingStartInstanceError: If the cached path requires a concrete start instance but the PathMap was constructed without one.
119
127
  """
120
128
  if isinstance(path_destination, type):
121
129
  path_destination = path_destination.__name__
@@ -124,15 +132,15 @@ class PathMap:
124
132
  if not tracer:
125
133
  return None
126
134
  if self.start_instance is None:
127
- raise ValueError("Cannot call goTo on a PathMap without a start instance.")
135
+ raise MissingStartInstanceError()
128
136
  return tracer.traversePath(self.start_instance)
129
137
 
130
138
  def getAllConnected(self) -> set[str]:
131
139
  """
132
- List the class names that are reachable from the configured starting point.
140
+ Return the set of destination class names that are reachable from the configured start.
133
141
 
134
142
  Returns:
135
- set[str]: Collection of destination class names that have a valid traversal path.
143
+ set[str]: Destination class names reachable from the current start_class_name.
136
144
  """
137
145
  connected_classes: set[str] = set()
138
146
  for path_tuple, path_obj in self.mapping.items():
@@ -5,6 +5,23 @@ from __future__ import annotations
5
5
  from importlib import import_module
6
6
  from typing import Any, Iterable, Mapping, MutableMapping, overload
7
7
 
8
+
9
+ class MissingExportError(AttributeError):
10
+ """Raised when a requested export is not defined in the public API."""
11
+
12
+ def __init__(self, module_name: str, attribute: str) -> None:
13
+ """
14
+ Initialize the MissingExportError with the originating module name and the missing attribute.
15
+
16
+ Constructs the exception message "module 'module_name' has no attribute 'attribute'".
17
+
18
+ Parameters:
19
+ module_name (str): Name of the module where the attribute was expected.
20
+ attribute (str): Name of the missing attribute.
21
+ """
22
+ super().__init__(f"module {module_name!r} has no attribute {attribute!r}")
23
+
24
+
8
25
  ModuleTarget = tuple[str, str]
9
26
  ModuleMap = Mapping[str, str | ModuleTarget]
10
27
 
@@ -30,9 +47,23 @@ def resolve_export(
30
47
  module_map: ModuleMap,
31
48
  module_globals: MutableMapping[str, Any],
32
49
  ) -> Any:
33
- """Resolve a lazily-loaded export for a package __init__ module."""
50
+ """
51
+ Resolve and cache a lazily-loaded export for a package __init__ module.
52
+
53
+ Parameters:
54
+ name (str): The public export name to resolve.
55
+ module_all (Iterable[str]): Iterable of names declared in the module's __all__; used to validate that `name` is an allowed export.
56
+ module_map (ModuleMap): Mapping from public export names to target module paths or (module path, attribute) pairs used to locate the actual object.
57
+ module_globals (MutableMapping[str, Any]): The module's globals dict; the resolved value will be stored here under `name`.
58
+
59
+ Returns:
60
+ Any: The resolved attribute value for `name`.
61
+
62
+ Raises:
63
+ MissingExportError: If `name` is not present in `module_all`.
64
+ """
34
65
  if name not in module_all:
35
- raise AttributeError(f"module {module_globals['__name__']!r} has no attribute {name!r}")
66
+ raise MissingExportError(module_globals["__name__"], name)
36
67
  module_path, attr_name = _normalize_target(name, module_map[name])
37
68
  module = import_module(module_path)
38
69
  value = getattr(module, attr_name)