simple-module-core 0.0.39__tar.gz → 0.0.40__tar.gz

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.
Files changed (129) hide show
  1. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/PKG-INFO +2 -2
  2. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/pyproject.toml +2 -2
  3. simple_module_core-0.0.40/simple_module_core/datetimes.py +12 -0
  4. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/feature_flags.py +7 -0
  5. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/invalidation.py +3 -5
  6. simple_module_core-0.0.40/simple_module_core/invalidation_refresh.py +51 -0
  7. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/permissions.py +8 -0
  8. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/user_directory.py +3 -0
  9. simple_module_core-0.0.40/simple_module_core/user_events.py +16 -0
  10. simple_module_core-0.0.40/tests/test_core_datetimes.py +19 -0
  11. simple_module_core-0.0.40/tests/test_core_invalidation_refresh.py +54 -0
  12. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_feature_flags.py +13 -0
  13. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_permissions.py +10 -0
  14. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/.gitignore +0 -0
  15. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/LICENSE +0 -0
  16. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/README.md +0 -0
  17. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/__init__.py +0 -0
  18. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/__main__.py +0 -0
  19. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/_i18n_flatten.py +0 -0
  20. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/_i18n_translator.py +0 -0
  21. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/_permission_grants.py +0 -0
  22. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/_request_hooks.py +0 -0
  23. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/activity_feed.py +0 -0
  24. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/app_state.py +0 -0
  25. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/audit_links.py +0 -0
  26. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/body_limits.py +0 -0
  27. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/correlation.py +0 -0
  28. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/cors.py +0 -0
  29. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/csp.py +0 -0
  30. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/deprecation.py +0 -0
  31. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/design_packs.py +0 -0
  32. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/__init__.py +0 -0
  33. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_coupling.py +0 -0
  34. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_css.py +0 -0
  35. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_db_extensions.py +0 -0
  36. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_dependencies.py +0 -0
  37. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_expression_index.py +0 -0
  38. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
  39. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_i18n.py +0 -0
  40. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  41. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  42. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_migration.py +0 -0
  43. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_module.py +0 -0
  44. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_pages.py +0 -0
  45. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_runner.py +0 -0
  46. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_spa_mount.py +0 -0
  47. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_tenancy.py +0 -0
  48. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_types.py +0 -0
  49. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/discovery.py +0 -0
  50. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/dotenv.py +0 -0
  51. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/durable_events.py +0 -0
  52. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/environments.py +0 -0
  53. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/events.py +0 -0
  54. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/exceptions.py +0 -0
  55. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/health.py +0 -0
  56. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/i18n.py +0 -0
  57. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/i18n_overrides.py +0 -0
  58. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/leases.py +0 -0
  59. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/mail.py +0 -0
  60. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/menu.py +0 -0
  61. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/module.py +0 -0
  62. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/module_meta.py +0 -0
  63. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/module_settings.py +0 -0
  64. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/path_rules.py +0 -0
  65. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/permission_denied.py +0 -0
  66. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/public_routes.py +0 -0
  67. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/py.typed +0 -0
  68. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/rate_limit.py +0 -0
  69. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/redirect_safety.py +0 -0
  70. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/resources.py +0 -0
  71. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/secret_fields.py +0 -0
  72. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/services.py +0 -0
  73. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/settings_base.py +0 -0
  74. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/settings_hydrate.py +0 -0
  75. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/setup_steps.py +0 -0
  76. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/spa_mount.py +0 -0
  77. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/stored_settings.py +0 -0
  78. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/tenancy.py +0 -0
  79. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/tenant_events.py +0 -0
  80. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/simple_module_core/versioning.py +0 -0
  81. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_audit_links.py +0 -0
  82. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_auth_provider_selection.py +0 -0
  83. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_core_app_state.py +0 -0
  84. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_core_leases.py +0 -0
  85. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_core_lifecycle_docs.py +0 -0
  86. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_core_mail_resolution.py +0 -0
  87. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_core_module_settings.py +0 -0
  88. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_core_user_directory.py +0 -0
  89. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_csp_registry.py +0 -0
  90. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_css_diagnostics.py +0 -0
  91. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_css_scanner.py +0 -0
  92. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_db_extensions_diagnostics.py +0 -0
  93. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_deprecation_helper.py +0 -0
  94. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_design_packs.py +0 -0
  95. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_diagnostics.py +0 -0
  96. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_diagnostics_tenancy.py +0 -0
  97. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_discovery.py +0 -0
  98. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_discovery_missing_deps.py +0 -0
  99. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_doctor_cli_overrides.py +0 -0
  100. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_dotenv.py +0 -0
  101. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_durable_events.py +0 -0
  102. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_environments.py +0 -0
  103. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_events.py +0 -0
  104. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_events_inline.py +0 -0
  105. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_expression_index_diagnostics.py +0 -0
  106. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_feature_flags_decorator.py +0 -0
  107. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_health_registry.py +0 -0
  108. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_heavy_task_imports_diagnostics.py +0 -0
  109. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_i18n.py +0 -0
  110. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_i18n_diagnostics.py +0 -0
  111. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_i18n_overrides.py +0 -0
  112. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_invalidation.py +0 -0
  113. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_invalidation_handlers.py +0 -0
  114. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_menu.py +0 -0
  115. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_module_base.py +0 -0
  116. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_module_diagnostics.py +0 -0
  117. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_path_rule_registries.py +0 -0
  118. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_permission_sources.py +0 -0
  119. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_permissions_admin_principal.py +0 -0
  120. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_public_routes.py +0 -0
  121. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_public_routes_rate_precedence.py +0 -0
  122. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_redirect_safety.py +0 -0
  123. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_services.py +0 -0
  124. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_settings_base.py +0 -0
  125. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_sm003_page_render.py +0 -0
  126. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_sm009_dynamic_coupling.py +0 -0
  127. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_tenant_exists.py +0 -0
  128. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_tenant_roles.py +0 -0
  129. {simple_module_core-0.0.39 → simple_module_core-0.0.40}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_core
3
- Version: 0.0.39
3
+ Version: 0.0.40
4
4
  Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -22,7 +22,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: babel<3,>=2.14
25
- Requires-Dist: fastapi<0.157,>=0.140
25
+ Requires-Dist: fastapi<0.159,>=0.140
26
26
  Requires-Dist: packaging<27,>=23.0
27
27
  Requires-Dist: pydantic-settings<3,>=2.0
28
28
  Requires-Dist: pydantic<3,>=2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.39"
3
+ version = "0.0.40"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  ]
23
23
  dependencies = [
24
24
  "babel>=2.14,<3",
25
- "fastapi>=0.140,<0.157",
25
+ "fastapi>=0.140,<0.159",
26
26
  "packaging>=23.0,<27",
27
27
  "pydantic>=2.0,<3",
28
28
  "pydantic-settings>=2.0,<3",
@@ -0,0 +1,12 @@
1
+ """UTC timestamps and normalization of legacy naive database values."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+
6
+ def utcnow() -> datetime:
7
+ return datetime.now(UTC)
8
+
9
+
10
+ def aware(value: datetime) -> datetime:
11
+ """Interpret a naive value as UTC; preserve an existing timezone."""
12
+ return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
@@ -71,6 +71,13 @@ class FeatureFlagRegistry:
71
71
  else:
72
72
  self._tenant_overrides.pop((name, tenant_id), None)
73
73
 
74
+ def replace_overrides(
75
+ self, system: dict[str, bool], tenants: dict[tuple[str, str], bool]
76
+ ) -> None:
77
+ """Replace a complete DB snapshot, removing overrides absent from it."""
78
+ self._system_overrides = dict(system)
79
+ self._tenant_overrides = dict(tenants)
80
+
74
81
  def tenant_override(self, name: str, tenant_id: str) -> bool | None:
75
82
  """Return the per-tenant override for ``(name, tenant_id)`` if set, else None."""
76
83
  return self._tenant_overrides.get((name, tenant_id))
@@ -9,11 +9,9 @@ share one failure shape: the worker that performed the write drops its own entry
9
9
  immediately, and **every other worker keeps serving the stale value until its own
10
10
  entry expires**.
11
11
 
12
- ``settings`` has the same shape without the TTL — ``apply_changes_and_reload``
13
- hot-swaps the settings object in the worker that handled the save and announces it
14
- on the in-process :class:`~simple_module_core.events.EventBus`, so the others serve
15
- the old configuration until restart. It needs a re-read rather than an eviction, so
16
- it is a consumer this bus makes *possible* rather than one it already serves.
12
+ ``settings``, role overlays and feature-flag overrides had it too (#621); they now
13
+ re-read the committed DB state on invalidation (and every 30 s as a fallback, see
14
+ :mod:`simple_module_core.invalidation_refresh`) rather than evicting an entry.
17
15
 
18
16
  The obvious fix — publish the write on Redis pub/sub so every worker drops its
19
17
  entry at once — was not reachable from any of those modules. Redis belongs to
@@ -0,0 +1,51 @@
1
+ """Reload DB-backed process state on invalidation and periodically after missed messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+ from contextlib import suppress
9
+
10
+ from simple_module_core.invalidation import Invalidation, InvalidationBus
11
+
12
+ logger = logging.getLogger(__name__)
13
+ REFRESH_SECONDS = 30
14
+
15
+
16
+ class InvalidationRefresh:
17
+ """Serialize reloads and own the bounded-staleness fallback's lifecycle."""
18
+
19
+ def __init__(
20
+ self, bus: InvalidationBus, channel: str, reload: Callable[[str | None], Awaitable[None]]
21
+ ) -> None:
22
+ self._reload = reload
23
+ self._lock = asyncio.Lock()
24
+ self._task: asyncio.Task | None = None
25
+ bus.subscribe(channel, self._receive)
26
+
27
+ async def _receive(self, message: Invalidation) -> None:
28
+ await self.refresh(message.key)
29
+
30
+ async def refresh(self, key: str | None = None) -> None:
31
+ async with self._lock:
32
+ await self._reload(key)
33
+
34
+ def start(self) -> None:
35
+ if self._task is None:
36
+ self._task = asyncio.create_task(self._periodic())
37
+
38
+ async def stop(self) -> None:
39
+ if self._task is not None:
40
+ self._task.cancel()
41
+ with suppress(asyncio.CancelledError):
42
+ await self._task
43
+ self._task = None
44
+
45
+ async def _periodic(self) -> None:
46
+ while True:
47
+ await asyncio.sleep(REFRESH_SECONDS)
48
+ try:
49
+ await self.refresh()
50
+ except Exception:
51
+ logger.exception("Periodic state reload failed; retrying on the next interval")
@@ -208,6 +208,14 @@ class PermissionRegistry:
208
208
  self._role_overlay[role] = set(permissions)
209
209
  self._invalidate()
210
210
 
211
+ def replace_role_overlays(self, overlays: dict[str, list[str]]) -> None:
212
+ """Replace the complete persisted snapshot, including removed grants."""
213
+ snapshot = {role: set(keys) for role, keys in overlays.items()}
214
+ if snapshot == self._role_overlay: # periodic reloads: keep derived caches warm
215
+ return
216
+ self._role_overlay = snapshot
217
+ self._invalidate()
218
+
211
219
  @property
212
220
  def role_map(self) -> dict[str, list[str]]:
213
221
  """Merged role→permission mapping (``DEFAULT_ROLE_PERMISSIONS`` + module maps)."""
@@ -45,6 +45,9 @@ class DirectoryUser:
45
45
  #: Role names, or ``None`` when the provider does not know them (Keycloak
46
46
  #: keeps roles in the IdP). Only filled when ``with_roles=True`` was asked.
47
47
  roles: tuple[str, ...] | None = None
48
+ #: Whether the account's email is proven, or ``None`` when the provider
49
+ #: does not track it (Keycloak: read the token's ``email_verified``).
50
+ email_verified: bool | None = None
48
51
 
49
52
  @property
50
53
  def label(self) -> str:
@@ -0,0 +1,16 @@
1
+ """User lifecycle contracts shared without a dependency on the users module."""
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+
6
+ from simple_module_core.events import Event
7
+
8
+
9
+ @dataclass
10
+ class UserDeleted(Event):
11
+ """The account is gone; owners of per-user rows must durably remove them.
12
+
13
+ Delivery is at least once, so handlers must be idempotent.
14
+ """
15
+
16
+ user_id: uuid.UUID
@@ -0,0 +1,19 @@
1
+ """Shared UTC helpers preserve aware values and interpret naive values as UTC."""
2
+
3
+ from datetime import UTC, datetime, timedelta, timezone
4
+
5
+ from simple_module_core.datetimes import aware, utcnow
6
+
7
+
8
+ def test_naive_is_utc_and_aware_keeps_its_offset():
9
+ naive = datetime(2026, 1, 1)
10
+ assert aware(naive) == datetime(2026, 1, 1, tzinfo=UTC)
11
+ offset = naive.replace(tzinfo=timezone(timedelta(hours=2)))
12
+ assert aware(offset) is offset
13
+
14
+
15
+ def test_utcnow_is_current_aware_utc():
16
+ before = datetime.now(UTC)
17
+ value = utcnow()
18
+ assert before <= value <= datetime.now(UTC)
19
+ assert value.tzinfo is UTC
@@ -0,0 +1,54 @@
1
+ """Missed messages and transient reload failures must not leave state stale forever."""
2
+
3
+ import asyncio
4
+
5
+ from simple_module_core.invalidation import InvalidationBus
6
+ from simple_module_core.invalidation_refresh import InvalidationRefresh
7
+
8
+
9
+ async def test_periodic_refresh_retries_without_a_message_and_stops(monkeypatch):
10
+ monkeypatch.setattr("simple_module_core.invalidation_refresh.REFRESH_SECONDS", 0.001)
11
+ attempts = []
12
+ refreshed = asyncio.Event()
13
+
14
+ async def reload(key):
15
+ attempts.append(key)
16
+ if len(attempts) == 1:
17
+ raise RuntimeError("transient DB failure")
18
+ refreshed.set()
19
+
20
+ refresh = InvalidationRefresh(InvalidationBus(), "test.snapshot", reload)
21
+ refresh.start()
22
+ try:
23
+ await asyncio.wait_for(refreshed.wait(), timeout=1)
24
+ finally:
25
+ await refresh.stop()
26
+ assert attempts[:2] == [None, None]
27
+ count = len(attempts)
28
+ await asyncio.sleep(0.01) # intentional-sleep: proves no reload runs after stop()
29
+ assert len(attempts) == count
30
+
31
+
32
+ async def test_reload_messages_are_serialized():
33
+ bus = InvalidationBus()
34
+ entered = asyncio.Event()
35
+ release = asyncio.Event()
36
+ keys = []
37
+
38
+ async def reload(key):
39
+ keys.append(key)
40
+ if key == "first":
41
+ entered.set()
42
+ await release.wait()
43
+
44
+ InvalidationRefresh(bus, "test.snapshot", reload)
45
+ first = asyncio.create_task(bus.publish("test.snapshot", key="first"))
46
+ await asyncio.wait_for(entered.wait(), timeout=1)
47
+ second = asyncio.create_task(bus.publish("test.snapshot", key="second"))
48
+ try:
49
+ await asyncio.sleep(0)
50
+ assert keys == ["first"]
51
+ finally:
52
+ release.set()
53
+ await asyncio.wait_for(asyncio.gather(first, second), timeout=1)
54
+ assert keys == ["first", "second"]
@@ -194,3 +194,16 @@ class TestConsumerHelpers:
194
194
 
195
195
  request.state.tenant_id = "other"
196
196
  assert is_flag_enabled(request, "beta_ui") is False
197
+
198
+
199
+ async def test_reloading_overrides_removes_deleted_values_in_both_scopes():
200
+ registry = FeatureFlagRegistry()
201
+ registry.add(FeatureFlagDefinition("switch", default_enabled=True))
202
+ registry.set_override("switch", False)
203
+ registry.set_override("switch", False, tenant_id="acme")
204
+ registry.replace_overrides({}, {("switch", "globex"): False})
205
+ assert registry.is_enabled("switch") is True
206
+ assert registry.is_enabled("switch", "acme") is True
207
+ assert registry.is_enabled("switch", "globex") is False
208
+ registry.replace_overrides({}, {})
209
+ assert registry.is_enabled("switch", "globex") is True
@@ -141,3 +141,13 @@ class TestGrants:
141
141
  """Callers hand it whatever they have — the middleware caches a set,
142
142
  a test hands a list."""
143
143
  assert grants(["users.manage"], "users.manage") is True
144
+
145
+
146
+ async def test_reloading_role_overlays_removes_deleted_grants_but_keeps_code_grants():
147
+ registry = PermissionRegistry()
148
+ registry.map_role("editor", ["code.view"])
149
+ registry.set_role_overlay("editor", ["db.view"])
150
+ registry.replace_role_overlays({"reviewer": ["db.review"]})
151
+ assert registry.role_map["editor"] == ["code.view"]
152
+ assert registry.role_map["reviewer"] == ["db.review"]
153
+ assert registry.role_map["admin"] == ["*"]