simple-module-core 0.0.35__tar.gz → 0.0.36__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.35 → simple_module_core-0.0.36}/PKG-INFO +4 -3
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/README.md +3 -2
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/pyproject.toml +1 -1
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/__init__.py +10 -1
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/__main__.py +53 -8
- simple_module_core-0.0.36/simple_module_core/_i18n_flatten.py +44 -0
- simple_module_core-0.0.36/simple_module_core/body_limits.py +71 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/csp.py +64 -18
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/__init__.py +2 -0
- simple_module_core-0.0.36/simple_module_core/diagnostics/_expression_index.py +106 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_i18n.py +78 -2
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_module.py +1 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_runner.py +22 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_tenancy.py +37 -4
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/dotenv.py +1 -1
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/i18n.py +29 -34
- simple_module_core-0.0.36/simple_module_core/i18n_overrides.py +48 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/menu.py +69 -3
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/module.py +9 -7
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/permissions.py +112 -9
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/public_routes.py +44 -10
- simple_module_core-0.0.36/simple_module_core/rate_limit.py +126 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/services.py +7 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/setup_steps.py +64 -0
- simple_module_core-0.0.36/simple_module_core/tenancy.py +89 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_csp_registry.py +67 -1
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_diagnostics_tenancy.py +22 -0
- simple_module_core-0.0.36/tests/test_doctor_cli_overrides.py +73 -0
- simple_module_core-0.0.36/tests/test_expression_index_diagnostics.py +86 -0
- simple_module_core-0.0.36/tests/test_i18n_overrides.py +113 -0
- simple_module_core-0.0.36/tests/test_permission_sources.py +52 -0
- simple_module_core-0.0.36/tests/test_public_routes_rate_precedence.py +23 -0
- simple_module_core-0.0.36/tests/test_tenant_exists.py +21 -0
- simple_module_core-0.0.36/tests/test_tenant_roles.py +33 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/.gitignore +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/LICENSE +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/audit_links.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/invalidation.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/module_meta.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/redirect_safety.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/settings_base.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_audit_links.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_events.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_invalidation.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_invalidation_handlers.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_redirect_safety.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_services.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_settings_base.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_sm003_page_render.py +0 -0
- {simple_module_core-0.0.35 → simple_module_core-0.0.36}/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.36
|
|
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
|
|
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
|
|
34
34
|
Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.
|
|
35
35
|
|
|
36
|
-
This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`
|
|
36
|
+
This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM025`) used by `make doctor`.
|
|
37
37
|
|
|
38
38
|
## Install
|
|
39
39
|
|
|
@@ -48,7 +48,8 @@ You usually don't install this directly — it's pulled in by `simple_module_hos
|
|
|
48
48
|
- `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
|
|
49
49
|
- `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
|
|
50
50
|
- `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
|
|
51
|
-
- Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `
|
|
51
|
+
- Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `SM025`.
|
|
52
|
+
- Tenant-role vocabulary (`simple_module_core.tenancy`) — `TenantRole`, `TENANT_ROLE_PREFIX`, `tenant_role()`, so any module can map `tenant:<role>` onto its permissions without depending on `tenants`.
|
|
52
53
|
- Tiny event-bus (`pyee`) for decoupled module-to-module communication.
|
|
53
54
|
|
|
54
55
|
## Usage
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.
|
|
4
4
|
|
|
5
|
-
This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`
|
|
5
|
+
This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM025`) used by `make doctor`.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -17,7 +17,8 @@ You usually don't install this directly — it's pulled in by `simple_module_hos
|
|
|
17
17
|
- `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
|
|
18
18
|
- `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
|
|
19
19
|
- `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
|
|
20
|
-
- Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `
|
|
20
|
+
- Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `SM025`.
|
|
21
|
+
- Tenant-role vocabulary (`simple_module_core.tenancy`) — `TenantRole`, `TENANT_ROLE_PREFIX`, `tenant_role()`, so any module can map `tenant:<role>` onto its permissions without depending on `tenants`.
|
|
21
22
|
- Tiny event-bus (`pyee`) for decoupled module-to-module communication.
|
|
22
23
|
|
|
23
24
|
## Usage
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
|
|
2
2
|
|
|
3
3
|
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelResolver
|
|
4
|
+
from simple_module_core.body_limits import BodyLimitRegistry
|
|
4
5
|
from simple_module_core.csp import CspSourceError, CspSourceRegistry
|
|
5
6
|
from simple_module_core.design_packs import DesignPack, DesignPackRegistry
|
|
6
7
|
from simple_module_core.diagnostics import (
|
|
@@ -46,14 +47,17 @@ from simple_module_core.module import ModuleBase, ModuleMeta
|
|
|
46
47
|
from simple_module_core.permissions import PermissionRegistry
|
|
47
48
|
from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
|
|
48
49
|
from simple_module_core.services import Services
|
|
49
|
-
from simple_module_core.setup_steps import SetupRegistry, SetupStep
|
|
50
|
+
from simple_module_core.setup_steps import SetupAction, SetupField, SetupRegistry, SetupStep
|
|
51
|
+
from simple_module_core.tenancy import TENANT_ROLE_PREFIX, TenantRole, is_tenant_role, tenant_role
|
|
50
52
|
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
|
|
51
53
|
|
|
52
54
|
__all__ = [
|
|
53
55
|
"DEFAULT_AUTH_PROVIDER",
|
|
54
56
|
"FRAMEWORK_API_VERSION",
|
|
57
|
+
"TENANT_ROLE_PREFIX",
|
|
55
58
|
"AuditLink",
|
|
56
59
|
"AuditLinkRegistry",
|
|
60
|
+
"BodyLimitRegistry",
|
|
57
61
|
"CircularDependencyError",
|
|
58
62
|
"CspSourceError",
|
|
59
63
|
"CspSourceRegistry",
|
|
@@ -87,8 +91,11 @@ __all__ = [
|
|
|
87
91
|
"PublicRoute",
|
|
88
92
|
"PublicRouteRegistry",
|
|
89
93
|
"Services",
|
|
94
|
+
"SetupAction",
|
|
95
|
+
"SetupField",
|
|
90
96
|
"SetupRegistry",
|
|
91
97
|
"SetupStep",
|
|
98
|
+
"TenantRole",
|
|
92
99
|
"Translator",
|
|
93
100
|
"ValidationError",
|
|
94
101
|
"check_framework_compatibility",
|
|
@@ -97,10 +104,12 @@ __all__ = [
|
|
|
97
104
|
"flag_enabled",
|
|
98
105
|
"get_module_package_name",
|
|
99
106
|
"is_flag_enabled",
|
|
107
|
+
"is_tenant_role",
|
|
100
108
|
"print_diagnostics",
|
|
101
109
|
"require_flag",
|
|
102
110
|
"resolve_auth_provider",
|
|
103
111
|
"run_diagnostics",
|
|
104
112
|
"select_auth_provider",
|
|
113
|
+
"tenant_role",
|
|
105
114
|
"topological_sort",
|
|
106
115
|
]
|
|
@@ -11,14 +11,14 @@ i18n checks always run. ``SM_I18N_SUPPORTED_LOCALES`` (env or ``.env``)
|
|
|
11
11
|
declares the locale set this install promises, which also enables SM013 for a
|
|
12
12
|
namespace that never shipped one; with it unset, each namespace is checked
|
|
13
13
|
against the locale files it actually has, so key drift between two shipped
|
|
14
|
-
translations is still caught. Host-level ``host/locales/``
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
current working dir).
|
|
14
|
+
translations is still caught. Host-level ``host/locales/`` is picked up
|
|
15
|
+
relative to the project root (``SM_PROJECT_ROOT``, else the directory of the
|
|
16
|
+
discovered ``.env``, else the current working dir).
|
|
18
17
|
"""
|
|
19
18
|
|
|
20
19
|
from __future__ import annotations
|
|
21
20
|
|
|
21
|
+
import importlib.util
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
24
|
import sys
|
|
@@ -30,6 +30,7 @@ from simple_module_core.diagnostics import (
|
|
|
30
30
|
print_diagnostics,
|
|
31
31
|
run_diagnostics,
|
|
32
32
|
)
|
|
33
|
+
from simple_module_core.diagnostics._i18n import unknown_overrides_for
|
|
33
34
|
from simple_module_core.discovery import (
|
|
34
35
|
discover_modules,
|
|
35
36
|
resolve_auth_provider,
|
|
@@ -73,20 +74,59 @@ def _load_i18n_settings_from_env() -> tuple[list[str] | None, str]:
|
|
|
73
74
|
return supported, default
|
|
74
75
|
|
|
75
76
|
|
|
77
|
+
def _database_dialect() -> str:
|
|
78
|
+
"""Dialect name of ``SM_DATABASE_URL`` (``.env`` merged by the i18n loader first).
|
|
79
|
+
|
|
80
|
+
Defaults to ``sqlite`` like ``Settings.database_url``. Reads the env var
|
|
81
|
+
directly for the same reason the i18n settings do: no hosting import.
|
|
82
|
+
"""
|
|
83
|
+
url = os.environ.get("SM_DATABASE_URL", "sqlite+aiosqlite:///./app.db")
|
|
84
|
+
return url.split(":", 1)[0].split("+", 1)[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _hosting_locales() -> Path | None:
|
|
88
|
+
"""The framework's own ``hosting`` catalog (setup wizard), found without importing it.
|
|
89
|
+
|
|
90
|
+
The running app's ``build_i18n_registry`` always adds it, so doctor must
|
|
91
|
+
too, or every ``hosting.*`` host override reads as unknown (SM027). Core
|
|
92
|
+
may not import ``simple_module_hosting``: locate the package on disk.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
spec = importlib.util.find_spec("simple_module_hosting")
|
|
96
|
+
except (ImportError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
if spec is None or not spec.origin:
|
|
99
|
+
return None
|
|
100
|
+
locales = Path(spec.origin).resolve().parent / "locales"
|
|
101
|
+
return locales if locales.is_dir() else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _hosting_ui_locales() -> Path | None:
|
|
105
|
+
"""The shared ``ui`` catalog, which ships inside ``simple_module_hosting`` (#426)."""
|
|
106
|
+
hosting = _hosting_locales()
|
|
107
|
+
if hosting is None:
|
|
108
|
+
return None
|
|
109
|
+
ui = hosting.parent / "ui_locales"
|
|
110
|
+
return ui if ui.is_dir() else None
|
|
111
|
+
|
|
112
|
+
|
|
76
113
|
def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]:
|
|
77
|
-
"""Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs."""
|
|
114
|
+
"""Return ``[(reporter, namespace, path), ...]`` for hosting + host + ui locale dirs."""
|
|
78
115
|
# Anchor on the same project root the `.env` was loaded from
|
|
79
116
|
# (`parse_dotenv` walks up from the cwd) — resolving against the bare cwd
|
|
80
117
|
# here would look for `host/locales` in the wrong directory whenever
|
|
81
118
|
# doctor runs from a subdirectory.
|
|
82
119
|
root = find_env_file().parent
|
|
83
120
|
out: list[tuple[str, str, Path]] = []
|
|
121
|
+
hosting_locales = _hosting_locales()
|
|
122
|
+
if hosting_locales is not None:
|
|
123
|
+
out.append(("simple_module_hosting", "hosting", hosting_locales))
|
|
84
124
|
host_locales = root / "host" / "locales"
|
|
85
125
|
if host_locales.is_dir():
|
|
86
126
|
out.append(("host", "host", host_locales))
|
|
87
|
-
ui_locales =
|
|
88
|
-
if ui_locales
|
|
89
|
-
out.append(("
|
|
127
|
+
ui_locales = _hosting_ui_locales()
|
|
128
|
+
if ui_locales is not None:
|
|
129
|
+
out.append(("simple_module_hosting/ui", "ui", ui_locales))
|
|
90
130
|
return out
|
|
91
131
|
|
|
92
132
|
|
|
@@ -130,12 +170,17 @@ def main() -> int:
|
|
|
130
170
|
supported, default = _load_i18n_settings_from_env()
|
|
131
171
|
extra = _discover_extra_locale_sources()
|
|
132
172
|
|
|
173
|
+
unknown_overrides, override_findings = unknown_overrides_for(modules, extra, supported, default)
|
|
174
|
+
|
|
133
175
|
diagnostics = run_diagnostics(
|
|
134
176
|
modules,
|
|
135
177
|
i18n_supported_locales=supported,
|
|
136
178
|
i18n_default_locale=default,
|
|
137
179
|
i18n_extra_sources=extra,
|
|
180
|
+
i18n_unknown_overrides=unknown_overrides,
|
|
181
|
+
database_dialect=_database_dialect(),
|
|
138
182
|
)
|
|
183
|
+
diagnostics.extend(override_findings)
|
|
139
184
|
print_diagnostics(diagnostics)
|
|
140
185
|
|
|
141
186
|
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Read locale catalogs and flatten nested locale dicts to dotted keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_catalog(path: Path) -> dict[str, Any]:
|
|
11
|
+
"""Parse a ``<locale>.json`` catalog; ValueError if it is not a JSON object."""
|
|
12
|
+
try:
|
|
13
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
14
|
+
except json.JSONDecodeError as exc:
|
|
15
|
+
raise ValueError(f"invalid JSON in {path}: {exc}") from exc
|
|
16
|
+
if not isinstance(raw, dict):
|
|
17
|
+
raise ValueError(f"{path} must contain a JSON object at the top level")
|
|
18
|
+
return raw
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def flatten_messages(
|
|
22
|
+
nested: dict[str, Any],
|
|
23
|
+
*,
|
|
24
|
+
prefix: str = "",
|
|
25
|
+
) -> dict[str, str]:
|
|
26
|
+
"""Flatten a nested dict of string leaves to dotted keys.
|
|
27
|
+
|
|
28
|
+
{"browse": {"title": "X"}} -> {"browse.title": "X"}
|
|
29
|
+
|
|
30
|
+
Raises ValueError if any leaf is not a string.
|
|
31
|
+
"""
|
|
32
|
+
out: dict[str, str] = {}
|
|
33
|
+
for key, value in nested.items():
|
|
34
|
+
composed = f"{prefix}.{key}" if prefix else key
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
out.update(flatten_messages(value, prefix=composed))
|
|
37
|
+
elif isinstance(value, str):
|
|
38
|
+
out[composed] = value
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Locale value at '{composed}' must be string or nested dict, "
|
|
42
|
+
f"got {type(value).__name__}"
|
|
43
|
+
)
|
|
44
|
+
return out
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Per-path request-body ceilings modules declare for the body-size guard.
|
|
2
|
+
|
|
3
|
+
The host ships one global ceiling (``HostSettings.max_request_body_bytes``).
|
|
4
|
+
A module whose endpoint legitimately takes more — a multipart upload — or
|
|
5
|
+
should take far less raises or lowers it for that route through
|
|
6
|
+
:meth:`~simple_module_core.module.ModuleBase.register_body_limits`::
|
|
7
|
+
|
|
8
|
+
def register_body_limits(self, registry):
|
|
9
|
+
registry.add_prefix("/api/file-storage/upload", 100 * 1024 * 1024)
|
|
10
|
+
|
|
11
|
+
``max_bytes`` may be a callable ``(app) -> int`` for a limit that is itself a
|
|
12
|
+
runtime setting; it is evaluated per request. The first matching rule in
|
|
13
|
+
registration order wins, and its value *replaces* the global ceiling.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable, Iterable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from simple_module_core.public_routes import PublicRoute
|
|
22
|
+
|
|
23
|
+
__all__ = ["BodyLimitRegistry"]
|
|
24
|
+
|
|
25
|
+
MaxBytes = int | Callable[[Any], int]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BodyLimitRegistry:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._rules: list[tuple[PublicRoute, MaxBytes]] = []
|
|
31
|
+
|
|
32
|
+
def add(
|
|
33
|
+
self,
|
|
34
|
+
pattern: str,
|
|
35
|
+
max_bytes: MaxBytes,
|
|
36
|
+
*,
|
|
37
|
+
methods: Iterable[str] | None = None,
|
|
38
|
+
kind: str = "prefix",
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Register a ceiling. ``kind`` is prefix / exact / suffix / regex."""
|
|
41
|
+
if isinstance(max_bytes, int) and max_bytes < 0:
|
|
42
|
+
raise ValueError("max_bytes must be >= 0 (0 means unlimited)")
|
|
43
|
+
self._rules.append((PublicRoute(pattern, methods=methods, kind=kind), max_bytes))
|
|
44
|
+
|
|
45
|
+
def add_prefix(
|
|
46
|
+
self, prefix: str, max_bytes: MaxBytes, *, methods: Iterable[str] | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
self.add(prefix, max_bytes, methods=methods, kind="prefix")
|
|
49
|
+
|
|
50
|
+
def add_exact(
|
|
51
|
+
self, path: str, max_bytes: MaxBytes, *, methods: Iterable[str] | None = None
|
|
52
|
+
) -> None:
|
|
53
|
+
self.add(path, max_bytes, methods=methods, kind="exact")
|
|
54
|
+
|
|
55
|
+
def add_regex(
|
|
56
|
+
self, pattern: str, max_bytes: MaxBytes, *, methods: Iterable[str] | None = None
|
|
57
|
+
) -> None:
|
|
58
|
+
self.add(pattern, max_bytes, methods=methods, kind="regex")
|
|
59
|
+
|
|
60
|
+
def limit_for(self, method: str, path: str, app: Any = None) -> int | None:
|
|
61
|
+
"""The override for this request, or ``None`` to use the global ceiling.
|
|
62
|
+
|
|
63
|
+
``0`` means "unlimited for this route".
|
|
64
|
+
"""
|
|
65
|
+
for route, max_bytes in self._rules:
|
|
66
|
+
if route.matches(method, path):
|
|
67
|
+
return max_bytes if isinstance(max_bytes, int) else int(max_bytes(app))
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def __len__(self) -> int:
|
|
71
|
+
return len(self._rules)
|
|
@@ -10,8 +10,13 @@ origin fails loudly at boot instead of silently weakening the header.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import re
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from typing import Any
|
|
13
15
|
|
|
14
|
-
__all__ = ["CspSourceError", "CspSourceRegistry"]
|
|
16
|
+
__all__ = ["CspProvider", "CspSourceError", "CspSourceRegistry", "is_valid_source"]
|
|
17
|
+
|
|
18
|
+
CspProvider = Callable[[Any], Iterable[tuple[str, str]]]
|
|
19
|
+
"""``fn(app) -> [(directive, source), ...]`` evaluated per response."""
|
|
15
20
|
|
|
16
21
|
# Fetch directives a module may extend. Deliberately excludes the policy's
|
|
17
22
|
# structural directives (default-src, base-uri, form-action, frame-ancestors,
|
|
@@ -37,8 +42,10 @@ _EXTENDABLE_DIRECTIVES = frozenset(
|
|
|
37
42
|
# a data-ish scheme keyword. One token — anything that could smuggle a second
|
|
38
43
|
# token or terminate/extend the clause (whitespace, ";", ",", quotes) is
|
|
39
44
|
# rejected, and a wildcard prefix must be followed by a real host (`*.` alone
|
|
40
|
-
# is not a source).
|
|
41
|
-
|
|
45
|
+
# is not a source). Only printable ASCII: the header is latin-1 encoded and
|
|
46
|
+
# h11 rejects control bytes, so anything else would fail at send time — on
|
|
47
|
+
# every response, for a provider reading an admin-edited setting.
|
|
48
|
+
_SOURCE_RE = re.compile(r"(?:[A-Za-z][A-Za-z0-9+.-]*:(?://)?)?(?:(?:\*\.)?(?:(?![;,'\"*])[!-~])+)?")
|
|
42
49
|
|
|
43
50
|
# CSP3 fallback chains: when a directive is absent from a policy, the browser
|
|
44
51
|
# consults these directives in order (ending at ``default-src``). A clause we
|
|
@@ -59,33 +66,64 @@ class CspSourceError(ValueError):
|
|
|
59
66
|
"""Invalid CSP directive or source declared by a module."""
|
|
60
67
|
|
|
61
68
|
|
|
69
|
+
def _checked(directive: str, source: str) -> str:
|
|
70
|
+
"""Validate one pair and return the normalized source token, or raise."""
|
|
71
|
+
if directive not in _EXTENDABLE_DIRECTIVES:
|
|
72
|
+
raise CspSourceError(
|
|
73
|
+
f"CSP directive {directive!r} is not extendable; "
|
|
74
|
+
f"choose one of {sorted(_EXTENDABLE_DIRECTIVES)}"
|
|
75
|
+
)
|
|
76
|
+
token = source.strip()
|
|
77
|
+
if not token or not _SOURCE_RE.fullmatch(token):
|
|
78
|
+
raise CspSourceError(
|
|
79
|
+
f"invalid CSP source {source!r} for {directive}: must be a single "
|
|
80
|
+
"origin or scheme token (no spaces, quotes, wildcards-only, or ';')"
|
|
81
|
+
)
|
|
82
|
+
return token
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_valid_source(directive: str, source: str) -> bool:
|
|
86
|
+
"""Whether ``add(directive, source)`` would accept the pair (never raises)."""
|
|
87
|
+
try:
|
|
88
|
+
_checked(directive, source)
|
|
89
|
+
except (CspSourceError, AttributeError, TypeError):
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
62
94
|
class CspSourceRegistry:
|
|
63
95
|
"""Collects per-directive extra CSP sources from modules."""
|
|
64
96
|
|
|
65
97
|
def __init__(self) -> None:
|
|
66
98
|
self._sources: dict[str, list[str]] = {}
|
|
99
|
+
self._providers: list[CspProvider] = []
|
|
67
100
|
|
|
68
101
|
def add(self, directive: str, source: str) -> None:
|
|
69
102
|
"""Allow ``source`` in ``directive``, e.g. ``add("style-src", "https://rsms.me")``."""
|
|
70
|
-
|
|
71
|
-
raise CspSourceError(
|
|
72
|
-
f"CSP directive {directive!r} is not extendable; "
|
|
73
|
-
f"choose one of {sorted(_EXTENDABLE_DIRECTIVES)}"
|
|
74
|
-
)
|
|
75
|
-
token = source.strip()
|
|
76
|
-
if not token or not _SOURCE_RE.match(token):
|
|
77
|
-
raise CspSourceError(
|
|
78
|
-
f"invalid CSP source {source!r} for {directive}: must be a single "
|
|
79
|
-
"origin or scheme token (no spaces, quotes, wildcards-only, or ';')"
|
|
80
|
-
)
|
|
103
|
+
token = _checked(directive, source)
|
|
81
104
|
bucket = self._sources.setdefault(directive, [])
|
|
82
105
|
if token not in bucket:
|
|
83
106
|
bucket.append(token)
|
|
84
107
|
|
|
108
|
+
def add_provider(self, provider: CspProvider) -> None:
|
|
109
|
+
"""Contribute sources that depend on runtime state (e.g. DB settings).
|
|
110
|
+
|
|
111
|
+
``provider(app)`` runs on every response and returns
|
|
112
|
+
``(directive, source)`` pairs. It must be synchronous and read only
|
|
113
|
+
in-memory state (``app.state.<pkg>.settings``) — never the DB. Pairs
|
|
114
|
+
that fail ``add()``'s validation, and providers that raise, are
|
|
115
|
+
dropped and logged by the host: the static policy still applies.
|
|
116
|
+
"""
|
|
117
|
+
self._providers.append(provider)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def providers(self) -> tuple[CspProvider, ...]:
|
|
121
|
+
return tuple(self._providers)
|
|
122
|
+
|
|
85
123
|
def __bool__(self) -> bool:
|
|
86
124
|
return bool(self._sources)
|
|
87
125
|
|
|
88
|
-
def extend_policy(self, policy: str) -> str:
|
|
126
|
+
def extend_policy(self, policy: str, extra: Iterable[tuple[str, str]] = ()) -> str:
|
|
89
127
|
"""Fold the registered sources into an existing policy string.
|
|
90
128
|
|
|
91
129
|
Existing clauses keep their order and gain only sources they don't
|
|
@@ -94,8 +132,16 @@ class CspSourceRegistry:
|
|
|
94
132
|
back to (per ``_FALLBACK_CHAINS``, ending at ``default-src``) —
|
|
95
133
|
without that seed the new clause would *narrow* the policy, since the
|
|
96
134
|
browser stops consulting the fallback once the directive exists.
|
|
135
|
+
|
|
136
|
+
``extra`` adds already-validated ``(directive, source)`` pairs (from
|
|
137
|
+
providers) for this call only — the registry itself is not mutated.
|
|
97
138
|
"""
|
|
98
|
-
|
|
139
|
+
sources = {d: list(v) for d, v in self._sources.items()}
|
|
140
|
+
for directive, source in extra:
|
|
141
|
+
bucket = sources.setdefault(directive, [])
|
|
142
|
+
if source not in bucket:
|
|
143
|
+
bucket.append(source)
|
|
144
|
+
if not sources:
|
|
99
145
|
return policy
|
|
100
146
|
directives: dict[str, list[str]] = {}
|
|
101
147
|
order: list[str] = []
|
|
@@ -106,7 +152,7 @@ class CspSourceRegistry:
|
|
|
106
152
|
directive, _, rest = clause.partition(" ")
|
|
107
153
|
directives[directive] = rest.split()
|
|
108
154
|
order.append(directive)
|
|
109
|
-
for directive, extras in
|
|
155
|
+
for directive, extras in sources.items():
|
|
110
156
|
if directive not in directives:
|
|
111
157
|
directives[directive] = list(self._seed_sources(directive, directives))
|
|
112
158
|
order.append(directive)
|
|
@@ -126,7 +172,7 @@ class CspSourceRegistry:
|
|
|
126
172
|
continue
|
|
127
173
|
bucket = directives[shadowing]
|
|
128
174
|
for base in chain:
|
|
129
|
-
extras =
|
|
175
|
+
extras = sources.get(base)
|
|
130
176
|
if extras:
|
|
131
177
|
bucket.extend(e for e in extras if e not in bucket)
|
|
132
178
|
return "; ".join(f"{d} {' '.join(directives[d])}".rstrip() for d in order)
|
{simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/__init__.py
RENAMED
|
@@ -12,6 +12,7 @@ from simple_module_core.diagnostics._migration import MigrationDiagnostics
|
|
|
12
12
|
from simple_module_core.diagnostics._module import ModuleDiagnostics
|
|
13
13
|
from simple_module_core.diagnostics._pages import collect_tsx_pages
|
|
14
14
|
from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics
|
|
15
|
+
from simple_module_core.diagnostics._tenancy import check_tenant_resolver
|
|
15
16
|
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
@@ -20,6 +21,7 @@ __all__ = [
|
|
|
20
21
|
"I18nDiagnostics",
|
|
21
22
|
"MigrationDiagnostics",
|
|
22
23
|
"ModuleDiagnostics",
|
|
24
|
+
"check_tenant_resolver",
|
|
23
25
|
"collect_tsx_pages",
|
|
24
26
|
"print_diagnostics",
|
|
25
27
|
"run_diagnostics",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Expression-index diagnostics (SM026).
|
|
2
|
+
|
|
3
|
+
SQLAlchemy cannot reflect expression indexes (``lower(email)``) on SQLite, so
|
|
4
|
+
autogenerate and ``alembic check`` silently skip them there and report a clean
|
|
5
|
+
diff. That is "unverified", not "clean": on Postgres the same index is either
|
|
6
|
+
missing from the history or drifted. SM026 says so whenever the configured
|
|
7
|
+
database is SQLite and any module model declares such an index.
|
|
8
|
+
|
|
9
|
+
Duck-typed on SQLAlchemy ``Table``/``Index`` objects (core does not depend on
|
|
10
|
+
SQLAlchemy).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from simple_module_core.module import ModuleBase
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def index_is_expression_based(index: Any) -> bool:
|
|
29
|
+
"""True when any element of ``index`` is an expression rather than a plain column.
|
|
30
|
+
|
|
31
|
+
A plain ``Column`` is attached to its table; a ``text()`` or function
|
|
32
|
+
element is not.
|
|
33
|
+
"""
|
|
34
|
+
return any(getattr(expr, "table", None) is None for expr in index.expressions)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_expression_indexes(tables: Iterable[Any]) -> list[tuple[str, str]]:
|
|
38
|
+
"""Return sorted ``(table name, index name)`` pairs for expression indexes."""
|
|
39
|
+
found = {
|
|
40
|
+
(table.name, index.name or "<unnamed>")
|
|
41
|
+
for table in tables
|
|
42
|
+
for index in table.indexes
|
|
43
|
+
if index_is_expression_based(index)
|
|
44
|
+
}
|
|
45
|
+
return sorted(found)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def module_all_tables(mod: ModuleBase) -> list[Any]:
|
|
49
|
+
"""Every table declared in the module's ``models`` submodule."""
|
|
50
|
+
pkg = type(mod).__module__.rsplit(".", 1)[0]
|
|
51
|
+
try:
|
|
52
|
+
models = importlib.import_module(f"{pkg}.models")
|
|
53
|
+
except ModuleNotFoundError:
|
|
54
|
+
return []
|
|
55
|
+
except Exception: # pragma: no cover - a broken models module fails elsewhere, loudly
|
|
56
|
+
logger.debug("Could not import %s.models for SM026", pkg, exc_info=True)
|
|
57
|
+
return []
|
|
58
|
+
seen: dict[str, Any] = {}
|
|
59
|
+
for value in vars(models).values():
|
|
60
|
+
table = getattr(value, "__table__", None)
|
|
61
|
+
if (
|
|
62
|
+
isinstance(value, type)
|
|
63
|
+
and table is not None
|
|
64
|
+
and hasattr(table, "indexes")
|
|
65
|
+
# Skip models re-imported from another module, or each importer would
|
|
66
|
+
# be told about an index it does not own.
|
|
67
|
+
and getattr(value, "__module__", "").startswith(pkg)
|
|
68
|
+
):
|
|
69
|
+
seen.setdefault(table.name, table)
|
|
70
|
+
return list(seen.values())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_expression_indexes_unverifiable(
|
|
74
|
+
tables: Iterable[Any], dialect: str | None, module_name: str
|
|
75
|
+
) -> list[Diagnostic]:
|
|
76
|
+
"""SM026: INFO on SQLite when the models declare indexes it cannot verify."""
|
|
77
|
+
if dialect != "sqlite":
|
|
78
|
+
return []
|
|
79
|
+
found = find_expression_indexes(tables)
|
|
80
|
+
if not found:
|
|
81
|
+
return []
|
|
82
|
+
names = ", ".join(f"{idx} ({tbl})" for tbl, idx in found)
|
|
83
|
+
return [
|
|
84
|
+
Diagnostic(
|
|
85
|
+
level=DiagnosticLevel.INFO,
|
|
86
|
+
code="SM026",
|
|
87
|
+
message=(
|
|
88
|
+
f"SQLite cannot reflect expression indexes, so autogenerate and "
|
|
89
|
+
f"`alembic check` cannot verify {names}; a clean diff here says nothing "
|
|
90
|
+
f"about them"
|
|
91
|
+
),
|
|
92
|
+
module_name=module_name,
|
|
93
|
+
suggestion=(
|
|
94
|
+
"Add them to a revision by hand and verify on Postgres: "
|
|
95
|
+
"`make migrations-roundtrip-pg`"
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
"check_expression_indexes_unverifiable",
|
|
103
|
+
"find_expression_indexes",
|
|
104
|
+
"index_is_expression_based",
|
|
105
|
+
"module_all_tables",
|
|
106
|
+
]
|