simple-module-core 0.0.37__tar.gz → 0.0.38__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.37 → simple_module_core-0.0.38}/.gitignore +1 -2
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/PKG-INFO +2 -2
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/pyproject.toml +2 -2
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/__init__.py +19 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/__main__.py +6 -0
- simple_module_core-0.0.38/simple_module_core/_i18n_translator.py +107 -0
- simple_module_core-0.0.38/simple_module_core/_permission_grants.py +74 -0
- simple_module_core-0.0.38/simple_module_core/_request_hooks.py +103 -0
- simple_module_core-0.0.38/simple_module_core/activity_feed.py +75 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/audit_links.py +27 -0
- simple_module_core-0.0.38/simple_module_core/correlation.py +12 -0
- simple_module_core-0.0.38/simple_module_core/cors.py +71 -0
- simple_module_core-0.0.38/simple_module_core/deprecation.py +111 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_coupling.py +59 -1
- simple_module_core-0.0.38/simple_module_core/diagnostics/_dependencies.py +36 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_module.py +7 -0
- simple_module_core-0.0.38/simple_module_core/diagnostics/_spa_mount.py +46 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/discovery.py +40 -14
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/durable_events.py +6 -4
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/events.py +5 -3
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/exceptions.py +19 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/feature_flags.py +2 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/i18n.py +1 -95
- simple_module_core-0.0.38/simple_module_core/leases.py +275 -0
- simple_module_core-0.0.38/simple_module_core/mail.py +115 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/module.py +35 -103
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/module_meta.py +10 -0
- simple_module_core-0.0.38/simple_module_core/module_settings.py +108 -0
- simple_module_core-0.0.38/simple_module_core/path_rules.py +121 -0
- simple_module_core-0.0.38/simple_module_core/permission_denied.py +28 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/permissions.py +21 -74
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/public_routes.py +27 -2
- simple_module_core-0.0.38/simple_module_core/rate_limit.py +228 -0
- simple_module_core-0.0.38/simple_module_core/resources.py +141 -0
- simple_module_core-0.0.38/simple_module_core/secret_fields.py +137 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/services.py +10 -0
- simple_module_core-0.0.38/simple_module_core/settings_hydrate.py +194 -0
- simple_module_core-0.0.38/simple_module_core/spa_mount.py +58 -0
- simple_module_core-0.0.38/simple_module_core/stored_settings.py +143 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/tenancy.py +30 -1
- simple_module_core-0.0.38/simple_module_core/user_directory.py +192 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_audit_links.py +30 -0
- simple_module_core-0.0.38/tests/test_core_leases.py +162 -0
- simple_module_core-0.0.38/tests/test_core_mail_resolution.py +107 -0
- simple_module_core-0.0.38/tests/test_core_module_settings.py +66 -0
- simple_module_core-0.0.38/tests/test_core_user_directory.py +152 -0
- simple_module_core-0.0.38/tests/test_deprecation_helper.py +59 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_discovery.py +1 -1
- simple_module_core-0.0.38/tests/test_discovery_missing_deps.py +108 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_events.py +8 -3
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_feature_flags.py +2 -2
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_feature_flags_decorator.py +2 -2
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_module_base.py +2 -2
- simple_module_core-0.0.38/tests/test_path_rule_registries.py +78 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_services.py +15 -15
- simple_module_core-0.0.38/tests/test_sm009_dynamic_coupling.py +84 -0
- simple_module_core-0.0.37/simple_module_core/rate_limit.py +0 -126
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/LICENSE +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/README.md +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/_i18n_flatten.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/body_limits.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_db_extensions.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_expression_index.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_tenancy.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/i18n_overrides.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/invalidation.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/menu.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/redirect_safety.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/settings_base.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/setup_steps.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_db_extensions_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_diagnostics_tenancy.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_doctor_cli_overrides.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_durable_events.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_expression_index_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_heavy_task_imports_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_i18n_overrides.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_invalidation.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_invalidation_handlers.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_permission_sources.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_permissions_admin_principal.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_public_routes_rate_precedence.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_redirect_safety.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_settings_base.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_sm003_page_render.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_tenant_exists.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/tests/test_tenant_roles.py +0 -0
- {simple_module_core-0.0.37 → simple_module_core-0.0.38}/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.38
|
|
4
4
|
Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: babel>=2.14
|
|
25
|
-
Requires-Dist: fastapi
|
|
25
|
+
Requires-Dist: fastapi<0.157,>=0.140
|
|
26
26
|
Requires-Dist: packaging>=23.0
|
|
27
27
|
Requires-Dist: pydantic-settings>=2.0
|
|
28
28
|
Requires-Dist: pydantic>=2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_core"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.38"
|
|
4
4
|
description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
24
|
"babel>=2.14",
|
|
25
|
-
"fastapi>=0.
|
|
25
|
+
"fastapi>=0.140,<0.157",
|
|
26
26
|
"packaging>=23.0",
|
|
27
27
|
"pydantic>=2.0",
|
|
28
28
|
"pydantic-settings>=2.0",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelResolver
|
|
4
4
|
from simple_module_core.body_limits import BodyLimitRegistry
|
|
5
|
+
from simple_module_core.cors import CorsPolicy, CorsPolicyRegistry
|
|
5
6
|
from simple_module_core.csp import CspSourceError, CspSourceRegistry
|
|
6
7
|
from simple_module_core.design_packs import DesignPack, DesignPackRegistry
|
|
7
8
|
from simple_module_core.diagnostics import (
|
|
@@ -14,6 +15,7 @@ from simple_module_core.discovery import (
|
|
|
14
15
|
DEFAULT_AUTH_PROVIDER,
|
|
15
16
|
discover_modules,
|
|
16
17
|
get_module_package_name,
|
|
18
|
+
missing_dependencies,
|
|
17
19
|
resolve_auth_provider,
|
|
18
20
|
select_auth_provider,
|
|
19
21
|
topological_sort,
|
|
@@ -28,6 +30,7 @@ from simple_module_core.exceptions import (
|
|
|
28
30
|
CircularDependencyError,
|
|
29
31
|
FrameworkVersionError,
|
|
30
32
|
InvalidModuleError,
|
|
33
|
+
MissingDependencyError,
|
|
31
34
|
ModuleError,
|
|
32
35
|
NotFoundError,
|
|
33
36
|
ValidationError,
|
|
@@ -51,8 +54,15 @@ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
|
51
54
|
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
52
55
|
from simple_module_core.permissions import PermissionRegistry
|
|
53
56
|
from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
|
|
57
|
+
from simple_module_core.resources import (
|
|
58
|
+
ResourcePolicy,
|
|
59
|
+
ResourcePrincipal,
|
|
60
|
+
ResourceType,
|
|
61
|
+
ResourceTypeRegistry,
|
|
62
|
+
)
|
|
54
63
|
from simple_module_core.services import Services
|
|
55
64
|
from simple_module_core.setup_steps import SetupAction, SetupField, SetupRegistry, SetupStep
|
|
65
|
+
from simple_module_core.spa_mount import SpaMount
|
|
56
66
|
from simple_module_core.tenancy import TENANT_ROLE_PREFIX, TenantRole, is_tenant_role, tenant_role
|
|
57
67
|
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
|
|
58
68
|
|
|
@@ -64,6 +74,8 @@ __all__ = [
|
|
|
64
74
|
"AuditLinkRegistry",
|
|
65
75
|
"BodyLimitRegistry",
|
|
66
76
|
"CircularDependencyError",
|
|
77
|
+
"CorsPolicy",
|
|
78
|
+
"CorsPolicyRegistry",
|
|
67
79
|
"CspSourceError",
|
|
68
80
|
"CspSourceRegistry",
|
|
69
81
|
"DesignPack",
|
|
@@ -91,6 +103,7 @@ __all__ = [
|
|
|
91
103
|
"MenuRegistry",
|
|
92
104
|
"MenuSection",
|
|
93
105
|
"MigrationDiagnostics",
|
|
106
|
+
"MissingDependencyError",
|
|
94
107
|
"ModuleBase",
|
|
95
108
|
"ModuleError",
|
|
96
109
|
"ModuleMeta",
|
|
@@ -98,11 +111,16 @@ __all__ = [
|
|
|
98
111
|
"PermissionRegistry",
|
|
99
112
|
"PublicRoute",
|
|
100
113
|
"PublicRouteRegistry",
|
|
114
|
+
"ResourcePolicy",
|
|
115
|
+
"ResourcePrincipal",
|
|
116
|
+
"ResourceType",
|
|
117
|
+
"ResourceTypeRegistry",
|
|
101
118
|
"Services",
|
|
102
119
|
"SetupAction",
|
|
103
120
|
"SetupField",
|
|
104
121
|
"SetupRegistry",
|
|
105
122
|
"SetupStep",
|
|
123
|
+
"SpaMount",
|
|
106
124
|
"TenantRole",
|
|
107
125
|
"Translator",
|
|
108
126
|
"ValidationError",
|
|
@@ -113,6 +131,7 @@ __all__ = [
|
|
|
113
131
|
"get_module_package_name",
|
|
114
132
|
"is_flag_enabled",
|
|
115
133
|
"is_tenant_role",
|
|
134
|
+
"missing_dependencies",
|
|
116
135
|
"print_diagnostics",
|
|
117
136
|
"require_flag",
|
|
118
137
|
"resolve_auth_provider",
|
|
@@ -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._dependencies import CODE as MISSING_DEPENDENCY_CODE
|
|
33
34
|
from simple_module_core.diagnostics._i18n import unknown_overrides_for
|
|
34
35
|
from simple_module_core.discovery import (
|
|
35
36
|
discover_modules,
|
|
@@ -181,6 +182,11 @@ def main() -> int:
|
|
|
181
182
|
database_dialect=_database_dialect(),
|
|
182
183
|
)
|
|
183
184
|
diagnostics.extend(override_findings)
|
|
185
|
+
# A missing dependency only warns at a dev boot, but production refuses
|
|
186
|
+
# to start with it (GH #501), so doctor reports it as what it will be.
|
|
187
|
+
for finding in diagnostics:
|
|
188
|
+
if finding.code == MISSING_DEPENDENCY_CODE:
|
|
189
|
+
finding.level = DiagnosticLevel.ERROR
|
|
184
190
|
print_diagnostics(diagnostics)
|
|
185
191
|
|
|
186
192
|
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Request-scoped translator and CLDR plural resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from babel import Locale
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from simple_module_core.i18n import I18nRegistry
|
|
13
|
+
|
|
14
|
+
# Logged under the historical ``simple_module_core.i18n`` name so existing log
|
|
15
|
+
# filters keep matching after the split.
|
|
16
|
+
logger = logging.getLogger("simple_module_core.i18n")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=64)
|
|
20
|
+
def _plural_rule(locale: str):
|
|
21
|
+
"""Cached CLDR plural rule for a locale tag (e.g. 'en', 'ru', 'pt_BR')."""
|
|
22
|
+
return Locale.parse(locale).plural_form
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _plural_form(locale: str, count: float) -> str:
|
|
26
|
+
"""Return CLDR plural category ('one', 'few', 'many', 'other', ...).
|
|
27
|
+
|
|
28
|
+
Falls back to 'other' if the locale cannot be parsed by Babel.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
rule = _plural_rule(locale)
|
|
32
|
+
except Exception:
|
|
33
|
+
return "other"
|
|
34
|
+
return rule(count)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _SafeFormatDict(dict):
|
|
38
|
+
"""Dict that returns ``{key}`` for missing keys so str.format_map doesn't raise."""
|
|
39
|
+
|
|
40
|
+
def __missing__(self, key: str) -> str:
|
|
41
|
+
return "{" + key + "}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Translator:
|
|
45
|
+
"""Request-scoped translator bound to a specific locale.
|
|
46
|
+
|
|
47
|
+
Construct via::
|
|
48
|
+
|
|
49
|
+
Translator(registry, locale=request.state.locale, default_locale="en")
|
|
50
|
+
|
|
51
|
+
Resolution order for :meth:`t`:
|
|
52
|
+
|
|
53
|
+
1. Look up key in ``locale``; if missing, fall back to ``default_locale``.
|
|
54
|
+
2. If still missing, return the key itself (with a debug log).
|
|
55
|
+
3. Interpolate ``{name}``-style placeholders using supplied kwargs.
|
|
56
|
+
Missing placeholders are left as ``{name}`` (not raised).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
registry: I18nRegistry,
|
|
62
|
+
locale: str,
|
|
63
|
+
default_locale: str,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._registry = registry
|
|
66
|
+
self.locale = locale
|
|
67
|
+
self.default_locale = default_locale
|
|
68
|
+
|
|
69
|
+
def t(self, key: str, **params: Any) -> str:
|
|
70
|
+
"""Translate ``key`` with optional interpolation and plural resolution.
|
|
71
|
+
|
|
72
|
+
When ``count`` is in params, look up ``<key>_<plural_form>`` using
|
|
73
|
+
Babel's CLDR plural rule for the active locale, falling back to
|
|
74
|
+
``<key>_other`` and finally ``<key>``.
|
|
75
|
+
"""
|
|
76
|
+
resolved_key = self._resolve_plural_key(key, params)
|
|
77
|
+
template = self._lookup(resolved_key)
|
|
78
|
+
if template is None and resolved_key != key:
|
|
79
|
+
template = self._lookup(key)
|
|
80
|
+
if template is None:
|
|
81
|
+
logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale)
|
|
82
|
+
return key
|
|
83
|
+
return template.format_map(_SafeFormatDict(params))
|
|
84
|
+
|
|
85
|
+
def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str:
|
|
86
|
+
count = params.get("count")
|
|
87
|
+
if count is None:
|
|
88
|
+
return key
|
|
89
|
+
form = _plural_form(self.locale, count)
|
|
90
|
+
# Prefer the exact form; fall back to _other if that form has no entry.
|
|
91
|
+
candidate = f"{key}_{form}"
|
|
92
|
+
if self._lookup(candidate) is not None:
|
|
93
|
+
return candidate
|
|
94
|
+
other = f"{key}_other"
|
|
95
|
+
if self._lookup(other) is not None:
|
|
96
|
+
return other
|
|
97
|
+
return key
|
|
98
|
+
|
|
99
|
+
def _lookup(self, key: str) -> str | None:
|
|
100
|
+
msgs = self._registry.messages(self.locale)
|
|
101
|
+
if key in msgs:
|
|
102
|
+
return msgs[key]
|
|
103
|
+
if self.locale != self.default_locale:
|
|
104
|
+
default = self._registry.messages(self.default_locale)
|
|
105
|
+
if key in default:
|
|
106
|
+
return default[key]
|
|
107
|
+
return None
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Permission primitives: the wildcard grant rule, the admin role and grant-source types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Collection, Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
PermissionSourceProvider = Callable[[], "Iterable[str] | Iterable[tuple[str, str]]"]
|
|
9
|
+
"""Sync callable returning permission keys, or ``(key, label)`` pairs."""
|
|
10
|
+
|
|
11
|
+
WILDCARD = "*"
|
|
12
|
+
|
|
13
|
+
ADMIN_ROLE = "admin"
|
|
14
|
+
"""The one role name the framework itself knows.
|
|
15
|
+
|
|
16
|
+
Everything else about roles is module-owned, but the framework needs this to
|
|
17
|
+
resolve the wildcard grant below and to decide who can still reach the app
|
|
18
|
+
while maintenance mode is on.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Default role→permission mapping. Admin gets all permissions via the wildcard.
|
|
22
|
+
# Additional mappings are added at registration time via PermissionRegistry.map_role.
|
|
23
|
+
DEFAULT_ROLE_PERMISSIONS: dict[str, list[str]] = {
|
|
24
|
+
ADMIN_ROLE: [WILDCARD],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_admin(roles: list[str] | None) -> bool:
|
|
29
|
+
"""Whether ``roles`` carries the framework's admin role."""
|
|
30
|
+
return bool(roles) and ADMIN_ROLE in roles
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_admin_principal(user: Any) -> bool:
|
|
34
|
+
"""Whether *user* may use the admin role's shortcuts — admin and not a token.
|
|
35
|
+
|
|
36
|
+
A principal carrying ``scopes`` (an ``api_tokens`` credential) is capped
|
|
37
|
+
at those scopes even when its owner is an admin, so every check that
|
|
38
|
+
admits admins on the role alone must use this rather than
|
|
39
|
+
:func:`is_admin`; otherwise an admin's ``feature_flags.view`` token would
|
|
40
|
+
still open the Doctor page. Duck-typed: ``UserContext`` lives in the auth
|
|
41
|
+
module, which framework code must not import.
|
|
42
|
+
"""
|
|
43
|
+
if user is None or getattr(user, "scopes", None) is not None:
|
|
44
|
+
return False
|
|
45
|
+
return is_admin(getattr(user, "roles", None))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def grants(held: Collection[str], required: str) -> bool:
|
|
49
|
+
"""Whether a principal holding *held* satisfies *required*.
|
|
50
|
+
|
|
51
|
+
One place for the wildcard rule, so code that gates something *inside* a
|
|
52
|
+
response — a column, a label, a card — reads the grant the same way
|
|
53
|
+
``RequiresPermission`` reads it at the door. Two hand-rolled ``in`` checks
|
|
54
|
+
are two chances to forget that ``admin`` holds ``*`` and nothing else.
|
|
55
|
+
|
|
56
|
+
An empty *required* is satisfied by anything: callers use it to mean "no
|
|
57
|
+
permission gates this", which is the default for a declaration that never
|
|
58
|
+
named one.
|
|
59
|
+
"""
|
|
60
|
+
if not required:
|
|
61
|
+
return True
|
|
62
|
+
return WILDCARD in held or required in held
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
GrantSource = Callable[[Any, Any], Awaitable[Collection[str]]]
|
|
66
|
+
"""``async (request, user) -> keys`` — permissions a principal holds beyond its roles.
|
|
67
|
+
|
|
68
|
+
How a module that stores grants of its own (``permissions``' per-user grants)
|
|
69
|
+
gets them into the one set every check reads, without the framework importing
|
|
70
|
+
it (SM009). Called once per authenticated request by
|
|
71
|
+
``simple_module_hosting.permissions.resolve_principal_permissions``, so a
|
|
72
|
+
source that reads the database must cache: it sits on the hot path of every
|
|
73
|
+
page load.
|
|
74
|
+
"""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Request-pipeline registration hooks, split out of ``ModuleBase``.
|
|
2
|
+
|
|
3
|
+
Everything a module declares about how HTTP requests reach it -- anonymous
|
|
4
|
+
routes, body ceilings, CSP origins, CORS policies, rate limits, middleware and
|
|
5
|
+
exception handlers. ``ModuleBase`` inherits this mixin, so ``from
|
|
6
|
+
simple_module_core.module import ModuleBase`` and every override are unchanged.
|
|
7
|
+
All hooks are no-ops by default and run once at boot, in dependency order.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from fastapi import FastAPI
|
|
16
|
+
|
|
17
|
+
from simple_module_core.body_limits import BodyLimitRegistry
|
|
18
|
+
from simple_module_core.cors import CorsPolicyRegistry
|
|
19
|
+
from simple_module_core.csp import CspSourceRegistry
|
|
20
|
+
from simple_module_core.public_routes import PublicRouteRegistry
|
|
21
|
+
from simple_module_core.rate_limit import RateLimitRegistry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RequestHooksMixin:
|
|
25
|
+
def register_public_routes(self, registry: PublicRouteRegistry) -> None:
|
|
26
|
+
"""Declare routes that must bypass authentication (anonymous access).
|
|
27
|
+
|
|
28
|
+
``AuthMiddleware`` gates every request behind the active auth provider.
|
|
29
|
+
Override this hook to exempt read-only or webhook routes that are meant
|
|
30
|
+
to be reached without a session — e.g. a STAC / OGC API surface::
|
|
31
|
+
|
|
32
|
+
def register_public_routes(self, registry):
|
|
33
|
+
registry.add_prefix("/api/gis/stac")
|
|
34
|
+
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"})
|
|
35
|
+
|
|
36
|
+
Rules are method-aware, so a GET read route nested under a prefix that
|
|
37
|
+
also carries POST/PATCH mutations can be exempted without opening the
|
|
38
|
+
mutations. Called once at boot, in dependency order.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def register_body_limits(self, registry: BodyLimitRegistry) -> None:
|
|
42
|
+
"""Per-route request-body ceilings; see docs/framework/request-guards.md."""
|
|
43
|
+
|
|
44
|
+
def register_csp_sources(self, registry: CspSourceRegistry) -> None:
|
|
45
|
+
"""Declare external origins this module's frontend loads assets from.
|
|
46
|
+
|
|
47
|
+
The host ships a strict Content-Security-Policy; a module whose
|
|
48
|
+
pages pull a stylesheet, font, or API from another origin must
|
|
49
|
+
declare it here or the browser blocks the request::
|
|
50
|
+
|
|
51
|
+
def register_csp_sources(self, registry):
|
|
52
|
+
registry.add("style-src", "https://rsms.me")
|
|
53
|
+
registry.add("font-src", "https://rsms.me")
|
|
54
|
+
|
|
55
|
+
Fetch directives only; invalid declarations raise at boot. Runs once,
|
|
56
|
+
*before* DB settings hydrate — for an origin read from settings use
|
|
57
|
+
``registry.add_provider(lambda app: [(directive, source)])`` (sync,
|
|
58
|
+
in-memory state only, evaluated per response).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def register_middleware(self, app: FastAPI) -> None:
|
|
62
|
+
"""Add middleware to the application.
|
|
63
|
+
|
|
64
|
+
Called after core middleware (session, security headers) positioning
|
|
65
|
+
is established but before the app starts.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def register_exception_handlers(self, app: FastAPI) -> None:
|
|
69
|
+
"""Register custom exception handlers (after the framework's own)."""
|
|
70
|
+
|
|
71
|
+
def register_cors_policies(self, registry: CorsPolicyRegistry) -> None:
|
|
72
|
+
"""Declare cross-origin policies for this module's routes.
|
|
73
|
+
|
|
74
|
+
The host already allows its configured origins on ``/api/``; override
|
|
75
|
+
this for a surface with different rules, e.g. publicly served files::
|
|
76
|
+
|
|
77
|
+
def register_cors_policies(self, registry):
|
|
78
|
+
registry.add_prefix(
|
|
79
|
+
"/files/public/",
|
|
80
|
+
CorsPolicy(origins=("*",), allow_credentials=False, methods=("GET", "HEAD")),
|
|
81
|
+
enabled=lambda: self._public_cors_on(),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
The most specific path match wins (exact, regex, longest prefix);
|
|
85
|
+
``enabled`` is evaluated per request. A wildcard origin cannot be
|
|
86
|
+
combined with credentials. Called once at boot, in dependency order.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def register_rate_limits(self, registry: RateLimitRegistry) -> None:
|
|
90
|
+
"""Declare per-route rate limits for this module's endpoints.
|
|
91
|
+
|
|
92
|
+
::
|
|
93
|
+
|
|
94
|
+
def register_rate_limits(self, registry):
|
|
95
|
+
registry.add_exact(
|
|
96
|
+
"/api/orders/export", RateLimitRule("5/minute", key="user")
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
``key`` picks who shares a budget: the signed-in user, the client IP,
|
|
100
|
+
``user_or_ip`` (user when signed in, else IP) or the active tenant.
|
|
101
|
+
The most specific path match wins. Called once at boot, in dependency
|
|
102
|
+
order.
|
|
103
|
+
"""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The activity feed: "what has this person done lately", without the audit log.
|
|
2
|
+
|
|
3
|
+
The users edit page shows a card of the account's recent actions. The audit log
|
|
4
|
+
is what records them, but ``users`` must not import ``audit_log`` (and
|
|
5
|
+
``audit_log`` must not import ``users``): each is optional to the other, and
|
|
6
|
+
importing both ways was a cycle (GH #501).
|
|
7
|
+
|
|
8
|
+
So ``audit_log`` registers an :class:`ActivityFeed` with
|
|
9
|
+
:func:`set_activity_feed` and the card asks :func:`get_activity_feed`. ``None``
|
|
10
|
+
means nothing records activity on this install — a card that is absent, which
|
|
11
|
+
is a different claim from an empty one ("nobody did anything").
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Collection
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Any, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
ACTIVITY_FEED_STATE_ATTR = "activity_feed"
|
|
22
|
+
"""``app.state`` attribute holding the active :class:`ActivityFeed`."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ActivityItem:
|
|
27
|
+
"""One thing an actor did."""
|
|
28
|
+
|
|
29
|
+
at: datetime
|
|
30
|
+
action: str
|
|
31
|
+
entity_type: str
|
|
32
|
+
entity_id: str
|
|
33
|
+
#: The row's display name, when its owner can name it and the reader may
|
|
34
|
+
#: see that name; ``None`` otherwise (the caller shows a short id).
|
|
35
|
+
entity_label: str | None = None
|
|
36
|
+
#: The recorded change set, whatever its shape, for summarising which
|
|
37
|
+
#: fields moved. ``None`` when the recorder kept none.
|
|
38
|
+
changes: Any = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class ActivityFeed(Protocol):
|
|
43
|
+
async def recent_for_actor(
|
|
44
|
+
self,
|
|
45
|
+
db: Any,
|
|
46
|
+
actor_id: str,
|
|
47
|
+
*,
|
|
48
|
+
limit: int,
|
|
49
|
+
permissions: Collection[str],
|
|
50
|
+
) -> list[ActivityItem]:
|
|
51
|
+
"""The latest *limit* actions by *actor_id*, newest first.
|
|
52
|
+
|
|
53
|
+
*permissions* are the reader's grants: labels whose owner gated them
|
|
54
|
+
behind a permission the reader lacks come back as ``None``.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_activity_feed(app: Any, feed: ActivityFeed | None) -> None:
|
|
60
|
+
"""Install (or, with ``None``, remove) the app's activity feed."""
|
|
61
|
+
setattr(app.state, ACTIVITY_FEED_STATE_ATTR, feed)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_activity_feed(app: Any) -> ActivityFeed | None:
|
|
65
|
+
"""The app's activity feed, or ``None`` when nothing records activity."""
|
|
66
|
+
return getattr(app.state, ACTIVITY_FEED_STATE_ATTR, None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"ACTIVITY_FEED_STATE_ATTR",
|
|
71
|
+
"ActivityFeed",
|
|
72
|
+
"ActivityItem",
|
|
73
|
+
"get_activity_feed",
|
|
74
|
+
"set_activity_feed",
|
|
75
|
+
]
|
|
@@ -108,6 +108,33 @@ class AuditLink:
|
|
|
108
108
|
f"every row would link to the same page"
|
|
109
109
|
)
|
|
110
110
|
|
|
111
|
+
@classmethod
|
|
112
|
+
def for_model(
|
|
113
|
+
cls,
|
|
114
|
+
model: type,
|
|
115
|
+
*,
|
|
116
|
+
url_template: str,
|
|
117
|
+
label: str = "",
|
|
118
|
+
label_key: str = "",
|
|
119
|
+
label_resolver: LabelResolver | None = None,
|
|
120
|
+
label_permission: str = "",
|
|
121
|
+
) -> AuditLink:
|
|
122
|
+
"""Build the link for a SQLModel table class.
|
|
123
|
+
|
|
124
|
+
Fills ``entity_type`` from ``model.__name__`` (the class name, never the
|
|
125
|
+
table name) and ``table_name`` from ``model.__tablename__``, so a caller
|
|
126
|
+
cannot key it off the wrong one. ``model`` must be a table model.
|
|
127
|
+
"""
|
|
128
|
+
return cls(
|
|
129
|
+
entity_type=model.__name__,
|
|
130
|
+
url_template=url_template,
|
|
131
|
+
label=label,
|
|
132
|
+
label_key=label_key,
|
|
133
|
+
table_name=model.__tablename__,
|
|
134
|
+
label_resolver=label_resolver,
|
|
135
|
+
label_permission=label_permission,
|
|
136
|
+
)
|
|
137
|
+
|
|
111
138
|
def url_for(self, entity_id: str) -> str | None:
|
|
112
139
|
"""The row's page, or ``None`` when this kind of row has no screen."""
|
|
113
140
|
if not self.url_template:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The request/task correlation id, shared by every layer.
|
|
2
|
+
|
|
3
|
+
Lives in core rather than ``simple_module_hosting.logging`` (which re-exports
|
|
4
|
+
it) so the db layer can stamp audit records with it without importing the
|
|
5
|
+
hosting package (#498).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
|
|
12
|
+
correlation_id: ContextVar[str] = ContextVar("correlation_id", default="")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""CORS policy registry: modules declare which origins may call which paths.
|
|
2
|
+
|
|
3
|
+
The host registers a policy for ``/api/`` from its ``cors_*`` settings; a module
|
|
4
|
+
with a public, cross-origin surface (e.g. publicly served files) adds its own
|
|
5
|
+
through :meth:`~simple_module_core.module.ModuleBase.register_cors_policies`::
|
|
6
|
+
|
|
7
|
+
def register_cors_policies(self, registry):
|
|
8
|
+
registry.add_prefix(
|
|
9
|
+
"/files/public/",
|
|
10
|
+
CorsPolicy(origins=("*",), allow_credentials=False, methods=("GET", "HEAD")),
|
|
11
|
+
enabled=lambda: app_settings.public_files_cors,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
The most specific match wins (exact, then regex, then longest prefix); a tie
|
|
15
|
+
keeps the first registration and logs a warning. ``enabled`` is evaluated per
|
|
16
|
+
request, so a DB-backed toggle needs no restart.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from simple_module_core.path_rules import KIND_EXACT, KIND_PREFIX, KIND_REGEX, PathRuleSet
|
|
25
|
+
|
|
26
|
+
__all__ = ["CorsPolicy", "CorsPolicyRegistry"]
|
|
27
|
+
|
|
28
|
+
WILDCARD = "*"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class CorsPolicy:
|
|
33
|
+
origins: tuple[str, ...]
|
|
34
|
+
allow_credentials: bool = False
|
|
35
|
+
methods: tuple[str, ...] = ("GET",)
|
|
36
|
+
headers: tuple[str, ...] = ()
|
|
37
|
+
expose_headers: tuple[str, ...] = ()
|
|
38
|
+
max_age: int = 600
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if self.allow_credentials and WILDCARD in self.origins:
|
|
42
|
+
raise ValueError("A CORS policy cannot combine origin '*' with credentials")
|
|
43
|
+
if self.max_age < 0:
|
|
44
|
+
raise ValueError("max_age must be >= 0")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CorsPolicyRegistry:
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._rules: PathRuleSet[CorsPolicy] = PathRuleSet("CORS policy")
|
|
50
|
+
|
|
51
|
+
def add_exact(
|
|
52
|
+
self, path: str, policy: CorsPolicy, *, enabled: Callable[[], bool] | None = None
|
|
53
|
+
) -> None:
|
|
54
|
+
self._rules.add(KIND_EXACT, path, policy, enabled=enabled)
|
|
55
|
+
|
|
56
|
+
def add_prefix(
|
|
57
|
+
self, prefix: str, policy: CorsPolicy, *, enabled: Callable[[], bool] | None = None
|
|
58
|
+
) -> None:
|
|
59
|
+
self._rules.add(KIND_PREFIX, prefix, policy, enabled=enabled)
|
|
60
|
+
|
|
61
|
+
def add_regex(
|
|
62
|
+
self, pattern: str, policy: CorsPolicy, *, enabled: Callable[[], bool] | None = None
|
|
63
|
+
) -> None:
|
|
64
|
+
self._rules.add(KIND_REGEX, pattern, policy, enabled=enabled)
|
|
65
|
+
|
|
66
|
+
def match(self, method: str, path: str) -> CorsPolicy | None:
|
|
67
|
+
"""The governing policy for this request, or ``None`` (no CORS headers)."""
|
|
68
|
+
return self._rules.match(method, path)
|
|
69
|
+
|
|
70
|
+
def __len__(self) -> int:
|
|
71
|
+
return len(self._rules)
|