simple-module-core 0.0.25__tar.gz → 0.0.27__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/.gitignore +1 -0
  2. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/PKG-INFO +1 -1
  3. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/pyproject.toml +1 -1
  4. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/__init__.py +3 -0
  5. simple_module_core-0.0.27/simple_module_core/design_packs.py +88 -0
  6. simple_module_core-0.0.27/simple_module_core/diagnostics/_css.py +214 -0
  7. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_module.py +2 -0
  8. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/module.py +17 -0
  9. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/services.py +2 -0
  10. simple_module_core-0.0.27/tests/test_css_diagnostics.py +97 -0
  11. simple_module_core-0.0.27/tests/test_css_scanner.py +242 -0
  12. simple_module_core-0.0.27/tests/test_design_packs.py +105 -0
  13. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_module_base.py +22 -0
  14. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_services.py +3 -0
  15. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/LICENSE +0 -0
  16. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/README.md +0 -0
  17. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/__main__.py +0 -0
  18. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/__init__.py +0 -0
  19. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_coupling.py +0 -0
  20. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_i18n.py +0 -0
  21. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  22. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  23. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_migration.py +0 -0
  24. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_pages.py +0 -0
  25. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_runner.py +0 -0
  26. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/diagnostics/_types.py +0 -0
  27. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/discovery.py +0 -0
  28. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/dotenv.py +0 -0
  29. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/environments.py +0 -0
  30. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/events.py +0 -0
  31. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/exceptions.py +0 -0
  32. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/feature_flags.py +0 -0
  33. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/health.py +0 -0
  34. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/i18n.py +0 -0
  35. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/menu.py +0 -0
  36. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/permissions.py +0 -0
  37. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/public_routes.py +0 -0
  38. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/py.typed +0 -0
  39. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/simple_module_core/versioning.py +0 -0
  40. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_diagnostics.py +0 -0
  41. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_discovery.py +0 -0
  42. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_dotenv.py +0 -0
  43. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_environments.py +0 -0
  44. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_events.py +0 -0
  45. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_feature_flags.py +0 -0
  46. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_feature_flags_decorator.py +0 -0
  47. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_health_registry.py +0 -0
  48. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_i18n.py +0 -0
  49. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_i18n_diagnostics.py +0 -0
  50. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_menu.py +0 -0
  51. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_module_diagnostics.py +0 -0
  52. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_permissions.py +0 -0
  53. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_public_routes.py +0 -0
  54. {simple_module_core-0.0.25 → simple_module_core-0.0.27}/tests/test_versioning.py +0 -0
@@ -45,6 +45,7 @@ docs/.vitepress/dist/
45
45
  host/client_app/modules.manifest.json
46
46
  host/client_app/modules.generated.ts
47
47
  host/client_app/modules.generated.css
48
+ host/client_app/modules.assets.json
48
49
 
49
50
  # Worktrees
50
51
  .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_core
3
- Version: 0.0.25
3
+ Version: 0.0.27
4
4
  Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.25"
3
+ version = "0.0.27"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,5 +1,6 @@
1
1
  """SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
2
2
 
3
+ from simple_module_core.design_packs import DesignPack, DesignPackRegistry
3
4
  from simple_module_core.diagnostics import (
4
5
  DiagnosticLevel,
5
6
  MigrationDiagnostics,
@@ -40,6 +41,8 @@ from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework
40
41
  __all__ = [
41
42
  "FRAMEWORK_API_VERSION",
42
43
  "CircularDependencyError",
44
+ "DesignPack",
45
+ "DesignPackRegistry",
43
46
  "DiagnosticLevel",
44
47
  "Event",
45
48
  "EventBus",
@@ -0,0 +1,88 @@
1
+ """Design-pack registry — modules contribute a selectable look for the public site.
2
+
3
+ A *design pack* is a stylesheet a module ships that restyles the reader-facing
4
+ site by overriding the base design tokens beneath a ``<value>-root`` class. A
5
+ module declares the packs it provides via
6
+ :meth:`~simple_module_core.module.ModuleBase.register_design_packs`; the host
7
+ collects them into one registry at boot and stores it on
8
+ ``app.state.design_packs``.
9
+
10
+ **The registry supplies the dropdown, not the stylesheet.** A pack's CSS still
11
+ reaches the bundle through the host's ``styles.css`` importing it by package
12
+ specifier. The registry's only job is to stop an administrator selecting a pack
13
+ that no installed module provides — which would otherwise put a class on the
14
+ document with nothing behind it and silently do nothing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+
22
+ SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
23
+ """A pack slug: lowercase alphanumerics and dashes, not starting with a dash.
24
+
25
+ The site root class is ``f"{value}-root"``, so the slug has to be usable as a
26
+ bare CSS identifier fragment.
27
+ """
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DesignPack:
32
+ """One selectable look.
33
+
34
+ Args:
35
+ value: Slug identifying the pack. The public site's root element
36
+ carries ``f"{value}-root"``, which is the hook the pack's own
37
+ stylesheet selects on.
38
+ label: Human-readable name, shown in the branding dropdown.
39
+ """
40
+
41
+ value: str
42
+ label: str
43
+
44
+ def __post_init__(self) -> None:
45
+ if not SLUG_RE.match(self.value):
46
+ raise ValueError(
47
+ f"DesignPack value {self.value!r} is not a usable class-name "
48
+ f"fragment; it must match {SLUG_RE.pattern}"
49
+ )
50
+
51
+
52
+ class DesignPackRegistry:
53
+ """Aggregates every module's :class:`DesignPack` declarations.
54
+
55
+ Populated once during boot (``register_design_packs`` hook) and read
56
+ thereafter — by the branding view to build its dropdown, and by the
57
+ branding API to validate a submitted slug.
58
+ """
59
+
60
+ def __init__(self) -> None:
61
+ self._packs: dict[str, DesignPack] = {}
62
+
63
+ def register(self, pack: DesignPack) -> None:
64
+ """Add *pack*, rejecting a slug another module already claimed.
65
+
66
+ Duplicate slugs are an error rather than a silent overwrite: two packs
67
+ sharing one root class would leave whichever stylesheet happened to
68
+ load last in charge, which is not something an administrator could
69
+ diagnose from the UI.
70
+ """
71
+ existing = self._packs.get(pack.value)
72
+ if existing is not None:
73
+ raise ValueError(
74
+ f"Design pack {pack.value!r} is already registered "
75
+ f"(as {existing.label!r}); slugs must be unique across modules"
76
+ )
77
+ self._packs[pack.value] = pack
78
+
79
+ def all(self) -> list[DesignPack]:
80
+ """Every registered pack, in registration order (a copy)."""
81
+ return list(self._packs.values())
82
+
83
+ def has(self, value: str) -> bool:
84
+ """Return ``True`` if some module registered a pack with this slug."""
85
+ return value in self._packs
86
+
87
+
88
+ __all__ = ["DesignPack", "DesignPackRegistry"]
@@ -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
@@ -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
 
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
10
10
  if TYPE_CHECKING:
11
11
  from fastapi import APIRouter, FastAPI
12
12
 
13
+ from simple_module_core.design_packs import DesignPackRegistry
13
14
  from simple_module_core.events import EventBus
14
15
  from simple_module_core.feature_flags import FeatureFlagRegistry
15
16
  from simple_module_core.health import HealthRegistry
@@ -137,6 +138,22 @@ class ModuleBase(ABC):
137
138
  mutations. Called once at boot, in dependency order.
138
139
  """
139
140
 
141
+ def register_design_packs(self, registry: DesignPackRegistry) -> None:
142
+ """Declare design packs this module ships for the public site.
143
+
144
+ A design pack is a stylesheet that restyles the reader-facing site by
145
+ overriding the base tokens beneath a ``<value>-root`` class. Override
146
+ this hook to make the pack selectable in branding::
147
+
148
+ def register_design_packs(self, registry):
149
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
150
+
151
+ Registering only advertises the pack — the stylesheet itself still
152
+ reaches the bundle through the host's ``styles.css``. Slugs are unique
153
+ across modules; a collision raises at boot. Called once, in dependency
154
+ order.
155
+ """
156
+
140
157
  def register_middleware(self, app: FastAPI) -> None:
141
158
  """Add middleware to the application.
142
159
 
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
20
20
  from simple_module_db.session import DatabaseState
21
21
  from simple_module_hosting.settings import Settings
22
22
 
23
+ from simple_module_core.design_packs import DesignPackRegistry
23
24
  from simple_module_core.events import EventBus
24
25
  from simple_module_core.feature_flags import FeatureFlagRegistry
25
26
  from simple_module_core.health import HealthRegistry
@@ -42,6 +43,7 @@ class Services:
42
43
  feature_flags: FeatureFlagRegistry
43
44
  health_registry: HealthRegistry
44
45
  public_routes: PublicRouteRegistry
46
+ design_packs: DesignPackRegistry
45
47
  i18n_registry: I18nRegistry
46
48
  inertia_config: InertiaConfig
47
49
  modules: tuple[ModuleBase, ...]
@@ -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) == []
@@ -0,0 +1,105 @@
1
+ """Tests for DesignPackRegistry — modules contribute site-wide design packs.
2
+
3
+ A design pack is a stylesheet a module ships that restyles the public site by
4
+ overriding the base tokens beneath a ``<value>-root`` class. The registry is
5
+ what lets branding offer an administrator only the packs an installed module
6
+ actually provides: selecting one nothing ships would put a class on the
7
+ document with no stylesheet behind it, silently doing nothing.
8
+
9
+ The registry supplies the dropdown, not the stylesheet — a pack's CSS still
10
+ reaches the bundle through the host's ``styles.css``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+
17
+ import pytest
18
+ from simple_module_core.design_packs import DesignPack, DesignPackRegistry
19
+
20
+
21
+ class TestDesignPack:
22
+ def test_carries_slug_and_label(self):
23
+ pack = DesignPack(value="gca", label="Canopy Atlas")
24
+ assert pack.value == "gca"
25
+ assert pack.label == "Canopy Atlas"
26
+
27
+ def test_is_frozen(self):
28
+ pack = DesignPack(value="gca", label="Canopy Atlas")
29
+ with pytest.raises(dataclasses.FrozenInstanceError):
30
+ pack.value = "other"
31
+
32
+ @pytest.mark.parametrize("value", ["gca", "g", "7", "canopy-atlas", "a1-b2"])
33
+ def test_accepts_a_css_safe_slug(self, value):
34
+ assert DesignPack(value=value, label="X").value == value
35
+
36
+ @pytest.mark.parametrize(
37
+ "value",
38
+ ["", "-gca", "GCA", "canopy_atlas", "canopy atlas", "gca!", "gca.pack"],
39
+ )
40
+ def test_rejects_a_slug_that_would_not_survive_a_class_name(self, value):
41
+ # The site root class is f"{value}-root", so anything that isn't a bare
42
+ # lowercase CSS identifier fragment either fails to select or, worse,
43
+ # silently selects something else.
44
+ with pytest.raises(ValueError, match="value"):
45
+ DesignPack(value=value, label="X")
46
+
47
+
48
+ class TestDesignPackRegistry:
49
+ def test_starts_empty(self):
50
+ assert DesignPackRegistry().all() == []
51
+
52
+ def test_registered_pack_is_returned(self):
53
+ registry = DesignPackRegistry()
54
+ pack = DesignPack(value="gca", label="Canopy Atlas")
55
+ registry.register(pack)
56
+ assert registry.all() == [pack]
57
+
58
+ def test_all_preserves_registration_order(self):
59
+ registry = DesignPackRegistry()
60
+ first = DesignPack(value="gca", label="Canopy Atlas")
61
+ second = DesignPack(value="aurora", label="Aurora")
62
+ registry.register(first)
63
+ registry.register(second)
64
+ assert registry.all() == [first, second]
65
+
66
+ def test_all_returns_a_copy(self):
67
+ registry = DesignPackRegistry()
68
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
69
+ registry.all().clear()
70
+ assert len(registry.all()) == 1
71
+
72
+ def test_duplicate_value_is_rejected(self):
73
+ # Two modules claiming one root class would leave whichever stylesheet
74
+ # loaded last in charge — a silent overwrite is the wrong answer.
75
+ registry = DesignPackRegistry()
76
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
77
+ with pytest.raises(ValueError, match="gca"):
78
+ registry.register(DesignPack(value="gca", label="Someone Else's Pack"))
79
+
80
+ def test_distinct_values_sharing_a_label_are_allowed(self):
81
+ registry = DesignPackRegistry()
82
+ registry.register(DesignPack(value="gca", label="Atlas"))
83
+ registry.register(DesignPack(value="gca-dark", label="Atlas"))
84
+ assert [p.value for p in registry.all()] == ["gca", "gca-dark"]
85
+
86
+ def test_has_reports_membership(self):
87
+ # Branding's PUT validates the submitted slug against this.
88
+ registry = DesignPackRegistry()
89
+ registry.register(DesignPack(value="gca", label="Canopy Atlas"))
90
+ assert registry.has("gca")
91
+ assert not registry.has("aurora")
92
+
93
+ def test_has_is_false_on_an_empty_registry(self):
94
+ assert not DesignPackRegistry().has("gca")
95
+
96
+
97
+ class TestPublicSurface:
98
+ def test_both_names_are_exported_from_the_package_root(self):
99
+ # Module authors import registries from ``simple_module_core``, the
100
+ # same way they reach MenuRegistry or PublicRouteRegistry.
101
+ import simple_module_core
102
+
103
+ assert simple_module_core.DesignPack is DesignPack
104
+ assert simple_module_core.DesignPackRegistry is DesignPackRegistry
105
+ assert {"DesignPack", "DesignPackRegistry"} <= set(simple_module_core.__all__)
@@ -125,6 +125,28 @@ class TestModuleNewHooks:
125
125
  assert reg.matches("GET", "/api/with-public/datasets/9/tilejson")
126
126
  assert not reg.matches("PATCH", "/api/with-public/datasets/9/tilejson")
127
127
 
128
+ async def test_register_design_packs_default_noop(self):
129
+ from simple_module_core.design_packs import DesignPackRegistry
130
+
131
+ mod = DummyModule()
132
+ reg = DesignPackRegistry()
133
+ mod.register_design_packs(reg)
134
+ assert reg.all() == []
135
+
136
+ async def test_register_design_packs_override(self):
137
+ from simple_module_core.design_packs import DesignPack, DesignPackRegistry
138
+
139
+ class ModWithPack(ModuleBase):
140
+ meta = ModuleMeta(name="WithPack")
141
+
142
+ def register_design_packs(self, registry):
143
+ registry.register(DesignPack(value="with-pack", label="With Pack"))
144
+
145
+ reg = DesignPackRegistry()
146
+ ModWithPack().register_design_packs(reg)
147
+ assert reg.has("with-pack")
148
+ assert [p.label for p in reg.all()] == ["With Pack"]
149
+
128
150
 
129
151
  class TestModuleAssetHooks:
130
152
  async def test_template_dirs_default_empty(self):
@@ -30,6 +30,7 @@ class TestServices:
30
30
  assert s.feature_flags is _SENTINEL_FLAGS
31
31
  assert s.health_registry is _SENTINEL_HEALTH
32
32
  assert s.public_routes is _SENTINEL_PUBLIC_ROUTES
33
+ assert s.design_packs is _SENTINEL_DESIGN_PACKS
33
34
  assert s.i18n_registry is _SENTINEL_I18N
34
35
  assert s.inertia_config is _SENTINEL_INERTIA
35
36
  assert s.modules == ()
@@ -43,6 +44,7 @@ _SENTINEL_PERMS = object()
43
44
  _SENTINEL_FLAGS = object()
44
45
  _SENTINEL_HEALTH = object()
45
46
  _SENTINEL_PUBLIC_ROUTES = object()
47
+ _SENTINEL_DESIGN_PACKS = object()
46
48
  _SENTINEL_I18N = object()
47
49
  _SENTINEL_INERTIA = object()
48
50
 
@@ -58,6 +60,7 @@ def _make_services() -> Services:
58
60
  feature_flags=_SENTINEL_FLAGS, # type: ignore[arg-type]
59
61
  health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
60
62
  public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
63
+ design_packs=_SENTINEL_DESIGN_PACKS, # type: ignore[arg-type]
61
64
  i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
62
65
  inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
63
66
  modules=(),