simple-module-core 0.0.39__tar.gz → 0.0.41__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.
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/PKG-INFO +2 -2
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/pyproject.toml +2 -2
- simple_module_core-0.0.41/simple_module_core/datetimes.py +12 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/feature_flags.py +7 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/invalidation.py +3 -5
- simple_module_core-0.0.41/simple_module_core/invalidation_refresh.py +51 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/permissions.py +8 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/user_directory.py +3 -0
- simple_module_core-0.0.41/simple_module_core/user_events.py +16 -0
- simple_module_core-0.0.41/tests/test_core_datetimes.py +19 -0
- simple_module_core-0.0.41/tests/test_core_invalidation_refresh.py +54 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_feature_flags.py +13 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_permissions.py +10 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/.gitignore +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/LICENSE +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/README.md +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/__init__.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/__main__.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_i18n_flatten.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_i18n_translator.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_permission_grants.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_request_hooks.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/activity_feed.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/app_state.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/audit_links.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/body_limits.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/correlation.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/cors.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/deprecation.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_db_extensions.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_dependencies.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_expression_index.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_module.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_spa_mount.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_tenancy.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/durable_events.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/i18n.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/i18n_overrides.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/leases.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/mail.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/menu.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/module.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/module_meta.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/module_settings.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/path_rules.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/permission_denied.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/rate_limit.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/redirect_safety.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/resources.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/secret_fields.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/services.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/settings_base.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/settings_hydrate.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/setup_steps.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/spa_mount.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/stored_settings.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/tenancy.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/tenant_events.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_audit_links.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_core_app_state.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_core_leases.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_core_lifecycle_docs.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_core_mail_resolution.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_core_module_settings.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_core_user_directory.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_db_extensions_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_deprecation_helper.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_diagnostics_tenancy.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_discovery_missing_deps.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_doctor_cli_overrides.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_durable_events.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_events.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_events_inline.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_expression_index_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_heavy_task_imports_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_i18n_overrides.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_invalidation.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_invalidation_handlers.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_path_rule_registries.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_permission_sources.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_permissions_admin_principal.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_public_routes_rate_precedence.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_redirect_safety.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_services.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_settings_base.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_sm003_page_render.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_sm009_dynamic_coupling.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_tenant_exists.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_tenant_roles.py +0 -0
- {simple_module_core-0.0.39 → simple_module_core-0.0.41}/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.
|
|
3
|
+
Version: 0.0.41
|
|
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.
|
|
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.
|
|
3
|
+
version = "0.0.41"
|
|
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.
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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)."""
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/user_directory.py
RENAMED
|
@@ -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"] == ["*"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_i18n_translator.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_permission_grants.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/_request_hooks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/__init__.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_coupling.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_css.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_i18n.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_migration.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_module.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_pages.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_runner.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_spa_mount.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_tenancy.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/diagnostics/_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/durable_events.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/i18n_overrides.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/module_settings.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/permission_denied.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/redirect_safety.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/settings_hydrate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/simple_module_core/stored_settings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_auth_provider_selection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_db_extensions_diagnostics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_discovery_missing_deps.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_expression_index_diagnostics.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_feature_flags_decorator.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_heavy_task_imports_diagnostics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_permissions_admin_principal.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_public_routes_rate_precedence.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.39 → simple_module_core-0.0.41}/tests/test_sm009_dynamic_coupling.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|