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.
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/PKG-INFO +1 -1
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/pyproject.toml +1 -1
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/__init__.py +5 -1
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/__main__.py +19 -9
- simple_module_core-0.0.34/simple_module_core/audit_links.py +142 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/__init__.py +2 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_i18n.py +27 -3
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_pages.py +67 -9
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_runner.py +7 -5
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/i18n.py +22 -2
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/menu.py +34 -2
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/module.py +34 -29
- simple_module_core-0.0.34/simple_module_core/module_meta.py +53 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/permissions.py +33 -2
- simple_module_core-0.0.34/simple_module_core/redirect_safety.py +78 -0
- simple_module_core-0.0.34/simple_module_core/services.py +95 -0
- simple_module_core-0.0.34/simple_module_core/settings_base.py +47 -0
- simple_module_core-0.0.34/simple_module_core/setup_steps.py +141 -0
- simple_module_core-0.0.34/tests/test_audit_links.py +166 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_i18n.py +35 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_i18n_diagnostics.py +40 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_menu.py +59 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_module_diagnostics.py +0 -36
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_permissions.py +29 -1
- simple_module_core-0.0.34/tests/test_redirect_safety.py +59 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_services.py +6 -1
- simple_module_core-0.0.34/tests/test_settings_base.py +75 -0
- simple_module_core-0.0.34/tests/test_sm003_page_render.py +122 -0
- simple_module_core-0.0.32/simple_module_core/audit_links.py +0 -84
- simple_module_core-0.0.32/simple_module_core/services.py +0 -51
- simple_module_core-0.0.32/tests/test_audit_links.py +0 -57
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/.gitignore +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/LICENSE +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/README.md +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_module.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_events.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.32 → simple_module_core-0.0.34}/tests/test_public_routes.py +0 -0
- {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.
|
|
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
|
"""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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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]
|
|
40
|
-
"""Return ``(supported_locales, default_locale)
|
|
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,
|
|
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,
|
|
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)
|
{simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/__init__.py
RENAMED
|
@@ -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
|
]
|
{simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_i18n.py
RENAMED
|
@@ -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
|
|
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"))
|
{simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_pages.py
RENAMED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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()
|
{simple_module_core-0.0.32 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_runner.py
RENAMED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|