simple-module-core 0.0.38__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.38 → simple_module_core-0.0.40}/PKG-INFO +7 -7
  2. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/pyproject.toml +7 -7
  3. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/_i18n_translator.py +5 -0
  4. simple_module_core-0.0.40/simple_module_core/app_state.py +199 -0
  5. simple_module_core-0.0.40/simple_module_core/datetimes.py +12 -0
  6. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/events.py +20 -0
  7. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/feature_flags.py +7 -0
  8. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/invalidation.py +10 -8
  9. simple_module_core-0.0.40/simple_module_core/invalidation_refresh.py +51 -0
  10. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/leases.py +6 -0
  11. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/mail.py +6 -0
  12. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/menu.py +6 -1
  13. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/module.py +6 -1
  14. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/module_meta.py +3 -1
  15. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/permissions.py +8 -0
  16. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/tenancy.py +4 -2
  17. simple_module_core-0.0.40/simple_module_core/tenant_events.py +20 -0
  18. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/user_directory.py +3 -0
  19. simple_module_core-0.0.40/simple_module_core/user_events.py +16 -0
  20. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/versioning.py +11 -0
  21. simple_module_core-0.0.40/tests/test_core_app_state.py +57 -0
  22. simple_module_core-0.0.40/tests/test_core_datetimes.py +19 -0
  23. simple_module_core-0.0.40/tests/test_core_invalidation_refresh.py +54 -0
  24. simple_module_core-0.0.40/tests/test_core_lifecycle_docs.py +20 -0
  25. simple_module_core-0.0.40/tests/test_events_inline.py +38 -0
  26. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_feature_flags.py +13 -0
  27. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_permissions.py +10 -0
  28. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/.gitignore +0 -0
  29. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/LICENSE +0 -0
  30. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/README.md +0 -0
  31. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/__init__.py +0 -0
  32. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/__main__.py +0 -0
  33. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/_i18n_flatten.py +0 -0
  34. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/_permission_grants.py +0 -0
  35. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/_request_hooks.py +0 -0
  36. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/activity_feed.py +0 -0
  37. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/audit_links.py +0 -0
  38. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/body_limits.py +0 -0
  39. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/correlation.py +0 -0
  40. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/cors.py +0 -0
  41. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/csp.py +0 -0
  42. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/deprecation.py +0 -0
  43. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/design_packs.py +0 -0
  44. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/__init__.py +0 -0
  45. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_coupling.py +0 -0
  46. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_css.py +0 -0
  47. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_db_extensions.py +0 -0
  48. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_dependencies.py +0 -0
  49. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_expression_index.py +0 -0
  50. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
  51. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_i18n.py +0 -0
  52. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  53. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  54. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_migration.py +0 -0
  55. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_module.py +0 -0
  56. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_pages.py +0 -0
  57. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_runner.py +0 -0
  58. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_spa_mount.py +0 -0
  59. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_tenancy.py +0 -0
  60. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/diagnostics/_types.py +0 -0
  61. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/discovery.py +0 -0
  62. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/dotenv.py +0 -0
  63. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/durable_events.py +0 -0
  64. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/environments.py +0 -0
  65. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/exceptions.py +0 -0
  66. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/health.py +0 -0
  67. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/i18n.py +0 -0
  68. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/i18n_overrides.py +0 -0
  69. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/module_settings.py +0 -0
  70. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/path_rules.py +0 -0
  71. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/permission_denied.py +0 -0
  72. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/public_routes.py +0 -0
  73. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/py.typed +0 -0
  74. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/rate_limit.py +0 -0
  75. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/redirect_safety.py +0 -0
  76. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/resources.py +0 -0
  77. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/secret_fields.py +0 -0
  78. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/services.py +0 -0
  79. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/settings_base.py +0 -0
  80. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/settings_hydrate.py +0 -0
  81. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/setup_steps.py +0 -0
  82. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/spa_mount.py +0 -0
  83. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/simple_module_core/stored_settings.py +0 -0
  84. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_audit_links.py +0 -0
  85. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_auth_provider_selection.py +0 -0
  86. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_core_leases.py +0 -0
  87. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_core_mail_resolution.py +0 -0
  88. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_core_module_settings.py +0 -0
  89. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_core_user_directory.py +0 -0
  90. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_csp_registry.py +0 -0
  91. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_css_diagnostics.py +0 -0
  92. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_css_scanner.py +0 -0
  93. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_db_extensions_diagnostics.py +0 -0
  94. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_deprecation_helper.py +0 -0
  95. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_design_packs.py +0 -0
  96. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_diagnostics.py +0 -0
  97. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_diagnostics_tenancy.py +0 -0
  98. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_discovery.py +0 -0
  99. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_discovery_missing_deps.py +0 -0
  100. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_doctor_cli_overrides.py +0 -0
  101. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_dotenv.py +0 -0
  102. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_durable_events.py +0 -0
  103. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_environments.py +0 -0
  104. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_events.py +0 -0
  105. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_expression_index_diagnostics.py +0 -0
  106. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_feature_flags_decorator.py +0 -0
  107. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_health_registry.py +0 -0
  108. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_heavy_task_imports_diagnostics.py +0 -0
  109. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_i18n.py +0 -0
  110. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_i18n_diagnostics.py +0 -0
  111. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_i18n_overrides.py +0 -0
  112. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_invalidation.py +0 -0
  113. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_invalidation_handlers.py +0 -0
  114. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_menu.py +0 -0
  115. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_module_base.py +0 -0
  116. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_module_diagnostics.py +0 -0
  117. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_path_rule_registries.py +0 -0
  118. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_permission_sources.py +0 -0
  119. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_permissions_admin_principal.py +0 -0
  120. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_public_routes.py +0 -0
  121. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_public_routes_rate_precedence.py +0 -0
  122. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_redirect_safety.py +0 -0
  123. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_services.py +0 -0
  124. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_settings_base.py +0 -0
  125. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_sm003_page_render.py +0 -0
  126. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_sm009_dynamic_coupling.py +0 -0
  127. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_tenant_exists.py +0 -0
  128. {simple_module_core-0.0.38 → simple_module_core-0.0.40}/tests/test_tenant_roles.py +0 -0
  129. {simple_module_core-0.0.38 → 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.38
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
@@ -21,12 +21,12 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: babel>=2.14
25
- Requires-Dist: fastapi<0.157,>=0.140
26
- Requires-Dist: packaging>=23.0
27
- Requires-Dist: pydantic-settings>=2.0
28
- Requires-Dist: pydantic>=2.0
29
- Requires-Dist: pyee>=12.0
24
+ Requires-Dist: babel<3,>=2.14
25
+ Requires-Dist: fastapi<0.159,>=0.140
26
+ Requires-Dist: packaging<27,>=23.0
27
+ Requires-Dist: pydantic-settings<3,>=2.0
28
+ Requires-Dist: pydantic<3,>=2.0
29
+ Requires-Dist: pyee<15,>=12.0
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_core
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.38"
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"
@@ -21,12 +21,12 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "babel>=2.14",
25
- "fastapi>=0.140,<0.157",
26
- "packaging>=23.0",
27
- "pydantic>=2.0",
28
- "pydantic-settings>=2.0",
29
- "pyee>=12.0",
24
+ "babel>=2.14,<3",
25
+ "fastapi>=0.140,<0.159",
26
+ "packaging>=23.0,<27",
27
+ "pydantic>=2.0,<3",
28
+ "pydantic-settings>=2.0,<3",
29
+ "pyee>=12.0,<15",
30
30
  ]
31
31
 
32
32
  [project.urls]
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
14
14
  # Logged under the historical ``simple_module_core.i18n`` name so existing log
15
15
  # filters keep matching after the split.
16
16
  logger = logging.getLogger("simple_module_core.i18n")
17
+ _plural_failure_warned = False
17
18
 
18
19
 
19
20
  @lru_cache(maxsize=64)
@@ -30,6 +31,10 @@ def _plural_form(locale: str, count: float) -> str:
30
31
  try:
31
32
  rule = _plural_rule(locale)
32
33
  except Exception:
34
+ global _plural_failure_warned
35
+ if not _plural_failure_warned:
36
+ _plural_failure_warned = True
37
+ logger.warning("Plural locale parsing failed; using other", exc_info=True)
33
38
  return "other"
34
39
  return rule(count)
35
40
 
@@ -0,0 +1,199 @@
1
+ """Typed reads of optional app services, preserving absent-state fallbacks.
2
+
3
+ Concrete hosting types are imported only for type checking, as in Services:
4
+ core stays usable without hosting or any plugin installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any, Protocol
10
+
11
+ if TYPE_CHECKING:
12
+ from asyncio import Lock
13
+ from collections.abc import Callable
14
+
15
+ from simple_module_hosting._host_services import _HostServices
16
+ from simple_module_hosting._tenant import TenantResolver
17
+ from simple_module_hosting.middleware import PrincipalSerializer
18
+ from simple_module_hosting.shared_props import SharedPropsProvider
19
+ from simple_module_inertia import Inertia
20
+ from starlette.requests import Request
21
+
22
+ from simple_module_core.body_limits import BodyLimitRegistry
23
+ from simple_module_core.design_packs import DesignPackRegistry
24
+ from simple_module_core.module import ModuleBase
25
+ from simple_module_core.public_routes import PublicRouteRegistry
26
+ from simple_module_core.rate_limit import RateLimitRegistry
27
+ from simple_module_core.services import Services
28
+ from simple_module_core.tenancy import TenantExists, TenantMembers
29
+
30
+
31
+ #: Attribute ``register_inertia_shared_provider`` writes and the renderer reads.
32
+ INERTIA_SHARED_PROVIDERS_STATE_ATTR = "inertia_shared_providers"
33
+
34
+
35
+ class AuthProvider(Protocol):
36
+ """Framework-facing auth extension; installed by users or keycloak."""
37
+
38
+ async def resolve_user(self, request: Request) -> Any: ...
39
+
40
+ def get_login_url(self, request: Request, next_url: str | None = None) -> str: ...
41
+
42
+
43
+ class BrandingState(Protocol):
44
+ """Optional branding services consumed by the document renderer."""
45
+
46
+ @property
47
+ def settings(self) -> object: ...
48
+
49
+ @property
50
+ def favicon_url(self) -> str | None: ...
51
+
52
+
53
+ def get_auth_provider(app: Any) -> AuthProvider | None:
54
+ """Auth.register_settings installs auth; users/keycloak install its provider."""
55
+ return getattr(getattr(app.state, "auth", None), "auth_provider", None)
56
+
57
+
58
+ def get_services(app: Any) -> Services | None:
59
+ """create_app installs the framework service container.
60
+
61
+ Returns None when absent.
62
+ """
63
+ return getattr(getattr(app, "state", None), "sm", None)
64
+
65
+
66
+ def get_host_services(app: Any) -> _HostServices | None:
67
+ """Host settings registration installs the host service container.
68
+
69
+ Returns None when absent.
70
+ """
71
+ return getattr(app.state, "host", None)
72
+
73
+
74
+ def get_public_routes(app: Any) -> PublicRouteRegistry | None:
75
+ """create_app installs the public-route registry.
76
+
77
+ Returns None when absent.
78
+ """
79
+ return getattr(app.state, "public_routes", None)
80
+
81
+
82
+ def get_body_limits(app: Any) -> BodyLimitRegistry | None:
83
+ """create_app installs the request-body limit registry.
84
+
85
+ Returns None when absent.
86
+ """
87
+ return getattr(app.state, "body_limits", None)
88
+
89
+
90
+ def get_rate_limit_rules(app: Any) -> RateLimitRegistry | None:
91
+ """create_app installs the per-route rate-limit registry.
92
+
93
+ Returns None when absent.
94
+ """
95
+ return getattr(app.state, "rate_limit_rules", None)
96
+
97
+
98
+ def get_design_packs(app: Any) -> DesignPackRegistry | None:
99
+ """create_app installs the design-pack registry.
100
+
101
+ Returns None when absent.
102
+ """
103
+ return getattr(app.state, "design_packs", None)
104
+
105
+
106
+ def get_migration_status(app: Any) -> dict[str, Any] | None:
107
+ """The hosting lifespan and setup wizard install the migration snapshot.
108
+
109
+ Returns None when absent.
110
+ """
111
+ return getattr(app.state, "migration", None)
112
+
113
+
114
+ def get_openapi_url(app: Any) -> str | None:
115
+ """Hosting boot records the served OpenAPI URL, or None when disabled.
116
+
117
+ Returns None when absent.
118
+ """
119
+ return getattr(app.state, "openapi_url", None)
120
+
121
+
122
+ def get_spa_prefixes(app: Any) -> tuple[str, ...]:
123
+ """SPA mounting records the mounted prefixes; absent means no mounts.
124
+
125
+ Returns () when absent.
126
+ """
127
+ return getattr(app.state, "spa_prefixes", ())
128
+
129
+
130
+ def get_deferred_startup(app: Any) -> list[ModuleBase] | None:
131
+ """The hosting lifespan records modules awaiting migrations.
132
+
133
+ Returns None when absent.
134
+ """
135
+ return getattr(app.state, "deferred_startup", None)
136
+
137
+
138
+ def get_deferred_startup_lock(app: Any) -> Lock | None:
139
+ """Deferred startup replay lazily installs its per-app lock.
140
+
141
+ Returns None when absent.
142
+ """
143
+ return getattr(app.state, "deferred_startup_lock", None)
144
+
145
+
146
+ def get_principal_serializer(app: Any) -> PrincipalSerializer | None:
147
+ """The auth module installs the principal-to-Inertia serializer.
148
+
149
+ Returns None when absent.
150
+ """
151
+ return getattr(app.state, "principal_serializer", None)
152
+
153
+
154
+ def get_tenant_resolver(app: Any) -> TenantResolver | None:
155
+ """The tenants module installs request tenant resolution.
156
+
157
+ Returns None when absent.
158
+ """
159
+ return getattr(getattr(app, "state", None), "tenant_resolver", None)
160
+
161
+
162
+ def get_tenant_exists(app: Any) -> TenantExists | None:
163
+ """The tenants module installs the tenant-existence callback.
164
+
165
+ Returns None when absent.
166
+ """
167
+ return getattr(app.state, "tenant_exists", None)
168
+
169
+
170
+ def get_tenant_members(app: Any) -> TenantMembers | None:
171
+ """The tenants module installs the membership lookup callback.
172
+
173
+ Returns None when absent.
174
+ """
175
+ return getattr(app.state, "tenant_members", None)
176
+
177
+
178
+ def get_inertia_dependency(app: Any) -> Callable[..., Inertia] | None:
179
+ """Hosting Inertia setup installs the configured dependency factory.
180
+
181
+ Returns None when absent.
182
+ """
183
+ return getattr(app.state, "inertia_dependency", None)
184
+
185
+
186
+ def get_inertia_shared_providers(app: Any) -> list[SharedPropsProvider] | None:
187
+ """register_inertia_shared_provider lazily installs the provider list.
188
+
189
+ Returns None when absent.
190
+ """
191
+ return getattr(app.state, INERTIA_SHARED_PROVIDERS_STATE_ATTR, None)
192
+
193
+
194
+ def get_branding(app: Any) -> BrandingState | None:
195
+ """The branding module installs the optional application theme services.
196
+
197
+ Returns None when absent.
198
+ """
199
+ return getattr(app.state, "branding", None)
@@ -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)
@@ -11,6 +11,7 @@ from typing import Any
11
11
  from pyee.asyncio import AsyncIOEventEmitter
12
12
 
13
13
  from simple_module_core.durable_events import (
14
+ DurableContext,
14
15
  DurableDeliveryUnavailable,
15
16
  DurableHandler,
16
17
  DurableRegistry,
@@ -141,3 +142,22 @@ class EventBus:
141
142
  if not names:
142
143
  return
143
144
  await transport.enqueue(event, names, db)
145
+
146
+ async def publish_durable_or_inline(self, event: Event, db: Any) -> None:
147
+ """Enqueue with a transport, otherwise run durable handlers in *db*.
148
+
149
+ Inline handlers run sequentially in subscription order on the caller's
150
+ transaction with attempt=1. They must flush rather than commit; errors
151
+ propagate so the caller can roll back. Ordinary subscribers are not run.
152
+ External effects must be deferred to the caller's post-commit hooks.
153
+ """
154
+ if self.durable_transport is not None:
155
+ await self.publish_durable(event, db)
156
+ return
157
+ ctx = DurableContext(db=db, attempt=1)
158
+ key = self._event_key(type(event))
159
+ for name in self.durable.handler_names(type(event)):
160
+ subscription = self.durable.lookup(key, name)
161
+ if subscription is None:
162
+ continue
163
+ await subscription.handler(event, ctx)
@@ -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
@@ -48,6 +46,7 @@ from dataclasses import dataclass
48
46
  from typing import Protocol, runtime_checkable
49
47
 
50
48
  logger = logging.getLogger(__name__)
49
+ _decode_failure_warned = False
51
50
 
52
51
  __all__ = [
53
52
  "WIRE_VERSION",
@@ -108,8 +107,8 @@ class Invalidation:
108
107
  routinely shared — with another app, with a stray ``redis-cli publish``,
109
108
  with the previous version of this code — and a listener loop that dies
110
109
  on one malformed payload takes cross-process invalidation down for the
111
- life of the process. Logged at debug: on a shared database this is
112
- normal traffic, not a fault.
110
+ life of the process. Decode exceptions warn once per process with a
111
+ traceback; unsupported shapes remain ordinary shared-database traffic.
113
112
  """
114
113
  try:
115
114
  data = json.loads(message)
@@ -123,7 +122,10 @@ class Invalidation:
123
122
  return None
124
123
  return cls(channel=channel, key=key, origin=str(data.get("origin") or ""))
125
124
  except Exception:
126
- logger.debug("Ignoring undecodable invalidation message: %r", message)
125
+ global _decode_failure_warned
126
+ if not _decode_failure_warned:
127
+ _decode_failure_warned = True
128
+ logger.warning("Ignoring undecodable invalidation message", exc_info=True)
127
129
  return None
128
130
 
129
131
 
@@ -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")
@@ -43,6 +43,7 @@ __all__ = [
43
43
  "configure_leases",
44
44
  "get_leases",
45
45
  "lease",
46
+ "reset_lease_backend",
46
47
  "semaphore",
47
48
  ]
48
49
 
@@ -153,6 +154,11 @@ def configure_leases(backend: LeaseBackend | None) -> None:
153
154
  _backend = backend if backend is not None else InProcessLeaseBackend()
154
155
 
155
156
 
157
+ def reset_lease_backend() -> None:
158
+ """Discard process lease configuration and held in-process leases."""
159
+ configure_leases(None)
160
+
161
+
156
162
  def get_leases() -> LeaseBackend:
157
163
  """The process-wide backend :func:`lease` and :func:`semaphore` use."""
158
164
  return _backend
@@ -93,6 +93,12 @@ def install_mail_resolver(resolver: MailSenderResolver | None) -> None:
93
93
  _resolver = resolver
94
94
 
95
95
 
96
+ def reset_mail_sender() -> None:
97
+ """Clear the process sender and resolver between isolated app/test runs."""
98
+ install_mail_sender(None)
99
+ install_mail_resolver(None)
100
+
101
+
96
102
  def mail_transport_installed() -> bool:
97
103
  """Whether a transport (sender or resolver) is installed in this process.
98
104
 
@@ -85,6 +85,7 @@ class MenuRegistry:
85
85
  self._items: list[MenuItem] = []
86
86
  self._sorted: list[MenuItem] | None = None
87
87
  self._providers: list[MenuProvider] = []
88
+ self._failed_providers: set[int] = set()
88
89
 
89
90
  def _invalidate(self) -> None:
90
91
  self._sorted = None
@@ -125,7 +126,11 @@ class MenuRegistry:
125
126
  # Materialise first: a generator failing midway must add nothing.
126
127
  items.extend(list(result))
127
128
  except Exception:
128
- logger.exception("Menu provider %r failed; contributing nothing", provider)
129
+ if id(provider) not in self._failed_providers:
130
+ self._failed_providers.add(id(provider))
131
+ logger.warning(
132
+ "Menu provider %r failed; contributing nothing", provider, exc_info=True
133
+ )
129
134
  return items
130
135
 
131
136
  @property
@@ -25,6 +25,9 @@ if TYPE_CHECKING:
25
25
  from simple_module_core.spa_mount import SpaMount
26
26
 
27
27
 
28
+ EVENT_HANDLERS_OPTIONAL_APP_REMOVE_IN = "0.1.0"
29
+
30
+
28
31
  class ModuleBase(RequestHooksMixin, ABC):
29
32
  """Base class for all modules.
30
33
 
@@ -75,7 +78,9 @@ class ModuleBase(RequestHooksMixin, ABC):
75
78
  def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
76
79
  """Subscribe to events published by other modules.
77
80
 
78
- ``app`` is optional for back-compat; pass it through to handlers
81
+ The optional ``app=None`` compatibility default has
82
+ ``remove_in="0.1.0"``. Overrides own the call, so the base class cannot
83
+ reliably warn when callers omit it. Pass ``app`` through to handlers
79
84
  that need ``app.state.sm.db.session_factory`` to persist on the
80
85
  framework's engine instead of building their own.
81
86
  """
@@ -50,7 +50,9 @@ class ModuleMeta:
50
50
 
51
51
  Example: ``">=1.0,<2.0"``. When set, the module is rejected at boot if the
52
52
  installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
53
- When ``None``, no compatibility check is performed (legacy modules).
53
+ When ``None``, no compatibility check is performed (legacy modules);
54
+ this bypass is deprecated with ``remove_in="0.1.0"`` (tracked by the
55
+ deprecation-expiry test; it does not warn at boot).
54
56
  """
55
57
  i18n_audience: str = "public"
56
58
  """Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
@@ -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)."""
@@ -17,6 +17,8 @@ from collections.abc import Awaitable, Callable, Collection
17
17
  from enum import StrEnum
18
18
  from typing import Any
19
19
 
20
+ from simple_module_core.app_state import get_tenant_exists, get_tenant_members
21
+
20
22
  TENANT_ROLE_PREFIX = "tenant:"
21
23
 
22
24
 
@@ -72,7 +74,7 @@ async def tenant_exists(app: Any, tenant_id: str) -> bool | None:
72
74
  URL ask through this, so they can refuse a typo without importing that
73
75
  module; with none installed the answer is ``None`` and they accept the id.
74
76
  """
75
- check: TenantExists | None = getattr(app.state, "tenant_exists", None)
77
+ check: TenantExists | None = get_tenant_exists(app)
76
78
  if check is None:
77
79
  return None
78
80
  return bool(await check(tenant_id))
@@ -97,7 +99,7 @@ async def tenant_member_ids(
97
99
  no membership store is installed; callers on a multi-tenant app treat it
98
100
  as "nobody" (fail closed).
99
101
  """
100
- check: TenantMembers | None = getattr(app.state, "tenant_members", None)
102
+ check: TenantMembers | None = get_tenant_members(app)
101
103
  if check is None:
102
104
  return None
103
105
  if not user_ids:
@@ -0,0 +1,20 @@
1
+ """Tenant lifecycle contracts shared without a dependency on a tenant provider."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from simple_module_core.events import Event
6
+
7
+ TENANT_DELETED_CHANNEL = "tenants.deleted"
8
+
9
+
10
+ @dataclass
11
+ class TenantDeleted(Event):
12
+ """The tenant registry row is gone; owners must durably tear down its data.
13
+
14
+ Delivery is at least once. Retention stores may keep historical rows,
15
+ identified by their original tenant id, but must never expose live resources.
16
+ """
17
+
18
+ tenant_id: str
19
+ slug: str
20
+ name: str
@@ -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
@@ -8,6 +8,7 @@ from collections.abc import Sequence
8
8
  from packaging.specifiers import InvalidSpecifier, SpecifierSet
9
9
  from packaging.version import Version
10
10
 
11
+ from simple_module_core.deprecation import deprecate
11
12
  from simple_module_core.exceptions import FrameworkVersionError
12
13
  from simple_module_core.module import ModuleBase
13
14
 
@@ -28,6 +29,13 @@ registries, the event bus, ``create_module_base``, or the model mixins.
28
29
  _FRAMEWORK_VERSION = Version(FRAMEWORK_API_VERSION)
29
30
 
30
31
 
32
+ _UNVERSIONED_MODULE = deprecate(
33
+ "ModuleMeta.requires_framework=None compatibility bypass",
34
+ remove_in="0.1.0",
35
+ use="ModuleMeta(requires_framework=...) with an explicit compatible range",
36
+ )
37
+
38
+
31
39
  def check_framework_compatibility(
32
40
  modules: Sequence[ModuleBase],
33
41
  framework_version: str = FRAMEWORK_API_VERSION,
@@ -48,6 +56,9 @@ def check_framework_compatibility(
48
56
  for mod in modules:
49
57
  spec_str = mod.meta.requires_framework
50
58
  if spec_str is None:
59
+ # Tracked for removal (``_UNVERSIONED_MODULE``) but not warned per
60
+ # module: every unversioned module, in-repo ones included, would
61
+ # warn on each boot.
51
62
  continue
52
63
  try:
53
64
  spec = SpecifierSet(spec_str)