simple-module-core 0.0.37__tar.gz → 0.0.39__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/.gitignore +1 -2
  2. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/PKG-INFO +7 -7
  3. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/pyproject.toml +7 -7
  4. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/__init__.py +19 -0
  5. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/__main__.py +6 -0
  6. simple_module_core-0.0.39/simple_module_core/_i18n_translator.py +112 -0
  7. simple_module_core-0.0.39/simple_module_core/_permission_grants.py +74 -0
  8. simple_module_core-0.0.39/simple_module_core/_request_hooks.py +103 -0
  9. simple_module_core-0.0.39/simple_module_core/activity_feed.py +75 -0
  10. simple_module_core-0.0.39/simple_module_core/app_state.py +199 -0
  11. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/audit_links.py +27 -0
  12. simple_module_core-0.0.39/simple_module_core/correlation.py +12 -0
  13. simple_module_core-0.0.39/simple_module_core/cors.py +71 -0
  14. simple_module_core-0.0.39/simple_module_core/deprecation.py +111 -0
  15. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_coupling.py +59 -1
  16. simple_module_core-0.0.39/simple_module_core/diagnostics/_dependencies.py +36 -0
  17. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_module.py +7 -0
  18. simple_module_core-0.0.39/simple_module_core/diagnostics/_spa_mount.py +46 -0
  19. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/discovery.py +40 -14
  20. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/durable_events.py +6 -4
  21. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/events.py +25 -3
  22. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/exceptions.py +19 -0
  23. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/feature_flags.py +2 -0
  24. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/i18n.py +1 -95
  25. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/invalidation.py +7 -3
  26. simple_module_core-0.0.39/simple_module_core/leases.py +281 -0
  27. simple_module_core-0.0.39/simple_module_core/mail.py +121 -0
  28. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/menu.py +6 -1
  29. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/module.py +41 -104
  30. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/module_meta.py +13 -1
  31. simple_module_core-0.0.39/simple_module_core/module_settings.py +108 -0
  32. simple_module_core-0.0.39/simple_module_core/path_rules.py +121 -0
  33. simple_module_core-0.0.39/simple_module_core/permission_denied.py +28 -0
  34. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/permissions.py +21 -74
  35. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/public_routes.py +27 -2
  36. simple_module_core-0.0.39/simple_module_core/rate_limit.py +228 -0
  37. simple_module_core-0.0.39/simple_module_core/resources.py +141 -0
  38. simple_module_core-0.0.39/simple_module_core/secret_fields.py +137 -0
  39. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/services.py +10 -0
  40. simple_module_core-0.0.39/simple_module_core/settings_hydrate.py +194 -0
  41. simple_module_core-0.0.39/simple_module_core/spa_mount.py +58 -0
  42. simple_module_core-0.0.39/simple_module_core/stored_settings.py +143 -0
  43. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/tenancy.py +33 -2
  44. simple_module_core-0.0.39/simple_module_core/tenant_events.py +20 -0
  45. simple_module_core-0.0.39/simple_module_core/user_directory.py +192 -0
  46. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/versioning.py +11 -0
  47. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_audit_links.py +30 -0
  48. simple_module_core-0.0.39/tests/test_core_app_state.py +57 -0
  49. simple_module_core-0.0.39/tests/test_core_leases.py +162 -0
  50. simple_module_core-0.0.39/tests/test_core_lifecycle_docs.py +20 -0
  51. simple_module_core-0.0.39/tests/test_core_mail_resolution.py +107 -0
  52. simple_module_core-0.0.39/tests/test_core_module_settings.py +66 -0
  53. simple_module_core-0.0.39/tests/test_core_user_directory.py +152 -0
  54. simple_module_core-0.0.39/tests/test_deprecation_helper.py +59 -0
  55. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_discovery.py +1 -1
  56. simple_module_core-0.0.39/tests/test_discovery_missing_deps.py +108 -0
  57. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_events.py +8 -3
  58. simple_module_core-0.0.39/tests/test_events_inline.py +38 -0
  59. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_feature_flags.py +2 -2
  60. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_feature_flags_decorator.py +2 -2
  61. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_module_base.py +2 -2
  62. simple_module_core-0.0.39/tests/test_path_rule_registries.py +78 -0
  63. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_services.py +15 -15
  64. simple_module_core-0.0.39/tests/test_sm009_dynamic_coupling.py +84 -0
  65. simple_module_core-0.0.37/simple_module_core/rate_limit.py +0 -126
  66. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/LICENSE +0 -0
  67. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/README.md +0 -0
  68. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/_i18n_flatten.py +0 -0
  69. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/body_limits.py +0 -0
  70. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/csp.py +0 -0
  71. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/design_packs.py +0 -0
  72. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/__init__.py +0 -0
  73. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_css.py +0 -0
  74. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_db_extensions.py +0 -0
  75. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_expression_index.py +0 -0
  76. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_heavy_task_imports.py +0 -0
  77. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_i18n.py +0 -0
  78. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  79. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  80. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_migration.py +0 -0
  81. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_pages.py +0 -0
  82. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_runner.py +0 -0
  83. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_tenancy.py +0 -0
  84. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/diagnostics/_types.py +0 -0
  85. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/dotenv.py +0 -0
  86. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/environments.py +0 -0
  87. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/health.py +0 -0
  88. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/i18n_overrides.py +0 -0
  89. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/py.typed +0 -0
  90. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/redirect_safety.py +0 -0
  91. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/settings_base.py +0 -0
  92. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/simple_module_core/setup_steps.py +0 -0
  93. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_auth_provider_selection.py +0 -0
  94. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_csp_registry.py +0 -0
  95. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_css_diagnostics.py +0 -0
  96. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_css_scanner.py +0 -0
  97. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_db_extensions_diagnostics.py +0 -0
  98. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_design_packs.py +0 -0
  99. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_diagnostics.py +0 -0
  100. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_diagnostics_tenancy.py +0 -0
  101. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_doctor_cli_overrides.py +0 -0
  102. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_dotenv.py +0 -0
  103. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_durable_events.py +0 -0
  104. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_environments.py +0 -0
  105. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_expression_index_diagnostics.py +0 -0
  106. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_health_registry.py +0 -0
  107. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_heavy_task_imports_diagnostics.py +0 -0
  108. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_i18n.py +0 -0
  109. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_i18n_diagnostics.py +0 -0
  110. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_i18n_overrides.py +0 -0
  111. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_invalidation.py +0 -0
  112. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_invalidation_handlers.py +0 -0
  113. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_menu.py +0 -0
  114. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_module_diagnostics.py +0 -0
  115. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_permission_sources.py +0 -0
  116. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_permissions.py +0 -0
  117. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_permissions_admin_principal.py +0 -0
  118. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_public_routes.py +0 -0
  119. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_public_routes_rate_precedence.py +0 -0
  120. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_redirect_safety.py +0 -0
  121. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_settings_base.py +0 -0
  122. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_sm003_page_render.py +0 -0
  123. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_tenant_exists.py +0 -0
  124. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_tenant_roles.py +0 -0
  125. {simple_module_core-0.0.37 → simple_module_core-0.0.39}/tests/test_versioning.py +0 -0
@@ -7,8 +7,6 @@ build/
7
7
  .venv/
8
8
  *.egg
9
9
 
10
- # UV
11
- uv.lock
12
10
 
13
11
  # Node
14
12
  node_modules/
@@ -65,3 +63,4 @@ host/client_app/.playwright-cli/*
65
63
  .qa/
66
64
  .verify/
67
65
  /openapi.json
66
+ qa-shots/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_core
3
- Version: 0.0.37
3
+ Version: 0.0.39
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
@@ -21,12 +21,12 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: babel>=2.14
25
- Requires-Dist: fastapi>=0.115
26
- Requires-Dist: packaging>=23.0
27
- Requires-Dist: pydantic-settings>=2.0
28
- Requires-Dist: pydantic>=2.0
29
- Requires-Dist: pyee>=12.0
24
+ Requires-Dist: babel<3,>=2.14
25
+ Requires-Dist: fastapi<0.157,>=0.140
26
+ Requires-Dist: packaging<27,>=23.0
27
+ Requires-Dist: pydantic-settings<3,>=2.0
28
+ Requires-Dist: pydantic<3,>=2.0
29
+ Requires-Dist: pyee<15,>=12.0
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # simple_module_core
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.37"
3
+ version = "0.0.39"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,12 +21,12 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "babel>=2.14",
25
- "fastapi>=0.115",
26
- "packaging>=23.0",
27
- "pydantic>=2.0",
28
- "pydantic-settings>=2.0",
29
- "pyee>=12.0",
24
+ "babel>=2.14,<3",
25
+ "fastapi>=0.140,<0.157",
26
+ "packaging>=23.0,<27",
27
+ "pydantic>=2.0,<3",
28
+ "pydantic-settings>=2.0,<3",
29
+ "pyee>=12.0,<15",
30
30
  ]
31
31
 
32
32
  [project.urls]
@@ -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,112 @@
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
+ _plural_failure_warned = False
18
+
19
+
20
+ @lru_cache(maxsize=64)
21
+ def _plural_rule(locale: str):
22
+ """Cached CLDR plural rule for a locale tag (e.g. 'en', 'ru', 'pt_BR')."""
23
+ return Locale.parse(locale).plural_form
24
+
25
+
26
+ def _plural_form(locale: str, count: float) -> str:
27
+ """Return CLDR plural category ('one', 'few', 'many', 'other', ...).
28
+
29
+ Falls back to 'other' if the locale cannot be parsed by Babel.
30
+ """
31
+ try:
32
+ rule = _plural_rule(locale)
33
+ except Exception:
34
+ global _plural_failure_warned
35
+ if not _plural_failure_warned:
36
+ _plural_failure_warned = True
37
+ logger.warning("Plural locale parsing failed; using other", exc_info=True)
38
+ return "other"
39
+ return rule(count)
40
+
41
+
42
+ class _SafeFormatDict(dict):
43
+ """Dict that returns ``{key}`` for missing keys so str.format_map doesn't raise."""
44
+
45
+ def __missing__(self, key: str) -> str:
46
+ return "{" + key + "}"
47
+
48
+
49
+ class Translator:
50
+ """Request-scoped translator bound to a specific locale.
51
+
52
+ Construct via::
53
+
54
+ Translator(registry, locale=request.state.locale, default_locale="en")
55
+
56
+ Resolution order for :meth:`t`:
57
+
58
+ 1. Look up key in ``locale``; if missing, fall back to ``default_locale``.
59
+ 2. If still missing, return the key itself (with a debug log).
60
+ 3. Interpolate ``{name}``-style placeholders using supplied kwargs.
61
+ Missing placeholders are left as ``{name}`` (not raised).
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ registry: I18nRegistry,
67
+ locale: str,
68
+ default_locale: str,
69
+ ) -> None:
70
+ self._registry = registry
71
+ self.locale = locale
72
+ self.default_locale = default_locale
73
+
74
+ def t(self, key: str, **params: Any) -> str:
75
+ """Translate ``key`` with optional interpolation and plural resolution.
76
+
77
+ When ``count`` is in params, look up ``<key>_<plural_form>`` using
78
+ Babel's CLDR plural rule for the active locale, falling back to
79
+ ``<key>_other`` and finally ``<key>``.
80
+ """
81
+ resolved_key = self._resolve_plural_key(key, params)
82
+ template = self._lookup(resolved_key)
83
+ if template is None and resolved_key != key:
84
+ template = self._lookup(key)
85
+ if template is None:
86
+ logger.debug("i18n: missing key '%s' in locale '%s'", key, self.locale)
87
+ return key
88
+ return template.format_map(_SafeFormatDict(params))
89
+
90
+ def _resolve_plural_key(self, key: str, params: dict[str, Any]) -> str:
91
+ count = params.get("count")
92
+ if count is None:
93
+ return key
94
+ form = _plural_form(self.locale, count)
95
+ # Prefer the exact form; fall back to _other if that form has no entry.
96
+ candidate = f"{key}_{form}"
97
+ if self._lookup(candidate) is not None:
98
+ return candidate
99
+ other = f"{key}_other"
100
+ if self._lookup(other) is not None:
101
+ return other
102
+ return key
103
+
104
+ def _lookup(self, key: str) -> str | None:
105
+ msgs = self._registry.messages(self.locale)
106
+ if key in msgs:
107
+ return msgs[key]
108
+ if self.locale != self.default_locale:
109
+ default = self._registry.messages(self.default_locale)
110
+ if key in default:
111
+ return default[key]
112
+ 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
+ ]