simple-module-core 0.0.38__tar.gz → 0.0.39__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 (124) hide show
  1. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/PKG-INFO +6 -6
  2. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/pyproject.toml +6 -6
  3. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/_i18n_translator.py +5 -0
  4. simple_module_core-0.0.39/simple_module_core/app_state.py +199 -0
  5. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/events.py +20 -0
  6. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/invalidation.py +7 -3
  7. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/leases.py +6 -0
  8. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/mail.py +6 -0
  9. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/menu.py +6 -1
  10. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/module.py +6 -1
  11. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/module_meta.py +3 -1
  12. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/tenancy.py +4 -2
  13. simple_module_core-0.0.39/simple_module_core/tenant_events.py +20 -0
  14. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/versioning.py +11 -0
  15. simple_module_core-0.0.39/tests/test_core_app_state.py +57 -0
  16. simple_module_core-0.0.39/tests/test_core_lifecycle_docs.py +20 -0
  17. simple_module_core-0.0.39/tests/test_events_inline.py +38 -0
  18. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/.gitignore +0 -0
  19. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/LICENSE +0 -0
  20. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/README.md +0 -0
  21. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/__init__.py +0 -0
  22. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/__main__.py +0 -0
  23. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/_i18n_flatten.py +0 -0
  24. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/_permission_grants.py +0 -0
  25. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/_request_hooks.py +0 -0
  26. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/activity_feed.py +0 -0
  27. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/audit_links.py +0 -0
  28. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/body_limits.py +0 -0
  29. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/correlation.py +0 -0
  30. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/cors.py +0 -0
  31. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/csp.py +0 -0
  32. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/deprecation.py +0 -0
  33. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/design_packs.py +0 -0
  34. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/__init__.py +0 -0
  35. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_coupling.py +0 -0
  36. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_css.py +0 -0
  37. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_db_extensions.py +0 -0
  38. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_dependencies.py +0 -0
  39. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_expression_index.py +0 -0
  40. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
  41. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_i18n.py +0 -0
  42. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  43. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  44. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_migration.py +0 -0
  45. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_module.py +0 -0
  46. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_pages.py +0 -0
  47. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_runner.py +0 -0
  48. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_spa_mount.py +0 -0
  49. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_tenancy.py +0 -0
  50. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_types.py +0 -0
  51. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/discovery.py +0 -0
  52. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/dotenv.py +0 -0
  53. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/durable_events.py +0 -0
  54. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/environments.py +0 -0
  55. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/exceptions.py +0 -0
  56. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/feature_flags.py +0 -0
  57. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/health.py +0 -0
  58. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/i18n.py +0 -0
  59. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/i18n_overrides.py +0 -0
  60. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/module_settings.py +0 -0
  61. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/path_rules.py +0 -0
  62. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/permission_denied.py +0 -0
  63. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/permissions.py +0 -0
  64. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/public_routes.py +0 -0
  65. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/py.typed +0 -0
  66. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/rate_limit.py +0 -0
  67. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/redirect_safety.py +0 -0
  68. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/resources.py +0 -0
  69. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/secret_fields.py +0 -0
  70. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/services.py +0 -0
  71. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/settings_base.py +0 -0
  72. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/settings_hydrate.py +0 -0
  73. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/setup_steps.py +0 -0
  74. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/spa_mount.py +0 -0
  75. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/stored_settings.py +0 -0
  76. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/simple_module_core/user_directory.py +0 -0
  77. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_audit_links.py +0 -0
  78. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_auth_provider_selection.py +0 -0
  79. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_core_leases.py +0 -0
  80. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_core_mail_resolution.py +0 -0
  81. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_core_module_settings.py +0 -0
  82. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_core_user_directory.py +0 -0
  83. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_csp_registry.py +0 -0
  84. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_css_diagnostics.py +0 -0
  85. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_css_scanner.py +0 -0
  86. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_db_extensions_diagnostics.py +0 -0
  87. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_deprecation_helper.py +0 -0
  88. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_design_packs.py +0 -0
  89. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_diagnostics.py +0 -0
  90. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_diagnostics_tenancy.py +0 -0
  91. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_discovery.py +0 -0
  92. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_discovery_missing_deps.py +0 -0
  93. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_doctor_cli_overrides.py +0 -0
  94. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_dotenv.py +0 -0
  95. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_durable_events.py +0 -0
  96. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_environments.py +0 -0
  97. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_events.py +0 -0
  98. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_expression_index_diagnostics.py +0 -0
  99. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_feature_flags.py +0 -0
  100. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_feature_flags_decorator.py +0 -0
  101. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_health_registry.py +0 -0
  102. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_heavy_task_imports_diagnostics.py +0 -0
  103. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_i18n.py +0 -0
  104. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_i18n_diagnostics.py +0 -0
  105. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_i18n_overrides.py +0 -0
  106. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_invalidation.py +0 -0
  107. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_invalidation_handlers.py +0 -0
  108. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_menu.py +0 -0
  109. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_module_base.py +0 -0
  110. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_module_diagnostics.py +0 -0
  111. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_path_rule_registries.py +0 -0
  112. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_permission_sources.py +0 -0
  113. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_permissions.py +0 -0
  114. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_permissions_admin_principal.py +0 -0
  115. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_public_routes.py +0 -0
  116. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_public_routes_rate_precedence.py +0 -0
  117. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_redirect_safety.py +0 -0
  118. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_services.py +0 -0
  119. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_settings_base.py +0 -0
  120. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_sm003_page_render.py +0 -0
  121. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_sm009_dynamic_coupling.py +0 -0
  122. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_tenant_exists.py +0 -0
  123. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/tests/test_tenant_roles.py +0 -0
  124. {simple_module_core-0.0.38 → simple_module_core-0.0.39}/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.39
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
24
+ Requires-Dist: babel<3,>=2.14
25
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
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.39"
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",
24
+ "babel>=2.14,<3",
25
25
  "fastapi>=0.140,<0.157",
26
- "packaging>=23.0",
27
- "pydantic>=2.0",
28
- "pydantic-settings>=2.0",
29
- "pyee>=12.0",
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)
@@ -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)
@@ -48,6 +48,7 @@ from dataclasses import dataclass
48
48
  from typing import Protocol, runtime_checkable
49
49
 
50
50
  logger = logging.getLogger(__name__)
51
+ _decode_failure_warned = False
51
52
 
52
53
  __all__ = [
53
54
  "WIRE_VERSION",
@@ -108,8 +109,8 @@ class Invalidation:
108
109
  routinely shared — with another app, with a stray ``redis-cli publish``,
109
110
  with the previous version of this code — and a listener loop that dies
110
111
  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.
112
+ life of the process. Decode exceptions warn once per process with a
113
+ traceback; unsupported shapes remain ordinary shared-database traffic.
113
114
  """
114
115
  try:
115
116
  data = json.loads(message)
@@ -123,7 +124,10 @@ class Invalidation:
123
124
  return None
124
125
  return cls(channel=channel, key=key, origin=str(data.get("origin") or ""))
125
126
  except Exception:
126
- logger.debug("Ignoring undecodable invalidation message: %r", message)
127
+ global _decode_failure_warned
128
+ if not _decode_failure_warned:
129
+ _decode_failure_warned = True
130
+ logger.warning("Ignoring undecodable invalidation message", exc_info=True)
127
131
  return None
128
132
 
129
133
 
@@ -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"``.
@@ -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
@@ -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)
@@ -0,0 +1,57 @@
1
+ """Optional app services retain their defaults and installed object identity."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from fastapi import FastAPI
7
+ from simple_module_core import app_state
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ ("attribute", "accessor_name", "default"),
12
+ [
13
+ ("sm", "get_services", None),
14
+ ("host", "get_host_services", None),
15
+ ("public_routes", "get_public_routes", None),
16
+ ("body_limits", "get_body_limits", None),
17
+ ("rate_limit_rules", "get_rate_limit_rules", None),
18
+ ("design_packs", "get_design_packs", None),
19
+ ("migration", "get_migration_status", None),
20
+ ("openapi_url", "get_openapi_url", None),
21
+ ("spa_prefixes", "get_spa_prefixes", ()),
22
+ ("deferred_startup", "get_deferred_startup", None),
23
+ ("deferred_startup_lock", "get_deferred_startup_lock", None),
24
+ ("principal_serializer", "get_principal_serializer", None),
25
+ ("tenant_resolver", "get_tenant_resolver", None),
26
+ ("tenant_exists", "get_tenant_exists", None),
27
+ ("tenant_members", "get_tenant_members", None),
28
+ ("inertia_dependency", "get_inertia_dependency", None),
29
+ ("inertia_shared_providers", "get_inertia_shared_providers", None),
30
+ ("branding", "get_branding", None),
31
+ ("auth", "get_auth_provider", None),
32
+ ],
33
+ )
34
+ def test_core_optional_app_service(attribute, accessor_name, default):
35
+ app = FastAPI()
36
+ accessor = getattr(app_state, accessor_name)
37
+ assert accessor(app) == default
38
+
39
+ installed = object()
40
+ value = SimpleNamespace(auth_provider=installed) if attribute == "auth" else installed
41
+ setattr(app.state, attribute, value)
42
+ assert accessor(app) is installed
43
+
44
+ setattr(app.state, attribute, None)
45
+ assert accessor(app) is None
46
+
47
+
48
+ def test_core_partial_auth_state_has_no_provider():
49
+ app = FastAPI()
50
+ app.state.auth = SimpleNamespace()
51
+ assert app_state.get_auth_provider(app) is None
52
+
53
+
54
+ def test_core_optional_scope_app_can_lack_state():
55
+ assert app_state.get_services(None) is None
56
+ assert app_state.get_services(SimpleNamespace()) is None
57
+ assert app_state.get_tenant_resolver(None) is None
@@ -0,0 +1,20 @@
1
+ """Keep the lifecycle reference complete as ModuleBase gains hooks."""
2
+
3
+ import inspect
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from simple_module_core.module import ModuleBase
8
+
9
+
10
+ def test_every_public_registration_hook_is_documented():
11
+ root = Path(__file__).resolve().parents[3]
12
+ document = (root / "docs/framework/lifecycle.md").read_text(encoding="utf-8")
13
+ hooks = {
14
+ name
15
+ for name, _ in inspect.getmembers(ModuleBase, predicate=inspect.isfunction)
16
+ if name.startswith("register_")
17
+ }
18
+ documented = set(re.findall(r"\bregister_\w+\b", document))
19
+
20
+ assert not (missing := hooks - documented), f"Undocumented lifecycle hooks: {sorted(missing)}"
@@ -0,0 +1,38 @@
1
+ """Explicit inline fallback preserves transaction ownership and error propagation."""
2
+
3
+ from unittest.mock import AsyncMock
4
+
5
+ import pytest
6
+ from simple_module_core.durable_events import DurableContext
7
+ from simple_module_core.events import EventBus
8
+ from simple_module_core.tenant_events import TenantDeleted
9
+
10
+
11
+ @pytest.mark.parametrize("transport", [False, True])
12
+ async def test_durable_or_inline_dispatch(transport):
13
+ bus = EventBus()
14
+ db = object()
15
+ event = TenantDeleted("id", "slug", "Name")
16
+ handlers = [AsyncMock(), AsyncMock()]
17
+ for index, handler in enumerate(handlers):
18
+ bus.subscribe_durable(TenantDeleted, handler, name=str(index))
19
+ ordinary = AsyncMock()
20
+ bus.subscribe(TenantDeleted, ordinary)
21
+ if transport:
22
+ bus.durable_transport = AsyncMock()
23
+ await bus.publish_durable_or_inline(event, db)
24
+ for handler in handlers:
25
+ if transport:
26
+ handler.assert_not_awaited()
27
+ else:
28
+ handler.assert_awaited_once_with(event, DurableContext(db=db, attempt=1))
29
+ ordinary.assert_not_awaited()
30
+ if transport:
31
+ bus.durable_transport.enqueue.assert_awaited_once_with(event, ["0", "1"], db)
32
+
33
+
34
+ async def test_inline_failure_propagates():
35
+ bus = EventBus()
36
+ bus.subscribe_durable(TenantDeleted, AsyncMock(side_effect=ValueError("failed")), name="fail")
37
+ with pytest.raises(ValueError, match="failed"):
38
+ await bus.publish_durable_or_inline(TenantDeleted("id", "slug", "Name"), object())