simple-module-core 0.0.32__tar.gz → 0.0.34__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 (69) hide show
  1. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/__init__.py +5 -1
  4. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/__main__.py +19 -9
  5. simple_module_core-0.0.34/simple_module_core/audit_links.py +142 -0
  6. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/__init__.py +2 -0
  7. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_i18n.py +27 -3
  8. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_pages.py +67 -9
  9. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_runner.py +7 -5
  10. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/i18n.py +22 -2
  11. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/menu.py +34 -2
  12. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/module.py +34 -29
  13. simple_module_core-0.0.34/simple_module_core/module_meta.py +53 -0
  14. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/permissions.py +33 -2
  15. simple_module_core-0.0.34/simple_module_core/redirect_safety.py +78 -0
  16. simple_module_core-0.0.34/simple_module_core/services.py +95 -0
  17. simple_module_core-0.0.34/simple_module_core/settings_base.py +47 -0
  18. simple_module_core-0.0.34/simple_module_core/setup_steps.py +141 -0
  19. simple_module_core-0.0.34/tests/test_audit_links.py +166 -0
  20. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_i18n.py +35 -0
  21. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_i18n_diagnostics.py +40 -0
  22. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_menu.py +59 -0
  23. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_module_diagnostics.py +0 -36
  24. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_permissions.py +29 -1
  25. simple_module_core-0.0.34/tests/test_redirect_safety.py +59 -0
  26. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_services.py +6 -1
  27. simple_module_core-0.0.34/tests/test_settings_base.py +75 -0
  28. simple_module_core-0.0.34/tests/test_sm003_page_render.py +122 -0
  29. simple_module_core-0.0.32/simple_module_core/audit_links.py +0 -84
  30. simple_module_core-0.0.32/simple_module_core/services.py +0 -51
  31. simple_module_core-0.0.32/tests/test_audit_links.py +0 -57
  32. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/.gitignore +0 -0
  33. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/LICENSE +0 -0
  34. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/README.md +0 -0
  35. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/csp.py +0 -0
  36. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/design_packs.py +0 -0
  37. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_coupling.py +0 -0
  38. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_css.py +0 -0
  39. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  40. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  41. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_migration.py +0 -0
  42. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_module.py +0 -0
  43. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_types.py +0 -0
  44. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/discovery.py +0 -0
  45. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/dotenv.py +0 -0
  46. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/environments.py +0 -0
  47. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/events.py +0 -0
  48. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/exceptions.py +0 -0
  49. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/feature_flags.py +0 -0
  50. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/health.py +0 -0
  51. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/public_routes.py +0 -0
  52. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/py.typed +0 -0
  53. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/versioning.py +0 -0
  54. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_auth_provider_selection.py +0 -0
  55. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_csp_registry.py +0 -0
  56. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_css_diagnostics.py +0 -0
  57. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_css_scanner.py +0 -0
  58. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_design_packs.py +0 -0
  59. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_diagnostics.py +0 -0
  60. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_discovery.py +0 -0
  61. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_dotenv.py +0 -0
  62. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_environments.py +0 -0
  63. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_events.py +0 -0
  64. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_feature_flags.py +0 -0
  65. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_feature_flags_decorator.py +0 -0
  66. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_health_registry.py +0 -0
  67. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_module_base.py +0 -0
  68. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_public_routes.py +0 -0
  69. {simple_module_core-0.0.32 → simple_module_core-0.0.34}/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.34
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.34"
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",
@@ -7,10 +7,14 @@ Usage::
7
7
 
8
8
  Exits with status 1 if any ERROR-level diagnostics are reported.
9
9
 
10
- i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
11
- (or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
12
- are picked up relative to the project root (``SM_PROJECT_ROOT``, else the
13
- directory of the discovered ``.env``, else the current working dir).
10
+ i18n checks always run. ``SM_I18N_SUPPORTED_LOCALES`` (env or ``.env``)
11
+ declares the locale set this install promises, which also enables SM013 for a
12
+ namespace that never shipped one; with it unset, each namespace is checked
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
18
  """
15
19
 
16
20
  from __future__ import annotations
@@ -36,8 +40,14 @@ from simple_module_core.dotenv import find_env_file, parse_dotenv
36
40
  from simple_module_core.exceptions import InvalidModuleError
37
41
 
38
42
 
39
- def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
40
- """Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
43
+ def _load_i18n_settings_from_env() -> tuple[list[str] | None, str]:
44
+ """Return ``(supported_locales, default_locale)``.
45
+
46
+ ``supported_locales`` is ``None`` when ``SM_I18N_SUPPORTED_LOCALES`` is
47
+ unset or unparseable — the diagnostic then falls back to the locale files
48
+ each namespace ships rather than running no locale checks at all. The
49
+ default locale always has a value, because a parity comparison needs to
50
+ know which side is the source of truth.
41
51
 
42
52
  Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
43
53
  Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
@@ -46,9 +56,10 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
46
56
  for key, value in parse_dotenv().items():
47
57
  os.environ.setdefault(key, value)
48
58
 
59
+ default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
49
60
  supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
50
61
  if not supported_raw:
51
- return None, None
62
+ return None, default
52
63
 
53
64
  try:
54
65
  supported = json.loads(supported_raw)
@@ -57,9 +68,8 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
57
68
  supported = [s.strip() for s in supported_raw.split(",") if s.strip()]
58
69
 
59
70
  if not isinstance(supported, list) or not supported:
60
- return None, None
71
+ return None, default
61
72
 
62
- default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
63
73
  return supported, default
64
74
 
65
75
 
@@ -0,0 +1,142 @@
1
+ """Audit-link registry — modules teach the audit log how to reach their records.
2
+
3
+ An audit entry stores the *model class name* and primary key of the row that
4
+ changed (``StoredFile``, ``a91f3c2b…`` — ``snapshot_changes`` records
5
+ ``type(obj).__name__``, never ``__tablename__``). That is enough to prove what
6
+ happened and useless for doing anything about it: the reader has an id and no
7
+ way to open the record it names.
8
+
9
+ A module declares where its rows live via
10
+ :meth:`~simple_module_core.module.ModuleBase.register_audit_links`; the host
11
+ collects them into one registry at boot and stores it on
12
+ ``app.state.sm.audit_links``.
13
+
14
+ **A link is a route, not an authorisation.** The registry does not verify the
15
+ row exists or that the reader may open it — following a link to a deleted
16
+ record lands on that screen's own 404, and permissions are enforced by the
17
+ target route as usual.
18
+
19
+ The one exception is ``label_permission``: the *name* of a row travels in the
20
+ audit payload itself, so no downstream route ever gets the chance to refuse it.
21
+ A module whose row names disclose something it gates elsewhere says so there,
22
+ and readers without that permission see the id instead.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Awaitable, Callable
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ LabelResolver = Callable[[Any, list[str]], Awaitable[dict[str, str]]]
32
+ """Batch "what is this row called" for one entity type.
33
+
34
+ Called with the request's database session and every entity id of that type on
35
+ the page, and returns ``{entity_id: display name}``. Ids it cannot name are
36
+ simply absent — the caller falls back to the id, which is what the audit row
37
+ actually stored.
38
+
39
+ The session is typed ``Any`` because ``simple_module_core`` must not depend on
40
+ SQLAlchemy; in practice it is the ``AsyncSession`` the request is already
41
+ using, so a resolver adds one query per entity type per page and no session
42
+ management of its own.
43
+ """
44
+
45
+ _ID_PLACEHOLDER = "{id}"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class AuditLink:
50
+ """Where the records of one audited model can be viewed.
51
+
52
+ Args:
53
+ entity_type: The **model class name**, matching
54
+ ``AuditEntry.entity_type`` (e.g. ``"User"``, not ``"users_user"``).
55
+ ``snapshot_changes`` records ``type(obj).__name__``, so keying this
56
+ off ``__tablename__`` silently never matches — and because an
57
+ unmatched lookup falls back to showing ``entity_type`` as the
58
+ label, a table-name key looks like it worked.
59
+ url_template: Path containing ``{id}``, substituted with the entity id
60
+ (e.g. ``"/admin/users/{id}/edit"``). Empty means *these rows have
61
+ no screen* — join tables and stored files are audited but have
62
+ nowhere to open — and the cell renders the name unlinked. A module
63
+ still registers those, because it is the only thing that knows what
64
+ the table is called and what its rows are named.
65
+ label: Human-readable name for the entity kind, shown instead of the
66
+ raw class name (e.g. ``"User account"``).
67
+ label_key: Catalog key for ``label``. Empty, or unresolved, falls back
68
+ to ``label`` — a missing translation shows English rather than a
69
+ raw dotted key. Rows are rendered server-side, so the audit view
70
+ translates these before they reach the page.
71
+ table_name: ``__tablename__`` of the table these rows live in
72
+ (``"users_user"``), shown as the type tag beside the row's name.
73
+ Not derivable from ``entity_type`` in either direction, so the
74
+ module that owns the table states both. ``None`` falls back to the
75
+ class name.
76
+ label_resolver: Optional :data:`LabelResolver` naming individual rows.
77
+ Without it a reader gets a primary key and has to paste it into
78
+ another screen to learn which record changed; only the owning
79
+ module knows that a ``Setting`` is named by its key and a ``User``
80
+ by ``full_name or email``. Excluded from equality: it is typically
81
+ a closure, and two boots would build two unequal objects out of one
82
+ module — a registry conflict is about two modules claiming one
83
+ entity type, which the identity fields already express.
84
+ label_permission: Permission a reader must hold before
85
+ ``label_resolver`` is *run* for them. Empty — the default — means
86
+ the name is safe for anyone allowed to read the audit trail at all.
87
+ Set it when naming the row discloses something the module gates
88
+ elsewhere: ``users`` names an account by ``full_name or email``, so
89
+ without this an ``audit_log.view`` grant is a back door onto the
90
+ user directory that ``users.manage`` guards at the front. Denied
91
+ readers still see the row, the action and the id — the audit trail
92
+ stays complete, it just stops volunteering the name.
93
+ """
94
+
95
+ entity_type: str
96
+ url_template: str
97
+ label: str = ""
98
+ label_key: str = ""
99
+ table_name: str | None = None
100
+ label_resolver: LabelResolver | None = field(default=None, compare=False)
101
+ label_permission: str = ""
102
+
103
+ def __post_init__(self) -> None:
104
+ if self.url_template and _ID_PLACEHOLDER not in self.url_template:
105
+ raise ValueError(
106
+ f"AuditLink for {self.entity_type!r} has url_template "
107
+ f"{self.url_template!r}, which contains no {_ID_PLACEHOLDER} — "
108
+ f"every row would link to the same page"
109
+ )
110
+
111
+ def url_for(self, entity_id: str) -> str | None:
112
+ """The row's page, or ``None`` when this kind of row has no screen."""
113
+ if not self.url_template:
114
+ return None
115
+ return self.url_template.replace(_ID_PLACEHOLDER, entity_id)
116
+
117
+
118
+ class AuditLinkRegistry:
119
+ """Aggregates every module's :class:`AuditLink` declarations.
120
+
121
+ Populated once during boot (``register_audit_links`` hook) and read
122
+ thereafter by the audit log view when it renders each row.
123
+ """
124
+
125
+ def __init__(self) -> None:
126
+ self._links: dict[str, AuditLink] = {}
127
+
128
+ def register(self, link: AuditLink) -> None:
129
+ existing = self._links.get(link.entity_type)
130
+ if existing is not None and existing != link:
131
+ raise ValueError(
132
+ f"Two modules claim audit links for {link.entity_type!r}: "
133
+ f"{existing.url_template!r} and {link.url_template!r}"
134
+ )
135
+ self._links[link.entity_type] = link
136
+
137
+ def get(self, entity_type: str) -> AuditLink | None:
138
+ return self._links.get(entity_type)
139
+
140
+ @property
141
+ def all_links(self) -> dict[str, AuditLink]:
142
+ return dict(self._links)
@@ -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
  ]
@@ -25,19 +25,28 @@ class I18nDiagnostics:
25
25
 
26
26
  def __init__(
27
27
  self,
28
- supported_locales: list[str],
28
+ supported_locales: list[str] | None,
29
29
  default_locale: str,
30
30
  extra_sources: list[tuple[str, str, Path]] | None = None,
31
31
  ) -> None:
32
32
  """Build the diagnostic.
33
33
 
34
+ ``supported_locales`` is the set of locales this install promises to
35
+ ship. ``None`` means *nobody declared one* — each namespace is then
36
+ checked against the locale files it actually has on disk. SM013 is
37
+ skipped in that mode (a locale nothing promised cannot be missing),
38
+ but SM014/SM015 still hold every shipped translation to the default
39
+ locale's key set. Without this, an install that never set
40
+ ``SM_I18N_SUPPORTED_LOCALES`` ran no locale checks at all and drift
41
+ accumulated with nothing to flag it.
42
+
34
43
  ``extra_sources`` is an optional list of ``(reporter_name, namespace,
35
44
  locale_dir)`` triples for locale directories that aren't owned by any
36
45
  ``ModuleBase`` instance — notably the host's ``host/locales/`` and
37
46
  the shared ``packages/ui/locales/``. ``reporter_name`` is used as the
38
47
  ``module_name`` field on findings for display purposes.
39
48
  """
40
- self.supported_locales = list(supported_locales)
49
+ self.supported_locales = None if supported_locales is None else list(supported_locales)
41
50
  self.default_locale = default_locale
42
51
  self.extra_sources = list(extra_sources or [])
43
52
 
@@ -55,10 +64,14 @@ class I18nDiagnostics:
55
64
  ) -> list[Diagnostic]:
56
65
  findings: list[Diagnostic] = []
57
66
  per_locale_keys: dict[str, set[str]] = {}
67
+ declared = self.supported_locales is not None
68
+ locales = self.supported_locales if declared else self._locales_on_disk(locale_dir)
58
69
 
59
- for locale in self.supported_locales:
70
+ for locale in locales or ():
60
71
  path = locale_dir / f"{locale}.json"
61
72
  if not path.is_file():
73
+ if not declared:
74
+ continue
62
75
  findings.append(
63
76
  Diagnostic(
64
77
  level=DiagnosticLevel.WARNING,
@@ -119,3 +132,14 @@ class I18nDiagnostics:
119
132
  )
120
133
  )
121
134
  return findings
135
+
136
+ @staticmethod
137
+ def _locales_on_disk(locale_dir: Path) -> list[str]:
138
+ """Locale tags a directory actually ships, from its ``<tag>.json`` files.
139
+
140
+ The default locale sorts first only by accident of the alphabet, which
141
+ does not matter: the parity comparison below looks it up by name.
142
+ """
143
+ if not locale_dir.is_dir():
144
+ return []
145
+ return sorted(path.stem for path in locale_dir.glob("*.json"))
@@ -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()
@@ -30,18 +30,20 @@ def run_diagnostics(
30
30
  """Convenience function to run all diagnostics.
31
31
 
32
32
  When ``migration_state`` is provided, also runs migration diagnostics.
33
- When ``i18n_supported_locales`` and ``i18n_default_locale`` are provided,
34
- also runs i18n locale coverage diagnostics. ``i18n_extra_sources`` lets
35
- callers include host/ui locale dirs that aren't owned by a ``ModuleBase``.
33
+ ``i18n_default_locale`` turns on the locale checks; ``i18n_supported_locales``
34
+ is the declared locale set, and leaving it empty runs those checks against
35
+ whatever locale files are on disk instead of skipping them entirely (see
36
+ :class:`I18nDiagnostics`). ``i18n_extra_sources`` lets callers include
37
+ host/ui locale dirs that aren't owned by a ``ModuleBase``.
36
38
  """
37
39
  diagnostics = ModuleDiagnostics().run(modules)
38
40
 
39
- if i18n_supported_locales and i18n_default_locale:
41
+ if i18n_default_locale:
40
42
  from simple_module_core.diagnostics._i18n import I18nDiagnostics
41
43
 
42
44
  diagnostics.extend(
43
45
  I18nDiagnostics(
44
- supported_locales=i18n_supported_locales,
46
+ supported_locales=i18n_supported_locales or None,
45
47
  default_locale=i18n_default_locale,
46
48
  extra_sources=i18n_extra_sources,
47
49
  ).run(modules)
@@ -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