simple-module-core 0.0.3__tar.gz → 0.0.4__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 (45) hide show
  1. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/PKG-INFO +1 -1
  2. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/pyproject.toml +1 -1
  3. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/__main__.py +27 -1
  4. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_module.py +50 -110
  5. simple_module_core-0.0.4/simple_module_core/diagnostics/_pages.py +121 -0
  6. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/dotenv.py +32 -1
  7. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/feature_flags.py +30 -2
  8. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/menu.py +6 -0
  9. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/module.py +7 -2
  10. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_discovery.py +5 -5
  11. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_menu.py +14 -0
  12. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_module_diagnostics.py +113 -0
  13. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/.gitignore +0 -0
  14. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/LICENSE +0 -0
  15. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/README.md +0 -0
  16. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/__init__.py +0 -0
  17. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/__init__.py +0 -0
  18. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_coupling.py +0 -0
  19. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_i18n.py +0 -0
  20. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  21. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  22. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_migration.py +0 -0
  23. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_runner.py +0 -0
  24. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_types.py +0 -0
  25. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/discovery.py +0 -0
  26. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/environments.py +0 -0
  27. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/events.py +0 -0
  28. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/exceptions.py +0 -0
  29. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/health.py +0 -0
  30. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/i18n.py +0 -0
  31. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/permissions.py +0 -0
  32. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/py.typed +0 -0
  33. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/services.py +0 -0
  34. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/versioning.py +0 -0
  35. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_diagnostics.py +0 -0
  36. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_events.py +0 -0
  37. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_feature_flags.py +0 -0
  38. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_feature_flags_decorator.py +0 -0
  39. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_health_registry.py +0 -0
  40. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_i18n.py +0 -0
  41. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_i18n_diagnostics.py +0 -0
  42. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_module_base.py +0 -0
  43. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_permissions.py +0 -0
  44. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_services.py +0 -0
  45. {simple_module_core-0.0.3 → simple_module_core-0.0.4}/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
3
+ Version: 0.0.4
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.3"
3
+ version = "0.0.4"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,12 +20,14 @@ import sys
20
20
  from pathlib import Path
21
21
 
22
22
  from simple_module_core.diagnostics import (
23
+ Diagnostic,
23
24
  DiagnosticLevel,
24
25
  print_diagnostics,
25
26
  run_diagnostics,
26
27
  )
27
28
  from simple_module_core.discovery import discover_modules, topological_sort
28
29
  from simple_module_core.dotenv import parse_dotenv
30
+ from simple_module_core.exceptions import InvalidModuleError
29
31
 
30
32
 
31
33
  def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
@@ -69,7 +71,31 @@ def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]:
69
71
 
70
72
 
71
73
  def main() -> int:
72
- modules = discover_modules()
74
+ # ``make doctor`` exists specifically to surface broken modules. The
75
+ # default (lenient) discovery would silently skip a module whose entry
76
+ # point fails to load, so doctor would report "all clear" while a
77
+ # feature was missing from the boot. Use strict and translate the
78
+ # raised error into a diagnostic so the rest of the run still happens.
79
+ try:
80
+ modules = discover_modules(strict=True)
81
+ except InvalidModuleError as exc:
82
+ print_diagnostics(
83
+ [
84
+ Diagnostic(
85
+ level=DiagnosticLevel.ERROR,
86
+ code="SM001",
87
+ message=str(exc),
88
+ module_name="<discovery>",
89
+ suggestion=(
90
+ "Fix the entry point above (broken import, missing 'meta', "
91
+ "or a class that isn't a ModuleBase subclass). Re-run "
92
+ "`make doctor` once resolved."
93
+ ),
94
+ )
95
+ ]
96
+ )
97
+ return 1
98
+
73
99
  if not modules:
74
100
  print("No modules discovered. Is the project installed (`uv sync --all-packages`)?")
75
101
  return 0
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import ast
6
5
  import importlib.util
7
6
  from pathlib import Path
8
7
  from typing import TYPE_CHECKING
@@ -10,40 +9,13 @@ from typing import TYPE_CHECKING
10
9
  from simple_module_core.diagnostics._coupling import check_framework_module_coupling
11
10
  from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
12
11
  from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
12
+ from simple_module_core.diagnostics._pages import check_pages, find_render_calls
13
13
  from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from simple_module_core.module import ModuleBase
17
17
 
18
18
 
19
- def _iter_render_components(tree: ast.Module) -> list[str]:
20
- """Yield ``X.render(component, ...)`` first-arg values, resolving Name constants."""
21
- consts = {
22
- s.targets[0].id: s.value.value
23
- for s in tree.body
24
- if isinstance(s, ast.Assign)
25
- and len(s.targets) == 1
26
- and isinstance(s.targets[0], ast.Name)
27
- and isinstance(s.value, ast.Constant)
28
- and isinstance(s.value.value, str)
29
- }
30
- found: list[str] = []
31
- for node in ast.walk(tree):
32
- if not (
33
- isinstance(node, ast.Call)
34
- and isinstance(node.func, ast.Attribute)
35
- and node.func.attr == "render"
36
- and node.args
37
- ):
38
- continue
39
- first = node.args[0]
40
- if isinstance(first, ast.Constant) and isinstance(first.value, str):
41
- found.append(first.value)
42
- elif isinstance(first, ast.Name) and first.id in consts:
43
- found.append(consts[first.id])
44
- return found
45
-
46
-
47
19
  class ModuleDiagnostics:
48
20
  """Validates module structure and configuration."""
49
21
 
@@ -53,15 +25,15 @@ class ModuleDiagnostics:
53
25
  diagnostics.extend(self._check_schema_conflicts(modules))
54
26
  diagnostics.extend(self._check_empty_modules(modules))
55
27
  diagnostics.extend(self._check_missing_meta(modules))
28
+ diagnostics.extend(self._check_views_without_menu(modules))
56
29
  diagnostics.extend(check_framework_module_coupling(modules))
57
30
 
58
31
  # File-based checks (need to find module source directories)
59
32
  for mod in modules:
60
33
  src_dir = self._find_source_dir(mod)
61
34
  if src_dir:
62
- rendered_pages = self._find_render_calls(mod, src_dir)
63
- diagnostics.extend(self._check_orphan_pages(mod, src_dir, rendered_pages))
64
- diagnostics.extend(self._check_phantom_renders(mod, src_dir, rendered_pages))
35
+ rendered_pages = find_render_calls(mod, src_dir)
36
+ diagnostics.extend(check_pages(mod, src_dir, rendered_pages))
65
37
  diagnostics.extend(check_js_workspace_files(mod, src_dir))
66
38
  diagnostics.extend(check_inertia_api_calls(mod, src_dir))
67
39
 
@@ -144,6 +116,52 @@ class ModuleDiagnostics:
144
116
  )
145
117
  return diags
146
118
 
119
+ def _check_views_without_menu(self, modules: list[ModuleBase]) -> list[Diagnostic]:
120
+ """Warn when a module ships view routes but is silently invisible.
121
+
122
+ A module that overrides ``register_routes`` and declares a non-empty
123
+ ``view_prefix`` produces user-facing pages. Without either
124
+ ``register_menu_items`` (so admins can navigate to it from the sidebar)
125
+ or ``register_permissions`` (so admins can see it in the role-permission
126
+ editor), the module is silently invisible from the admin UI.
127
+
128
+ Modules that surface their views as sub-pages of another module (e.g.
129
+ deep-link edit forms reached from buttons elsewhere) typically register
130
+ permissions even when they don't add a sidebar entry — that suffices to
131
+ keep them discoverable through the role editor.
132
+ """
133
+ diags: list[Diagnostic] = []
134
+ for mod in modules:
135
+ cls = type(mod)
136
+ meta = getattr(mod, "meta", None)
137
+ silently_invisible = (
138
+ meta is not None
139
+ and getattr(meta, "view_prefix", "")
140
+ and "register_routes" in cls.__dict__
141
+ and "register_menu_items" not in cls.__dict__
142
+ and "register_permissions" not in cls.__dict__
143
+ )
144
+ if not silently_invisible:
145
+ continue
146
+ diags.append(
147
+ Diagnostic(
148
+ level=DiagnosticLevel.WARNING,
149
+ code="SM019",
150
+ message=(
151
+ f"Module '{meta.name}' registers view routes "
152
+ f"(view_prefix={meta.view_prefix!r}) but no menu items "
153
+ "or permissions"
154
+ ),
155
+ module_name=meta.name,
156
+ suggestion=(
157
+ "Override register_menu_items() to surface this module "
158
+ "in the sidebar, register_permissions() to surface it in "
159
+ "the role editor, or clear view_prefix if it's API-only"
160
+ ),
161
+ )
162
+ )
163
+ return diags
164
+
147
165
  def _check_missing_meta(self, modules: list[ModuleBase]) -> list[Diagnostic]:
148
166
  diags: list[Diagnostic] = []
149
167
  for mod in modules:
@@ -168,84 +186,6 @@ class ModuleDiagnostics:
168
186
  return Path(locations[0])
169
187
  return None
170
188
 
171
- def _collect_tsx_pages(self, pages_dir: Path) -> set[str]:
172
- """Collect .tsx page identifiers relative to pages_dir, without extension.
173
-
174
- Nested files are represented with forward slashes so the set compares
175
- directly against inertia.render("Module/Sub/Page") keys. Subdirectories
176
- whose names start with a lowercase letter (e.g. ``components/``,
177
- ``hooks/``) are treated as helper folders — not Inertia page roots —
178
- and skipped, matching the PascalCase convention Inertia uses.
179
- """
180
- if not pages_dir.exists():
181
- return set()
182
- pages: set[str] = set()
183
- for f in pages_dir.rglob("*.tsx"):
184
- rel = f.relative_to(pages_dir)
185
- if any(part[:1].islower() for part in rel.parts[:-1]):
186
- continue
187
- pages.add(rel.with_suffix("").as_posix())
188
- return pages
189
-
190
- def _check_orphan_pages(
191
- self,
192
- mod: ModuleBase,
193
- src_dir: Path,
194
- rendered_pages: set[str],
195
- ) -> list[Diagnostic]:
196
- """Find .tsx pages that aren't referenced by any inertia.render() call."""
197
- pages_dir = src_dir / "pages"
198
- tsx_pages = self._collect_tsx_pages(pages_dir)
199
- orphans = tsx_pages - rendered_pages
200
-
201
- return [
202
- Diagnostic(
203
- level=DiagnosticLevel.WARNING,
204
- code="SM003",
205
- message=f"Page '{name}.tsx' exists but no matching inertia.render() found",
206
- module_name=mod.meta.name,
207
- file=str(pages_dir / f"{name}.tsx"),
208
- suggestion=f'Add inertia.render("{mod.meta.name}/{name}", ...) in a view endpoint',
209
- )
210
- for name in orphans
211
- ]
212
-
213
- def _check_phantom_renders(
214
- self,
215
- mod: ModuleBase,
216
- src_dir: Path,
217
- rendered_pages: set[str],
218
- ) -> list[Diagnostic]:
219
- """Find inertia.render() calls that reference non-existent pages."""
220
- pages_dir = src_dir / "pages"
221
- tsx_pages = self._collect_tsx_pages(pages_dir)
222
- phantoms = rendered_pages - tsx_pages
223
-
224
- return [
225
- Diagnostic(
226
- level=DiagnosticLevel.WARNING,
227
- code="SM004",
228
- message=f'inertia.render("{mod.meta.name}/{name}") but no {name}.tsx exists',
229
- module_name=mod.meta.name,
230
- suggestion=f"Create {pages_dir / f'{name}.tsx'}",
231
- )
232
- for name in phantoms
233
- ]
234
-
235
- def _find_render_calls(self, mod: ModuleBase, src_dir: Path) -> set[str]:
236
- """Find inertia.render("Module/Page") calls, resolving module-level string consts."""
237
- rendered: set[str] = set()
238
- prefix = f"{mod.meta.name}/"
239
- for py_file in src_dir.rglob("*.py"):
240
- try:
241
- tree = ast.parse(py_file.read_text(), filename=str(py_file))
242
- except SyntaxError:
243
- continue
244
- for component in _iter_render_components(tree):
245
- if component.startswith(prefix):
246
- rendered.add(component[len(prefix) :])
247
- return rendered
248
-
249
189
  def _find_source_dir(self, mod: ModuleBase) -> Path | None:
250
190
  """Locate the source directory for a module's package."""
251
191
  pkg_name = type(mod).__module__.rsplit(".", 1)[0]
@@ -0,0 +1,121 @@
1
+ """SM003/SM004 page-vs-render diagnostics for Inertia view modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
10
+
11
+ if TYPE_CHECKING:
12
+ from simple_module_core.module import ModuleBase
13
+
14
+
15
+ def _module_level_str_consts(tree: ast.Module) -> dict[str, str]:
16
+ """Return ``{name: literal}`` for top-level ``NAME = "string"`` assignments."""
17
+ return {
18
+ s.targets[0].id: s.value.value
19
+ for s in tree.body
20
+ if isinstance(s, ast.Assign)
21
+ and len(s.targets) == 1
22
+ and isinstance(s.targets[0], ast.Name)
23
+ and isinstance(s.value, ast.Constant)
24
+ and isinstance(s.value.value, str)
25
+ }
26
+
27
+
28
+ def _iter_render_components(tree: ast.Module, consts: dict[str, str]) -> list[str]:
29
+ """Yield ``X.render(component, ...)`` first-arg values, resolving Name references."""
30
+ found: list[str] = []
31
+ for node in ast.walk(tree):
32
+ if not (
33
+ isinstance(node, ast.Call)
34
+ and isinstance(node.func, ast.Attribute)
35
+ and node.func.attr == "render"
36
+ and node.args
37
+ ):
38
+ continue
39
+ first = node.args[0]
40
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
41
+ found.append(first.value)
42
+ elif isinstance(first, ast.Name) and first.id in consts:
43
+ found.append(consts[first.id])
44
+ return found
45
+
46
+
47
+ def collect_tsx_pages(pages_dir: Path) -> set[str]:
48
+ """Collect .tsx page identifiers relative to pages_dir, without extension.
49
+
50
+ Nested files are represented with forward slashes so the set compares
51
+ directly against inertia.render("Module/Sub/Page") keys. Subdirectories
52
+ whose names start with a lowercase letter (``components/``, ``hooks/``,
53
+ ...) are treated as helper folders, not Inertia page roots, matching the
54
+ PascalCase convention Inertia uses.
55
+ """
56
+ pages: set[str] = set()
57
+ for f in pages_dir.rglob("*.tsx"):
58
+ rel = f.relative_to(pages_dir)
59
+ if any(part[:1].islower() for part in rel.parts[:-1]):
60
+ continue
61
+ pages.add(rel.with_suffix("").as_posix())
62
+ return pages
63
+
64
+
65
+ def find_render_calls(mod: ModuleBase, src_dir: Path) -> set[str]:
66
+ """Find inertia.render("Module/Page") calls in this module's source tree.
67
+
68
+ Resolves ``inertia.render(NAME)`` where ``NAME`` is a string constant
69
+ defined at module scope in any sibling .py file (e.g. ``constants.py``).
70
+ Each .py file is parsed once: a first pass collects every module-level
71
+ string const, a second pass walks render calls against the merged map.
72
+ """
73
+ trees: list[ast.Module] = []
74
+ for py_file in src_dir.rglob("*.py"):
75
+ try:
76
+ trees.append(ast.parse(py_file.read_text(), filename=str(py_file)))
77
+ except (SyntaxError, OSError):
78
+ continue
79
+ consts: dict[str, str] = {}
80
+ for tree in trees:
81
+ consts.update(_module_level_str_consts(tree))
82
+
83
+ prefix = f"{mod.meta.name}/"
84
+ rendered: set[str] = set()
85
+ for tree in trees:
86
+ for component in _iter_render_components(tree, consts):
87
+ if component.startswith(prefix):
88
+ rendered.add(component[len(prefix) :])
89
+ return rendered
90
+
91
+
92
+ def check_pages(
93
+ mod: ModuleBase,
94
+ src_dir: Path,
95
+ rendered_pages: set[str],
96
+ ) -> list[Diagnostic]:
97
+ """Diff .tsx pages against rendered_pages — emits SM003 + SM004 in one pass."""
98
+ pages_dir = src_dir / "pages"
99
+ tsx_pages = collect_tsx_pages(pages_dir)
100
+ diags: list[Diagnostic] = [
101
+ Diagnostic(
102
+ level=DiagnosticLevel.WARNING,
103
+ code="SM003",
104
+ message=f"Page '{name}.tsx' exists but no matching inertia.render() found",
105
+ module_name=mod.meta.name,
106
+ file=str(pages_dir / f"{name}.tsx"),
107
+ suggestion=f'Add inertia.render("{mod.meta.name}/{name}", ...) in a view endpoint',
108
+ )
109
+ for name in tsx_pages - rendered_pages
110
+ ]
111
+ diags.extend(
112
+ Diagnostic(
113
+ level=DiagnosticLevel.WARNING,
114
+ code="SM004",
115
+ message=f'inertia.render("{mod.meta.name}/{name}") but no {name}.tsx exists',
116
+ module_name=mod.meta.name,
117
+ suggestion=f"Create {pages_dir / f'{name}.tsx'}",
118
+ )
119
+ for name in rendered_pages - tsx_pages
120
+ )
121
+ return diags
@@ -1,4 +1,4 @@
1
- """Minimal ``.env`` parser — dependency-free.
1
+ """Minimal ``.env`` parser + env-var helpers — dependency-free.
2
2
 
3
3
  Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
4
4
  diagnostics CLI runs before the host package is imported; the users-module
@@ -11,6 +11,9 @@ from __future__ import annotations
11
11
  import os
12
12
  from pathlib import Path
13
13
 
14
+ BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
15
+ BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})
16
+
14
17
 
15
18
  def parse_dotenv(path: Path | None = None) -> dict[str, str]:
16
19
  """Parse a ``.env`` file into a dict. Empty dict if the file is missing.
@@ -36,3 +39,31 @@ def parse_dotenv(path: Path | None = None) -> dict[str, str]:
36
39
  key, _, value = line.partition("=")
37
40
  parsed[key.strip()] = value.strip().strip('"').strip("'")
38
41
  return parsed
42
+
43
+
44
+ def load_dotenv_into_environ(path: Path | None = None) -> None:
45
+ """Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``.
46
+
47
+ Same precedence as the web process under uvicorn: real environment wins
48
+ over file values. Worker entrypoints call this before importing settings.
49
+ """
50
+ for key, value in parse_dotenv(path).items():
51
+ os.environ.setdefault(key, value)
52
+
53
+
54
+ def env_str(name: str, default: str) -> str:
55
+ """Return ``$name`` if set and non-empty, else ``default``."""
56
+ value = os.environ.get(name, "").strip()
57
+ return value or default
58
+
59
+
60
+ def env_bool(name: str, default: bool = False) -> bool:
61
+ """Parse ``$name`` as a boolean, returning ``default`` when unset/blank."""
62
+ raw = os.environ.get(name, "").strip().lower()
63
+ if not raw:
64
+ return default
65
+ if raw in BOOL_LITERALS_TRUE:
66
+ return True
67
+ if raw in BOOL_LITERALS_FALSE:
68
+ return False
69
+ return default
@@ -144,6 +144,11 @@ def feature_flag(
144
144
  directly to the handler. The decorated function must accept a
145
145
  ``request: Request`` parameter (FastAPI injects it automatically).
146
146
 
147
+ For new code prefer ``Depends(require_flag(...))`` — it's the standard
148
+ FastAPI pattern, composes with ``dependencies=[]``, and avoids any
149
+ per-request signature inspection. The decorator stays for sites that
150
+ already use it.
151
+
147
152
  Usage::
148
153
 
149
154
  @router.post("/bulk")
@@ -164,11 +169,34 @@ def feature_flag(
164
169
  f"'request: Request' parameter for the decorator to read tenant state"
165
170
  )
166
171
 
172
+ # Pre-compute the request param's positional index so the wrapper
173
+ # avoids a ``sig.bind`` call per request — sig.bind allocates a
174
+ # BoundArguments object and walks every parameter, which is
175
+ # measurable on hot endpoints under load.
176
+ positional_params = [
177
+ p.name
178
+ for p in sig.parameters.values()
179
+ if p.kind
180
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
181
+ ]
182
+ try:
183
+ request_index: int | None = positional_params.index(request_param)
184
+ except ValueError:
185
+ request_index = None
186
+
187
+ def _resolve_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request:
188
+ if request_param in kwargs:
189
+ return kwargs[request_param]
190
+ if request_index is not None and request_index < len(args):
191
+ return args[request_index]
192
+ # Fallback for unusual call shapes (kw-only, partial application).
193
+ return sig.bind(*args, **kwargs).arguments[request_param]
194
+
167
195
  if inspect.iscoroutinefunction(fn):
168
196
 
169
197
  @functools.wraps(fn)
170
198
  async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
171
- request = sig.bind(*args, **kwargs).arguments[request_param]
199
+ request = _resolve_request(args, kwargs)
172
200
  if not is_flag_enabled(request, name):
173
201
  raise HTTPException(status_code=404, detail="Feature not available")
174
202
  return await fn(*args, **kwargs)
@@ -177,7 +205,7 @@ def feature_flag(
177
205
 
178
206
  @functools.wraps(fn)
179
207
  def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
180
- request = sig.bind(*args, **kwargs).arguments[request_param]
208
+ request = _resolve_request(args, kwargs)
181
209
  if not is_flag_enabled(request, name):
182
210
  raise HTTPException(status_code=404, detail="Feature not available")
183
211
  return fn(*args, **kwargs)
@@ -33,6 +33,11 @@ class MenuItem:
33
33
  method: MenuItemMethod = "get"
34
34
  """HTTP method used when the item is activated. ``"post"`` renders as an
35
35
  Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
36
+ group: str = ""
37
+ """Sidebar group label. Empty = ungrouped (renders flat, no header).
38
+ Items in the same section that share a group are visually clustered under a
39
+ header in the order they already sort by ``order``; the group's own position
40
+ is set by the lowest-ordered item that belongs to it."""
36
41
 
37
42
 
38
43
  class MenuRegistry:
@@ -83,6 +88,7 @@ class MenuRegistry:
83
88
  "url": item.url,
84
89
  "icon": item.icon,
85
90
  "method": item.method,
91
+ "group": item.group,
86
92
  }
87
93
  )
88
94
 
@@ -107,8 +107,13 @@ class ModuleBase(ABC):
107
107
  def register_feature_flags(self, registry: FeatureFlagRegistry) -> None:
108
108
  """Declare feature flags this module exposes."""
109
109
 
110
- def register_event_handlers(self, bus: EventBus) -> None:
111
- """Subscribe to events published by other modules."""
110
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
111
+ """Subscribe to events published by other modules.
112
+
113
+ ``app`` is optional for back-compat; pass it through to handlers
114
+ that need ``app.state.sm.db.session_factory`` to persist on the
115
+ framework's engine instead of building their own.
116
+ """
112
117
 
113
118
  def register_health_checks(self, registry: HealthRegistry) -> None:
114
119
  """Contribute health checks for the ``/health/ready`` endpoint."""
@@ -115,9 +115,9 @@ class TestDiscoverModules:
115
115
  """discover_modules() should find modules registered via entry_points."""
116
116
  modules = discover_modules()
117
117
  names = [m.meta.name for m in modules]
118
- assert "Products" in names
119
118
  assert "Auth" in names
120
119
  assert "Dashboard" in names
120
+ assert "Users" in names
121
121
 
122
122
 
123
123
  class TestDiscoverModulesAdvanced:
@@ -221,7 +221,7 @@ class TestSelectiveModuleLoading:
221
221
  """Passing enabled=None keeps existing behaviour (load all installed modules)."""
222
222
  all_mods = discover_modules(enabled=None)
223
223
  names = {m.meta.name for m in all_mods}
224
- assert {"Auth", "Products", "Dashboard"}.issubset(names)
224
+ assert {"Auth", "Users", "Dashboard"}.issubset(names)
225
225
 
226
226
  async def test_discover_with_allowlist_filters(self):
227
227
  """Passing enabled=['Auth'] loads only Auth, even if other modules are installed."""
@@ -234,9 +234,9 @@ class TestSelectiveModuleLoading:
234
234
  assert discover_modules(enabled=[]) == []
235
235
 
236
236
  async def test_discover_allowlist_case_insensitive(self):
237
- """Allowlist matching ignores case so 'products' and 'Products' both work."""
238
- names = [m.meta.name for m in discover_modules(enabled=["products"])]
239
- assert names == ["Products"]
237
+ """Allowlist matching ignores case so 'dashboard' and 'Dashboard' both work."""
238
+ names = [m.meta.name for m in discover_modules(enabled=["dashboard"])]
239
+ assert names == ["Dashboard"]
240
240
 
241
241
  async def test_discover_unknown_name_logged_and_ignored(self, caplog):
242
242
  """Names in enabled that don't match any installed module log a warning but don't raise."""
@@ -100,3 +100,17 @@ class TestMenuRegistryAdvanced:
100
100
  reg.add(MenuItem(label="Home", url="/", icon="home"))
101
101
  result = reg.get_for_user(is_authenticated=True)
102
102
  assert result["sidebar"][0]["icon"] == "home"
103
+
104
+ async def test_group_default_empty(self):
105
+ reg = MenuRegistry()
106
+ reg.add(MenuItem(label="Home", url="/"))
107
+ result = reg.get_for_user(is_authenticated=True)
108
+ assert result["sidebar"][0]["group"] == ""
109
+
110
+ async def test_group_serialized(self):
111
+ reg = MenuRegistry()
112
+ reg.add(MenuItem(label="Users", url="/users", group="Administration"))
113
+ reg.add(MenuItem(label="Settings", url="/settings", group="System"))
114
+ result = reg.get_for_user(is_authenticated=True)
115
+ groups = [i["group"] for i in result["sidebar"]]
116
+ assert groups == ["Administration", "System"]
@@ -33,6 +33,42 @@ def _mk_module_tree(root: Path, name: str, *, with_pkg_json: bool, with_tsconfig
33
33
  return src_dir
34
34
 
35
35
 
36
+ class TestSm003PageRenderResolution:
37
+ """SM003 must resolve PAGE_X constants imported from sibling files."""
38
+
39
+ def _diags(self, src_dir: Path, mod_name: str):
40
+ from simple_module_core.diagnostics._pages import check_pages, find_render_calls
41
+
42
+ mod = _FakeModule(meta=_FakeMeta(name=mod_name))
43
+ rendered = find_render_calls(mod, src_dir) # pyright: ignore[reportArgumentType]
44
+ return [d for d in check_pages(mod, src_dir, rendered) if d.code == "SM003"] # pyright: ignore[reportArgumentType]
45
+
46
+ async def test_resolves_constant_imported_from_sibling_file(self, tmp_path: Path):
47
+ src_dir = tmp_path / "feature_flags" / "feature_flags"
48
+ (src_dir / "pages").mkdir(parents=True)
49
+ (src_dir / "pages" / "Browse.tsx").write_text("export default function B() {}")
50
+ (src_dir / "constants.py").write_text('PAGE_BROWSE = "FeatureFlags/Browse"\n')
51
+ endpoints = src_dir / "endpoints"
52
+ endpoints.mkdir()
53
+ (endpoints / "views.py").write_text(
54
+ "from feature_flags.constants import PAGE_BROWSE\n"
55
+ "async def view(inertia):\n"
56
+ " return await inertia.render(PAGE_BROWSE, {})\n"
57
+ )
58
+ assert self._diags(src_dir, "FeatureFlags") == []
59
+
60
+ async def test_still_flags_truly_orphan_pages(self, tmp_path: Path):
61
+ src_dir = tmp_path / "m" / "m"
62
+ (src_dir / "pages").mkdir(parents=True)
63
+ (src_dir / "pages" / "Ghost.tsx").write_text("export default function G() {}")
64
+ (src_dir / "endpoints.py").write_text(
65
+ 'async def view(inertia):\n return await inertia.render("M/Other", {})\n'
66
+ )
67
+ results = self._diags(src_dir, "M")
68
+ assert [r.code for r in results] == ["SM003"]
69
+ assert "Ghost.tsx" in results[0].message
70
+
71
+
36
72
  class TestSm017JsWorkspaceFiles:
37
73
  async def test_fires_when_both_missing(self, tmp_path: Path):
38
74
  src_dir = _mk_module_tree(tmp_path, "orders", with_pkg_json=False, with_tsconfig=False)
@@ -144,3 +180,80 @@ class TestSM018InertiaApiCalls:
144
180
  results = check_inertia_api_calls(mod, src_dir) # pyright: ignore[reportArgumentType]
145
181
 
146
182
  assert results == []
183
+
184
+
185
+ class TestSM019ViewsWithoutMenu:
186
+ """SM019 fires when a module ships view routes but never registers a menu item."""
187
+
188
+ def _diags(self, modules):
189
+ from simple_module_core.diagnostics._module import ModuleDiagnostics
190
+
191
+ return list(ModuleDiagnostics()._check_views_without_menu(modules))
192
+
193
+ async def test_fires_when_views_present_but_no_menu(self):
194
+ from simple_module_core.module import ModuleBase, ModuleMeta
195
+
196
+ class ViewsNoMenu(ModuleBase):
197
+ meta = ModuleMeta(name="ViewsNoMenu", view_prefix="/views_no_menu")
198
+
199
+ def register_routes(self, api_router, view_router):
200
+ pass
201
+
202
+ results = self._diags([ViewsNoMenu()])
203
+ assert len(results) == 1
204
+ assert results[0].code == "SM019"
205
+ assert results[0].level == DiagnosticLevel.WARNING
206
+ assert "ViewsNoMenu" in results[0].message
207
+
208
+ async def test_silent_when_menu_registered(self):
209
+ from simple_module_core.module import ModuleBase, ModuleMeta
210
+
211
+ class WithMenu(ModuleBase):
212
+ meta = ModuleMeta(name="WithMenu", view_prefix="/with_menu")
213
+
214
+ def register_routes(self, api_router, view_router):
215
+ pass
216
+
217
+ def register_menu_items(self, registry):
218
+ pass
219
+
220
+ assert self._diags([WithMenu()]) == []
221
+
222
+ async def test_silent_when_api_only_module(self):
223
+ from simple_module_core.module import ModuleBase, ModuleMeta
224
+
225
+ class ApiOnly(ModuleBase):
226
+ meta = ModuleMeta(name="ApiOnly", route_prefix="/api/only", view_prefix="")
227
+
228
+ def register_routes(self, api_router, view_router):
229
+ pass
230
+
231
+ assert self._diags([ApiOnly()]) == []
232
+
233
+ async def test_silent_when_register_routes_not_overridden(self):
234
+ from simple_module_core.module import ModuleBase, ModuleMeta
235
+
236
+ class NoRoutes(ModuleBase):
237
+ meta = ModuleMeta(name="NoRoutes", view_prefix="/no_routes")
238
+
239
+ assert self._diags([NoRoutes()]) == []
240
+
241
+ async def test_silent_when_permissions_registered(self):
242
+ """A module that registers permissions is visible in the role editor.
243
+
244
+ This covers modules whose views are sub-pages of another module (e.g.
245
+ Permissions' RoleEdit/UserEdit views, reached from the Users admin
246
+ page) — they don't need a sidebar entry to be discoverable.
247
+ """
248
+ from simple_module_core.module import ModuleBase, ModuleMeta
249
+
250
+ class WithPermissions(ModuleBase):
251
+ meta = ModuleMeta(name="WithPermissions", view_prefix="/with_permissions")
252
+
253
+ def register_routes(self, api_router, view_router):
254
+ pass
255
+
256
+ def register_permissions(self, registry):
257
+ pass
258
+
259
+ assert self._diags([WithPermissions()]) == []