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
@@ -5,6 +5,7 @@ import time
5
5
  import ast
6
6
  import re
7
7
  import logging
8
+ from datetime import date, datetime
8
9
 
9
10
  from django.core.cache import cache
10
11
  from general_manager.cache.signals import post_data_change, pre_data_change
@@ -32,6 +33,22 @@ type Dependency = Tuple[general_manager_name, filter_type, str]
32
33
 
33
34
  logger = logging.getLogger(__name__)
34
35
 
36
+
37
+ class DependencyLockTimeoutError(TimeoutError):
38
+ """Raised when the dependency index lock cannot be acquired within the timeout."""
39
+
40
+ def __init__(self, operation: str) -> None:
41
+ """
42
+ Error raised when acquiring the dependency index lock times out.
43
+
44
+ Parameters:
45
+ operation (str): Name or description of the operation during which lock acquisition timed out.
46
+ """
47
+ super().__init__(
48
+ f"Timed out acquiring dependency index lock during {operation}."
49
+ )
50
+
51
+
35
52
  # -----------------------------------------------------------------------------
36
53
  # CONFIG
37
54
  # -----------------------------------------------------------------------------
@@ -39,6 +56,7 @@ INDEX_KEY = "dependency_index" # Cache key storing the complete dependency inde
39
56
  LOCK_KEY = "dependency_index_lock" # Cache key used for the dependency lock
40
57
  LOCK_TIMEOUT = 5 # Lock TTL in seconds
41
58
  UNDEFINED = object() # Sentinel for undefined values
59
+ ACTIONS: tuple[Literal["filter"], Literal["exclude"]] = ("filter", "exclude")
42
60
 
43
61
 
44
62
  # -----------------------------------------------------------------------------
@@ -112,23 +130,22 @@ def record_dependencies(
112
130
  ],
113
131
  ) -> None:
114
132
  """
115
- Register cache keys against the filters and exclusions they depend on.
133
+ Register a cache key as dependent on the given manager-level filters, exclusions, or identifications.
116
134
 
117
135
  Parameters:
118
- cache_key (str): Cache key produced for the cached queryset.
136
+ cache_key (str): The cache key to associate with the declared dependencies.
119
137
  dependencies (Iterable[tuple[str, Literal["filter", "exclude", "identification"], str]]):
120
- Collection describing manager name, dependency type, and identifying data.
121
-
122
- Returns:
123
- None
138
+ Iterable of tuples describing each dependency as (manager_name, action, identifier).
139
+ - For `filter` and `exclude`, `identifier` is a string representation of a mapping of lookups to values.
140
+ - For `identification`, `identifier` is the identifying value (treated as a lookup on `id`).
124
141
 
125
142
  Raises:
126
- TimeoutError: If the dependency lock cannot be acquired within `LOCK_TIMEOUT`.
143
+ DependencyLockTimeoutError: If a lock cannot be acquired within the configured timeout while updating the index.
127
144
  """
128
145
  start = time.time()
129
146
  while not acquire_lock():
130
147
  if time.time() - start > LOCK_TIMEOUT:
131
- raise TimeoutError("Could not aquire lock for record_dependencies")
148
+ raise DependencyLockTimeoutError("record_dependencies")
132
149
  time.sleep(0.05)
133
150
 
134
151
  try:
@@ -138,6 +155,11 @@ def record_dependencies(
138
155
  action_key = cast(Literal["filter", "exclude"], action)
139
156
  params = ast.literal_eval(identifier)
140
157
  section = idx[action_key].setdefault(model_name, {})
158
+ if len(params) > 1:
159
+ cache_dependencies = section.setdefault(
160
+ "__cache_dependencies__", {}
161
+ )
162
+ cache_dependencies.setdefault(cache_key, set()).add(identifier)
141
163
  for lookup, val in params.items():
142
164
  lookup_map = section.setdefault(lookup, {})
143
165
  val_key = repr(val)
@@ -161,29 +183,31 @@ def record_dependencies(
161
183
  # -----------------------------------------------------------------------------
162
184
  def remove_cache_key_from_index(cache_key: str) -> None:
163
185
  """
164
- Remove a cache entry from all dependency mappings.
186
+ Remove a cache key from all dependency mappings in the stored dependency index.
165
187
 
166
- Parameters:
167
- cache_key (str): Cache key that should be expunged from the index.
188
+ Acquires the dependency lock to update and persist the index; if the lock cannot be obtained within LOCK_TIMEOUT the operation fails.
168
189
 
169
- Returns:
170
- None
190
+ Parameters:
191
+ cache_key (str): The cache key to expunge from all recorded filter, exclude, and identification mappings.
171
192
 
172
193
  Raises:
173
- TimeoutError: If the dependency lock cannot be acquired within `LOCK_TIMEOUT`.
194
+ DependencyLockTimeoutError: If the dependency lock cannot be acquired within LOCK_TIMEOUT.
174
195
  """
175
196
  start = time.time()
176
197
  while not acquire_lock():
177
198
  if time.time() - start > LOCK_TIMEOUT:
178
- raise TimeoutError("Could not aquire lock for remove_cache_key_from_index")
199
+ raise DependencyLockTimeoutError("remove_cache_key_from_index")
179
200
  time.sleep(0.05)
180
201
 
181
202
  try:
182
203
  idx = get_full_index()
183
- for action in ("filter", "exclude"):
184
- action_section = idx.get(action, {})
204
+ for action in ACTIONS:
205
+ action_section = idx[action]
185
206
  for mname, model_section in list(action_section.items()):
207
+ cache_dependencies = model_section.get("__cache_dependencies__", {})
186
208
  for lookup, lookup_map in list(model_section.items()):
209
+ if lookup.startswith("__"):
210
+ continue
187
211
  for val_key, key_set in list(lookup_map.items()):
188
212
  if cache_key in key_set:
189
213
  key_set.remove(cache_key)
@@ -191,6 +215,10 @@ def remove_cache_key_from_index(cache_key: str) -> None:
191
215
  del lookup_map[val_key]
192
216
  if not lookup_map:
193
217
  del model_section[lookup]
218
+ if cache_dependencies:
219
+ cache_dependencies.pop(cache_key, None)
220
+ if not cache_dependencies:
221
+ model_section.pop("__cache_dependencies__", None)
194
222
  if not model_section:
195
223
  del action_section[mname]
196
224
  set_full_index(idx)
@@ -221,15 +249,10 @@ def capture_old_values(
221
249
  **kwargs: object,
222
250
  ) -> None:
223
251
  """
224
- Cache the field values referenced by tracked filters before an update.
252
+ Record the current values of fields referenced by tracked filters on the given manager instance before it changes.
225
253
 
226
254
  Parameters:
227
- sender (type[GeneralManager]): Manager class dispatching the signal.
228
- instance (GeneralManager | None): Manager instance about to change.
229
- **kwargs: Additional signal metadata.
230
-
231
- Returns:
232
- None
255
+ instance (GeneralManager | None): Manager instance about to change; if provided, this function sets instance._old_values to a mapping of lookup keys to their current values for use by post-change invalidation logic.
233
256
  """
234
257
  if instance is None:
235
258
  return
@@ -237,8 +260,16 @@ def capture_old_values(
237
260
  idx = get_full_index()
238
261
  # get all lookups for this model
239
262
  lookups = set()
240
- for action in ("filter", "exclude"):
241
- lookups |= set(idx.get(action, {}).get(manager_name, {}))
263
+ for action in ACTIONS:
264
+ model_section = idx[action].get(manager_name)
265
+ if isinstance(model_section, dict):
266
+ lookups |= {
267
+ lookup
268
+ for lookup in model_section.keys()
269
+ if isinstance(lookup, str) and not lookup.startswith("__")
270
+ }
271
+ elif isinstance(model_section, list):
272
+ lookups |= set(model_section)
242
273
  if lookups and instance.identification:
243
274
  # save old values for later comparison
244
275
  vals: dict[str, object] = {}
@@ -251,7 +282,7 @@ def capture_old_values(
251
282
  break
252
283
  current = getattr(current, attr, None)
253
284
  vals[lookup] = current
254
- setattr(instance, "_old_values", vals)
285
+ instance._old_values = vals
255
286
 
256
287
 
257
288
  @receiver(post_data_change)
@@ -262,41 +293,150 @@ def generic_cache_invalidation(
262
293
  **kwargs: object,
263
294
  ) -> None:
264
295
  """
265
- Invalidate cached query results affected by a data change.
296
+ Invalidate cache entries whose recorded dependencies are affected by changes to a GeneralManager instance.
266
297
 
267
- Parameters:
268
- sender (type[GeneralManager]): Manager class that triggered the signal.
269
- instance (GeneralManager): Updated manager instance.
270
- old_relevant_values (dict[str, Any]): Previously captured values for tracked lookups.
271
- **kwargs: Additional signal metadata.
298
+ Uses the dependency index to compare previously captured values against the instance's current values for tracked lookups, evaluates both simple and composite dependency conditions for "filter" and "exclude" actions, and for any dependency that warrants invalidation it deletes the corresponding cache entry and removes its references from the index.
272
299
 
273
- Returns:
274
- None
300
+ Parameters:
301
+ sender (type[GeneralManager]): Manager class that emitted the signal.
302
+ instance (GeneralManager): The manager instance that was changed.
303
+ old_relevant_values (dict[str, Any]): Mapping of lookup paths (joined by "__") to their values as captured before the change; used to compare old vs. new values for invalidation decisions.
275
304
  """
276
305
  manager_name = sender.__name__
277
306
  idx = get_full_index()
278
307
 
308
+ def _safe_literal_eval(value: Any) -> Any:
309
+ if isinstance(value, str):
310
+ try:
311
+ return ast.literal_eval(value)
312
+ except (ValueError, SyntaxError):
313
+ return value
314
+ return value
315
+
316
+ def _coerce_to_type(sample: Any, raw: Any) -> Any | None:
317
+ """
318
+ Coerces a raw value to match the type and semantics of a sample value.
319
+
320
+ Attempts to convert `raw` into the same type as `sample`. Handles:
321
+ - datetimes: parses ISO-like strings, preserves or aligns timezone info with `sample`,
322
+ - dates: parses ISO date strings,
323
+ - booleans: recognizes common textual and numeric boolean representations,
324
+ - other types: attempts to call the sample's type on `raw`.
325
+
326
+ Parameters:
327
+ sample: A value whose type and semantics should be used as the target.
328
+ raw: The input value to coerce.
329
+
330
+ Returns:
331
+ The coerced value of the same type as `sample`, or `None` if `raw` cannot be sensibly converted.
332
+ """
333
+ if sample is None:
334
+ return None
335
+
336
+ if isinstance(sample, datetime):
337
+ if isinstance(raw, datetime):
338
+ parsed = raw
339
+ elif isinstance(raw, str):
340
+ candidate = raw.replace("Z", "+00:00")
341
+ candidate = candidate.replace(" ", "T", 1)
342
+ try:
343
+ parsed = datetime.fromisoformat(candidate)
344
+ except ValueError:
345
+ return None
346
+ else:
347
+ return None
348
+
349
+ if sample.tzinfo and parsed.tzinfo is None:
350
+ parsed = parsed.replace(tzinfo=sample.tzinfo)
351
+ elif not sample.tzinfo and parsed.tzinfo is not None:
352
+ parsed = parsed.replace(tzinfo=None)
353
+ return parsed
354
+
355
+ if isinstance(sample, date) and not isinstance(sample, datetime):
356
+ if isinstance(raw, date) and not isinstance(raw, datetime):
357
+ return raw
358
+ if isinstance(raw, str):
359
+ try:
360
+ return date.fromisoformat(raw)
361
+ except ValueError:
362
+ return None
363
+ return None
364
+
365
+ # Booleans: avoid bool("False") == True
366
+ if isinstance(sample, bool):
367
+ if isinstance(raw, bool):
368
+ return raw
369
+ if isinstance(raw, (int,)):
370
+ return bool(raw)
371
+ if isinstance(raw, str):
372
+ s = raw.strip().lower()
373
+ if s in {"true", "1", "yes", "y", "t"}:
374
+ return True
375
+ if s in {"false", "0", "no", "n", "f"}:
376
+ return False
377
+ return None
378
+ try:
379
+ return type(sample)(raw) # type: ignore
380
+ except (TypeError, ValueError):
381
+ if isinstance(raw, type(sample)):
382
+ return raw
383
+ return None
384
+
279
385
  def matches(op: str, value: Any, val_key: Any) -> bool:
386
+ """
387
+ Evaluate whether a given value satisfies a lookup operation described by `op` and `val_key`.
388
+
389
+ Supports operators:
390
+ - "eq": equality; attempts to interpret `val_key` as a literal and coerce it to `value`'s type before comparing.
391
+ - "in": membership; expects `val_key` to be a literal iterable and checks if any coerced element equals `value`.
392
+ - "gt", "gte", "lt", "lte": numeric/date comparisons; `val_key` is interpreted as a literal and coerced to `value`'s type.
393
+ - "contains", "startswith", "endswith": string containment/prefix/suffix checks; both sides are compared as strings.
394
+ - "regex": treats `val_key` as a regular expression pattern and tests it against the string form of `value`.
395
+
396
+ Behavior notes:
397
+ - If `value` is None the function returns `False`.
398
+ - Literal parsing of `val_key` is attempted via AST literal evaluation; if parsing or coercion fails, the function falls back to conservative comparisons or returns `False` where appropriate.
399
+ - Regex patterns that fail to compile are treated as non-matching.
400
+
401
+ Parameters:
402
+ op (str): The lookup operator name (one of the supported operators above).
403
+ value (Any): The runtime value to test.
404
+ val_key (Any): The stored comparison key (often a string representation) to interpret for the comparison.
405
+
406
+ Returns:
407
+ bool: `True` if the comparison defined by `op` and `val_key` matches `value`, `False` otherwise.
408
+ """
280
409
  if value is None:
281
410
  return False
282
411
 
283
412
  # eq
284
413
  if op == "eq":
285
- return repr(value) == val_key
414
+ literal_val = _safe_literal_eval(val_key)
415
+ comparable = _coerce_to_type(value, literal_val)
416
+ if comparable is None:
417
+ return repr(value) == val_key
418
+ return value == comparable
286
419
 
287
420
  # in
288
421
  if op == "in":
289
422
  try:
290
423
  seq = ast.literal_eval(val_key)
291
- return value in seq
292
- except:
424
+ except (ValueError, SyntaxError):
293
425
  return False
426
+ for item in seq:
427
+ comparable = _coerce_to_type(value, item)
428
+ if comparable is not None:
429
+ if value == comparable:
430
+ return True
431
+ elif repr(value) == repr(item):
432
+ return True
433
+ return False
294
434
 
295
435
  # range comparisons
296
436
  if op in ("gt", "gte", "lt", "lte"):
297
- try:
298
- thr = type(value)(ast.literal_eval(val_key))
299
- except:
437
+ literal_val = _safe_literal_eval(val_key)
438
+ thr = _coerce_to_type(value, literal_val)
439
+ if thr is None:
300
440
  return False
301
441
  if op == "gt":
302
442
  return value > thr
@@ -311,7 +451,7 @@ def generic_cache_invalidation(
311
451
  if op in ("contains", "startswith", "endswith", "regex"):
312
452
  try:
313
453
  literal = ast.literal_eval(val_key)
314
- except Exception:
454
+ except (ValueError, SyntaxError):
315
455
  literal = val_key
316
456
 
317
457
  # ensure we always work with strings to avoid TypeErrors
@@ -325,16 +465,110 @@ def generic_cache_invalidation(
325
465
  # regex: treat the stored key as the regex pattern
326
466
  if op == "regex":
327
467
  try:
328
- pattern = re.compile(val_key)
468
+ pattern_source = (
469
+ literal if isinstance(literal, str) else str(literal)
470
+ )
471
+ pattern = re.compile(pattern_source)
329
472
  except re.error:
330
473
  return False
331
474
  return bool(pattern.search(text))
332
475
 
333
476
  return False
334
477
 
335
- for action in ("filter", "exclude"):
336
- model_section = idx.get(action, {}).get(manager_name, {})
478
+ def current_value_for_path(path: list[str]) -> Any:
479
+ """
480
+ Fetches the current value from the captured `instance` by following a sequence of attribute names.
481
+
482
+ Parameters:
483
+ path (list[str]): Ordered attribute names to traverse on the instance (e.g., ["user", "profile", "email"]).
484
+
485
+ Returns:
486
+ The value found at the end of the attribute path, or `None` if any attribute along the path is missing.
487
+ """
488
+ current: object = instance
489
+ for attr in path:
490
+ current = getattr(current, attr, UNDEFINED)
491
+ if current is UNDEFINED:
492
+ return None
493
+ return current
494
+
495
+ def evaluate_composite(
496
+ cache_key: str,
497
+ lookup_key: str,
498
+ action: Literal["filter", "exclude"],
499
+ model_section: dict[str, dict[str, set[str]]],
500
+ ) -> bool | None:
501
+ """
502
+ Determine whether a composite dependency (multiple lookup params grouped under a single identifier)
503
+ for a given cache key and lookup should cause cache invalidation.
504
+
505
+ Parameters:
506
+ cache_key (str): The cache key being evaluated.
507
+ lookup_key (str): The specific lookup (operator and attribute path joined by `"__"`) that prompted evaluation.
508
+ action (Literal["filter", "exclude"]): The dependency action context; "filter" treats a match as cause for invalidation,
509
+ "exclude" treats a change in match membership as cause for invalidation.
510
+ model_section (dict[str, dict[str, set[str]]]): The index section for the model containing lookup maps and an
511
+ optional "__cache_dependencies__" mapping from cache keys to sets of identifier strings (each identifier
512
+ encodes multiple lookup parameters).
513
+
514
+ Returns:
515
+ bool | None: `True` if the composite dependency indicates the cache entry should be invalidated,
516
+ `False` if it indicates no invalidation is required, or `None` if there are no composite identifiers
517
+ registered for `cache_key`.
518
+ """
519
+ cache_dependencies = model_section.get("__cache_dependencies__", {})
520
+ identifiers = cache_dependencies.get(cache_key) if cache_dependencies else None
521
+ if not identifiers:
522
+ return None
523
+
524
+ for identifier in identifiers:
525
+ params = ast.literal_eval(identifier)
526
+ if lookup_key not in params:
527
+ continue
528
+ old_all = True
529
+ new_all = True
530
+ for param_lookup, expected in params.items():
531
+ parts_param = param_lookup.split("__")
532
+ if parts_param[-1] in (
533
+ "gt",
534
+ "gte",
535
+ "lt",
536
+ "lte",
537
+ "in",
538
+ "contains",
539
+ "startswith",
540
+ "endswith",
541
+ "regex",
542
+ ):
543
+ op_param = parts_param[-1]
544
+ attr_path_param = parts_param[:-1]
545
+ else:
546
+ op_param = "eq"
547
+ attr_path_param = parts_param
548
+ expected_key = repr(expected)
549
+ old_val_param = old_relevant_values.get("__".join(attr_path_param))
550
+ new_val_param = current_value_for_path(attr_path_param)
551
+ if not matches(op_param, old_val_param, expected_key):
552
+ old_all = False
553
+ if not matches(op_param, new_val_param, expected_key):
554
+ new_all = False
555
+ if not old_all and not new_all and action == "filter":
556
+ break
557
+ if action == "filter":
558
+ if old_all or new_all:
559
+ return True
560
+ else: # exclude
561
+ if old_all != new_all:
562
+ return True
563
+ return False
564
+
565
+ for action in ACTIONS:
566
+ model_section = idx[action].get(manager_name)
567
+ if not isinstance(model_section, dict):
568
+ continue
337
569
  for lookup, lookup_map in model_section.items():
570
+ if lookup.startswith("__"):
571
+ continue
338
572
  # 1) get operator and attribute path
339
573
  parts = lookup.split("__")
340
574
  if parts[-1] in (
@@ -371,20 +605,36 @@ def generic_cache_invalidation(
371
605
 
372
606
  if action == "filter":
373
607
  # Filter: invalidate if new match or old match
374
- if new_match or old_match:
375
- logger.info(
376
- f"Invalidate cache key {cache_keys} for filter {lookup} with value {val_key}"
608
+ for ck in list(cache_keys):
609
+ composite_decision = evaluate_composite(
610
+ ck, lookup, action, model_section
377
611
  )
378
- for ck in list(cache_keys):
612
+ should_invalidate = (
613
+ composite_decision
614
+ if composite_decision is not None
615
+ else (new_match or old_match)
616
+ )
617
+ if should_invalidate:
618
+ logger.info(
619
+ f"Invalidate cache key {ck} for filter {lookup} with value {val_key}"
620
+ )
379
621
  invalidate_cache_key(ck)
380
622
  remove_cache_key_from_index(ck)
381
623
 
382
624
  else: # action == 'exclude'
383
625
  # Excludes: invalidate only if matches changed
384
- if old_match != new_match:
385
- logger.info(
386
- f"Invalidate cache key {cache_keys} for exclude {lookup} with value {val_key}"
626
+ for ck in list(cache_keys):
627
+ composite_decision = evaluate_composite(
628
+ ck, lookup, action, model_section
629
+ )
630
+ should_invalidate = (
631
+ composite_decision
632
+ if composite_decision is not None
633
+ else (old_match != new_match)
387
634
  )
388
- for ck in list(cache_keys):
635
+ if should_invalidate:
636
+ logger.info(
637
+ f"Invalidate cache key {ck} for exclude {lookup} with value {val_key}"
638
+ )
389
639
  invalidate_cache_key(ck)
390
640
  remove_cache_key_from_index(ck)
@@ -27,14 +27,16 @@ def dataChange(func: Callable[P, R]) -> Callable[P, R]:
27
27
  @wraps(func)
28
28
  def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
29
29
  """
30
- Execute the wrapped function while emitting data change signals.
30
+ Emit pre_data_change and post_data_change signals around the wrapped function call.
31
+
32
+ Emits a pre_data_change signal before invoking the wrapped function and a post_data_change signal afterwards. Signals are sent with `sender`, `instance`, and `action`; the post-change signal also includes `old_relevant_values`. After signaling, the wrapper removes the `_old_values` attribute from the pre-change instance if it exists.
31
33
 
32
34
  Parameters:
33
35
  *args: Positional arguments forwarded to the wrapped function.
34
36
  **kwargs: Keyword arguments forwarded to the wrapped function.
35
37
 
36
38
  Returns:
37
- R: Result produced by the wrapped function.
39
+ R: The result returned by the wrapped function.
38
40
  """
39
41
  action = func.__name__
40
42
  if func.__name__ == "create":
@@ -66,6 +68,11 @@ def dataChange(func: Callable[P, R]) -> Callable[P, R]:
66
68
  old_relevant_values=old_relevant_values,
67
69
  **kwargs,
68
70
  )
71
+ if instance_before is not None:
72
+ try:
73
+ delattr(instance_before, "_old_values")
74
+ except AttributeError:
75
+ pass
69
76
  return result
70
77
 
71
78
  return wrapper
@@ -12,10 +12,19 @@ __all__ = list(FACTORY_EXPORTS)
12
12
  _MODULE_MAP = FACTORY_EXPORTS
13
13
 
14
14
  if TYPE_CHECKING:
15
- from general_manager._types.factory import * # noqa: F401,F403
15
+ from general_manager._types.factory import * # noqa: F403
16
16
 
17
17
 
18
18
  def __getattr__(name: str) -> Any:
19
+ """
20
+ Dynamically resolve and return a named export from this module.
21
+
22
+ Parameters:
23
+ name (str): The attribute name to resolve.
24
+
25
+ Returns:
26
+ Any: The resolved attribute object corresponding to `name`.
27
+ """
19
28
  return resolve_export(
20
29
  name,
21
30
  module_all=__all__,
@@ -15,6 +15,40 @@ if TYPE_CHECKING:
15
15
  modelsModel = TypeVar("modelsModel", bound=models.Model)
16
16
 
17
17
 
18
+ class InvalidGeneratedObjectError(TypeError):
19
+ """Raised when factory generation produces non-model instances."""
20
+
21
+ def __init__(self) -> None:
22
+ """
23
+ Initialize the exception indicating a generated object is not a Django model instance.
24
+
25
+ Sets a default error message explaining that the generated object is not a Django model instance.
26
+ """
27
+ super().__init__("Generated object is not a Django model instance.")
28
+
29
+
30
+ class InvalidAutoFactoryModelError(TypeError):
31
+ """Raised when the factory metadata does not reference a Django model class."""
32
+
33
+ def __init__(self) -> None:
34
+ """
35
+ Raised when an AutoFactory target model is not a Django model class.
36
+
37
+ The exception carries a default message explaining that `_meta.model` must be a Django model class.
38
+ """
39
+ super().__init__("AutoFactory requires _meta.model to be a Django model class.")
40
+
41
+
42
+ class UndefinedAdjustmentMethodError(ValueError):
43
+ """Raised when an adjustment method is required but not configured."""
44
+
45
+ def __init__(self) -> None:
46
+ """
47
+ Initialize the UndefinedAdjustmentMethodError with the default message indicating that a generate/adjustment function is not configured.
48
+ """
49
+ super().__init__("_adjustmentMethod is not defined.")
50
+
51
+
18
52
  class AutoFactory(DjangoModelFactory[modelsModel]):
19
53
  """Factory that auto-populates model fields based on interface metadata."""
20
54
 
@@ -28,18 +62,26 @@ class AutoFactory(DjangoModelFactory[modelsModel]):
28
62
  cls, strategy: Literal["build", "create"], params: dict[str, Any]
29
63
  ) -> models.Model | list[models.Model]:
30
64
  """
31
- Generate and populate model instances with automatically derived field values.
65
+ Generate and populate model instances using automatically derived field values.
32
66
 
33
67
  Parameters:
34
- strategy (Literal["build", "create"]): Whether to persist the generated instances.
35
- params (dict[str, Any]): Field values supplied by the caller.
68
+ strategy (Literal["build", "create"]): Either "build" (unsaved instance) or "create" (saved instance).
69
+ params (dict[str, Any]): Field values supplied by the caller; any missing non-auto fields will be populated automatically.
36
70
 
37
71
  Returns:
38
- models.Model | list[models.Model]: Generated instance(s) matching the requested strategy.
72
+ models.Model | list[models.Model]: A generated model instance or a list of generated model instances.
73
+
74
+ Raises:
75
+ InvalidAutoFactoryModelError: If the factory target `_meta.model` is not a Django model class.
76
+ InvalidGeneratedObjectError: If an element of a generated list is not a Django model instance.
39
77
  """
40
78
  model = cls._meta.model
41
- if not issubclass(model, models.Model):
42
- raise ValueError("Model must be a type")
79
+ try:
80
+ is_model = isinstance(model, type) and issubclass(model, models.Model)
81
+ except TypeError:
82
+ is_model = False
83
+ if not is_model:
84
+ raise InvalidAutoFactoryModelError
43
85
  field_name_list, to_ignore_list = cls.interface.handleCustomFields(model)
44
86
 
45
87
  fields = [
@@ -71,7 +113,7 @@ class AutoFactory(DjangoModelFactory[modelsModel]):
71
113
  if isinstance(obj, list):
72
114
  for item in obj:
73
115
  if not isinstance(item, models.Model):
74
- raise ValueError("Model must be a type")
116
+ raise InvalidGeneratedObjectError()
75
117
  cls._handleManyToManyFieldsAfterCreation(item, params)
76
118
  else:
77
119
  cls._handleManyToManyFieldsAfterCreation(obj, params)
@@ -191,21 +233,21 @@ class AutoFactory(DjangoModelFactory[modelsModel]):
191
233
  cls, use_creation_method: bool, params: dict[str, Any]
192
234
  ) -> models.Model | list[models.Model]:
193
235
  """
194
- Create or build instance(s) using the configured adjustment method.
236
+ Create or build model instance(s) using the configured adjustment method.
195
237
 
196
238
  Parameters:
197
- use_creation_method (bool): Whether generated objects should be saved.
198
- params (dict[str, Any]): Arguments forwarded to the adjustment callback.
239
+ use_creation_method (bool): If True, created records are validated and saved; if False, unsaved instances are returned.
240
+ params (dict[str, Any]): Keyword arguments forwarded to the adjustment method to produce record dict(s).
199
241
 
200
242
  Returns:
201
- models.Model | list[models.Model]: Created or built instance(s).
243
+ models.Model | list[models.Model]: A single model instance or a list of instances — saved instances when `use_creation_method` is True, unsaved otherwise.
202
244
 
203
245
  Raises:
204
- ValueError: If no adjustment method has been configured.
246
+ UndefinedAdjustmentMethodError: If no adjustment method has been configured on the factory.
205
247
  """
206
248
  model_cls = cls._meta.model
207
249
  if cls._adjustmentMethod is None:
208
- raise ValueError("generate_func is not defined")
250
+ raise UndefinedAdjustmentMethodError()
209
251
  records = cls._adjustmentMethod(**params)
210
252
  if isinstance(records, dict):
211
253
  if use_creation_method: