simple-module-core 0.0.33__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.33 → simple_module_core-0.0.34}/PKG-INFO +1 -1
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/pyproject.toml +1 -1
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/__main__.py +19 -9
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/audit_links.py +19 -4
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_i18n.py +27 -3
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_runner.py +7 -5
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/permissions.py +18 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_audit_links.py +19 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_i18n_diagnostics.py +40 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_permissions.py +29 -1
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/.gitignore +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/LICENSE +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/README.md +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/__init__.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/csp.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_module.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/i18n.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/menu.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/module.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/module_meta.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/redirect_safety.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/services.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/settings_base.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/setup_steps.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_csp_registry.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_events.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_redirect_safety.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_services.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_settings_base.py +0 -0
- {simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_sm003_page_render.py +0 -0
- {simple_module_core-0.0.33 → 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
|
|
@@ -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
|
|
|
@@ -11,10 +11,15 @@ A module declares where its rows live via
|
|
|
11
11
|
collects them into one registry at boot and stores it on
|
|
12
12
|
``app.state.sm.audit_links``.
|
|
13
13
|
|
|
14
|
-
**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
18
23
|
"""
|
|
19
24
|
|
|
20
25
|
from __future__ import annotations
|
|
@@ -76,6 +81,15 @@ class AuditLink:
|
|
|
76
81
|
a closure, and two boots would build two unequal objects out of one
|
|
77
82
|
module — a registry conflict is about two modules claiming one
|
|
78
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.
|
|
79
93
|
"""
|
|
80
94
|
|
|
81
95
|
entity_type: str
|
|
@@ -84,6 +98,7 @@ class AuditLink:
|
|
|
84
98
|
label_key: str = ""
|
|
85
99
|
table_name: str | None = None
|
|
86
100
|
label_resolver: LabelResolver | None = field(default=None, compare=False)
|
|
101
|
+
label_permission: str = ""
|
|
87
102
|
|
|
88
103
|
def __post_init__(self) -> None:
|
|
89
104
|
if self.url_template and _ID_PLACEHOLDER not in self.url_template:
|
{simple_module_core-0.0.33 → 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.33 → 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)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Collection
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
|
|
7
8
|
WILDCARD = "*"
|
|
@@ -26,6 +27,23 @@ def is_admin(roles: list[str] | None) -> bool:
|
|
|
26
27
|
return bool(roles) and ADMIN_ROLE in roles
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def grants(held: Collection[str], required: str) -> bool:
|
|
31
|
+
"""Whether a principal holding *held* satisfies *required*.
|
|
32
|
+
|
|
33
|
+
One place for the wildcard rule, so code that gates something *inside* a
|
|
34
|
+
response — a column, a label, a card — reads the grant the same way
|
|
35
|
+
``RequiresPermission`` reads it at the door. Two hand-rolled ``in`` checks
|
|
36
|
+
are two chances to forget that ``admin`` holds ``*`` and nothing else.
|
|
37
|
+
|
|
38
|
+
An empty *required* is satisfied by anything: callers use it to mean "no
|
|
39
|
+
permission gates this", which is the default for a declaration that never
|
|
40
|
+
named one.
|
|
41
|
+
"""
|
|
42
|
+
if not required:
|
|
43
|
+
return True
|
|
44
|
+
return WILDCARD in held or required in held
|
|
45
|
+
|
|
46
|
+
|
|
29
47
|
@dataclass
|
|
30
48
|
class PermissionGroup:
|
|
31
49
|
"""A named group of related permissions (typically one per module)."""
|
|
@@ -113,6 +113,25 @@ class TestLabelResolver:
|
|
|
113
113
|
)
|
|
114
114
|
assert len(reg.all_links) == 1
|
|
115
115
|
|
|
116
|
+
def test_label_permission_defaults_to_ungated(self):
|
|
117
|
+
"""Most row names disclose nothing a reader of the audit log should not
|
|
118
|
+
already see — a setting key, a filename — so declaring nothing means
|
|
119
|
+
"anyone who may read the trail may read the name"."""
|
|
120
|
+
assert AuditLink(entity_type="Setting", url_template="/s/{id}").label_permission == ""
|
|
121
|
+
|
|
122
|
+
def test_label_permission_is_part_of_the_claim(self):
|
|
123
|
+
"""Unlike the resolver, it is compared: two modules disagreeing about
|
|
124
|
+
who may see a name is exactly the conflict the registry exists to
|
|
125
|
+
catch, and silently keeping the looser one would be the wrong answer."""
|
|
126
|
+
reg = AuditLinkRegistry()
|
|
127
|
+
reg.register(AuditLink(entity_type="User", url_template="/u/{id}"))
|
|
128
|
+
with pytest.raises(ValueError, match="Two modules claim"):
|
|
129
|
+
reg.register(
|
|
130
|
+
AuditLink(
|
|
131
|
+
entity_type="User", url_template="/u/{id}", label_permission="users.manage"
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
116
135
|
def test_genuinely_conflicting_claims_still_raise(self):
|
|
117
136
|
reg = AuditLinkRegistry()
|
|
118
137
|
reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
|
|
@@ -99,3 +99,43 @@ def test_extra_sources_detect_key_drift(tmp_path: Path) -> None:
|
|
|
99
99
|
).run([])
|
|
100
100
|
codes = {f.code for f in findings}
|
|
101
101
|
assert "SM014" in codes
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestUndeclaredLocaleSet:
|
|
105
|
+
"""``supported_locales=None`` — nobody said which locales this install ships.
|
|
106
|
+
|
|
107
|
+
``make doctor`` runs with no ``SM_I18N_SUPPORTED_LOCALES`` on a stock
|
|
108
|
+
checkout, and the whole diagnostic used to be skipped in that state: drift
|
|
109
|
+
between two catalogs that *are* both shipped went unreported because no
|
|
110
|
+
third file declared them. The files on disk are themselves the declaration.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def test_key_drift_is_reported_without_a_declared_locale_set(self, tmp_path: Path) -> None:
|
|
114
|
+
_write(tmp_path / "p", "en", {"a": "1"})
|
|
115
|
+
_write(tmp_path / "p", "es", {"a": "1", "orphan": "x"})
|
|
116
|
+
mod = _FakeModule("P", {"p": tmp_path / "p"})
|
|
117
|
+
findings = I18nDiagnostics(supported_locales=None, default_locale="en").run([mod])
|
|
118
|
+
assert {f.code for f in findings} == {"SM015"}
|
|
119
|
+
|
|
120
|
+
def test_a_locale_nobody_promised_is_not_reported_missing(self, tmp_path: Path) -> None:
|
|
121
|
+
"""SM013 is a broken promise; with no declared set there is no promise."""
|
|
122
|
+
_write(tmp_path / "p", "en", {"a": "1"})
|
|
123
|
+
_write(tmp_path / "q", "en", {"a": "1"})
|
|
124
|
+
_write(tmp_path / "q", "es", {"a": "1"})
|
|
125
|
+
mod = _FakeModule("P", {"p": tmp_path / "p", "q": tmp_path / "q"})
|
|
126
|
+
findings = I18nDiagnostics(supported_locales=None, default_locale="en").run([mod])
|
|
127
|
+
assert findings == []
|
|
128
|
+
|
|
129
|
+
def test_extra_sources_are_auto_detected_too(self, tmp_path: Path) -> None:
|
|
130
|
+
_write(tmp_path / "ui_locales", "en", {"a": "1", "b": "2"})
|
|
131
|
+
_write(tmp_path / "ui_locales", "es", {"a": "1"})
|
|
132
|
+
findings = I18nDiagnostics(
|
|
133
|
+
supported_locales=None,
|
|
134
|
+
default_locale="en",
|
|
135
|
+
extra_sources=[("packages/ui", "ui", tmp_path / "ui_locales")],
|
|
136
|
+
).run([])
|
|
137
|
+
assert [f.code for f in findings] == ["SM014"]
|
|
138
|
+
|
|
139
|
+
def test_a_missing_locale_dir_is_silently_skipped(self, tmp_path: Path) -> None:
|
|
140
|
+
mod = _FakeModule("P", {"p": tmp_path / "nope"})
|
|
141
|
+
assert I18nDiagnostics(supported_locales=None, default_locale="en").run([mod]) == []
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from simple_module_core.permissions import WILDCARD, PermissionRegistry
|
|
5
|
+
from simple_module_core.permissions import WILDCARD, PermissionRegistry, grants
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class TestPermissionRegistry:
|
|
@@ -113,3 +113,31 @@ class TestPermissionRegistryMapRole:
|
|
|
113
113
|
for key, val in result.items():
|
|
114
114
|
assert isinstance(key, str)
|
|
115
115
|
assert isinstance(val, list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestGrants:
|
|
119
|
+
"""The wildcard rule, in one place.
|
|
120
|
+
|
|
121
|
+
Anything gating part of a *response* rather than a route reads grants
|
|
122
|
+
through here — a second hand-rolled ``in`` check is a second chance to
|
|
123
|
+
forget that ``admin`` holds ``*`` and no named permission at all.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
async def test_a_held_permission_is_granted(self):
|
|
127
|
+
assert grants({"users.manage"}, "users.manage") is True
|
|
128
|
+
|
|
129
|
+
async def test_a_missing_permission_is_not(self):
|
|
130
|
+
assert grants({"audit_log.view"}, "users.manage") is False
|
|
131
|
+
|
|
132
|
+
async def test_the_wildcard_satisfies_anything(self):
|
|
133
|
+
assert grants({WILDCARD}, "users.manage") is True
|
|
134
|
+
|
|
135
|
+
async def test_an_empty_requirement_is_satisfied_by_nothing_held(self):
|
|
136
|
+
"""Empty means "no permission gates this" — the default for a
|
|
137
|
+
declaration that never named one, not a requirement nobody meets."""
|
|
138
|
+
assert grants(set(), "") is True
|
|
139
|
+
|
|
140
|
+
async def test_it_reads_any_collection(self):
|
|
141
|
+
"""Callers hand it whatever they have — the middleware caches a set,
|
|
142
|
+
a test hands a list."""
|
|
143
|
+
assert grants(["users.manage"], "users.manage") is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/__init__.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_coupling.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_css.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_migration.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_module.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_pages.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/diagnostics/_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/simple_module_core/redirect_safety.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_auth_provider_selection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.33 → simple_module_core-0.0.34}/tests/test_feature_flags_decorator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|