simple-module-core 0.0.41__tar.gz → 0.0.42__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 (133) hide show
  1. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/PKG-INFO +2 -2
  2. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/pyproject.toml +2 -2
  3. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/__init__.py +2 -0
  4. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/__main__.py +8 -1
  5. simple_module_core-0.0.42/simple_module_core/_data_doctor.py +22 -0
  6. simple_module_core-0.0.42/simple_module_core/data_compatibility.py +43 -0
  7. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/durable_events.py +11 -0
  8. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/events.py +7 -1
  9. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/feature_flags.py +6 -0
  10. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/module.py +11 -1
  11. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/resources.py +44 -3
  12. simple_module_core-0.0.42/tests/test_core_data_compatibility_doctor.py +45 -0
  13. simple_module_core-0.0.42/tests/test_core_inline_delivery_context.py +19 -0
  14. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/.gitignore +0 -0
  15. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/LICENSE +0 -0
  16. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/README.md +0 -0
  17. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/_i18n_flatten.py +0 -0
  18. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/_i18n_translator.py +0 -0
  19. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/_permission_grants.py +0 -0
  20. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/_request_hooks.py +0 -0
  21. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/activity_feed.py +0 -0
  22. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/app_state.py +0 -0
  23. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/audit_links.py +0 -0
  24. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/body_limits.py +0 -0
  25. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/correlation.py +0 -0
  26. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/cors.py +0 -0
  27. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/csp.py +0 -0
  28. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/datetimes.py +0 -0
  29. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/deprecation.py +0 -0
  30. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/design_packs.py +0 -0
  31. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/__init__.py +0 -0
  32. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_coupling.py +0 -0
  33. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_css.py +0 -0
  34. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_db_extensions.py +0 -0
  35. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_dependencies.py +0 -0
  36. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_expression_index.py +0 -0
  37. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
  38. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_i18n.py +0 -0
  39. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  40. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  41. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_migration.py +0 -0
  42. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_module.py +0 -0
  43. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_pages.py +0 -0
  44. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_runner.py +0 -0
  45. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_spa_mount.py +0 -0
  46. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_tenancy.py +0 -0
  47. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/diagnostics/_types.py +0 -0
  48. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/discovery.py +0 -0
  49. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/dotenv.py +0 -0
  50. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/environments.py +0 -0
  51. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/exceptions.py +0 -0
  52. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/health.py +0 -0
  53. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/i18n.py +0 -0
  54. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/i18n_overrides.py +0 -0
  55. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/invalidation.py +0 -0
  56. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/invalidation_refresh.py +0 -0
  57. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/leases.py +0 -0
  58. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/mail.py +0 -0
  59. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/menu.py +0 -0
  60. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/module_meta.py +0 -0
  61. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/module_settings.py +0 -0
  62. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/path_rules.py +0 -0
  63. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/permission_denied.py +0 -0
  64. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/permissions.py +0 -0
  65. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/public_routes.py +0 -0
  66. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/py.typed +0 -0
  67. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/rate_limit.py +0 -0
  68. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/redirect_safety.py +0 -0
  69. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/secret_fields.py +0 -0
  70. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/services.py +0 -0
  71. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/settings_base.py +0 -0
  72. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/settings_hydrate.py +0 -0
  73. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/setup_steps.py +0 -0
  74. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/spa_mount.py +0 -0
  75. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/stored_settings.py +0 -0
  76. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/tenancy.py +0 -0
  77. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/tenant_events.py +0 -0
  78. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/user_directory.py +0 -0
  79. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/user_events.py +0 -0
  80. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/simple_module_core/versioning.py +0 -0
  81. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_audit_links.py +0 -0
  82. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_auth_provider_selection.py +0 -0
  83. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_app_state.py +0 -0
  84. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_datetimes.py +0 -0
  85. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_invalidation_refresh.py +0 -0
  86. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_leases.py +0 -0
  87. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_lifecycle_docs.py +0 -0
  88. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_mail_resolution.py +0 -0
  89. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_module_settings.py +0 -0
  90. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_core_user_directory.py +0 -0
  91. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_csp_registry.py +0 -0
  92. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_css_diagnostics.py +0 -0
  93. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_css_scanner.py +0 -0
  94. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_db_extensions_diagnostics.py +0 -0
  95. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_deprecation_helper.py +0 -0
  96. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_design_packs.py +0 -0
  97. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_diagnostics.py +0 -0
  98. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_diagnostics_tenancy.py +0 -0
  99. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_discovery.py +0 -0
  100. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_discovery_missing_deps.py +0 -0
  101. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_doctor_cli_overrides.py +0 -0
  102. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_dotenv.py +0 -0
  103. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_durable_events.py +0 -0
  104. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_environments.py +0 -0
  105. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_events.py +0 -0
  106. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_events_inline.py +0 -0
  107. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_expression_index_diagnostics.py +0 -0
  108. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_feature_flags.py +0 -0
  109. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_feature_flags_decorator.py +0 -0
  110. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_health_registry.py +0 -0
  111. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_heavy_task_imports_diagnostics.py +0 -0
  112. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_i18n.py +0 -0
  113. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_i18n_diagnostics.py +0 -0
  114. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_i18n_overrides.py +0 -0
  115. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_invalidation.py +0 -0
  116. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_invalidation_handlers.py +0 -0
  117. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_menu.py +0 -0
  118. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_module_base.py +0 -0
  119. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_module_diagnostics.py +0 -0
  120. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_path_rule_registries.py +0 -0
  121. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_permission_sources.py +0 -0
  122. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_permissions.py +0 -0
  123. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_permissions_admin_principal.py +0 -0
  124. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_public_routes.py +0 -0
  125. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_public_routes_rate_precedence.py +0 -0
  126. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_redirect_safety.py +0 -0
  127. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_services.py +0 -0
  128. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_settings_base.py +0 -0
  129. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_sm003_page_render.py +0 -0
  130. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_sm009_dynamic_coupling.py +0 -0
  131. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_tenant_exists.py +0 -0
  132. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/tests/test_tenant_roles.py +0 -0
  133. {simple_module_core-0.0.41 → simple_module_core-0.0.42}/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.41
3
+ Version: 0.0.42
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.159,>=0.140
25
+ Requires-Dist: fastapi<0.160,>=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.41"
3
+ version = "0.0.42"
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.159",
25
+ "fastapi>=0.140,<0.160",
26
26
  "packaging>=23.0,<27",
27
27
  "pydantic>=2.0,<3",
28
28
  "pydantic-settings>=2.0,<3",
@@ -4,6 +4,7 @@ from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelRe
4
4
  from simple_module_core.body_limits import BodyLimitRegistry
5
5
  from simple_module_core.cors import CorsPolicy, CorsPolicyRegistry
6
6
  from simple_module_core.csp import CspSourceError, CspSourceRegistry
7
+ from simple_module_core.data_compatibility import DataCompatibilityIssue
7
8
  from simple_module_core.design_packs import DesignPack, DesignPackRegistry
8
9
  from simple_module_core.diagnostics import (
9
10
  DiagnosticLevel,
@@ -78,6 +79,7 @@ __all__ = [
78
79
  "CorsPolicyRegistry",
79
80
  "CspSourceError",
80
81
  "CspSourceRegistry",
82
+ "DataCompatibilityIssue",
81
83
  "DesignPack",
82
84
  "DesignPackRegistry",
83
85
  "DiagnosticLevel",
@@ -24,6 +24,7 @@ import os
24
24
  import sys
25
25
  from pathlib import Path
26
26
 
27
+ from simple_module_core._data_doctor import data_compatibility_diagnostics
27
28
  from simple_module_core.diagnostics import (
28
29
  Diagnostic,
29
30
  DiagnosticLevel,
@@ -75,13 +76,18 @@ def _load_i18n_settings_from_env() -> tuple[list[str] | None, str]:
75
76
  return supported, default
76
77
 
77
78
 
79
+ def _database_url() -> str:
80
+ """``SM_DATABASE_URL``, defaulting like ``Settings.database_url``."""
81
+ return os.environ.get("SM_DATABASE_URL", "sqlite+aiosqlite:///./app.db")
82
+
83
+
78
84
  def _database_dialect() -> str:
79
85
  """Dialect name of ``SM_DATABASE_URL`` (``.env`` merged by the i18n loader first).
80
86
 
81
87
  Defaults to ``sqlite`` like ``Settings.database_url``. Reads the env var
82
88
  directly for the same reason the i18n settings do: no hosting import.
83
89
  """
84
- url = os.environ.get("SM_DATABASE_URL", "sqlite+aiosqlite:///./app.db")
90
+ url = _database_url()
85
91
  return url.split(":", 1)[0].split("+", 1)[0]
86
92
 
87
93
 
@@ -182,6 +188,7 @@ def main() -> int:
182
188
  database_dialect=_database_dialect(),
183
189
  )
184
190
  diagnostics.extend(override_findings)
191
+ diagnostics.extend(data_compatibility_diagnostics(modules, _database_url()))
185
192
  # A missing dependency only warns at a dev boot, but production refuses
186
193
  # to start with it (GH #501), so doctor reports it as what it will be.
187
194
  for finding in diagnostics:
@@ -0,0 +1,22 @@
1
+ """Optional database diagnostics for Doctor without requiring a host app import."""
2
+
3
+ import asyncio
4
+ import importlib.util
5
+ from collections.abc import Sequence
6
+
7
+ from simple_module_core.data_compatibility import checking_modules
8
+ from simple_module_core.diagnostics import Diagnostic
9
+ from simple_module_core.module import ModuleBase
10
+
11
+
12
+ def data_compatibility_diagnostics(
13
+ modules: Sequence[ModuleBase], database_url: str
14
+ ) -> list[Diagnostic]:
15
+ # Core-only installations have no database runtime to inspect.
16
+ if importlib.util.find_spec("simple_module_db") is None:
17
+ return []
18
+ if not checking_modules(modules):
19
+ return []
20
+ from simple_module_db.data_compatibility import database_compatibility_diagnostics
21
+
22
+ return asyncio.run(database_compatibility_diagnostics(modules, database_url))
@@ -0,0 +1,43 @@
1
+ """Semantic, read-only checks for upgrades in host-owned migration histories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, Literal
8
+
9
+ if TYPE_CHECKING:
10
+ from simple_module_core.module import ModuleBase
11
+
12
+
13
+ @dataclass(frozen=True, kw_only=True)
14
+ class DataCompatibilityIssue:
15
+ """An outstanding module upgrade, independent of Alembic revision ids."""
16
+
17
+ upgrade_id: str
18
+ message: str
19
+ severity: Literal["warning", "error"]
20
+
21
+ def remediation(self) -> str:
22
+ return f"smpy migrations prepare {self.upgrade_id} --head <host-head-or-label@head>"
23
+
24
+
25
+ async def collect_data_compatibility(
26
+ modules: Sequence[ModuleBase], db: Any
27
+ ) -> list[DataCompatibilityIssue]:
28
+ """Read module state without applying migrations or changing credentials."""
29
+ issues = []
30
+ for module in checking_modules(modules):
31
+ issues.extend(await module.check_data_compatibility(db))
32
+ return issues
33
+
34
+
35
+ def checking_modules(modules: Sequence[ModuleBase]) -> list[ModuleBase]:
36
+ """Modules that override the hook; the rest need no database session."""
37
+ from simple_module_core.module import ModuleBase
38
+
39
+ return [
40
+ module
41
+ for module in modules
42
+ if type(module).check_data_compatibility is not ModuleBase.check_data_compatibility
43
+ ]
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import logging
23
23
  from collections.abc import Awaitable, Callable, Sequence
24
+ from contextvars import ContextVar
24
25
  from dataclasses import dataclass
25
26
  from functools import cache
26
27
  from typing import Any, Protocol, runtime_checkable
@@ -33,6 +34,14 @@ logger = logging.getLogger(__name__)
33
34
  # subscribe time so an over-long name fails at boot, not on a publisher's flush.
34
35
  MAX_NAME_LENGTH = 255
35
36
 
37
+ # Set by delivery transports only while invoking a durable handler.
38
+ IN_DELIVERY: ContextVar[bool] = ContextVar("durable_event_in_delivery", default=False)
39
+
40
+
41
+ def in_delivery() -> bool:
42
+ """Whether this context runs in a durable handler (including child tasks)."""
43
+ return IN_DELIVERY.get()
44
+
36
45
 
37
46
  class DurableDeliveryUnavailable(RuntimeError): # noqa: N818 - name fixed by the design
38
47
  """``publish_durable`` was called but no durable transport is installed.
@@ -177,6 +186,7 @@ class DurableRegistry:
177
186
 
178
187
 
179
188
  __all__ = [
189
+ "IN_DELIVERY",
180
190
  "DurableContext",
181
191
  "DurableDeliveryUnavailable",
182
192
  "DurableHandler",
@@ -186,5 +196,6 @@ __all__ = [
186
196
  "deserialize_event",
187
197
  "event_key",
188
198
  "handler_name",
199
+ "in_delivery",
189
200
  "serialize_event",
190
201
  ]
@@ -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
+ IN_DELIVERY,
14
15
  DurableContext,
15
16
  DurableDeliveryUnavailable,
16
17
  DurableHandler,
@@ -160,4 +161,9 @@ class EventBus:
160
161
  subscription = self.durable.lookup(key, name)
161
162
  if subscription is None:
162
163
  continue
163
- await subscription.handler(event, ctx)
164
+ # Inline delivery is still a durable delivery for ``in_delivery()`` (#682).
165
+ token = IN_DELIVERY.set(True)
166
+ try:
167
+ await subscription.handler(event, ctx)
168
+ finally:
169
+ IN_DELIVERY.reset(token)
@@ -71,6 +71,12 @@ class FeatureFlagRegistry:
71
71
  else:
72
72
  self._tenant_overrides.pop((name, tenant_id), None)
73
73
 
74
+ def clear_tenant_overrides(self, tenant_id: str) -> None:
75
+ """Forget every override for a deleted tenant, including unknown flags."""
76
+ self._tenant_overrides = {
77
+ key: value for key, value in self._tenant_overrides.items() if key[1] != tenant_id
78
+ }
79
+
74
80
  def replace_overrides(
75
81
  self, system: dict[str, bool], tenants: dict[tuple[str, str], bool]
76
82
  ) -> None:
@@ -3,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from abc import ABC
6
+ from collections.abc import Sequence
6
7
  from pathlib import Path
7
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, Any
8
9
 
9
10
  from simple_module_core._request_hooks import RequestHooksMixin
11
+ from simple_module_core.data_compatibility import DataCompatibilityIssue
10
12
  from simple_module_core.module_meta import ModuleMeta
11
13
 
12
14
  if TYPE_CHECKING:
@@ -226,6 +228,14 @@ class ModuleBase(RequestHooksMixin, ABC):
226
228
 
227
229
  # ── Lifecycle ─────────────────────────────────────────────
228
230
 
231
+ async def check_data_compatibility(self, db: Any) -> Sequence[DataCompatibilityIssue]:
232
+ """Read-only upgrade checks before settings hydration and module startup.
233
+
234
+ Report semantic state, never demand an upstream Alembic revision id:
235
+ scaffolded hosts own their histories and may port an equivalent step.
236
+ """
237
+ return ()
238
+
229
239
  async def on_startup(self, app: FastAPI) -> None:
230
240
  """Called after all modules are registered, during app startup."""
231
241
 
@@ -25,10 +25,11 @@ typed ``Any``; the stock policy lives in ``simple_module_db.resource_policy``.
25
25
  from __future__ import annotations
26
26
 
27
27
  import re
28
- from collections.abc import Callable
28
+ from collections.abc import Callable, Mapping
29
29
  from dataclasses import dataclass, field
30
30
  from typing import Any, Protocol, runtime_checkable
31
31
 
32
+ from simple_module_core.deprecation import deprecate
32
33
  from simple_module_core.permissions import grants
33
34
 
34
35
  ACTION_VIEW = "view"
@@ -39,7 +40,13 @@ ACTION_DELETE = "delete"
39
40
  ACTION_SHARE = "share"
40
41
  """Granting someone else access; the ``sharing`` module checks it on the granter."""
41
42
 
43
+ _SHAREABLE_ACTIONS = deprecate(
44
+ "ResourceType.shareable_actions", remove_in="0.1.0", use="ResourceType.share_roles"
45
+ )
46
+
42
47
  _TYPE_NAME = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
48
+ # A share role id is stored in a 32-character column (``sharing`` ``ROLE_MAX``).
49
+ _ROLE_ID = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
43
50
 
44
51
 
45
52
  @dataclass(frozen=True)
@@ -87,13 +94,36 @@ class ResourceType:
87
94
  ``name`` is dotted and owned by the module (``"notes.note"``). ``select_by_id``
88
95
  builds the unfiltered ``SELECT`` for one row from its string id; callers
89
96
  always pass it through ``policy.filter`` before executing it.
97
+
98
+ ``share_roles`` maps each share role id this type allows to the actions it
99
+ implies (each must include ``view``; checked at registration). ``None``
100
+ means the ``sharing`` module's default view/edit roles. Pass the same
101
+ mapping to ``sharing``'s ``share_exists_for(..., roles=...)``.
102
+ ``shareable_actions`` is deprecated in favour of ``share_roles``.
90
103
  """
91
104
 
92
105
  name: str
93
106
  policy: ResourcePolicy
94
107
  select_by_id: StatementForId
95
- #: Actions a share may grant on this type, beyond :data:`ACTION_VIEW`.
96
- shareable_actions: tuple[str, ...] = field(default=(ACTION_VIEW, ACTION_EDIT))
108
+ #: Deprecated role allowlist; retained for view/edit subsets until 0.1.0.
109
+ shareable_actions: tuple[str, ...] | None = None
110
+ #: Role ids mapped to their implied actions; None uses sharing's view/edit default.
111
+ share_roles: Mapping[str, tuple[str, ...]] | None = field(
112
+ default=None, kw_only=True, hash=False
113
+ )
114
+
115
+ def __post_init__(self) -> None:
116
+ if self.shareable_actions is not None:
117
+ _SHAREABLE_ACTIONS.warn(stacklevel=3)
118
+ if self.share_roles is None:
119
+ object.__setattr__(
120
+ self,
121
+ "share_roles",
122
+ {
123
+ role: (ACTION_VIEW,) if role == ACTION_VIEW else (ACTION_VIEW, role)
124
+ for role in self.shareable_actions
125
+ },
126
+ )
97
127
 
98
128
 
99
129
  class ResourceTypeError(ValueError):
@@ -115,6 +145,17 @@ class ResourceTypeRegistry:
115
145
  raise ResourceTypeError(f"Resource type name {name!r} must look like 'module.thing'")
116
146
  if name in self._types:
117
147
  raise ResourceTypeError(f"Resource type {name!r} is registered twice")
148
+ if resource_type.share_roles is not None:
149
+ for role, actions in resource_type.share_roles.items():
150
+ if not isinstance(role, str) or not _ROLE_ID.fullmatch(role):
151
+ raise ResourceTypeError(
152
+ f"Resource type {name!r} share role {role!r} must be a lowercase "
153
+ "identifier of at most 32 characters"
154
+ )
155
+ if isinstance(actions, str) or ACTION_VIEW not in actions:
156
+ raise ResourceTypeError(
157
+ f"Resource type {name!r} share role {role!r} must include {ACTION_VIEW!r}"
158
+ )
118
159
  self._types[name] = resource_type
119
160
 
120
161
  def get(self, name: str) -> ResourceType | None:
@@ -0,0 +1,45 @@
1
+ """Both Doctor entry points report upgrade ids and return a failing status (#711)."""
2
+
3
+ import pytest
4
+ import sqlalchemy as sa
5
+ from simple_module_core import ModuleBase, ModuleMeta
6
+ from simple_module_core import __main__ as doctor
7
+ from simple_module_core.data_compatibility import DataCompatibilityIssue
8
+
9
+
10
+ @pytest.mark.parametrize("entrypoint", ["make", "smpy"])
11
+ def test_core_doctor_reports_semantic_data_errors(tmp_path, monkeypatch, capsys, entrypoint):
12
+ path = tmp_path / "doctor.db"
13
+ engine = sa.create_engine(f"sqlite:///{path}")
14
+ with engine.begin() as conn:
15
+ conn.execute(sa.text("CREATE TABLE alembic_version (version_num VARCHAR(32))"))
16
+ conn.execute(sa.text("INSERT INTO alembic_version VALUES ('local')"))
17
+ engine.dispose()
18
+
19
+ class Module(ModuleBase):
20
+ meta = ModuleMeta(name="Example")
21
+
22
+ async def check_data_compatibility(self, db):
23
+ assert await db.scalar(sa.text("SELECT version_num FROM alembic_version")) == "local"
24
+ return [
25
+ DataCompatibilityIssue(
26
+ upgrade_id="example.v1", message="Upgrade required", severity="error"
27
+ )
28
+ ]
29
+
30
+ monkeypatch.setenv("SM_DATABASE_URL", f"sqlite+aiosqlite:///{path}")
31
+ monkeypatch.setattr(doctor, "discover_modules", lambda strict: [Module()])
32
+ monkeypatch.setattr(doctor, "parse_dotenv", dict)
33
+ monkeypatch.setattr(doctor, "run_diagnostics", lambda *a, **kw: [])
34
+ if entrypoint == "make":
35
+ assert doctor.main() == 1
36
+ output = capsys.readouterr().err
37
+ else:
38
+ from simple_module_cli.cli import app
39
+ from typer.testing import CliRunner
40
+
41
+ result = CliRunner().invoke(app, ["doctor"])
42
+ assert result.exit_code == 1
43
+ output = result.output
44
+ assert "example.v1" in output
45
+ assert "smpy migrations prepare example.v1" in output
@@ -0,0 +1,19 @@
1
+ """Inline durable dispatch marks its handlers as in-delivery (#682)."""
2
+
3
+ from simple_module_core.durable_events import in_delivery
4
+ from simple_module_core.events import EventBus
5
+ from simple_module_core.tenant_events import TenantDeleted
6
+
7
+
8
+ async def test_inline_durable_handler_sees_in_delivery():
9
+ bus = EventBus()
10
+ seen: list[bool] = []
11
+
12
+ async def handler(event, ctx):
13
+ seen.append(in_delivery())
14
+
15
+ bus.subscribe_durable(TenantDeleted, handler, name="probe")
16
+ assert in_delivery() is False
17
+ await bus.publish_durable_or_inline(TenantDeleted("id", "slug", "Name"), object())
18
+ assert seen == [True]
19
+ assert in_delivery() is False