simple-module-core 0.0.32__tar.gz → 0.0.33__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 (68) hide show
  1. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/__init__.py +5 -1
  4. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/audit_links.py +47 -4
  5. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/__init__.py +2 -0
  6. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_pages.py +67 -9
  7. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/i18n.py +22 -2
  8. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/menu.py +34 -2
  9. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/module.py +34 -29
  10. simple_module_core-0.0.33/simple_module_core/module_meta.py +53 -0
  11. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/permissions.py +15 -2
  12. simple_module_core-0.0.33/simple_module_core/redirect_safety.py +78 -0
  13. simple_module_core-0.0.33/simple_module_core/services.py +95 -0
  14. simple_module_core-0.0.33/simple_module_core/settings_base.py +47 -0
  15. simple_module_core-0.0.33/simple_module_core/setup_steps.py +141 -0
  16. simple_module_core-0.0.33/tests/test_audit_links.py +147 -0
  17. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_i18n.py +35 -0
  18. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_menu.py +59 -0
  19. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_module_diagnostics.py +0 -36
  20. simple_module_core-0.0.33/tests/test_redirect_safety.py +59 -0
  21. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_services.py +6 -1
  22. simple_module_core-0.0.33/tests/test_settings_base.py +75 -0
  23. simple_module_core-0.0.33/tests/test_sm003_page_render.py +122 -0
  24. simple_module_core-0.0.32/simple_module_core/services.py +0 -51
  25. simple_module_core-0.0.32/tests/test_audit_links.py +0 -57
  26. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/.gitignore +0 -0
  27. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/LICENSE +0 -0
  28. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/README.md +0 -0
  29. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/__main__.py +0 -0
  30. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/csp.py +0 -0
  31. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/design_packs.py +0 -0
  32. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_coupling.py +0 -0
  33. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_css.py +0 -0
  34. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_i18n.py +0 -0
  35. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  36. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  37. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_migration.py +0 -0
  38. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_module.py +0 -0
  39. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_runner.py +0 -0
  40. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/diagnostics/_types.py +0 -0
  41. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/discovery.py +0 -0
  42. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/dotenv.py +0 -0
  43. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/environments.py +0 -0
  44. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/events.py +0 -0
  45. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/exceptions.py +0 -0
  46. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/feature_flags.py +0 -0
  47. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/health.py +0 -0
  48. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/public_routes.py +0 -0
  49. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/py.typed +0 -0
  50. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/simple_module_core/versioning.py +0 -0
  51. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_auth_provider_selection.py +0 -0
  52. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_csp_registry.py +0 -0
  53. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_css_diagnostics.py +0 -0
  54. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_css_scanner.py +0 -0
  55. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_design_packs.py +0 -0
  56. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_diagnostics.py +0 -0
  57. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_discovery.py +0 -0
  58. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_dotenv.py +0 -0
  59. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_environments.py +0 -0
  60. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_events.py +0 -0
  61. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_feature_flags.py +0 -0
  62. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_feature_flags_decorator.py +0 -0
  63. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_health_registry.py +0 -0
  64. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_i18n_diagnostics.py +0 -0
  65. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_module_base.py +0 -0
  66. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_permissions.py +0 -0
  67. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/tests/test_public_routes.py +0 -0
  68. {simple_module_core-0.0.32 → simple_module_core-0.0.33}/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.32
3
+ Version: 0.0.33
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.32"
3
+ version = "0.0.33"
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,6 @@
1
1
  """SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
2
2
 
3
- from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
3
+ from simple_module_core.audit_links import AuditLink, AuditLinkRegistry, LabelResolver
4
4
  from simple_module_core.csp import CspSourceError, CspSourceRegistry
5
5
  from simple_module_core.design_packs import DesignPack, DesignPackRegistry
6
6
  from simple_module_core.diagnostics import (
@@ -41,6 +41,7 @@ from simple_module_core.module import ModuleBase, ModuleMeta
41
41
  from simple_module_core.permissions import PermissionRegistry
42
42
  from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
43
43
  from simple_module_core.services import Services
44
+ from simple_module_core.setup_steps import SetupRegistry, SetupStep
44
45
  from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
45
46
 
46
47
  __all__ = [
@@ -65,6 +66,7 @@ __all__ = [
65
66
  "HealthStatus",
66
67
  "I18nRegistry",
67
68
  "InvalidModuleError",
69
+ "LabelResolver",
68
70
  "MenuItem",
69
71
  "MenuRegistry",
70
72
  "MenuSection",
@@ -77,6 +79,8 @@ __all__ = [
77
79
  "PublicRoute",
78
80
  "PublicRouteRegistry",
79
81
  "Services",
82
+ "SetupRegistry",
83
+ "SetupStep",
80
84
  "Translator",
81
85
  "ValidationError",
82
86
  "check_framework_compatibility",
@@ -19,7 +19,23 @@ the target route as usual.
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- from dataclasses import dataclass
22
+ from collections.abc import Awaitable, Callable
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ LabelResolver = Callable[[Any, list[str]], Awaitable[dict[str, str]]]
27
+ """Batch "what is this row called" for one entity type.
28
+
29
+ Called with the request's database session and every entity id of that type on
30
+ the page, and returns ``{entity_id: display name}``. Ids it cannot name are
31
+ simply absent — the caller falls back to the id, which is what the audit row
32
+ actually stored.
33
+
34
+ The session is typed ``Any`` because ``simple_module_core`` must not depend on
35
+ SQLAlchemy; in practice it is the ``AsyncSession`` the request is already
36
+ using, so a resolver adds one query per entity type per page and no session
37
+ management of its own.
38
+ """
23
39
 
24
40
  _ID_PLACEHOLDER = "{id}"
25
41
 
@@ -36,24 +52,51 @@ class AuditLink:
36
52
  unmatched lookup falls back to showing ``entity_type`` as the
37
53
  label, a table-name key looks like it worked.
38
54
  url_template: Path containing ``{id}``, substituted with the entity id
39
- (e.g. ``"/admin/users/{id}/edit"``).
55
+ (e.g. ``"/admin/users/{id}/edit"``). Empty means *these rows have
56
+ no screen* — join tables and stored files are audited but have
57
+ nowhere to open — and the cell renders the name unlinked. A module
58
+ still registers those, because it is the only thing that knows what
59
+ the table is called and what its rows are named.
40
60
  label: Human-readable name for the entity kind, shown instead of the
41
61
  raw class name (e.g. ``"User account"``).
62
+ label_key: Catalog key for ``label``. Empty, or unresolved, falls back
63
+ to ``label`` — a missing translation shows English rather than a
64
+ raw dotted key. Rows are rendered server-side, so the audit view
65
+ translates these before they reach the page.
66
+ table_name: ``__tablename__`` of the table these rows live in
67
+ (``"users_user"``), shown as the type tag beside the row's name.
68
+ Not derivable from ``entity_type`` in either direction, so the
69
+ module that owns the table states both. ``None`` falls back to the
70
+ class name.
71
+ label_resolver: Optional :data:`LabelResolver` naming individual rows.
72
+ Without it a reader gets a primary key and has to paste it into
73
+ another screen to learn which record changed; only the owning
74
+ module knows that a ``Setting`` is named by its key and a ``User``
75
+ by ``full_name or email``. Excluded from equality: it is typically
76
+ a closure, and two boots would build two unequal objects out of one
77
+ module — a registry conflict is about two modules claiming one
78
+ entity type, which the identity fields already express.
42
79
  """
43
80
 
44
81
  entity_type: str
45
82
  url_template: str
46
83
  label: str = ""
84
+ label_key: str = ""
85
+ table_name: str | None = None
86
+ label_resolver: LabelResolver | None = field(default=None, compare=False)
47
87
 
48
88
  def __post_init__(self) -> None:
49
- if _ID_PLACEHOLDER not in self.url_template:
89
+ if self.url_template and _ID_PLACEHOLDER not in self.url_template:
50
90
  raise ValueError(
51
91
  f"AuditLink for {self.entity_type!r} has url_template "
52
92
  f"{self.url_template!r}, which contains no {_ID_PLACEHOLDER} — "
53
93
  f"every row would link to the same page"
54
94
  )
55
95
 
56
- def url_for(self, entity_id: str) -> str:
96
+ def url_for(self, entity_id: str) -> str | None:
97
+ """The row's page, or ``None`` when this kind of row has no screen."""
98
+ if not self.url_template:
99
+ return None
57
100
  return self.url_template.replace(_ID_PLACEHOLDER, entity_id)
58
101
 
59
102
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  from simple_module_core.diagnostics._i18n import I18nDiagnostics
11
11
  from simple_module_core.diagnostics._migration import MigrationDiagnostics
12
12
  from simple_module_core.diagnostics._module import ModuleDiagnostics
13
+ from simple_module_core.diagnostics._pages import collect_tsx_pages
13
14
  from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics
14
15
  from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
15
16
 
@@ -19,6 +20,7 @@ __all__ = [
19
20
  "I18nDiagnostics",
20
21
  "MigrationDiagnostics",
21
22
  "ModuleDiagnostics",
23
+ "collect_tsx_pages",
22
24
  "print_diagnostics",
23
25
  "run_diagnostics",
24
26
  ]
@@ -12,17 +12,62 @@ if TYPE_CHECKING:
12
12
  from simple_module_core.module import ModuleBase
13
13
 
14
14
 
15
+ def _assignment(s: ast.stmt) -> tuple[ast.Name, ast.expr] | None:
16
+ """Return ``(target, value)`` for a single-target top-level assignment.
17
+
18
+ Covers both ``NAME = ...`` and the annotated ``NAME: Final = ...`` form
19
+ module constants are conventionally written in.
20
+ """
21
+ if isinstance(s, ast.Assign) and len(s.targets) == 1 and isinstance(s.targets[0], ast.Name):
22
+ return s.targets[0], s.value
23
+ if isinstance(s, ast.AnnAssign) and isinstance(s.target, ast.Name) and s.value is not None:
24
+ return s.target, s.value
25
+ return None
26
+
27
+
15
28
  def _module_level_str_consts(tree: ast.Module) -> dict[str, str]:
16
29
  """Return ``{name: literal}`` for top-level ``NAME = "string"`` assignments."""
17
- return {
18
- s.targets[0].id: s.value.value
19
- for s in tree.body
20
- if isinstance(s, ast.Assign)
21
- and len(s.targets) == 1
22
- and isinstance(s.targets[0], ast.Name)
23
- and isinstance(s.value, ast.Constant)
24
- and isinstance(s.value.value, str)
25
- }
30
+ consts: dict[str, str] = {}
31
+ for s in tree.body:
32
+ assignment = _assignment(s)
33
+ if assignment is None:
34
+ continue
35
+ target, value = assignment
36
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
37
+ consts[target.id] = value.value
38
+ return consts
39
+
40
+
41
+ def _resolve_fstring_consts(tree: ast.Module, consts: dict[str, str]) -> dict[str, str]:
42
+ """Resolve top-level ``NAME = f"{CONST}/lit"`` against already-known consts.
43
+
44
+ Only plain interpolations of known string constants count — a conversion,
45
+ format spec, or unknown name makes the value non-static, so it is skipped
46
+ rather than guessed at.
47
+ """
48
+ resolved: dict[str, str] = {}
49
+ for s in tree.body:
50
+ assignment = _assignment(s)
51
+ if assignment is None or not isinstance(assignment[1], ast.JoinedStr):
52
+ continue
53
+ target, value = assignment
54
+ parts: list[str] = []
55
+ for piece in value.values:
56
+ if isinstance(piece, ast.Constant) and isinstance(piece.value, str):
57
+ parts.append(piece.value)
58
+ elif (
59
+ isinstance(piece, ast.FormattedValue)
60
+ and isinstance(piece.value, ast.Name)
61
+ and piece.value.id in consts
62
+ and piece.conversion == -1
63
+ and piece.format_spec is None
64
+ ):
65
+ parts.append(consts[piece.value.id])
66
+ else:
67
+ break
68
+ else:
69
+ resolved[target.id] = "".join(parts)
70
+ return resolved
26
71
 
27
72
 
28
73
  def _iter_render_components(tree: ast.Module, consts: dict[str, str]) -> list[str]:
@@ -79,6 +124,19 @@ def find_render_calls(mod: ModuleBase, src_dir: Path) -> set[str]:
79
124
  consts: dict[str, str] = {}
80
125
  for tree in trees:
81
126
  consts.update(_module_level_str_consts(tree))
127
+ # Then f-string constants built from the plain ones above
128
+ # (``PAGE = f"{MODULE_NAME}/Browse"`` is the conventional shape). Repeat to
129
+ # a fixed point so a chain — ``PREFIX = f"{NAME}/sub"`` then
130
+ # ``PAGE = f"{PREFIX}/Browse"`` — resolves too; one pass would only learn
131
+ # the first hop and report the page as an SM003 orphan.
132
+ while True:
133
+ resolved: dict[str, str] = {}
134
+ for tree in trees:
135
+ resolved.update(_resolve_fstring_consts(tree, consts))
136
+ new = {k: v for k, v in resolved.items() if consts.get(k) != v}
137
+ if not new:
138
+ break
139
+ consts.update(new)
82
140
 
83
141
  prefix = f"{mod.meta.name}/"
84
142
  rendered: set[str] = set()
@@ -145,8 +145,18 @@ class I18nRegistry:
145
145
  # Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
146
146
  # once here rather than on every Inertia render. The public variant
147
147
  # (admin namespaces excluded) is what anonymous visitors receive.
148
- self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()}
149
- self._public_snapshots = public_messages
148
+ #
149
+ # Each non-default locale layers over the default one, so a key it has
150
+ # not translated yet resolves to the default language instead of
151
+ # reaching the browser missing. The client cannot recover on its own:
152
+ # i18next is initialised with ``fallbackLng`` equal to the active
153
+ # locale, so an absent key renders as the raw dotted key — a partially
154
+ # translated locale would put "dashboard.home.health" on screen. The
155
+ # server-side ``Translator`` has always fallen back this way; this
156
+ # makes the payload behave the same, and makes partial translations a
157
+ # safe, incremental state to be in.
158
+ self._message_snapshots = self._layered_snapshots(self._messages)
159
+ self._public_snapshots = self._layered_snapshots(public_messages)
150
160
  self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
151
161
  self._available_locales_list = list(self._available_locales)
152
162
  self._loaded = True
@@ -162,6 +172,16 @@ class I18nRegistry:
162
172
  return self._available_locales_list
163
173
  return [locale for locale, msgs in self._messages.items() if msgs]
164
174
 
175
+ def _layered_snapshots(self, messages: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
176
+ """Snapshot per locale, each layered over the default locale's."""
177
+ base = messages.get(self.default_locale, {})
178
+ return {
179
+ locale: dict(msgs)
180
+ if locale == self.default_locale
181
+ else {**base, **msgs} # translated entries win; the rest read as default
182
+ for locale, msgs in messages.items()
183
+ }
184
+
165
185
  def messages(self, locale: str) -> Mapping[str, str]:
166
186
  """Flat dotted-key map for the given locale. Empty mapping if unknown.
167
187
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from dataclasses import dataclass, field
6
7
  from enum import StrEnum
7
8
  from typing import Literal
@@ -24,6 +25,15 @@ class MenuItem:
24
25
 
25
26
  label: str
26
27
  url: str
28
+ label_key: str = ""
29
+ """Catalog key for ``label``. Empty = ship ``label`` verbatim.
30
+
31
+ Menu labels are rendered on every page, so they are translated on the
32
+ server — the payload carries finished text and every render site (sidebar,
33
+ topbar, command palette) keeps working untouched. A key that resolves to
34
+ nothing falls back to ``label``, so a missing translation degrades to
35
+ English rather than to a raw dotted key on screen.
36
+ """
27
37
  icon: str = ""
28
38
  order: int = 0
29
39
  section: MenuSection = MenuSection.SIDEBAR
@@ -42,6 +52,14 @@ class MenuItem:
42
52
  method: MenuItemMethod = "get"
43
53
  """HTTP method used when the item is activated. ``"post"`` renders as an
44
54
  Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
55
+ group_key: str = ""
56
+ """Catalog key for ``group``, with the same fallback rule as ``label_key``.
57
+
58
+ Group headers are shared vocabulary — several modules file entries under
59
+ "Administration" — so they live in the ``ui`` namespace. Letting each
60
+ module invent its own key would let one translation drift from another and
61
+ split a single header in two.
62
+ """
45
63
  group: str = ""
46
64
  """Sidebar group label. Empty = ungrouped (renders flat, no header).
47
65
  Items in the same section that share a group are visually clustered under a
@@ -79,6 +97,7 @@ class MenuRegistry:
79
97
  is_authenticated: bool,
80
98
  roles: list[str] | None = None,
81
99
  permissions: list[str] | None = None,
100
+ translate: Callable[[str], str] | None = None,
82
101
  ) -> dict[str, list[dict]]:
83
102
  """Return menu items grouped by section, filtered by auth/roles/permissions.
84
103
 
@@ -86,12 +105,25 @@ class MenuRegistry:
86
105
  wildcards). Items declaring permissions the caller lacks are dropped,
87
106
  so the sidebar never offers a screen that will 403 on click.
88
107
 
108
+ ``translate`` resolves ``label_key``/``group_key`` against the request's
109
+ locale. Omitting it (or omitting the keys) ships the literal ``label``
110
+ and ``group``, which is what third-party modules predating the keys do.
111
+
89
112
  Returns a dict ready to be serialized into Inertia shared props.
90
113
  """
91
114
  roles = roles or []
92
115
  granted = set(permissions or [])
93
116
  result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}
94
117
 
118
+ def render(key: str, fallback: str) -> str:
119
+ # Translator.t() echoes the key back when the catalog has no entry.
120
+ # Showing "users.nav.users" in the sidebar would be worse than the
121
+ # English it replaced, so an unresolved key keeps the literal.
122
+ if not key or translate is None:
123
+ return fallback
124
+ translated = translate(key)
125
+ return fallback if translated == key else translated
126
+
95
127
  for item in self.all_items:
96
128
  if item.requires_auth and not is_authenticated:
97
129
  continue
@@ -103,11 +135,11 @@ class MenuRegistry:
103
135
  continue
104
136
  result[item.section.value].append(
105
137
  {
106
- "label": item.label,
138
+ "label": render(item.label_key, item.label),
107
139
  "url": item.url,
108
140
  "icon": item.icon,
109
141
  "method": item.method,
110
- "group": item.group,
142
+ "group": render(item.group_key, item.group),
111
143
  }
112
144
  )
113
145
 
@@ -1,12 +1,13 @@
1
- """Module base class and metadata."""
1
+ """Module lifecycle base class. ``ModuleMeta`` lives in ``module_meta``."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  from abc import ABC
6
- from dataclasses import dataclass, field
7
6
  from pathlib import Path
8
7
  from typing import TYPE_CHECKING
9
8
 
9
+ from simple_module_core.module_meta import ModuleMeta
10
+
10
11
  if TYPE_CHECKING:
11
12
  from fastapi import APIRouter, FastAPI
12
13
 
@@ -19,33 +20,7 @@ if TYPE_CHECKING:
19
20
  from simple_module_core.menu import MenuRegistry
20
21
  from simple_module_core.permissions import PermissionRegistry
21
22
  from simple_module_core.public_routes import PublicRouteRegistry
22
-
23
-
24
- @dataclass(frozen=True)
25
- class ModuleMeta:
26
- """Metadata describing a module."""
27
-
28
- name: str
29
- route_prefix: str = ""
30
- view_prefix: str = ""
31
- depends_on: list[str] = field(default_factory=list)
32
- version: str = "1.0.0"
33
- requires_framework: str | None = None
34
- """PEP 440 specifier for the framework API version this module supports.
35
-
36
- Example: ``">=1.0,<2.0"``. When set, the module is rejected at boot if the
37
- installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
38
- When ``None``, no compatibility check is performed (legacy modules).
39
- """
40
- i18n_audience: str = "public"
41
- """Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
42
-
43
- ``"public"`` (the default) ships the catalog in every Inertia payload.
44
- ``"admin"`` ships it only to authenticated users — declare it on modules
45
- whose UI sits entirely behind login (settings, permissions, dashboards) so
46
- anonymous visitors don't download admin form labels on every public page.
47
- The catalog is always available server-side (``Translator``) either way.
48
- """
23
+ from simple_module_core.setup_steps import SetupRegistry
49
24
 
50
25
 
51
26
  class ModuleBase(ABC):
@@ -111,6 +86,19 @@ class ModuleBase(ABC):
111
86
  ) -> None:
112
87
  """Register API endpoints and Inertia view routes."""
113
88
 
89
+ def register_admin_routes(self, admin_router: APIRouter) -> None:
90
+ """Register admin-only view routes, mounted at ``meta.admin_view_prefix``.
91
+
92
+ Only needed by modules that serve both public and administrative
93
+ pages — see ``ModuleMeta.admin_view_prefix``. A module whose views are
94
+ all administrative should point ``view_prefix`` at ``/admin/...`` and
95
+ keep using ``register_routes``.
96
+
97
+ Nothing here is gated automatically: the prefix is a URL convention,
98
+ not a permission. Guard these routes with the same dependencies you
99
+ would use anywhere else.
100
+ """
101
+
114
102
  def register_menu_items(self, registry: MenuRegistry) -> None:
115
103
  """Contribute menu items visible in the UI."""
116
104
 
@@ -131,6 +119,20 @@ class ModuleBase(ABC):
131
119
  def register_health_checks(self, registry: HealthRegistry) -> None:
132
120
  """Contribute health checks for the ``/health/ready`` endpoint."""
133
121
 
122
+ def register_setup_steps(self, registry: SetupRegistry) -> None:
123
+ """Declare what this module needs before the install is usable.
124
+
125
+ While any required step reports incomplete, ``SetupMiddleware`` serves
126
+ the first-run wizard instead of the app::
127
+
128
+ registry.add(SetupStep(id="users.administrator", ...))
129
+
130
+ A module that registers nothing never gates the app — that is how an
131
+ external identity provider opts out. See docs/module-authoring.md.
132
+
133
+ Called once at boot, in dependency order.
134
+ """
135
+
134
136
  def register_public_routes(self, registry: PublicRouteRegistry) -> None:
135
137
  """Declare routes that must bypass authentication (anonymous access).
136
138
 
@@ -272,3 +274,6 @@ class ModuleBase(ABC):
272
274
 
273
275
  async def on_shutdown(self, app: FastAPI) -> None:
274
276
  """Called during app shutdown."""
277
+
278
+
279
+ __all__ = ["ModuleBase", "ModuleMeta"]
@@ -0,0 +1,53 @@
1
+ """Module metadata — the declarative half of a module definition.
2
+
3
+ Split from ``module.py`` so that file holds only the lifecycle base class.
4
+ ``ModuleMeta`` is what a module *declares* about itself (name, mount
5
+ points, dependencies, compatibility); ``ModuleBase`` is what it *does*.
6
+ They change for different reasons and are read by different callers —
7
+ discovery reads the metadata, the host invokes the hooks.
8
+
9
+ Re-exported from ``module.py``, so every existing import still works.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ModuleMeta:
19
+ """Metadata describing a module."""
20
+
21
+ name: str
22
+ route_prefix: str = ""
23
+ view_prefix: str = ""
24
+ admin_view_prefix: str = ""
25
+ """Mount point for admin-only view routes, e.g. ``"/admin/users"``.
26
+
27
+ A module gets exactly one ``view_prefix``, which is a problem for modules
28
+ that are only *partly* administrative: ``users`` serves ``/users/login``
29
+ and the user-management CRUD from the same package, and those belong in
30
+ different places in the URL space. Declaring this gives such a module a
31
+ second view router mounted here, populated by ``register_admin_routes``.
32
+
33
+ Modules that are administrative end to end don't need it — they just point
34
+ ``view_prefix`` at ``/admin/...`` directly.
35
+ """
36
+ depends_on: list[str] = field(default_factory=list)
37
+ version: str = "1.0.0"
38
+ requires_framework: str | None = None
39
+ """PEP 440 specifier for the framework API version this module supports.
40
+
41
+ Example: ``">=1.0,<2.0"``. When set, the module is rejected at boot if the
42
+ installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
43
+ When ``None``, no compatibility check is performed (legacy modules).
44
+ """
45
+ i18n_audience: str = "public"
46
+ """Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
47
+
48
+ ``"public"`` (the default) ships the catalog in every Inertia payload.
49
+ ``"admin"`` ships it only to authenticated users — declare it on modules
50
+ whose UI sits entirely behind login (settings, permissions, dashboards) so
51
+ anonymous visitors don't download admin form labels on every public page.
52
+ The catalog is always available server-side (``Translator``) either way.
53
+ """
@@ -6,13 +6,26 @@ from dataclasses import dataclass, field
6
6
 
7
7
  WILDCARD = "*"
8
8
 
9
+ ADMIN_ROLE = "admin"
10
+ """The one role name the framework itself knows.
11
+
12
+ Everything else about roles is module-owned, but the framework needs this to
13
+ resolve the wildcard grant below and to decide who can still reach the app
14
+ while maintenance mode is on.
15
+ """
16
+
9
17
  # Default role→permission mapping. Admin gets all permissions via the wildcard.
10
18
  # Additional mappings are added at registration time via PermissionRegistry.map_role.
11
19
  DEFAULT_ROLE_PERMISSIONS: dict[str, list[str]] = {
12
- "admin": [WILDCARD],
20
+ ADMIN_ROLE: [WILDCARD],
13
21
  }
14
22
 
15
23
 
24
+ def is_admin(roles: list[str] | None) -> bool:
25
+ """Whether ``roles`` carries the framework's admin role."""
26
+ return bool(roles) and ADMIN_ROLE in roles
27
+
28
+
16
29
  @dataclass
17
30
  class PermissionGroup:
18
31
  """A named group of related permissions (typically one per module)."""
@@ -111,7 +124,7 @@ class PermissionRegistry:
111
124
  other roles get none. Override for richer mapping.
112
125
  """
113
126
  if role_permission_map is None:
114
- if "admin" in roles:
127
+ if is_admin(roles):
115
128
  return set(self.all_permissions)
116
129
  return set()
117
130
 
@@ -0,0 +1,78 @@
1
+ """Safety net for user-influenced redirect targets.
2
+
3
+ Several flows park "where the visitor was heading" somewhere a browser can
4
+ reach — ``AuthMiddleware`` stashes it in the session before bouncing an
5
+ anonymous visitor to login, ``site_lock`` puts it in the unlock page's query
6
+ string. Anything replayed into a ``Location`` header is an open-redirect
7
+ surface, so every producer and consumer funnels through :func:`safe_next`.
8
+
9
+ This lives in the framework rather than in whichever module needed it first:
10
+ it encodes no plugin knowledge, and duplicating URL-safety rules per module is
11
+ how one copy ends up missing a case.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ DEFAULT_FALLBACK = "/"
17
+
18
+ SESSION_NEXT_KEY = "next"
19
+ """Session key holding the post-login destination.
20
+
21
+ This is the contract between ``AuthMiddleware`` (which writes it when it
22
+ bounces an anonymous visitor) and whichever provider completes the login and
23
+ sends the visitor onward. It is shared rather than redeclared per module
24
+ because a provider that reads a *different* key silently loses every deep
25
+ link — which is exactly how the local provider drifted from the Keycloak one.
26
+ """
27
+
28
+
29
+ def safe_next(raw: str | None, *, fallback: str = DEFAULT_FALLBACK) -> str:
30
+ """Return ``raw`` if it is a same-site absolute path, else ``fallback``.
31
+
32
+ Rejects protocol-relative (``//host``) and backslash-prefixed (``/\\host``)
33
+ targets — browsers resolve both off-site — plus anything carrying CR/LF,
34
+ which could otherwise be smuggled into the redirect header.
35
+ """
36
+ if not raw or not raw.startswith("/"):
37
+ return fallback
38
+ if raw.startswith(("//", "/\\")):
39
+ return fallback
40
+ if "\r" in raw or "\n" in raw:
41
+ return fallback
42
+ return raw
43
+
44
+
45
+ def safe_next_or_none(raw: str | None) -> str | None:
46
+ """Like :func:`safe_next`, but ``None`` when ``raw`` is unusable.
47
+
48
+ Callers that fall back to a *configured* destination (rather than ``/``)
49
+ need to tell "no target" apart from "the target was ``/``" — returning the
50
+ fallback would silently outrank a configured ``login_redirect_url``.
51
+ """
52
+ result = safe_next(raw, fallback="")
53
+ return result or None
54
+
55
+
56
+ def non_empty_redirect(value: str, *, default: str) -> str:
57
+ """Normalise a configured redirect destination, never returning ``""``.
58
+
59
+ Any auth provider can expose a ``login_redirect_url``-style setting, and
60
+ nothing stops an admin clearing it in the generic module-settings editor.
61
+ Every consumer treats the value as a destination — Inertia's
62
+ ``router.visit("")`` silently reloads the current page, and an empty
63
+ ``Location`` header is a broken redirect — so providers normalise on their
64
+ settings class, where hydration and ``apply_changes_and_reload`` both run.
65
+ It lives here rather than in one provider because the providers must not
66
+ import each other (cross-module coupling), and this is the same concern as
67
+ the rest of this module.
68
+ """
69
+ return value.strip() or default
70
+
71
+
72
+ __all__ = [
73
+ "DEFAULT_FALLBACK",
74
+ "SESSION_NEXT_KEY",
75
+ "non_empty_redirect",
76
+ "safe_next",
77
+ "safe_next_or_none",
78
+ ]