simple-module-core 0.0.36__tar.gz → 0.0.37__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 (92) hide show
  1. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/.gitignore +1 -0
  2. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/PKG-INFO +1 -1
  3. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/pyproject.toml +1 -1
  4. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/__init__.py +8 -0
  5. simple_module_core-0.0.37/simple_module_core/diagnostics/_db_extensions.py +39 -0
  6. simple_module_core-0.0.37/simple_module_core/diagnostics/_heavy_task_imports.py +93 -0
  7. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_module.py +3 -0
  8. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_runner.py +4 -1
  9. simple_module_core-0.0.37/simple_module_core/durable_events.py +188 -0
  10. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/events.py +50 -0
  11. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/module.py +13 -13
  12. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/permissions.py +46 -6
  13. simple_module_core-0.0.37/tests/test_db_extensions_diagnostics.py +44 -0
  14. simple_module_core-0.0.37/tests/test_durable_events.py +216 -0
  15. simple_module_core-0.0.37/tests/test_heavy_task_imports_diagnostics.py +70 -0
  16. simple_module_core-0.0.37/tests/test_permission_sources.py +133 -0
  17. simple_module_core-0.0.37/tests/test_permissions_admin_principal.py +23 -0
  18. simple_module_core-0.0.36/tests/test_permission_sources.py +0 -52
  19. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/LICENSE +0 -0
  20. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/README.md +0 -0
  21. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/__main__.py +0 -0
  22. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/_i18n_flatten.py +0 -0
  23. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/audit_links.py +0 -0
  24. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/body_limits.py +0 -0
  25. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/csp.py +0 -0
  26. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/design_packs.py +0 -0
  27. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/__init__.py +0 -0
  28. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_coupling.py +0 -0
  29. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_css.py +0 -0
  30. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_expression_index.py +0 -0
  31. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_i18n.py +0 -0
  32. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  33. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  34. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_migration.py +0 -0
  35. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_pages.py +0 -0
  36. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_tenancy.py +0 -0
  37. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_types.py +0 -0
  38. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/discovery.py +0 -0
  39. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/dotenv.py +0 -0
  40. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/environments.py +0 -0
  41. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/exceptions.py +0 -0
  42. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/feature_flags.py +0 -0
  43. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/health.py +0 -0
  44. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/i18n.py +0 -0
  45. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/i18n_overrides.py +0 -0
  46. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/invalidation.py +0 -0
  47. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/menu.py +0 -0
  48. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/module_meta.py +0 -0
  49. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/public_routes.py +0 -0
  50. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/py.typed +0 -0
  51. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/rate_limit.py +0 -0
  52. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/redirect_safety.py +0 -0
  53. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/services.py +0 -0
  54. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/settings_base.py +0 -0
  55. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/setup_steps.py +0 -0
  56. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/tenancy.py +0 -0
  57. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/versioning.py +0 -0
  58. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_audit_links.py +0 -0
  59. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_auth_provider_selection.py +0 -0
  60. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_csp_registry.py +0 -0
  61. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_css_diagnostics.py +0 -0
  62. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_css_scanner.py +0 -0
  63. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_design_packs.py +0 -0
  64. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_diagnostics.py +0 -0
  65. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_diagnostics_tenancy.py +0 -0
  66. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_discovery.py +0 -0
  67. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_doctor_cli_overrides.py +0 -0
  68. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_dotenv.py +0 -0
  69. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_environments.py +0 -0
  70. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_events.py +0 -0
  71. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_expression_index_diagnostics.py +0 -0
  72. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_feature_flags.py +0 -0
  73. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_feature_flags_decorator.py +0 -0
  74. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_health_registry.py +0 -0
  75. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_i18n.py +0 -0
  76. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_i18n_diagnostics.py +0 -0
  77. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_i18n_overrides.py +0 -0
  78. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_invalidation.py +0 -0
  79. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_invalidation_handlers.py +0 -0
  80. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_menu.py +0 -0
  81. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_module_base.py +0 -0
  82. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_module_diagnostics.py +0 -0
  83. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_permissions.py +0 -0
  84. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_public_routes.py +0 -0
  85. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_public_routes_rate_precedence.py +0 -0
  86. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_redirect_safety.py +0 -0
  87. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_services.py +0 -0
  88. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_settings_base.py +0 -0
  89. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_sm003_page_render.py +0 -0
  90. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_tenant_exists.py +0 -0
  91. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_tenant_roles.py +0 -0
  92. {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_versioning.py +0 -0
@@ -64,3 +64,4 @@ host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
66
  .verify/
67
+ /openapi.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_core
3
- Version: 0.0.36
3
+ Version: 0.0.37
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.36"
3
+ version = "0.0.37"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -18,6 +18,11 @@ from simple_module_core.discovery import (
18
18
  select_auth_provider,
19
19
  topological_sort,
20
20
  )
21
+ from simple_module_core.durable_events import (
22
+ DurableContext,
23
+ DurableDeliveryUnavailable,
24
+ DurableTransport,
25
+ )
21
26
  from simple_module_core.events import Event, EventBus
22
27
  from simple_module_core.exceptions import (
23
28
  CircularDependencyError,
@@ -64,6 +69,9 @@ __all__ = [
64
69
  "DesignPack",
65
70
  "DesignPackRegistry",
66
71
  "DiagnosticLevel",
72
+ "DurableContext",
73
+ "DurableDeliveryUnavailable",
74
+ "DurableTransport",
67
75
  "Event",
68
76
  "EventBus",
69
77
  "FeatureFlagDefinition",
@@ -0,0 +1,39 @@
1
+ """Database-extension diagnostics (SM029).
2
+
3
+ A module that declares ``db_extensions`` needs Postgres: SQLite has no
4
+ extensions, so ``ensure_db_extensions`` refuses to run there. SM029 says so
5
+ up front instead of at the first ``alembic upgrade``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
13
+
14
+ if TYPE_CHECKING:
15
+ from simple_module_core.module import ModuleBase
16
+
17
+
18
+ def check_db_extensions_need_postgres(mod: ModuleBase, dialect: str | None) -> list[Diagnostic]:
19
+ """SM029: INFO when ``mod`` declares extensions and the database is SQLite."""
20
+ if dialect != "sqlite":
21
+ return []
22
+ extensions = mod.db_extensions()
23
+ if not extensions:
24
+ return []
25
+ return [
26
+ Diagnostic(
27
+ level=DiagnosticLevel.INFO,
28
+ code="SM029",
29
+ message=(
30
+ f"Module declares Postgres extensions ({', '.join(extensions)}) but the "
31
+ "database is SQLite; migrations will refuse to run until it is Postgres"
32
+ ),
33
+ module_name=mod.meta.name,
34
+ suggestion="Point SM_DATABASE_URL at Postgres (see .env.example)",
35
+ )
36
+ ]
37
+
38
+
39
+ __all__ = ["check_db_extensions_need_postgres"]
@@ -0,0 +1,93 @@
1
+ """SM028: flag a ``tasks.py`` that imports a heavy package at module level.
2
+
3
+ Celery imports every installed module's ``tasks.py`` in each process that
4
+ loads the module (the web app, every worker, beat). A top-level
5
+ ``import torch`` there makes all of them pay for it, and defeats the per-worker
6
+ subset (``SM_BG_TASKS_WORKER_QUEUES`` / ``include_modules``), which exists so a
7
+ light worker never imports the heavy one. Import the package inside the task
8
+ body instead. Uses ``ast`` and never imports the module being checked.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
18
+
19
+ if TYPE_CHECKING:
20
+ from simple_module_core.module import ModuleBase
21
+
22
+ HEAVY_PACKAGES = frozenset(
23
+ {"torch", "tensorflow", "ultralytics", "osgeo", "rasterio", "cv2", "sklearn"}
24
+ )
25
+
26
+
27
+ def _heavy_roots(node: ast.stmt) -> list[str]:
28
+ if isinstance(node, ast.Import):
29
+ names = [alias.name for alias in node.names]
30
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
31
+ names = [node.module]
32
+ else:
33
+ return []
34
+ return [n.split(".", 1)[0] for n in names if n.split(".", 1)[0] in HEAVY_PACKAGES]
35
+
36
+
37
+ def _top_level_statements(body: list[ast.stmt]) -> list[ast.stmt]:
38
+ """Statements that run at import time, descending into ``if``/``try``/``with``.
39
+
40
+ Function and class bodies are skipped: an import there is deferred, which is
41
+ the fix. ``if TYPE_CHECKING:`` blocks are skipped too — never executed.
42
+ """
43
+ out: list[ast.stmt] = []
44
+ for node in body:
45
+ out.append(node)
46
+ if isinstance(node, ast.If):
47
+ test = ast.unparse(node.test)
48
+ if test in ("TYPE_CHECKING", "typing.TYPE_CHECKING"):
49
+ out.extend(_top_level_statements(node.orelse))
50
+ continue
51
+ out.extend(_top_level_statements(node.body + node.orelse))
52
+ elif isinstance(node, ast.Try):
53
+ out.extend(
54
+ _top_level_statements(
55
+ node.body + [s for h in node.handlers for s in h.body] + node.orelse
56
+ )
57
+ )
58
+ elif isinstance(node, ast.With):
59
+ out.extend(_top_level_statements(node.body))
60
+ return out
61
+
62
+
63
+ def check_heavy_task_imports(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]:
64
+ """Warn when ``tasks.py`` imports a heavy package outside a function."""
65
+ tasks_py = src_dir / "tasks.py"
66
+ if not tasks_py.is_file():
67
+ return []
68
+ try:
69
+ tree = ast.parse(tasks_py.read_text(), filename=str(tasks_py))
70
+ except (OSError, SyntaxError):
71
+ return []
72
+
73
+ diags: list[Diagnostic] = []
74
+ for node in _top_level_statements(tree.body):
75
+ for root in _heavy_roots(node):
76
+ diags.append(
77
+ Diagnostic(
78
+ level=DiagnosticLevel.WARNING,
79
+ code="SM028",
80
+ message=(
81
+ f"tasks.py imports heavy package '{root}' at module level — every "
82
+ "process that loads this module's tasks pays for it"
83
+ ),
84
+ module_name=mod.meta.name,
85
+ file=f"{tasks_py}:{node.lineno}",
86
+ suggestion=(
87
+ f"Move `import {root}` inside the task function, so only a worker "
88
+ "that actually runs the task loads it (and a queue-restricted "
89
+ "worker never does)."
90
+ ),
91
+ )
92
+ )
93
+ return diags
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
8
8
 
9
9
  from simple_module_core.diagnostics._coupling import check_framework_module_coupling
10
10
  from simple_module_core.diagnostics._css import check_module_css
11
+ from simple_module_core.diagnostics._heavy_task_imports import check_heavy_task_imports
11
12
  from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
12
13
  from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
13
14
  from simple_module_core.diagnostics._pages import check_pages, find_render_calls
@@ -42,6 +43,7 @@ class ModuleDiagnostics:
42
43
  diagnostics.extend(check_js_workspace_files(mod, src_dir))
43
44
  diagnostics.extend(check_inertia_api_calls(mod, src_dir))
44
45
  diagnostics.extend(check_module_css(mod, src_dir))
46
+ diagnostics.extend(check_heavy_task_imports(mod, src_dir))
45
47
 
46
48
  return diagnostics
47
49
 
@@ -109,6 +111,7 @@ class ModuleDiagnostics:
109
111
  "template_dirs",
110
112
  "static_mounts",
111
113
  "locale_dirs",
114
+ "db_extensions",
112
115
  "on_startup",
113
116
  "on_shutdown",
114
117
  )
@@ -7,6 +7,7 @@ import sys
7
7
  from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
+ from simple_module_core.diagnostics._db_extensions import check_db_extensions_need_postgres
10
11
  from simple_module_core.diagnostics._expression_index import (
11
12
  check_expression_indexes_unverifiable,
12
13
  module_all_tables,
@@ -42,7 +43,8 @@ def run_diagnostics(
42
43
  :class:`I18nDiagnostics`). ``i18n_extra_sources`` lets callers include
43
44
  host/ui locale dirs that aren't owned by a ``ModuleBase``.
44
45
  ``database_dialect`` (``"sqlite"``, ``"postgresql"``...) enables SM026, which
45
- flags expression indexes a SQLite database cannot verify.
46
+ flags expression indexes a SQLite database cannot verify, and SM029, which flags
47
+ modules declaring ``db_extensions`` on SQLite.
46
48
  ``i18n_unknown_overrides`` (``I18nRegistry.unknown_override_keys``) enables SM027.
47
49
  """
48
50
  diagnostics = ModuleDiagnostics().run(modules)
@@ -54,6 +56,7 @@ def run_diagnostics(
54
56
  module_all_tables(mod), database_dialect, mod.meta.name
55
57
  )
56
58
  )
59
+ diagnostics.extend(check_db_extensions_need_postgres(mod, database_dialect))
57
60
 
58
61
  if i18n_default_locale:
59
62
  from simple_module_core.diagnostics._i18n import I18nDiagnostics
@@ -0,0 +1,188 @@
1
+ """Durable (at-least-once, cross-process) event delivery primitives.
2
+
3
+ ``EventBus.publish`` is in-process and forgets an event the moment the process
4
+ dies. A *durable* subscription is delivered by a transport instead: the event is
5
+ written to an outbox in the publisher's own transaction and a worker runs the
6
+ handler later, retrying until it succeeds. The framework only defines the
7
+ contract — the subscription registry, the event (de)serialisation and the
8
+ transport protocol. ``background_tasks`` ships the transport.
9
+
10
+ Handlers are called as ``await handler(event, ctx)`` where ``ctx`` is a
11
+ :class:`DurableContext`. Delivery is at-least-once; a handler that writes only
12
+ through ``ctx.db`` gets exactly-once *database* effects, because the transport
13
+ commits that session in the same transaction that marks the delivery done. The
14
+ handler must therefore not commit ``ctx.db`` itself (the ``background_tasks``
15
+ transport refuses it), and anything it does outside ``ctx.db`` is
16
+ at-least-once and must be idempotent.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from collections.abc import Awaitable, Callable, Sequence
23
+ from dataclasses import dataclass
24
+ from functools import cache
25
+ from typing import Any, Protocol, runtime_checkable
26
+
27
+ from pydantic import TypeAdapter
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Width of the outbox's ``event_key`` / ``handler_name`` columns. Checked at
32
+ # subscribe time so an over-long name fails at boot, not on a publisher's flush.
33
+ MAX_NAME_LENGTH = 255
34
+
35
+
36
+ class DurableDeliveryUnavailable(RuntimeError): # noqa: N818 - name fixed by the design
37
+ """``publish_durable`` was called but no durable transport is installed.
38
+
39
+ Durable delivery needs an outbox and a worker; the ``background_tasks``
40
+ module installs both. Raised instead of silently degrading to in-process
41
+ delivery, which would drop the guarantee the caller asked for.
42
+ """
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class DurableContext:
47
+ """What a durable handler receives next to the event.
48
+
49
+ ``db`` is an ``AsyncSession`` the transport commits together with marking
50
+ the delivery done — write through it (flush, never commit) and the effect
51
+ lands exactly once.
52
+ ``attempt`` is 1 on the first delivery and grows on each retry.
53
+ """
54
+
55
+ db: Any
56
+ attempt: int
57
+
58
+
59
+ type DurableHandler = Callable[[Any, DurableContext], Awaitable[None]]
60
+
61
+
62
+ @runtime_checkable
63
+ class DurableTransport(Protocol):
64
+ """Persists an event for later delivery to the named durable handlers.
65
+
66
+ ``enqueue`` must write in *db*'s transaction, so that a rollback of the
67
+ publisher's transaction also discards the event.
68
+ """
69
+
70
+ async def enqueue(self, event: Any, handler_names: Sequence[str], db: Any) -> None: ...
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class DurableSubscription:
75
+ event_type: type
76
+ name: str
77
+ handler: DurableHandler
78
+
79
+
80
+ def event_key(event_type: type) -> str:
81
+ """The stable wire name of an event type (same key ``EventBus`` uses)."""
82
+ return f"{event_type.__module__}.{event_type.__qualname__}"
83
+
84
+
85
+ def handler_name(handler: Callable[..., Any]) -> str:
86
+ """Default durable name: the handler's import path."""
87
+ module = getattr(handler, "__module__", None) or "unknown"
88
+ qualname = getattr(handler, "__qualname__", None) or repr(handler)
89
+ return f"{module}.{qualname}"
90
+
91
+
92
+ @cache
93
+ def _adapter(event_type: type) -> TypeAdapter[Any]:
94
+ return TypeAdapter(event_type)
95
+
96
+
97
+ def check_serializable(event_type: type) -> None:
98
+ """Fail fast at subscribe time if *event_type* cannot travel as JSON.
99
+
100
+ The class itself must be decorated with ``@dataclass``: a bare subclass of
101
+ one inherits ``is_dataclass`` but silently drops its own annotated fields.
102
+ """
103
+ if not (isinstance(event_type, type) and "__dataclass_fields__" in vars(event_type)):
104
+ raise TypeError(f"Durable events must be @dataclass classes; got {event_type!r}")
105
+ try:
106
+ adapter = _adapter(event_type)
107
+ if not getattr(adapter, "pydantic_complete", True):
108
+ # An annotation pydantic could not resolve (a forward reference to
109
+ # a name not importable from the event's module).
110
+ adapter.rebuild(raise_errors=True)
111
+ except Exception as exc:
112
+ raise TypeError(
113
+ f"Event {event_key(event_type)} cannot be serialised for durable delivery: {exc}"
114
+ ) from exc
115
+
116
+
117
+ def serialize_event(event: Any) -> dict[str, Any]:
118
+ """Event → JSON-compatible dict. UUID, datetime, Enum, Decimal and nested
119
+ dataclasses become their JSON forms."""
120
+ check_serializable(type(event))
121
+ return _adapter(type(event)).dump_python(event, mode="json")
122
+
123
+
124
+ def deserialize_event(event_type: type, payload: dict[str, Any]) -> Any:
125
+ """JSON dict → an instance of *event_type* (inverse of :func:`serialize_event`)."""
126
+ return _adapter(event_type).validate_python(payload)
127
+
128
+
129
+ class DurableRegistry:
130
+ """Durable subscriptions of one ``EventBus``, keyed by (event key, name)."""
131
+
132
+ def __init__(self) -> None:
133
+ self._subs: dict[str, dict[str, DurableSubscription]] = {}
134
+ self._types: dict[str, type] = {}
135
+
136
+ def add(self, event_type: type, handler: DurableHandler, name: str | None) -> str:
137
+ if not callable(handler):
138
+ raise TypeError("Durable handler must be callable")
139
+ check_serializable(event_type)
140
+ key = event_key(event_type)
141
+ resolved = name or handler_name(handler)
142
+ if len(resolved) > MAX_NAME_LENGTH or len(key) > MAX_NAME_LENGTH:
143
+ raise ValueError(
144
+ f"Durable names are limited to {MAX_NAME_LENGTH} characters; "
145
+ f"pass a shorter name= (got {resolved!r} for {key})"
146
+ )
147
+ if name is None and ("<lambda>" in resolved or "<locals>" in resolved):
148
+ logger.warning(
149
+ "Durable handler %s has an unstable default name; pass name= so "
150
+ "undelivered rows survive a refactor",
151
+ resolved,
152
+ )
153
+ by_name = self._subs.setdefault(key, {})
154
+ existing = by_name.get(resolved)
155
+ if existing is not None and existing.handler != handler:
156
+ raise ValueError(
157
+ f"Durable handler name {resolved!r} is already subscribed to {key}; "
158
+ "pass a distinct name="
159
+ )
160
+ by_name[resolved] = DurableSubscription(event_type, resolved, handler)
161
+ self._types[key] = event_type
162
+ return resolved
163
+
164
+ def handler_names(self, event_type: type) -> list[str]:
165
+ return list(self._subs.get(event_key(event_type), {}))
166
+
167
+ def lookup(self, key: str, name: str) -> DurableSubscription | None:
168
+ return self._subs.get(key, {}).get(name)
169
+
170
+ def event_type(self, key: str) -> type | None:
171
+ return self._types.get(key)
172
+
173
+ def __len__(self) -> int:
174
+ return sum(len(v) for v in self._subs.values())
175
+
176
+
177
+ __all__ = [
178
+ "DurableContext",
179
+ "DurableDeliveryUnavailable",
180
+ "DurableHandler",
181
+ "DurableRegistry",
182
+ "DurableSubscription",
183
+ "DurableTransport",
184
+ "deserialize_event",
185
+ "event_key",
186
+ "handler_name",
187
+ "serialize_event",
188
+ ]
@@ -10,6 +10,13 @@ from typing import Any
10
10
 
11
11
  from pyee.asyncio import AsyncIOEventEmitter
12
12
 
13
+ from simple_module_core.durable_events import (
14
+ DurableDeliveryUnavailable,
15
+ DurableHandler,
16
+ DurableRegistry,
17
+ DurableTransport,
18
+ )
19
+
13
20
  logger = logging.getLogger(__name__)
14
21
 
15
22
  EventHandler = Callable[..., Coroutine[Any, Any, None]]
@@ -36,11 +43,17 @@ class EventBus:
36
43
 
37
44
  * ``publish`` — awaits all handlers via ``asyncio.gather`` (error-isolated).
38
45
  * ``publish_nowait`` — fire-and-forget via pyee's event-loop scheduling.
46
+ * ``publish_durable`` — written to an outbox in the caller's transaction and
47
+ delivered at-least-once by the installed :class:`DurableTransport`
48
+ (``background_tasks``) to handlers registered with ``subscribe_durable``.
39
49
  """
40
50
 
41
51
  def __init__(self) -> None:
42
52
  self._emitter = AsyncIOEventEmitter()
43
53
  self._emitter.on("error", self._on_emitter_error)
54
+ self.durable = DurableRegistry()
55
+ # Installed by the module that owns the outbox (background_tasks).
56
+ self.durable_transport: DurableTransport | None = None
44
57
 
45
58
  @staticmethod
46
59
  def _on_emitter_error(error: Exception) -> None:
@@ -89,3 +102,40 @@ class EventBus:
89
102
  handlers as tasks on the running loop.
90
103
  """
91
104
  self._emitter.emit(self._event_key(type(event)), event)
105
+
106
+ def subscribe_durable(
107
+ self, event_type: type[Event], handler: DurableHandler, *, name: str | None = None
108
+ ) -> str:
109
+ """Register a durable handler, called as ``await handler(event, ctx)``.
110
+
111
+ *name* defaults to the handler's import path. Delivery state is keyed by
112
+ it, so it must stay stable across deploys: renaming a handler strands
113
+ its undelivered rows (they retry as "unknown handler", then go
114
+ ``dead``). Returns the resolved name.
115
+
116
+ Durable subscriptions are also built in the worker, which calls
117
+ ``register_event_handlers(bus, None)`` — register them before any
118
+ ``if app is None: return``.
119
+ """
120
+ resolved = self.durable.add(event_type, handler, name)
121
+ logger.debug("Durable subscription %s on %s", resolved, event_type.__name__)
122
+ return resolved
123
+
124
+ async def publish_durable(self, event: Event, db: Any) -> None:
125
+ """Persist *event* for every durable handler in *db*'s transaction.
126
+
127
+ Nothing is delivered unless that transaction commits. In-process
128
+ ``subscribe`` handlers are **not** called; publish separately if you
129
+ want both. Raises :class:`DurableDeliveryUnavailable` when no transport
130
+ is installed (``background_tasks`` not installed or not started).
131
+ """
132
+ transport = self.durable_transport
133
+ if transport is None:
134
+ raise DurableDeliveryUnavailable(
135
+ f"Cannot publish {type(event).__name__} durably: no durable transport "
136
+ "is installed. Install and enable the background_tasks module."
137
+ )
138
+ names = self.durable.handler_names(type(event))
139
+ if not names:
140
+ return
141
+ await transport.enqueue(event, names, db)
@@ -234,15 +234,11 @@ class ModuleBase(ABC):
234
234
  """Add middleware to the application.
235
235
 
236
236
  Called after core middleware (session, security headers) positioning
237
- is established but before the app starts. Modules that need to
238
- inject middleware (e.g. auth) override this method.
237
+ is established but before the app starts.
239
238
  """
240
239
 
241
240
  def register_exception_handlers(self, app: FastAPI) -> None:
242
- """Register custom exception handlers on the application.
243
-
244
- Called after framework exception handlers are set up.
245
- """
241
+ """Register custom exception handlers (after the framework's own)."""
246
242
 
247
243
  # ── Asset contribution (templates & static files) ─────────
248
244
 
@@ -253,19 +249,15 @@ class ModuleBase(ABC):
253
249
  Jinja2 loader, so templates in ``<module>/templates/`` become resolvable
254
250
  from any module. Return an empty list (default) to contribute none.
255
251
 
256
- Each path is typically computed via
257
- ``importlib.resources.files(__package__) / "templates"`` so it resolves
258
- correctly when the module is installed as a PyPI package.
252
+ Typically ``importlib.resources.files(__package__) / "templates"``.
259
253
  """
260
254
  return []
261
255
 
262
256
  def static_mounts(self) -> dict[str, Path]:
263
257
  """Return static file mounts this module contributes.
264
258
 
265
- Mapping of URL prefix → filesystem directory. The host mounts each entry
266
- via ``StaticFiles`` during app boot. Convention: prefix with
267
- ``/modules/<name>/static`` to avoid collisions with the host's own
268
- ``/static`` mount. Return an empty dict (default) to contribute none.
259
+ Mapping of URL prefix → directory, mounted via ``StaticFiles`` at boot.
260
+ Convention: prefix with ``/modules/<name>/static``. Default: none.
269
261
  """
270
262
  return {}
271
263
 
@@ -287,6 +279,14 @@ class ModuleBase(ABC):
287
279
  """
288
280
  return {}
289
281
 
282
+ def db_extensions(self) -> list[str]:
283
+ """Postgres extensions this module needs (``["ltree", "pg_trgm"]``).
284
+
285
+ Names match ``^[a-z][a-z0-9_-]*$``. ``ensure_db_extensions`` creates them
286
+ before migrations run; on SQLite that raises (doctor SM029 warns early).
287
+ """
288
+ return []
289
+
290
290
  # ── Lifecycle ─────────────────────────────────────────────
291
291
 
292
292
  async def on_startup(self, app: FastAPI) -> None:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import time
6
7
  from collections.abc import Awaitable, Callable, Collection, Iterable
7
8
  from dataclasses import dataclass, field
8
9
  from typing import Any
@@ -34,6 +35,21 @@ def is_admin(roles: list[str] | None) -> bool:
34
35
  return bool(roles) and ADMIN_ROLE in roles
35
36
 
36
37
 
38
+ def is_admin_principal(user: Any) -> bool:
39
+ """Whether *user* may use the admin role's shortcuts — admin and not a token.
40
+
41
+ A principal carrying ``scopes`` (an ``api_tokens`` credential) is capped
42
+ at those scopes even when its owner is an admin, so every check that
43
+ admits admins on the role alone must use this rather than
44
+ :func:`is_admin`; otherwise an admin's ``feature_flags.view`` token would
45
+ still open the Doctor page. Duck-typed: ``UserContext`` lives in the auth
46
+ module, which framework code must not import.
47
+ """
48
+ if user is None or getattr(user, "scopes", None) is not None:
49
+ return False
50
+ return is_admin(getattr(user, "roles", None))
51
+
52
+
37
53
  def grants(held: Collection[str], required: str) -> bool:
38
54
  """Whether a principal holding *held* satisfies *required*.
39
55
 
@@ -80,7 +96,11 @@ class PermissionRegistry:
80
96
  every mutation.
81
97
  """
82
98
 
83
- def __init__(self) -> None:
99
+ #: Seconds a failed source is answered as empty before it is called again.
100
+ SOURCE_RETRY_SECONDS = 30.0
101
+
102
+ def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None:
103
+ self._clock = clock
84
104
  self._groups: dict[str, PermissionGroup] = {}
85
105
  self._role_map: dict[str, set[str]] = {}
86
106
  # Admin-editor grants (DB-backed). Kept apart from ``_role_map`` (code
@@ -91,6 +111,8 @@ class PermissionRegistry:
91
111
  self._grant_sources: list[GrantSource] = []
92
112
  self._sources: dict[str, PermissionSourceProvider] = {}
93
113
  self._source_cache: dict[str, tuple[list[str], dict[str, str]]] = {}
114
+ self._source_failed_at: dict[str, float] = {} # name → clock() of last failure
115
+ self._all_permissions_expires: float | None = None
94
116
 
95
117
  def _invalidate(self) -> None:
96
118
  self._all_permissions_cache = None
@@ -105,21 +127,28 @@ class PermissionRegistry:
105
127
  is **sync** and should read a module-maintained in-memory cache — the
106
128
  registry is consulted on every request. Its output is cached here until
107
129
  :meth:`invalidate_source` is called. A provider that raises is logged
108
- and contributes nothing.
130
+ once and contributes nothing for ``SOURCE_RETRY_SECONDS``, then is
131
+ retried — a transient failure recovers on its own (GH #517).
109
132
  """
110
133
  self._sources[name] = provider
111
134
  self._source_cache.pop(name, None)
135
+ self._source_failed_at.pop(name, None)
112
136
  self._invalidate()
113
137
 
114
138
  def invalidate_source(self, name: str) -> None:
115
- """Drop the cached output of source *name*; re-read on next access."""
139
+ """Drop the cached output (or failure) of source *name*; re-read on next access."""
116
140
  self._source_cache.pop(name, None)
141
+ self._source_failed_at.pop(name, None)
117
142
  self._invalidate()
118
143
 
119
144
  def _source_output(self, name: str) -> tuple[list[str], dict[str, str]]:
120
145
  cached = self._source_cache.get(name)
121
146
  if cached is not None:
122
147
  return cached
148
+ failed_at = self._source_failed_at.get(name)
149
+ if failed_at is not None and self._clock() - failed_at < self.SOURCE_RETRY_SECONDS:
150
+ # In the retry window: empty, uncalled, no traceback per request.
151
+ return [], {}
123
152
  keys: list[str] = []
124
153
  labels: dict[str, str] = {}
125
154
  try:
@@ -131,8 +160,12 @@ class PermissionRegistry:
131
160
  keys.append(key)
132
161
  labels[key] = label
133
162
  except Exception:
134
- logger.exception("Permission source %r failed; contributing nothing", name)
135
- keys, labels = [], {}
163
+ logger.exception("Permission source %r failed; contributing nothing until retry", name)
164
+ # Remembered for the retry window only: a transient failure must
165
+ # not hide the source until someone invalidates it.
166
+ self._source_failed_at[name] = self._clock()
167
+ return [], {}
168
+ self._source_failed_at.pop(name, None)
136
169
  out = (sorted(set(keys)), labels)
137
170
  self._source_cache[name] = out
138
171
  return out
@@ -161,11 +194,18 @@ class PermissionRegistry:
161
194
  @property
162
195
  def all_permissions(self) -> list[str]:
163
196
  """All registered permission strings, sorted."""
164
- if self._all_permissions_cache is None:
197
+ expires = self._all_permissions_expires
198
+ if self._all_permissions_cache is None or (expires and self._clock() >= expires):
165
199
  perms: set[str] = set()
166
200
  for group in self.groups:
167
201
  perms.update(group.permissions)
168
202
  self._all_permissions_cache = sorted(perms)
203
+ # A snapshot taken while a source is failing lives only until that
204
+ # source's retry, so a recovery shows up without an invalidation.
205
+ failed = self._source_failed_at.values()
206
+ self._all_permissions_expires = (
207
+ min(failed) + self.SOURCE_RETRY_SECONDS if failed else None
208
+ )
169
209
  return self._all_permissions_cache
170
210
 
171
211
  @property