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

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

Potentially problematic release.


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

@@ -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
@@ -138,6 +139,9 @@ def record_dependencies(
138
139
  action_key = cast(Literal["filter", "exclude"], action)
139
140
  params = ast.literal_eval(identifier)
140
141
  section = idx[action_key].setdefault(model_name, {})
142
+ if len(params) > 1:
143
+ cache_dependencies = section.setdefault("__cache_dependencies__", {})
144
+ cache_dependencies.setdefault(cache_key, set()).add(identifier)
141
145
  for lookup, val in params.items():
142
146
  lookup_map = section.setdefault(lookup, {})
143
147
  val_key = repr(val)
@@ -183,7 +187,10 @@ def remove_cache_key_from_index(cache_key: str) -> None:
183
187
  for action in ("filter", "exclude"):
184
188
  action_section = idx.get(action, {})
185
189
  for mname, model_section in list(action_section.items()):
190
+ cache_dependencies = model_section.get("__cache_dependencies__", {})
186
191
  for lookup, lookup_map in list(model_section.items()):
192
+ if lookup.startswith("__"):
193
+ continue
187
194
  for val_key, key_set in list(lookup_map.items()):
188
195
  if cache_key in key_set:
189
196
  key_set.remove(cache_key)
@@ -191,6 +198,10 @@ def remove_cache_key_from_index(cache_key: str) -> None:
191
198
  del lookup_map[val_key]
192
199
  if not lookup_map:
193
200
  del model_section[lookup]
201
+ if cache_dependencies:
202
+ cache_dependencies.pop(cache_key, None)
203
+ if not cache_dependencies:
204
+ model_section.pop("__cache_dependencies__", None)
194
205
  if not model_section:
195
206
  del action_section[mname]
196
207
  set_full_index(idx)
@@ -238,7 +249,15 @@ def capture_old_values(
238
249
  # get all lookups for this model
239
250
  lookups = set()
240
251
  for action in ("filter", "exclude"):
241
- lookups |= set(idx.get(action, {}).get(manager_name, {}))
252
+ model_section = idx.get(action, {}).get(manager_name, {})
253
+ if isinstance(model_section, dict):
254
+ lookups |= {
255
+ lookup
256
+ for lookup in model_section.keys()
257
+ if isinstance(lookup, str) and not lookup.startswith("__")
258
+ }
259
+ elif isinstance(model_section, list):
260
+ lookups |= set(model_section)
242
261
  if lookups and instance.identification:
243
262
  # save old values for later comparison
244
263
  vals: dict[str, object] = {}
@@ -276,27 +295,86 @@ def generic_cache_invalidation(
276
295
  manager_name = sender.__name__
277
296
  idx = get_full_index()
278
297
 
298
+ def _safe_literal_eval(value: Any) -> Any:
299
+ if isinstance(value, str):
300
+ try:
301
+ return ast.literal_eval(value)
302
+ except (ValueError, SyntaxError):
303
+ return value
304
+ return value
305
+
306
+ def _coerce_to_type(sample: Any, raw: Any) -> Any | None:
307
+ if sample is None:
308
+ return None
309
+
310
+ if isinstance(sample, datetime):
311
+ if isinstance(raw, datetime):
312
+ parsed = raw
313
+ elif isinstance(raw, str):
314
+ candidate = raw.replace("Z", "+00:00")
315
+ candidate = candidate.replace(" ", "T", 1)
316
+ try:
317
+ parsed = datetime.fromisoformat(candidate)
318
+ except ValueError:
319
+ return None
320
+ else:
321
+ return None
322
+
323
+ if sample.tzinfo and parsed.tzinfo is None:
324
+ parsed = parsed.replace(tzinfo=sample.tzinfo)
325
+ elif not sample.tzinfo and parsed.tzinfo is not None:
326
+ parsed = parsed.replace(tzinfo=None)
327
+ return parsed
328
+
329
+ if isinstance(sample, date) and not isinstance(sample, datetime):
330
+ if isinstance(raw, date) and not isinstance(raw, datetime):
331
+ return raw
332
+ if isinstance(raw, str):
333
+ try:
334
+ return date.fromisoformat(raw)
335
+ except ValueError:
336
+ return None
337
+ return None
338
+
339
+ try:
340
+ return type(sample)(raw) # type: ignore
341
+ except Exception:
342
+ if isinstance(raw, type(sample)):
343
+ return raw
344
+ return None
345
+
279
346
  def matches(op: str, value: Any, val_key: Any) -> bool:
280
347
  if value is None:
281
348
  return False
282
349
 
283
350
  # eq
284
351
  if op == "eq":
285
- return repr(value) == val_key
352
+ literal_val = _safe_literal_eval(val_key)
353
+ comparable = _coerce_to_type(value, literal_val)
354
+ if comparable is None:
355
+ return repr(value) == val_key
356
+ return value == comparable
286
357
 
287
358
  # in
288
359
  if op == "in":
289
360
  try:
290
361
  seq = ast.literal_eval(val_key)
291
- return value in seq
292
- except:
362
+ except (ValueError, SyntaxError):
293
363
  return False
364
+ for item in seq:
365
+ comparable = _coerce_to_type(value, item)
366
+ if comparable is not None:
367
+ if value == comparable:
368
+ return True
369
+ elif repr(value) == repr(item):
370
+ return True
371
+ return False
294
372
 
295
373
  # range comparisons
296
374
  if op in ("gt", "gte", "lt", "lte"):
297
- try:
298
- thr = type(value)(ast.literal_eval(val_key))
299
- except:
375
+ literal_val = _safe_literal_eval(val_key)
376
+ thr = _coerce_to_type(value, literal_val)
377
+ if thr is None:
300
378
  return False
301
379
  if op == "gt":
302
380
  return value > thr
@@ -311,7 +389,7 @@ def generic_cache_invalidation(
311
389
  if op in ("contains", "startswith", "endswith", "regex"):
312
390
  try:
313
391
  literal = ast.literal_eval(val_key)
314
- except Exception:
392
+ except (ValueError, SyntaxError):
315
393
  literal = val_key
316
394
 
317
395
  # ensure we always work with strings to avoid TypeErrors
@@ -325,16 +403,78 @@ def generic_cache_invalidation(
325
403
  # regex: treat the stored key as the regex pattern
326
404
  if op == "regex":
327
405
  try:
328
- pattern = re.compile(val_key)
406
+ pattern_source = literal if isinstance(literal, str) else str(literal)
407
+ pattern = re.compile(pattern_source)
329
408
  except re.error:
330
409
  return False
331
410
  return bool(pattern.search(text))
332
411
 
333
412
  return False
334
413
 
414
+ def current_value_for_path(path: list[str]) -> Any:
415
+ current: object = instance
416
+ for attr in path:
417
+ current = getattr(current, attr, UNDEFINED)
418
+ if current is UNDEFINED:
419
+ return None
420
+ return current
421
+
422
+ def evaluate_composite(
423
+ cache_key: str, lookup_key: str, action: str
424
+ ) -> bool | None:
425
+ cache_dependencies = model_section.get("__cache_dependencies__", {})
426
+ identifiers = cache_dependencies.get(cache_key) if cache_dependencies else None
427
+ if not identifiers:
428
+ return None
429
+
430
+ for identifier in identifiers:
431
+ params = ast.literal_eval(identifier)
432
+ if lookup_key not in params:
433
+ continue
434
+ old_all = True
435
+ new_all = True
436
+ for param_lookup, expected in params.items():
437
+ parts_param = param_lookup.split("__")
438
+ if parts_param[-1] in (
439
+ "gt",
440
+ "gte",
441
+ "lt",
442
+ "lte",
443
+ "in",
444
+ "contains",
445
+ "startswith",
446
+ "endswith",
447
+ "regex",
448
+ ):
449
+ op_param = parts_param[-1]
450
+ attr_path_param = parts_param[:-1]
451
+ else:
452
+ op_param = "eq"
453
+ attr_path_param = parts_param
454
+ expected_key = repr(expected)
455
+ old_val_param = old_relevant_values.get("__".join(attr_path_param))
456
+ new_val_param = current_value_for_path(attr_path_param)
457
+ if not matches(op_param, old_val_param, expected_key):
458
+ old_all = False
459
+ if not matches(op_param, new_val_param, expected_key):
460
+ new_all = False
461
+ if not old_all and not new_all and action == "filter":
462
+ break
463
+ if action == "filter":
464
+ if old_all or new_all:
465
+ return True
466
+ else: # exclude
467
+ if old_all != new_all:
468
+ return True
469
+ return False
470
+
335
471
  for action in ("filter", "exclude"):
336
472
  model_section = idx.get(action, {}).get(manager_name, {})
473
+ if not model_section:
474
+ continue
337
475
  for lookup, lookup_map in model_section.items():
476
+ if lookup.startswith("__"):
477
+ continue
338
478
  # 1) get operator and attribute path
339
479
  parts = lookup.split("__")
340
480
  if parts[-1] in (
@@ -371,20 +511,32 @@ def generic_cache_invalidation(
371
511
 
372
512
  if action == "filter":
373
513
  # 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}"
514
+ for ck in list(cache_keys):
515
+ composite_decision = evaluate_composite(ck, lookup, action)
516
+ should_invalidate = (
517
+ composite_decision
518
+ if composite_decision is not None
519
+ else (new_match or old_match)
377
520
  )
378
- for ck in list(cache_keys):
521
+ if should_invalidate:
522
+ logger.info(
523
+ f"Invalidate cache key {ck} for filter {lookup} with value {val_key}"
524
+ )
379
525
  invalidate_cache_key(ck)
380
526
  remove_cache_key_from_index(ck)
381
527
 
382
528
  else: # action == 'exclude'
383
529
  # 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}"
530
+ for ck in list(cache_keys):
531
+ composite_decision = evaluate_composite(ck, lookup, action)
532
+ should_invalidate = (
533
+ composite_decision
534
+ if composite_decision is not None
535
+ else (old_match != new_match)
387
536
  )
388
- for ck in list(cache_keys):
537
+ if should_invalidate:
538
+ logger.info(
539
+ f"Invalidate cache key {ck} for exclude {lookup} with value {val_key}"
540
+ )
389
541
  invalidate_cache_key(ck)
390
542
  remove_cache_key_from_index(ck)
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
  from typing import Any, Generic, Iterator, Type, cast, get_args
5
5
  from datetime import datetime, date, time
6
- from general_manager.api.graphql import GraphQLProperty
6
+ from general_manager.api.property import GraphQLProperty
7
7
  from general_manager.measurement import Measurement
8
8
  from general_manager.manager.generalManager import GeneralManager
9
9
  from general_manager.bucket.baseBucket import (
@@ -80,15 +80,18 @@ class GMTestCaseMeta(type):
80
80
  attrs: dict[str, object],
81
81
  ) -> type:
82
82
  """
83
- Construct a new test case class that wires GeneralManager-specific initialisation into `setUpClass`.
84
-
83
+ Create a new test case class that injects GeneralManager-specific initialization into `setUpClass`.
84
+
85
+ The constructed class replaces or wraps any user-defined `setUpClass` with logic that resets GraphQL and manager registries, configures an optional fallback app lookup, ensures database tables for managed models exist, initializes GeneralManager and GraphQL registrations, and then calls the standard GraphQL test setup.
86
+
85
87
  Parameters:
86
- name (str): Name of the dynamically created test case class.
87
- bases (tuple[type, ...]): Base classes that the new test case should inherit.
88
- attrs (dict[str, Any]): Namespace containing class attributes, potentially including a custom `setUpClass`.
89
-
88
+ mcs (type[GMTestCaseMeta]): Metaclass constructing the new class.
89
+ name (str): Name of the class to create.
90
+ bases (tuple[type, ...]): Base classes for the new class.
91
+ attrs (dict[str, object]): Class namespace; may contain a user-defined `setUpClass` and `fallback_app`.
92
+
90
93
  Returns:
91
- type: Newly constructed class with an augmented `setUpClass` implementation.
94
+ type: The newly created test case class whose `setUpClass` has been augmented for GeneralManager testing.
92
95
  """
93
96
  user_setup = attrs.get("setUpClass")
94
97
  fallback_app = cast(str | None, attrs.get("fallback_app", "general_manager"))
@@ -99,22 +102,22 @@ class GMTestCaseMeta(type):
99
102
  cls: type["GeneralManagerTransactionTestCase"],
100
103
  ) -> None:
101
104
  """
102
- Prepare the test harness with GeneralManager-specific setup prior to executing tests.
103
-
104
- The method resets GraphQL registries, configures optional fallback app lookups, synchronises database tables for managed models, and finally invokes the parent `setUpClass`.
105
-
106
- Parameters:
107
- cls (type[GeneralManagerTransactionTestCase]): Test case subclass whose environment is being initialised.
108
-
109
- Returns:
110
- None
105
+ Prepare the test environment for GeneralManager GraphQL tests.
106
+
107
+ Resets GraphQL and manager registries, optionally patches Django's app-config lookup to use a fallback app, ensures database tables exist for models provided by the test's configured manager classes, initializes GeneralManager classes (including read-only interfaces) and GraphQL registrations, and finally invokes the base class setUpClass.
111
108
  """
112
109
  GraphQL._query_class = None
113
110
  GraphQL._mutation_class = None
111
+ GraphQL._subscription_class = None
114
112
  GraphQL._mutations = {}
115
113
  GraphQL._query_fields = {}
114
+ GraphQL._subscription_fields = {}
116
115
  GraphQL.graphql_type_registry = {}
117
116
  GraphQL.graphql_filter_type_registry = {}
117
+ GraphQL._subscription_payload_registry = {}
118
+ GraphQL._page_type_registry = {}
119
+ GraphQL.manager_registry = {}
120
+ GraphQL._schema = None
118
121
 
119
122
  if fallback_app is not None:
120
123
  setattr(
@@ -219,11 +222,17 @@ class LoggingCache(LocMemCache):
219
222
  "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
220
223
  "LOCATION": "test-cache",
221
224
  }
222
- }
225
+ },
226
+ CHANNEL_LAYERS={
227
+ "default": {
228
+ "BACKEND": "channels.layers.InMemoryChannelLayer",
229
+ }
230
+ },
223
231
  )
224
232
  class GeneralManagerTransactionTestCase(
225
233
  GraphQLTransactionTestCase, metaclass=GMTestCaseMeta
226
234
  ):
235
+ GRAPHQL_URL = "/graphql/"
227
236
  general_manager_classes: list[type[GeneralManager]] = []
228
237
  read_only_classes: list[type[GeneralManager]] = []
229
238
  fallback_app: str | None = "general_manager"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeneralManager
3
- Version: 0.16.1
3
+ Version: 0.17.0
4
4
  Summary: Modular Django-based data management framework with ORM, GraphQL, fine-grained permissions, rule validation, calculations and caching.
5
5
  Author-email: Tim Kleindick <tkleindick@yahoo.de>
6
6
  License: MIT License
@@ -43,6 +43,7 @@ Requires-Python: >=3.12
43
43
  Description-Content-Type: text/markdown
44
44
  License-File: LICENSE
45
45
  Requires-Dist: asgiref>=3.8.1
46
+ Requires-Dist: channels>=4.1.0
46
47
  Requires-Dist: Django>=5.2.7
47
48
  Requires-Dist: django-simple-history>=3.8.0
48
49
  Requires-Dist: exrex>=0.12.0
@@ -1,5 +1,5 @@
1
1
  general_manager/__init__.py,sha256=OmRYpjg3N9w0yX1eAq32WdLtcf8I55M3sqhO-Qrawjo,742
2
- general_manager/apps.py,sha256=On2nTp2-QwNcOpYfIzjNE0Xnum4BMk_5YHbEloBIM-U,10351
2
+ general_manager/apps.py,sha256=Fsqvh7Xznp_7zOAyHABGtwJz10oT4kdY3C4MgmOGnAs,16723
3
3
  general_manager/public_api_registry.py,sha256=xKfgbyvXB_oZZku9ksLNzOGQbmL5QfWuRGE2qH2FOtc,7338
4
4
  general_manager/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  general_manager/_types/__init__.py,sha256=RmS0Ok-1-CwWOZL_socLWksEk9MfsshL9eQx879I4mU,60
@@ -15,7 +15,8 @@ general_manager/_types/permission.py,sha256=BG-RMsGgVCRVHyJgns9a1VTcwtqS9WzbG2HO
15
15
  general_manager/_types/rule.py,sha256=Ju5t7NhGO76KnqwOElXk2cMI6cIYyMW5sEPayXExJPM,239
16
16
  general_manager/_types/utils.py,sha256=jze1yRZQw_OKVOmJNMv4MEu92rP01CVtjLBCEMqxCqk,1026
17
17
  general_manager/api/__init__.py,sha256=6Usf6aEc5RalaOE4xzqqSp7KChxi9W4Nts4nFIFHtt4,677
18
- general_manager/api/graphql.py,sha256=4pox3eRXWtZG9VshSgCJVrJ_9ONUUuzWhmXwetV5EgI,42798
18
+ general_manager/api/graphql.py,sha256=2OYYYtCDu5-L5aBRTv2Ns6s6O2lnyEQvootmoXQiuPY,74261
19
+ general_manager/api/graphql_subscription_consumer.py,sha256=JXJ8FRi3kP07jTXs9kSeSLaAc_AueiqJf_eeaVu8w4s,17916
19
20
  general_manager/api/mutation.py,sha256=6T-UHElnTxWubG99-Svy7AeFlHuXaKc8nKvRPADmI0E,7347
20
21
  general_manager/api/property.py,sha256=tT-eAKcuPhy0q_v0fKL4gPXcCVASC5M15_DYWMCQXjc,4358
21
22
  general_manager/bucket/__init__.py,sha256=Ay3kVmdxWC8nt3jYx3FFnND89ysD_z0GaD5rIr-v7Ec,690
@@ -26,7 +27,7 @@ general_manager/bucket/groupBucket.py,sha256=-cN9jA_AOsnrpwmjiy6jTB_TFoUqLMeM1Sd
26
27
  general_manager/cache/__init__.py,sha256=_jwrpqGzlwaL1moqG1B9XL9V-QW08VVdtDbkVDfN_sU,698
27
28
  general_manager/cache/cacheDecorator.py,sha256=kgUvJHcC8tilqf1JH2QvyG-158AT_rmuKf6pgUy1IBs,3406
28
29
  general_manager/cache/cacheTracker.py,sha256=rb637hGHOe79sehpTZLhfO979qrYLw3ATufo7kr_VvM,2991
29
- general_manager/cache/dependencyIndex.py,sha256=gaSbEpw9qdIMgf_5jRryuTAvq9x3hwMxP2bi5S4xj84,13206
30
+ general_manager/cache/dependencyIndex.py,sha256=FWUqnG1BAqF7FdAFl8bqa4JX3ecN8dpesyeRbo0JsQU,19389
30
31
  general_manager/cache/modelDependencyCollector.py,sha256=iFiuuQKO3-sEcUxbpA1svxL2TMkr8F6afAtRXK5fUBk,2645
31
32
  general_manager/cache/signals.py,sha256=pKibv1EL7fYL4KB7O13TpqVTKZLnqo6A620vKlXaNkg,2015
32
33
  general_manager/factory/__init__.py,sha256=t9RLlp_ZPU_0Xu5I7hg-xwo3yy1eSwnvfXMYvpGCFtE,714
@@ -42,7 +43,7 @@ general_manager/interface/models.py,sha256=rtuhOKeSgD2DboeZIMAQU99JyCOT7SuiuGqWv
42
43
  general_manager/interface/readOnlyInterface.py,sha256=UcaCClVJv8IT-EBm__IN4jU6td_sXRRCN_Y_OKngs_4,11392
43
44
  general_manager/manager/__init__.py,sha256=UwvLs13_UpYsq5igSY78cbyg11C6iTrHlr-rtDql43I,703
44
45
  general_manager/manager/generalManager.py,sha256=A6kYjM2duxT0AAeN0er87gRWjLmcPmEjPmNHTQgACgs,9577
45
- general_manager/manager/groupManager.py,sha256=3Wl40cBRc1hL35zBCQDi8V2AIERh_dtbUeL1h1ZCnak,6428
46
+ general_manager/manager/groupManager.py,sha256=zGSupjNVgASinyjLkpaM_SeR4Kb61hyXbkcj-R3sPa0,6429
46
47
  general_manager/manager/input.py,sha256=UoVU0FDXHDEmuOk7mppx63DuwbPK2_qolcILar2Kk6U,3031
47
48
  general_manager/manager/meta.py,sha256=iGYwuVrM0SWyvzVPLTS0typ8VT38QoxFuWhA1b1FBnA,5032
48
49
  general_manager/measurement/__init__.py,sha256=Tt2Es7J-dGWcd8bFK4PR2m4AKBlRoj2WGXcKy64fOTY,711
@@ -68,9 +69,9 @@ general_manager/utils/makeCacheKey.py,sha256=T9YTHDW8VN50iW_Yzklm9Xw-mp1Q7PKMphQ
68
69
  general_manager/utils/noneToZero.py,sha256=e3zk8Ofh3AsYW8spYmZWiv7FjOsr0jvfB9AOQbaPMWY,665
69
70
  general_manager/utils/pathMapping.py,sha256=3BWRUM1EimUKeh8i_UK6nYsKtOJDykgmZgCA9dgYjqU,9531
70
71
  general_manager/utils/public_api.py,sha256=SNTI_tRMcbv0qMttm-wMBoAEkqSEFMTI6ZHMajOnDlg,1437
71
- general_manager/utils/testing.py,sha256=BCquJ5RNiLbRFdcrgYP227TxSRfQKgpkvmWvsJqJAjk,13276
72
- generalmanager-0.16.1.dist-info/licenses/LICENSE,sha256=OezwCA4X2-xXmRDvMaqHvHCeUmGtyCYjZ8F3XUxSGwc,1069
73
- generalmanager-0.16.1.dist-info/METADATA,sha256=YHfoHcCYb9AdJH0_XkWeVXHmfmW6vzZ5AnBaral6Jrk,8175
74
- generalmanager-0.16.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
75
- generalmanager-0.16.1.dist-info/top_level.txt,sha256=sTDtExP9ga-YP3h3h42yivUY-A2Q23C2nw6LNKOho4I,16
76
- generalmanager-0.16.1.dist-info/RECORD,,
72
+ general_manager/utils/testing.py,sha256=etf_JlSaulD85A1CjPeOS0M0CbWCfPnaOxgHi0ozJLQ,14083
73
+ generalmanager-0.17.0.dist-info/licenses/LICENSE,sha256=OezwCA4X2-xXmRDvMaqHvHCeUmGtyCYjZ8F3XUxSGwc,1069
74
+ generalmanager-0.17.0.dist-info/METADATA,sha256=-YVROYZscJ9qFKOuwPEN7IJ8vunvto6P4OEm_Elsevo,8206
75
+ generalmanager-0.17.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
+ generalmanager-0.17.0.dist-info/top_level.txt,sha256=sTDtExP9ga-YP3h3h42yivUY-A2Q23C2nw6LNKOho4I,16
77
+ generalmanager-0.17.0.dist-info/RECORD,,