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.
Files changed (84) hide show
  1. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/PKG-INFO +4 -3
  2. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/README.md +3 -2
  3. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/pyproject.toml +1 -1
  4. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/__init__.py +10 -1
  5. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/__main__.py +53 -8
  6. simple_module_core-0.0.36/simple_module_core/_i18n_flatten.py +44 -0
  7. simple_module_core-0.0.36/simple_module_core/body_limits.py +71 -0
  8. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/csp.py +64 -18
  9. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/__init__.py +2 -0
  10. simple_module_core-0.0.36/simple_module_core/diagnostics/_expression_index.py +106 -0
  11. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_i18n.py +78 -2
  12. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_module.py +1 -0
  13. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_runner.py +22 -0
  14. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_tenancy.py +37 -4
  15. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/dotenv.py +1 -1
  16. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/i18n.py +29 -34
  17. simple_module_core-0.0.36/simple_module_core/i18n_overrides.py +48 -0
  18. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/menu.py +69 -3
  19. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/module.py +9 -7
  20. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/permissions.py +112 -9
  21. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/public_routes.py +44 -10
  22. simple_module_core-0.0.36/simple_module_core/rate_limit.py +126 -0
  23. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/services.py +7 -0
  24. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/setup_steps.py +64 -0
  25. simple_module_core-0.0.36/simple_module_core/tenancy.py +89 -0
  26. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_csp_registry.py +67 -1
  27. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_diagnostics_tenancy.py +22 -0
  28. simple_module_core-0.0.36/tests/test_doctor_cli_overrides.py +73 -0
  29. simple_module_core-0.0.36/tests/test_expression_index_diagnostics.py +86 -0
  30. simple_module_core-0.0.36/tests/test_i18n_overrides.py +113 -0
  31. simple_module_core-0.0.36/tests/test_permission_sources.py +52 -0
  32. simple_module_core-0.0.36/tests/test_public_routes_rate_precedence.py +23 -0
  33. simple_module_core-0.0.36/tests/test_tenant_exists.py +21 -0
  34. simple_module_core-0.0.36/tests/test_tenant_roles.py +33 -0
  35. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/.gitignore +0 -0
  36. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/LICENSE +0 -0
  37. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/audit_links.py +0 -0
  38. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/design_packs.py +0 -0
  39. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_coupling.py +0 -0
  40. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_css.py +0 -0
  41. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  42. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  43. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_migration.py +0 -0
  44. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_pages.py +0 -0
  45. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/diagnostics/_types.py +0 -0
  46. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/discovery.py +0 -0
  47. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/environments.py +0 -0
  48. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/events.py +0 -0
  49. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/exceptions.py +0 -0
  50. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/feature_flags.py +0 -0
  51. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/health.py +0 -0
  52. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/invalidation.py +0 -0
  53. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/module_meta.py +0 -0
  54. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/py.typed +0 -0
  55. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/redirect_safety.py +0 -0
  56. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/settings_base.py +0 -0
  57. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/simple_module_core/versioning.py +0 -0
  58. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_audit_links.py +0 -0
  59. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_auth_provider_selection.py +0 -0
  60. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_css_diagnostics.py +0 -0
  61. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_css_scanner.py +0 -0
  62. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_design_packs.py +0 -0
  63. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_diagnostics.py +0 -0
  64. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_discovery.py +0 -0
  65. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_dotenv.py +0 -0
  66. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_environments.py +0 -0
  67. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_events.py +0 -0
  68. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_feature_flags.py +0 -0
  69. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_feature_flags_decorator.py +0 -0
  70. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_health_registry.py +0 -0
  71. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_i18n.py +0 -0
  72. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_i18n_diagnostics.py +0 -0
  73. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_invalidation.py +0 -0
  74. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_invalidation_handlers.py +0 -0
  75. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_menu.py +0 -0
  76. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_module_base.py +0 -0
  77. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_module_diagnostics.py +0 -0
  78. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_permissions.py +0 -0
  79. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_public_routes.py +0 -0
  80. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_redirect_safety.py +0 -0
  81. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_services.py +0 -0
  82. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_settings_base.py +0 -0
  83. {simple_module_core-0.0.35 → simple_module_core-0.0.36}/tests/test_sm003_page_render.py +0 -0
  84. {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.35
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`–`SM021`) used by `make doctor`.
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 `SM021`.
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`–`SM021`) used by `make doctor`.
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 `SM021`.
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,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.35"
3
+ version = "0.0.36"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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/`` and shared
15
- ``packages/ui/locales/`` are picked up relative to the project root
16
- (``SM_PROJECT_ROOT``, else the directory of the discovered ``.env``, else the
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 = root / "packages" / "ui" / "locales"
88
- if ui_locales.is_dir():
89
- out.append(("packages/ui", "ui", ui_locales))
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
- _SOURCE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://)?)?(?:(?:\*\.)?[^\s;,'\"*]+)?$")
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
- if directive not in _EXTENDABLE_DIRECTIVES:
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
- if not self._sources:
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 self._sources.items():
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 = self._sources.get(base)
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)
@@ -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
+ ]