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.
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/.gitignore +1 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/PKG-INFO +1 -1
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/pyproject.toml +1 -1
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/__init__.py +8 -0
- simple_module_core-0.0.37/simple_module_core/diagnostics/_db_extensions.py +39 -0
- simple_module_core-0.0.37/simple_module_core/diagnostics/_heavy_task_imports.py +93 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_module.py +3 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_runner.py +4 -1
- simple_module_core-0.0.37/simple_module_core/durable_events.py +188 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/events.py +50 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/module.py +13 -13
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/permissions.py +46 -6
- simple_module_core-0.0.37/tests/test_db_extensions_diagnostics.py +44 -0
- simple_module_core-0.0.37/tests/test_durable_events.py +216 -0
- simple_module_core-0.0.37/tests/test_heavy_task_imports_diagnostics.py +70 -0
- simple_module_core-0.0.37/tests/test_permission_sources.py +133 -0
- simple_module_core-0.0.37/tests/test_permissions_admin_principal.py +23 -0
- simple_module_core-0.0.36/tests/test_permission_sources.py +0 -52
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/LICENSE +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/README.md +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/__main__.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/_i18n_flatten.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/audit_links.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/body_limits.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_expression_index.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_tenancy.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/i18n.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/i18n_overrides.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/invalidation.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/menu.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/module_meta.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/rate_limit.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/redirect_safety.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/services.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/settings_base.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/setup_steps.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/tenancy.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_audit_links.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_diagnostics_tenancy.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_doctor_cli_overrides.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_events.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_expression_index_diagnostics.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_i18n_overrides.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_invalidation.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_invalidation_handlers.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_public_routes_rate_precedence.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_redirect_safety.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_services.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_settings_base.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_sm003_page_render.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_tenant_exists.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_tenant_roles.py +0 -0
- {simple_module_core-0.0.36 → simple_module_core-0.0.37}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.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
|
|
@@ -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
|
{simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_module.py
RENAMED
|
@@ -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
|
)
|
{simple_module_core-0.0.36 → simple_module_core-0.0.37}/simple_module_core/diagnostics/_runner.py
RENAMED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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 →
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|