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.
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/PKG-INFO +1 -1
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/pyproject.toml +1 -1
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/__main__.py +27 -1
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_module.py +50 -110
- simple_module_core-0.0.4/simple_module_core/diagnostics/_pages.py +121 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/dotenv.py +32 -1
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/feature_flags.py +30 -2
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/menu.py +6 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/module.py +7 -2
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_discovery.py +5 -5
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_menu.py +14 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_module_diagnostics.py +113 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/.gitignore +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/LICENSE +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/README.md +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/__init__.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/health.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/i18n.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/permissions.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/services.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_events.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_health_registry.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.3 → simple_module_core-0.0.4}/tests/test_services.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
|
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
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_module.py
RENAMED
|
@@ -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 =
|
|
63
|
-
diagnostics.extend(
|
|
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 =
|
|
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 =
|
|
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", "
|
|
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 '
|
|
238
|
-
names = [m.meta.name for m in discover_modules(enabled=["
|
|
239
|
-
assert names == ["
|
|
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()]) == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/__init__.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_coupling.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_i18n.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_inertia_api.py
RENAMED
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_migration.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/simple_module_core/diagnostics/_runner.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.3 → simple_module_core-0.0.4}/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
|
|
File without changes
|