simple-module-core 0.0.26__tar.gz → 0.0.28__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.26 → simple_module_core-0.0.28}/.gitignore +1 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/PKG-INFO +1 -1
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/pyproject.toml +1 -1
- simple_module_core-0.0.28/simple_module_core/diagnostics/_css.py +214 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_module.py +2 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/i18n.py +28 -9
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/module.py +9 -0
- simple_module_core-0.0.28/tests/test_css_diagnostics.py +97 -0
- simple_module_core-0.0.28/tests/test_css_scanner.py +242 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_i18n.py +23 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/LICENSE +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/README.md +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/__init__.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/__main__.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/dotenv.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/menu.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/permissions.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/services.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_dotenv.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_events.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_menu.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_services.py +0 -0
- {simple_module_core-0.0.26 → simple_module_core-0.0.28}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.28
|
|
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
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""SM022/SM023: module CSS placed in the file with the wrong cascade position.
|
|
2
|
+
|
|
3
|
+
A module ships its styles in two files, and which one a construct lands in
|
|
4
|
+
decides how it cascades:
|
|
5
|
+
|
|
6
|
+
* ``theme.css`` is imported **unlayered**, because a ``@theme`` block inside a
|
|
7
|
+
cascade layer registers no design tokens at all. Unlayered CSS also outranks
|
|
8
|
+
every layered rule, so a plain rule here would beat any Tailwind utility.
|
|
9
|
+
* ``styles.css`` is imported into ``layer(components)``, so its rules always
|
|
10
|
+
lose to a utility — which is what you want for component CSS, and useless
|
|
11
|
+
for ``@theme``.
|
|
12
|
+
|
|
13
|
+
Both codes are warnings, not errors: the misplaced CSS is legal and builds
|
|
14
|
+
fine, it just cascades in a way the author almost certainly did not intend.
|
|
15
|
+
|
|
16
|
+
Detection is a line-oriented scan that tracks brace depth, not a CSS parse —
|
|
17
|
+
adding a CSS parser as a runtime dependency to power a lint is not worth it.
|
|
18
|
+
This catches the ordinary mistake of putting a construct in the wrong file; it
|
|
19
|
+
is not a conformance checker, and pathological input (an at-rule assembled by
|
|
20
|
+
preprocessing, say) is out of scope.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from simple_module_core.module import ModuleBase
|
|
32
|
+
|
|
33
|
+
THEME_CSS = "theme.css"
|
|
34
|
+
STYLES_CSS = "styles.css"
|
|
35
|
+
|
|
36
|
+
# Constructs that only do anything unlayered, so they belong in theme.css.
|
|
37
|
+
THEME_ONLY_AT_RULES = ("@theme", "@custom-variant", "@utility")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _top_level_preludes(text: str) -> list[tuple[str, int]]:
|
|
41
|
+
"""Return ``(prelude, line_number)`` for every top-level construct.
|
|
42
|
+
|
|
43
|
+
A "prelude" is the selector or at-rule text preceding a top-level ``{``,
|
|
44
|
+
or a whole statement at-rule terminated by ``;`` at depth 0. Anything
|
|
45
|
+
nested inside braces is skipped, which is what keeps a rule inside
|
|
46
|
+
``@layer components { ... }`` from being mistaken for a top-level one.
|
|
47
|
+
|
|
48
|
+
Comments and quoted strings are consumed inline rather than pre-stripped,
|
|
49
|
+
because a brace inside either is not structural. ``content: "{"`` in an
|
|
50
|
+
icon-font rule would otherwise desynchronise the depth counter for the
|
|
51
|
+
whole rest of the file — every later top-level construct would be read as
|
|
52
|
+
nested and silently skipped.
|
|
53
|
+
|
|
54
|
+
A quote only opens a string when its partner is found before the line
|
|
55
|
+
ends, which is the rule CSS itself applies (a string cannot contain a raw
|
|
56
|
+
newline). Treating every quote as an opener is what makes an *unmatched*
|
|
57
|
+
one dangerous: the lone apostrophe in ``url(it's.png); }`` would swallow
|
|
58
|
+
the closing brace on that same line and desync the counter permanently.
|
|
59
|
+
An unmatched quote is therefore just an ordinary character.
|
|
60
|
+
"""
|
|
61
|
+
out: list[tuple[str, int]] = []
|
|
62
|
+
depth = 0
|
|
63
|
+
buf: list[str] = []
|
|
64
|
+
line = 1
|
|
65
|
+
buf_line = 1
|
|
66
|
+
i = 0
|
|
67
|
+
n = len(text)
|
|
68
|
+
|
|
69
|
+
def flush() -> None:
|
|
70
|
+
prelude = "".join(buf).strip()
|
|
71
|
+
if prelude:
|
|
72
|
+
out.append((prelude, buf_line))
|
|
73
|
+
|
|
74
|
+
def string_end(start: int, quote: str) -> int | None:
|
|
75
|
+
"""Index just past the closing quote, or None if it never closes."""
|
|
76
|
+
j = start + 1
|
|
77
|
+
while j < n:
|
|
78
|
+
c = text[j]
|
|
79
|
+
if c == "\\":
|
|
80
|
+
# An escape consumes the next character whole — including a
|
|
81
|
+
# newline, the one way a CSS string legitimately spans lines.
|
|
82
|
+
j += 2
|
|
83
|
+
continue
|
|
84
|
+
if c == "\n":
|
|
85
|
+
return None
|
|
86
|
+
if c == quote:
|
|
87
|
+
return j + 1
|
|
88
|
+
j += 1
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
while i < n:
|
|
92
|
+
ch = text[i]
|
|
93
|
+
|
|
94
|
+
if ch == "/" and i + 1 < n and text[i + 1] == "*":
|
|
95
|
+
end = text.find("*/", i + 2)
|
|
96
|
+
end = n if end == -1 else end + 2
|
|
97
|
+
line += text.count("\n", i, end)
|
|
98
|
+
i = end
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if ch == "\\" and i + 1 < n:
|
|
102
|
+
# CSS escapes apply outside strings too — Tailwind leans on this
|
|
103
|
+
# heavily (`.mt-\[773px\]`). Consuming the pair keeps an escaped
|
|
104
|
+
# quote from being mistaken for a string opener, which is also
|
|
105
|
+
# what stops `\'` repeated across a long line from making
|
|
106
|
+
# string_end re-scan that line once per quote (quadratic).
|
|
107
|
+
if depth == 0:
|
|
108
|
+
if not buf:
|
|
109
|
+
buf_line = line
|
|
110
|
+
buf.append(text[i : i + 2])
|
|
111
|
+
if text[i + 1] == "\n":
|
|
112
|
+
line += 1
|
|
113
|
+
i += 2
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if ch in "\"'":
|
|
117
|
+
end = string_end(i, ch)
|
|
118
|
+
if end is not None:
|
|
119
|
+
if depth == 0:
|
|
120
|
+
if not buf:
|
|
121
|
+
buf_line = line
|
|
122
|
+
buf.append(text[i:end])
|
|
123
|
+
line += text.count("\n", i, end)
|
|
124
|
+
i = end
|
|
125
|
+
continue
|
|
126
|
+
# Unmatched: fall through and treat it as an ordinary character.
|
|
127
|
+
|
|
128
|
+
if ch == "{":
|
|
129
|
+
if depth == 0:
|
|
130
|
+
flush()
|
|
131
|
+
buf.clear()
|
|
132
|
+
depth += 1
|
|
133
|
+
elif ch == "}":
|
|
134
|
+
depth = max(0, depth - 1)
|
|
135
|
+
if depth == 0:
|
|
136
|
+
buf.clear()
|
|
137
|
+
elif depth == 0:
|
|
138
|
+
if ch == ";":
|
|
139
|
+
flush()
|
|
140
|
+
buf.clear()
|
|
141
|
+
elif buf:
|
|
142
|
+
buf.append(ch)
|
|
143
|
+
elif not ch.isspace():
|
|
144
|
+
# Start the buffer at the first non-space character, so
|
|
145
|
+
# buf_line points at the construct rather than at whatever
|
|
146
|
+
# blank lines preceded it.
|
|
147
|
+
buf_line = line
|
|
148
|
+
buf.append(ch)
|
|
149
|
+
|
|
150
|
+
if ch == "\n":
|
|
151
|
+
line += 1
|
|
152
|
+
i += 1
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_root_selector(prelude: str) -> bool:
|
|
157
|
+
"""True if every comma-separated part of the selector is :root-based.
|
|
158
|
+
|
|
159
|
+
``:root``, ``:root[data-theme="dark"]`` and a comma-separated list of both
|
|
160
|
+
are the normal way to declare design tokens, so they are legitimate in
|
|
161
|
+
theme.css even though they are plain rules.
|
|
162
|
+
"""
|
|
163
|
+
parts = [p.strip() for p in prelude.split(",") if p.strip()]
|
|
164
|
+
return bool(parts) and all(p.startswith(":root") for p in parts)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_module_css(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]:
|
|
168
|
+
"""Warn when module CSS sits in the file with the wrong cascade position."""
|
|
169
|
+
diagnostics: list[Diagnostic] = []
|
|
170
|
+
|
|
171
|
+
styles = src_dir / STYLES_CSS
|
|
172
|
+
if styles.is_file():
|
|
173
|
+
for prelude, line in _top_level_preludes(styles.read_text("utf-8")):
|
|
174
|
+
at_rule = prelude.split()[0].lower() if prelude.split() else ""
|
|
175
|
+
if at_rule in THEME_ONLY_AT_RULES:
|
|
176
|
+
diagnostics.append(
|
|
177
|
+
Diagnostic(
|
|
178
|
+
level=DiagnosticLevel.WARNING,
|
|
179
|
+
code="SM022",
|
|
180
|
+
message=(
|
|
181
|
+
f"{at_rule} in {STYLES_CSS} is inert — that file is "
|
|
182
|
+
"imported into layer(components)"
|
|
183
|
+
),
|
|
184
|
+
module_name=mod.meta.name,
|
|
185
|
+
file=f"{styles}:{line}",
|
|
186
|
+
suggestion=(
|
|
187
|
+
f"Move the {at_rule} block to {src_dir / THEME_CSS}, which is "
|
|
188
|
+
"imported unlayered so its tokens actually register"
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
theme = src_dir / THEME_CSS
|
|
194
|
+
if theme.is_file():
|
|
195
|
+
for prelude, line in _top_level_preludes(theme.read_text("utf-8")):
|
|
196
|
+
if prelude.startswith("@") or _is_root_selector(prelude):
|
|
197
|
+
continue
|
|
198
|
+
diagnostics.append(
|
|
199
|
+
Diagnostic(
|
|
200
|
+
level=DiagnosticLevel.WARNING,
|
|
201
|
+
code="SM023",
|
|
202
|
+
message=(
|
|
203
|
+
f"Unlayered rule '{prelude}' in {THEME_CSS} outranks every Tailwind utility"
|
|
204
|
+
),
|
|
205
|
+
module_name=mod.meta.name,
|
|
206
|
+
file=f"{theme}:{line}",
|
|
207
|
+
suggestion=(
|
|
208
|
+
f"Move it to {src_dir / STYLES_CSS}, which is imported into "
|
|
209
|
+
"layer(components) so utilities still win"
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return diagnostics
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_module.py
RENAMED
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
9
|
from simple_module_core.diagnostics._coupling import check_framework_module_coupling
|
|
10
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
10
11
|
from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
|
|
11
12
|
from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
|
|
12
13
|
from simple_module_core.diagnostics._pages import check_pages, find_render_calls
|
|
@@ -37,6 +38,7 @@ class ModuleDiagnostics:
|
|
|
37
38
|
diagnostics.extend(check_pages(mod, src_dir, rendered_pages))
|
|
38
39
|
diagnostics.extend(check_js_workspace_files(mod, src_dir))
|
|
39
40
|
diagnostics.extend(check_inertia_api_calls(mod, src_dir))
|
|
41
|
+
diagnostics.extend(check_module_css(mod, src_dir))
|
|
40
42
|
|
|
41
43
|
return diagnostics
|
|
42
44
|
|
|
@@ -78,7 +78,7 @@ class I18nRegistry:
|
|
|
78
78
|
def __init__(self, default_locale: str, supported_locales: list[str]) -> None:
|
|
79
79
|
self.default_locale = default_locale
|
|
80
80
|
self.supported_locales = list(supported_locales)
|
|
81
|
-
self._sources: list[tuple[str, Path]] = []
|
|
81
|
+
self._sources: list[tuple[str, Path, str]] = []
|
|
82
82
|
self._messages: dict[str, dict[str, str]] = {}
|
|
83
83
|
# Immutable views into ``_messages`` — handed out by ``messages()`` to
|
|
84
84
|
# avoid a per-call dict copy. Rebuilt whenever ``load()`` runs.
|
|
@@ -88,15 +88,22 @@ class I18nRegistry:
|
|
|
88
88
|
# avoids per-request dict copies that used to dominate allocations on the
|
|
89
89
|
# Inertia render path.
|
|
90
90
|
self._message_snapshots: dict[str, dict[str, str]] = {}
|
|
91
|
+
self._public_snapshots: dict[str, dict[str, str]] = {}
|
|
91
92
|
self._available_locales: tuple[str, ...] = ()
|
|
92
93
|
self._available_locales_list: list[str] = []
|
|
93
94
|
self._empty_view: MappingProxyType[str, str] = MappingProxyType({})
|
|
94
95
|
self._empty_snapshot: dict[str, str] = {}
|
|
95
96
|
self._loaded = False
|
|
96
97
|
|
|
97
|
-
def add_source(self, namespace: str, locale_dir: Path) -> None:
|
|
98
|
-
"""Queue a module's locale directory for loading under a namespace.
|
|
99
|
-
|
|
98
|
+
def add_source(self, namespace: str, locale_dir: Path, *, audience: str = "public") -> None:
|
|
99
|
+
"""Queue a module's locale directory for loading under a namespace.
|
|
100
|
+
|
|
101
|
+
``audience="admin"`` keeps the namespace out of the public snapshot
|
|
102
|
+
(:meth:`messages_snapshot` with ``include_admin=False``) so catalogs
|
|
103
|
+
for login-gated UI aren't shipped to anonymous visitors. Server-side
|
|
104
|
+
lookups (:meth:`messages`) always see every namespace.
|
|
105
|
+
"""
|
|
106
|
+
self._sources.append((namespace, Path(locale_dir), audience))
|
|
100
107
|
|
|
101
108
|
def load(self) -> None:
|
|
102
109
|
"""Read and flatten all registered JSON files.
|
|
@@ -105,8 +112,11 @@ class I18nRegistry:
|
|
|
105
112
|
warning but do not raise. Malformed JSON raises ValueError.
|
|
106
113
|
"""
|
|
107
114
|
self._messages = {locale: {} for locale in self.supported_locales}
|
|
115
|
+
public_messages: dict[str, dict[str, str]] = {
|
|
116
|
+
locale: {} for locale in self.supported_locales
|
|
117
|
+
}
|
|
108
118
|
|
|
109
|
-
for namespace, locale_dir in self._sources:
|
|
119
|
+
for namespace, locale_dir, audience in self._sources:
|
|
110
120
|
for locale in self.supported_locales:
|
|
111
121
|
path = locale_dir / f"{locale}.json"
|
|
112
122
|
if not path.is_file():
|
|
@@ -124,6 +134,8 @@ class I18nRegistry:
|
|
|
124
134
|
raise ValueError(f"{path} must contain a JSON object at the top level")
|
|
125
135
|
flat = flatten_messages(raw, prefix=namespace)
|
|
126
136
|
self._messages[locale].update(flat)
|
|
137
|
+
if audience != "admin":
|
|
138
|
+
public_messages[locale].update(flat)
|
|
127
139
|
|
|
128
140
|
# Cache the derived views now that loading is complete. Downstream
|
|
129
141
|
# (middleware, translator, switcher) reads these on every request.
|
|
@@ -131,8 +143,10 @@ class I18nRegistry:
|
|
|
131
143
|
locale: MappingProxyType(msgs) for locale, msgs in self._messages.items()
|
|
132
144
|
}
|
|
133
145
|
# Plain-dict snapshots for serialization callers. ``dict(msgs)`` runs
|
|
134
|
-
# once here rather than on every Inertia render.
|
|
146
|
+
# once here rather than on every Inertia render. The public variant
|
|
147
|
+
# (admin namespaces excluded) is what anonymous visitors receive.
|
|
135
148
|
self._message_snapshots = {locale: dict(msgs) for locale, msgs in self._messages.items()}
|
|
149
|
+
self._public_snapshots = public_messages
|
|
136
150
|
self._available_locales = tuple(locale for locale, msgs in self._messages.items() if msgs)
|
|
137
151
|
self._available_locales_list = list(self._available_locales)
|
|
138
152
|
self._loaded = True
|
|
@@ -165,7 +179,7 @@ class I18nRegistry:
|
|
|
165
179
|
return self._empty_view
|
|
166
180
|
return MappingProxyType(raw)
|
|
167
181
|
|
|
168
|
-
def messages_snapshot(self, locale: str) -> dict[str, str]:
|
|
182
|
+
def messages_snapshot(self, locale: str, *, include_admin: bool = True) -> dict[str, str]:
|
|
169
183
|
"""Plain-dict snapshot for callers that JSON-serialize the result.
|
|
170
184
|
|
|
171
185
|
Built once at :meth:`load` time and handed out by reference on every
|
|
@@ -173,12 +187,17 @@ class I18nRegistry:
|
|
|
173
187
|
corrupts subsequent responses. Used by the Inertia shared-props builder
|
|
174
188
|
where it sits on the request hot path; prior to this method,
|
|
175
189
|
``dict(messages(locale))`` per request was the top own-code allocator.
|
|
190
|
+
|
|
191
|
+
``include_admin=False`` returns the variant without ``audience="admin"``
|
|
192
|
+
namespaces — what anonymous visitors are served.
|
|
176
193
|
"""
|
|
177
|
-
|
|
194
|
+
pool = self._message_snapshots if include_admin else self._public_snapshots
|
|
195
|
+
snapshot = pool.get(locale)
|
|
178
196
|
if snapshot is not None:
|
|
179
197
|
return snapshot
|
|
180
198
|
# Fallback for tests that skip ``load()``: synthesize the snapshot on
|
|
181
|
-
# demand from whatever ``_messages`` holds
|
|
199
|
+
# demand from whatever ``_messages`` holds (audience information only
|
|
200
|
+
# exists for sources that went through ``load()``).
|
|
182
201
|
raw = self._messages.get(locale)
|
|
183
202
|
if raw is None:
|
|
184
203
|
return self._empty_snapshot
|
|
@@ -35,6 +35,15 @@ class ModuleMeta:
|
|
|
35
35
|
installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
|
|
36
36
|
When ``None``, no compatibility check is performed (legacy modules).
|
|
37
37
|
"""
|
|
38
|
+
i18n_audience: str = "public"
|
|
39
|
+
"""Who this module's locale catalog is shipped to: ``"public"`` or ``"admin"``.
|
|
40
|
+
|
|
41
|
+
``"public"`` (the default) ships the catalog in every Inertia payload.
|
|
42
|
+
``"admin"`` ships it only to authenticated users — declare it on modules
|
|
43
|
+
whose UI sits entirely behind login (settings, permissions, dashboards) so
|
|
44
|
+
anonymous visitors don't download admin form labels on every public page.
|
|
45
|
+
The catalog is always available server-side (``Translator``) either way.
|
|
46
|
+
"""
|
|
38
47
|
|
|
39
48
|
|
|
40
49
|
class ModuleBase(ABC):
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""SM022/SM023 — module CSS placed in the wrong file.
|
|
2
|
+
|
|
3
|
+
The theme.css / styles.css split is what makes the cascade rules structural
|
|
4
|
+
rather than merely documented, so putting a construct in the wrong file
|
|
5
|
+
produces surprising-but-legal CSS. Both codes are warnings for that reason:
|
|
6
|
+
the build succeeds either way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _mod():
|
|
13
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
14
|
+
|
|
15
|
+
class Styled(ModuleBase):
|
|
16
|
+
meta = ModuleMeta(name="Styled")
|
|
17
|
+
|
|
18
|
+
return Styled()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestSm022ThemeConstructsInStylesCss:
|
|
22
|
+
def test_theme_at_rule_in_styles_css(self, tmp_path):
|
|
23
|
+
"""@theme inside styles.css is inert — styles.css is imported layered."""
|
|
24
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
25
|
+
|
|
26
|
+
(tmp_path / "styles.css").write_text("@theme {\n --color-x: red;\n}\n")
|
|
27
|
+
|
|
28
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
29
|
+
|
|
30
|
+
assert [d.code for d in diags] == ["SM022"]
|
|
31
|
+
assert diags[0].level.value == "warning"
|
|
32
|
+
|
|
33
|
+
def test_custom_variant_in_styles_css(self, tmp_path):
|
|
34
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
35
|
+
|
|
36
|
+
(tmp_path / "styles.css").write_text("@custom-variant dark (&:where(.dark, .dark *));\n")
|
|
37
|
+
|
|
38
|
+
assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"]
|
|
39
|
+
|
|
40
|
+
def test_utility_at_rule_in_styles_css(self, tmp_path):
|
|
41
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
42
|
+
|
|
43
|
+
(tmp_path / "styles.css").write_text("@utility tab-4 {\n tab-size: 4;\n}\n")
|
|
44
|
+
|
|
45
|
+
assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"]
|
|
46
|
+
|
|
47
|
+
def test_reports_the_offending_line(self, tmp_path):
|
|
48
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
49
|
+
|
|
50
|
+
(tmp_path / "styles.css").write_text(
|
|
51
|
+
"@layer components {\n .x { color: red; }\n}\n\n@theme {\n --a: 1;\n}\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
55
|
+
|
|
56
|
+
assert len(diags) == 1
|
|
57
|
+
# Line number rides in `file` as path:line, matching _inertia_api.py.
|
|
58
|
+
assert diags[0].file.endswith("styles.css:5")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestSm023UnlayeredRulesInThemeCss:
|
|
62
|
+
def test_plain_rule_in_theme_css(self, tmp_path):
|
|
63
|
+
"""An unlayered rule in theme.css outranks every Tailwind utility."""
|
|
64
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
65
|
+
|
|
66
|
+
(tmp_path / "theme.css").write_text(".card {\n padding: 0;\n}\n")
|
|
67
|
+
|
|
68
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
69
|
+
|
|
70
|
+
assert [d.code for d in diags] == ["SM023"]
|
|
71
|
+
assert diags[0].level.value == "warning"
|
|
72
|
+
|
|
73
|
+
def test_root_block_allowed(self, tmp_path):
|
|
74
|
+
""":root custom-property blocks are legitimate in theme.css."""
|
|
75
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
76
|
+
|
|
77
|
+
(tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n:root {\n --b: 2;\n}\n")
|
|
78
|
+
|
|
79
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
80
|
+
|
|
81
|
+
def test_root_variants_allowed(self, tmp_path):
|
|
82
|
+
"""Dark-mode token blocks keyed off :root are the normal pattern."""
|
|
83
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
84
|
+
|
|
85
|
+
(tmp_path / "theme.css").write_text(':root,\n:root[data-theme="dark"] {\n --b: 2;\n}\n')
|
|
86
|
+
|
|
87
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
88
|
+
|
|
89
|
+
def test_font_face_allowed(self, tmp_path):
|
|
90
|
+
"""@font-face is explicitly part of what theme.css is for."""
|
|
91
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
92
|
+
|
|
93
|
+
(tmp_path / "theme.css").write_text(
|
|
94
|
+
"@font-face {\n font-family: X;\n src: url(x.woff2);\n}\n"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""The CSS scanner behind SM022/SM023.
|
|
2
|
+
|
|
3
|
+
`check_module_css` is only as good as its ability to find *top-level*
|
|
4
|
+
constructs, and it does that with a hand-rolled brace-depth scan rather than a
|
|
5
|
+
CSS parse — no parser joins the runtime dependencies to power a lint. These
|
|
6
|
+
tests pin the tokenising rules that scan depends on: comments, quoted strings,
|
|
7
|
+
escapes, and the line numbers reported alongside a finding.
|
|
8
|
+
|
|
9
|
+
Every case here is a bug that shipped at some point during review: each one is
|
|
10
|
+
a way a stray character silently desynced the depth counter and made the lint
|
|
11
|
+
stop reporting anything at all.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mod():
|
|
18
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
19
|
+
|
|
20
|
+
class Styled(ModuleBase):
|
|
21
|
+
meta = ModuleMeta(name="Styled")
|
|
22
|
+
|
|
23
|
+
return Styled()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestScannerRobustness:
|
|
27
|
+
def test_nested_at_rule_not_flagged(self, tmp_path):
|
|
28
|
+
"""Only top-level constructs count — brace depth is tracked."""
|
|
29
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
30
|
+
|
|
31
|
+
(tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n")
|
|
32
|
+
|
|
33
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
34
|
+
|
|
35
|
+
def test_nested_rule_in_theme_css_not_flagged(self, tmp_path):
|
|
36
|
+
"""A rule nested inside @theme is not a top-level rule."""
|
|
37
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
38
|
+
|
|
39
|
+
(tmp_path / "theme.css").write_text("@theme {\n --a: 1;\n}\n")
|
|
40
|
+
|
|
41
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
42
|
+
|
|
43
|
+
def test_commented_out_construct_is_not_a_finding(self, tmp_path):
|
|
44
|
+
"""A commented-out @theme is not a finding."""
|
|
45
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
46
|
+
|
|
47
|
+
(tmp_path / "styles.css").write_text("/* @theme { --x: 1; } */\n.y { color: red; }\n")
|
|
48
|
+
|
|
49
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
50
|
+
|
|
51
|
+
def test_comment_does_not_shift_line_numbers(self, tmp_path):
|
|
52
|
+
"""Newlines inside a skipped comment must still be counted."""
|
|
53
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
54
|
+
|
|
55
|
+
(tmp_path / "styles.css").write_text("/* a\n multi-line\n comment */\n@theme {\n}\n")
|
|
56
|
+
|
|
57
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
58
|
+
|
|
59
|
+
assert [d.file.rsplit(":", 1)[1] for d in diags] == ["4"]
|
|
60
|
+
|
|
61
|
+
def test_clean_module_has_no_findings(self, tmp_path):
|
|
62
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
63
|
+
|
|
64
|
+
(tmp_path / "theme.css").write_text("@theme {\n --color-x: red;\n}\n")
|
|
65
|
+
(tmp_path / "styles.css").write_text("@layer components {\n .x { color: red; }\n}\n")
|
|
66
|
+
|
|
67
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
68
|
+
|
|
69
|
+
def test_missing_files_are_not_findings(self, tmp_path):
|
|
70
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
71
|
+
|
|
72
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
73
|
+
|
|
74
|
+
def test_open_brace_in_a_string_does_not_hide_later_findings(self, tmp_path):
|
|
75
|
+
"""A brace inside a string must not desync the depth counter.
|
|
76
|
+
|
|
77
|
+
An icon-font rule like `content: "{"` used to leave the scanner
|
|
78
|
+
permanently one level deep, so every later top-level construct read as
|
|
79
|
+
nested — silently swallowing the very @theme SM022 exists to catch.
|
|
80
|
+
"""
|
|
81
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
82
|
+
|
|
83
|
+
(tmp_path / "styles.css").write_text('.icon { content: "{"; }\n@theme {\n --a: 1;\n}\n')
|
|
84
|
+
|
|
85
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
86
|
+
|
|
87
|
+
assert [d.code for d in diags] == ["SM022"]
|
|
88
|
+
assert diags[0].file.endswith("styles.css:2")
|
|
89
|
+
|
|
90
|
+
def test_close_brace_in_a_string_is_not_a_finding(self, tmp_path):
|
|
91
|
+
"""A closing brace inside a string must not end the block early."""
|
|
92
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
93
|
+
|
|
94
|
+
(tmp_path / "theme.css").write_text(':root {\n --icon-close: "}";\n}\n')
|
|
95
|
+
|
|
96
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
97
|
+
|
|
98
|
+
def test_escaped_quote_in_a_string(self, tmp_path):
|
|
99
|
+
"""A backslash-escaped quote does not terminate the string."""
|
|
100
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
101
|
+
|
|
102
|
+
(tmp_path / "theme.css").write_text(':root {\n --q: "a\\"{b";\n}\n')
|
|
103
|
+
|
|
104
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
105
|
+
|
|
106
|
+
def test_single_quoted_strings_handled(self, tmp_path):
|
|
107
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
108
|
+
|
|
109
|
+
(tmp_path / "styles.css").write_text(
|
|
110
|
+
".icon { content: '{'; }\n@utility tab-4 {\n tab-size: 4;\n}\n"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"]
|
|
114
|
+
|
|
115
|
+
def test_unterminated_string_does_not_swallow_the_file(self, tmp_path):
|
|
116
|
+
"""A stray quote must not disable the lint for everything after it.
|
|
117
|
+
|
|
118
|
+
A CSS string cannot contain a raw newline, so an unterminated one ends
|
|
119
|
+
at end-of-line. Without that bound, a single typo'd quote left the
|
|
120
|
+
scanner permanently "inside a string" and every later brace stopped
|
|
121
|
+
counting — silently hiding the rest of the file's findings.
|
|
122
|
+
"""
|
|
123
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
124
|
+
|
|
125
|
+
(tmp_path / "styles.css").write_text(
|
|
126
|
+
'.a { color: red; }\n.b {\n content: "unterminated;\n}\n@theme {\n --b: 1;\n}\n'
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
130
|
+
|
|
131
|
+
assert [d.code for d in diags] == ["SM022"]
|
|
132
|
+
assert diags[0].file.endswith("styles.css:5")
|
|
133
|
+
|
|
134
|
+
def test_apostrophe_in_unquoted_url_does_not_swallow_the_file(self, tmp_path):
|
|
135
|
+
"""An unquoted url() token may hold a lone apostrophe, e.g. inline SVG."""
|
|
136
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
137
|
+
|
|
138
|
+
(tmp_path / "styles.css").write_text(
|
|
139
|
+
".icon {\n"
|
|
140
|
+
" background: url(data:image/svg+xml,<text>it's here</text>);\n"
|
|
141
|
+
"}\n"
|
|
142
|
+
"@theme {\n"
|
|
143
|
+
" --a: 1;\n"
|
|
144
|
+
"}\n"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
assert [d.code for d in check_module_css(_mod(), tmp_path)] == ["SM022"]
|
|
148
|
+
|
|
149
|
+
def test_stray_quote_does_not_eat_a_brace_on_its_own_line(self, tmp_path):
|
|
150
|
+
"""The unmatched-quote case, with the closing brace on the SAME line.
|
|
151
|
+
|
|
152
|
+
Bounding strings at newlines was not enough: the `}` here sits on the
|
|
153
|
+
stray quote's own line, so it was consumed as string content and the
|
|
154
|
+
depth counter stayed desynced for the rest of the file. A quote only
|
|
155
|
+
opens a string if its partner appears before the line ends.
|
|
156
|
+
"""
|
|
157
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
158
|
+
|
|
159
|
+
(tmp_path / "styles.css").write_text(
|
|
160
|
+
".icon { background: url(it's.png); }\n@theme {\n --a: 1;\n}\n"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
164
|
+
|
|
165
|
+
assert [d.code for d in diags] == ["SM022"]
|
|
166
|
+
assert diags[0].file.endswith("styles.css:2")
|
|
167
|
+
|
|
168
|
+
def test_multiline_quoted_value_is_not_a_finding(self, tmp_path):
|
|
169
|
+
"""grid-template-areas spreads several complete strings over lines."""
|
|
170
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
171
|
+
|
|
172
|
+
(tmp_path / "styles.css").write_text(
|
|
173
|
+
"@layer components {\n"
|
|
174
|
+
" .g {\n"
|
|
175
|
+
" grid-template-areas:\n"
|
|
176
|
+
' "a b"\n'
|
|
177
|
+
' "c d";\n'
|
|
178
|
+
" }\n"
|
|
179
|
+
"}\n"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
183
|
+
|
|
184
|
+
def test_escaped_selector_is_not_a_string_opener(self, tmp_path):
|
|
185
|
+
"""CSS escapes apply outside strings — Tailwind selectors rely on it."""
|
|
186
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
187
|
+
|
|
188
|
+
(tmp_path / "styles.css").write_text(
|
|
189
|
+
".mt-\\[773px\\] { margin-top: 773px; }\n@theme {\n --a: 1;\n}\n"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
193
|
+
|
|
194
|
+
assert [d.code for d in diags] == ["SM022"]
|
|
195
|
+
assert diags[0].file.endswith("styles.css:2")
|
|
196
|
+
|
|
197
|
+
def test_scanner_is_linear_on_pathological_input(self, tmp_path):
|
|
198
|
+
"""A long run of escaped quotes must not make the scan quadratic.
|
|
199
|
+
|
|
200
|
+
`doctor` runs this over every installed module, including third-party
|
|
201
|
+
ones, so a minified or vendored stylesheet must not stall the lint.
|
|
202
|
+
Before escapes were consumed outside strings, 58KB of `\\'` took ~10s.
|
|
203
|
+
"""
|
|
204
|
+
import time
|
|
205
|
+
|
|
206
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
207
|
+
|
|
208
|
+
(tmp_path / "styles.css").write_text("\\'" * 60_000)
|
|
209
|
+
|
|
210
|
+
started = time.perf_counter()
|
|
211
|
+
check_module_css(_mod(), tmp_path)
|
|
212
|
+
elapsed = time.perf_counter() - started
|
|
213
|
+
|
|
214
|
+
assert elapsed < 2.0, f"scan took {elapsed:.1f}s — likely quadratic again"
|
|
215
|
+
|
|
216
|
+
def test_escaped_newline_continues_a_string(self, tmp_path):
|
|
217
|
+
"""A backslash-escaped newline is the one way a string spans lines."""
|
|
218
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
219
|
+
|
|
220
|
+
(tmp_path / "theme.css").write_text(':root {\n --a: "x\\\n y";\n}\n')
|
|
221
|
+
|
|
222
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
223
|
+
|
|
224
|
+
def test_brace_inside_a_comment_is_not_structural(self, tmp_path):
|
|
225
|
+
"""An unbalanced brace in a comment must not shift depth either."""
|
|
226
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
227
|
+
|
|
228
|
+
(tmp_path / "styles.css").write_text("/* } { */\n@theme {\n --a: 1;\n}\n")
|
|
229
|
+
|
|
230
|
+
diags = check_module_css(_mod(), tmp_path)
|
|
231
|
+
|
|
232
|
+
assert [d.code for d in diags] == ["SM022"]
|
|
233
|
+
assert diags[0].file.endswith("styles.css:2")
|
|
234
|
+
|
|
235
|
+
def test_import_statement_allowed_in_either_file(self, tmp_path):
|
|
236
|
+
"""A statement at-rule ending in ; is not a rule block."""
|
|
237
|
+
from simple_module_core.diagnostics._css import check_module_css
|
|
238
|
+
|
|
239
|
+
(tmp_path / "theme.css").write_text('@import "other.css";\n')
|
|
240
|
+
(tmp_path / "styles.css").write_text('@charset "utf-8";\n')
|
|
241
|
+
|
|
242
|
+
assert check_module_css(_mod(), tmp_path) == []
|
|
@@ -65,6 +65,29 @@ class TestI18nRegistry:
|
|
|
65
65
|
reg.load()
|
|
66
66
|
assert sorted(reg.available_locales()) == ["en", "es"]
|
|
67
67
|
|
|
68
|
+
def test_admin_sources_are_excluded_from_the_public_snapshot(self, tmp_path: Path) -> None:
|
|
69
|
+
"""Anonymous visitors must not download catalogs for login-gated UI."""
|
|
70
|
+
self._write_locale(tmp_path / "pages", "en", {"title": "Pages"})
|
|
71
|
+
self._write_locale(tmp_path / "settings", "en", {"form": {"key": "Key"}})
|
|
72
|
+
reg = I18nRegistry(default_locale="en", supported_locales=["en"])
|
|
73
|
+
reg.add_source("pages", tmp_path / "pages")
|
|
74
|
+
reg.add_source("settings", tmp_path / "settings", audience="admin")
|
|
75
|
+
reg.load()
|
|
76
|
+
assert reg.messages_snapshot("en", include_admin=False) == {"pages.title": "Pages"}
|
|
77
|
+
# The full snapshot and server-side lookups still see everything.
|
|
78
|
+
assert reg.messages_snapshot("en") == {
|
|
79
|
+
"pages.title": "Pages",
|
|
80
|
+
"settings.form.key": "Key",
|
|
81
|
+
}
|
|
82
|
+
assert reg.messages("en")["settings.form.key"] == "Key"
|
|
83
|
+
|
|
84
|
+
def test_public_snapshot_defaults_to_everything(self, tmp_path: Path) -> None:
|
|
85
|
+
self._write_locale(tmp_path / "p", "en", {"title": "Products"})
|
|
86
|
+
reg = I18nRegistry(default_locale="en", supported_locales=["en"])
|
|
87
|
+
reg.add_source("products", tmp_path / "p")
|
|
88
|
+
reg.load()
|
|
89
|
+
assert reg.messages_snapshot("en", include_admin=False) == reg.messages_snapshot("en")
|
|
90
|
+
|
|
68
91
|
def test_missing_locale_file_is_warning_not_error(
|
|
69
92
|
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
70
93
|
) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/__init__.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_coupling.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_i18n.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_migration.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_pages.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/simple_module_core/diagnostics/_runner.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.26 → simple_module_core-0.0.28}/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
|
|
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.26 → simple_module_core-0.0.28}/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
|