simple-module-core 0.0.1__py3-none-any.whl

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.
@@ -0,0 +1,76 @@
1
+ """SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
2
+
3
+ from simple_module_core.diagnostics import (
4
+ DiagnosticLevel,
5
+ MigrationDiagnostics,
6
+ print_diagnostics,
7
+ run_diagnostics,
8
+ )
9
+ from simple_module_core.discovery import (
10
+ discover_modules,
11
+ get_module_package_name,
12
+ topological_sort,
13
+ )
14
+ from simple_module_core.events import Event, EventBus
15
+ from simple_module_core.exceptions import (
16
+ CircularDependencyError,
17
+ FrameworkVersionError,
18
+ InvalidModuleError,
19
+ ModuleError,
20
+ NotFoundError,
21
+ ValidationError,
22
+ )
23
+ from simple_module_core.feature_flags import (
24
+ FeatureFlagDefinition,
25
+ FeatureFlagRegistry,
26
+ feature_flag,
27
+ flag_enabled,
28
+ is_flag_enabled,
29
+ require_flag,
30
+ )
31
+ from simple_module_core.health import HealthCheck, HealthCheckResult, HealthRegistry, HealthStatus
32
+ from simple_module_core.i18n import I18nRegistry, Translator
33
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
34
+ from simple_module_core.module import ModuleBase, ModuleMeta
35
+ from simple_module_core.permissions import PermissionRegistry
36
+ from simple_module_core.services import Services
37
+ from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
38
+
39
+ __all__ = [
40
+ "FRAMEWORK_API_VERSION",
41
+ "CircularDependencyError",
42
+ "DiagnosticLevel",
43
+ "Event",
44
+ "EventBus",
45
+ "FeatureFlagDefinition",
46
+ "FeatureFlagRegistry",
47
+ "FrameworkVersionError",
48
+ "HealthCheck",
49
+ "HealthCheckResult",
50
+ "HealthRegistry",
51
+ "HealthStatus",
52
+ "I18nRegistry",
53
+ "InvalidModuleError",
54
+ "MenuItem",
55
+ "MenuRegistry",
56
+ "MenuSection",
57
+ "MigrationDiagnostics",
58
+ "ModuleBase",
59
+ "ModuleError",
60
+ "ModuleMeta",
61
+ "NotFoundError",
62
+ "PermissionRegistry",
63
+ "Services",
64
+ "Translator",
65
+ "ValidationError",
66
+ "check_framework_compatibility",
67
+ "discover_modules",
68
+ "feature_flag",
69
+ "flag_enabled",
70
+ "get_module_package_name",
71
+ "is_flag_enabled",
72
+ "print_diagnostics",
73
+ "require_flag",
74
+ "run_diagnostics",
75
+ "topological_sort",
76
+ ]
@@ -0,0 +1,96 @@
1
+ """Run module diagnostics from the command line.
2
+
3
+ Usage::
4
+
5
+ python -m simple_module_core # discover modules, run diagnostics
6
+ make doctor # same thing, wrapped
7
+
8
+ Exits with status 1 if any ERROR-level diagnostics are reported.
9
+
10
+ i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
11
+ (or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
12
+ are picked up relative to ``SM_PROJECT_ROOT`` (or the current working dir).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from simple_module_core.diagnostics import (
23
+ DiagnosticLevel,
24
+ print_diagnostics,
25
+ run_diagnostics,
26
+ )
27
+ from simple_module_core.discovery import discover_modules, topological_sort
28
+ from simple_module_core.dotenv import parse_dotenv
29
+
30
+
31
+ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
32
+ """Return ``(supported_locales, default_locale)`` or ``(None, None)`` if unset.
33
+
34
+ Reads env vars directly to avoid a dependency on ``simple_module_hosting``.
35
+ Honors ``.env`` by merging it into ``os.environ`` if present (pydantic-
36
+ settings isn't imported here).
37
+ """
38
+ for key, value in parse_dotenv().items():
39
+ os.environ.setdefault(key, value)
40
+
41
+ supported_raw = os.environ.get("SM_I18N_SUPPORTED_LOCALES")
42
+ if not supported_raw:
43
+ return None, None
44
+
45
+ try:
46
+ supported = json.loads(supported_raw)
47
+ except json.JSONDecodeError:
48
+ # Also accept comma-separated (e.g. "en,es,de").
49
+ supported = [s.strip() for s in supported_raw.split(",") if s.strip()]
50
+
51
+ if not isinstance(supported, list) or not supported:
52
+ return None, None
53
+
54
+ default = os.environ.get("SM_I18N_DEFAULT_LOCALE", "en")
55
+ return supported, default
56
+
57
+
58
+ def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]:
59
+ """Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs."""
60
+ root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
61
+ out: list[tuple[str, str, Path]] = []
62
+ host_locales = root / "host" / "locales"
63
+ if host_locales.is_dir():
64
+ out.append(("host", "host", host_locales))
65
+ ui_locales = root / "packages" / "ui" / "locales"
66
+ if ui_locales.is_dir():
67
+ out.append(("packages/ui", "ui", ui_locales))
68
+ return out
69
+
70
+
71
+ def main() -> int:
72
+ modules = discover_modules()
73
+ if not modules:
74
+ print("No modules discovered. Is the project installed (`uv sync --all-packages`)?")
75
+ return 0
76
+
77
+ # Topological sort surfaces CircularDependencyError early.
78
+ modules = topological_sort(modules)
79
+
80
+ supported, default = _load_i18n_settings_from_env()
81
+ extra = _discover_extra_locale_sources()
82
+
83
+ diagnostics = run_diagnostics(
84
+ modules,
85
+ i18n_supported_locales=supported,
86
+ i18n_default_locale=default,
87
+ i18n_extra_sources=extra,
88
+ )
89
+ print_diagnostics(diagnostics)
90
+
91
+ errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
92
+ return 1 if errors else 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())
@@ -0,0 +1,24 @@
1
+ """Module diagnostics — validates structure and patterns at startup or via CLI.
2
+
3
+ This is the public surface re-exported from the submodules below.
4
+ Callers import from ``simple_module_core.diagnostics`` and should not
5
+ need to reach into ``._module`` or ``._migration`` directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from simple_module_core.diagnostics._i18n import I18nDiagnostics
11
+ from simple_module_core.diagnostics._migration import MigrationDiagnostics
12
+ from simple_module_core.diagnostics._module import ModuleDiagnostics
13
+ from simple_module_core.diagnostics._runner import print_diagnostics, run_diagnostics
14
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
15
+
16
+ __all__ = [
17
+ "Diagnostic",
18
+ "DiagnosticLevel",
19
+ "I18nDiagnostics",
20
+ "MigrationDiagnostics",
21
+ "ModuleDiagnostics",
22
+ "print_diagnostics",
23
+ "run_diagnostics",
24
+ ]
@@ -0,0 +1,81 @@
1
+ """SM009: detect framework packages that import from plugin module packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.util
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
11
+
12
+ if TYPE_CHECKING:
13
+ from simple_module_core.module import ModuleBase
14
+
15
+ FRAMEWORK_PACKAGES = ("simple_module_core", "simple_module_hosting", "simple_module_db")
16
+
17
+
18
+ def _find_package_dir(package_name: str) -> Path | None:
19
+ spec = importlib.util.find_spec(package_name)
20
+ if spec and spec.submodule_search_locations:
21
+ locations = list(spec.submodule_search_locations)
22
+ if locations:
23
+ return Path(locations[0])
24
+ return None
25
+
26
+
27
+ def _imported_plugin_pkg(node: ast.AST, module_packages: dict[str, str]) -> str | None:
28
+ if isinstance(node, ast.Import):
29
+ for alias in node.names:
30
+ top = alias.name.split(".")[0]
31
+ if top in module_packages:
32
+ return top
33
+ elif isinstance(node, ast.ImportFrom) and node.module:
34
+ top = node.module.split(".")[0]
35
+ if top in module_packages:
36
+ return top
37
+ return None
38
+
39
+
40
+ def check_framework_module_coupling(modules: list[ModuleBase]) -> list[Diagnostic]:
41
+ """The framework (core, hosting, db) must never import from a plugin module."""
42
+ module_packages: dict[str, str] = {
43
+ type(mod).__module__.split(".")[0]: mod.meta.name for mod in modules
44
+ }
45
+ if not module_packages:
46
+ return []
47
+
48
+ framework_dirs: list[tuple[str, Path]] = []
49
+ for fw_pkg in FRAMEWORK_PACKAGES:
50
+ fw_dir = _find_package_dir(fw_pkg)
51
+ if fw_dir:
52
+ framework_dirs.append((fw_pkg, fw_dir))
53
+
54
+ diags: list[Diagnostic] = []
55
+ for fw_pkg, fw_dir in framework_dirs:
56
+ for py_file in fw_dir.rglob("*.py"):
57
+ try:
58
+ tree = ast.parse(py_file.read_text(), filename=str(py_file))
59
+ except SyntaxError:
60
+ continue
61
+ for node in ast.walk(tree):
62
+ imported_pkg = _imported_plugin_pkg(node, module_packages)
63
+ if imported_pkg:
64
+ diags.append(
65
+ Diagnostic(
66
+ level=DiagnosticLevel.ERROR,
67
+ code="SM009",
68
+ message=(
69
+ f"Framework package '{fw_pkg}' directly imports "
70
+ f"from module package '{imported_pkg}'"
71
+ ),
72
+ module_name=module_packages[imported_pkg],
73
+ file=str(py_file),
74
+ suggestion=(
75
+ "Use a ModuleBase lifecycle hook "
76
+ "(register_middleware, register_routes, etc.) "
77
+ "instead of importing module code from the framework"
78
+ ),
79
+ )
80
+ )
81
+ return diags
@@ -0,0 +1,121 @@
1
+ """Diagnostics that validate i18n locale file coverage and consistency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
10
+ from simple_module_core.i18n import flatten_messages
11
+
12
+ if TYPE_CHECKING:
13
+ from simple_module_core.module import ModuleBase
14
+
15
+
16
+ class I18nDiagnostics:
17
+ """Validates locale file coverage per module.
18
+
19
+ Codes:
20
+ - SM013: missing locale file for a supported locale.
21
+ - SM014: non-default locale is missing keys present in the default.
22
+ - SM015: non-default locale has keys not present in the default.
23
+ - SM016: locale JSON fails to parse or has non-string leaves.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ supported_locales: list[str],
29
+ default_locale: str,
30
+ extra_sources: list[tuple[str, str, Path]] | None = None,
31
+ ) -> None:
32
+ """Build the diagnostic.
33
+
34
+ ``extra_sources`` is an optional list of ``(reporter_name, namespace,
35
+ locale_dir)`` triples for locale directories that aren't owned by any
36
+ ``ModuleBase`` instance — notably the host's ``host/locales/`` and
37
+ the shared ``packages/ui/locales/``. ``reporter_name`` is used as the
38
+ ``module_name`` field on findings for display purposes.
39
+ """
40
+ self.supported_locales = list(supported_locales)
41
+ self.default_locale = default_locale
42
+ self.extra_sources = list(extra_sources or [])
43
+
44
+ def run(self, modules: list[ModuleBase]) -> list[Diagnostic]:
45
+ findings: list[Diagnostic] = []
46
+ for mod in modules:
47
+ for namespace, locale_dir in mod.locale_dirs().items():
48
+ findings.extend(self._check_namespace(mod.meta.name, namespace, Path(locale_dir)))
49
+ for reporter_name, namespace, locale_dir in self.extra_sources:
50
+ findings.extend(self._check_namespace(reporter_name, namespace, Path(locale_dir)))
51
+ return findings
52
+
53
+ def _check_namespace(
54
+ self, module_name: str, namespace: str, locale_dir: Path
55
+ ) -> list[Diagnostic]:
56
+ findings: list[Diagnostic] = []
57
+ per_locale_keys: dict[str, set[str]] = {}
58
+
59
+ for locale in self.supported_locales:
60
+ path = locale_dir / f"{locale}.json"
61
+ if not path.is_file():
62
+ findings.append(
63
+ Diagnostic(
64
+ level=DiagnosticLevel.WARNING,
65
+ code="SM013",
66
+ message=(f"Missing locale file {locale}.json for namespace '{namespace}'"),
67
+ module_name=module_name,
68
+ file=str(path),
69
+ suggestion=f"Create {path} (even if empty: '{{}}')",
70
+ )
71
+ )
72
+ continue
73
+ try:
74
+ raw = json.loads(path.read_text(encoding="utf-8"))
75
+ if not isinstance(raw, dict):
76
+ raise ValueError("top-level JSON must be an object")
77
+ flat = flatten_messages(raw)
78
+ except (json.JSONDecodeError, ValueError) as exc:
79
+ findings.append(
80
+ Diagnostic(
81
+ level=DiagnosticLevel.ERROR,
82
+ code="SM016",
83
+ message=f"Invalid locale JSON in {path}: {exc}",
84
+ module_name=module_name,
85
+ file=str(path),
86
+ )
87
+ )
88
+ continue
89
+ per_locale_keys[locale] = set(flat.keys())
90
+
91
+ default_keys = per_locale_keys.get(self.default_locale, set())
92
+ for locale, keys in per_locale_keys.items():
93
+ if locale == self.default_locale:
94
+ continue
95
+ missing = default_keys - keys
96
+ extra = keys - default_keys
97
+ if missing:
98
+ findings.append(
99
+ Diagnostic(
100
+ level=DiagnosticLevel.WARNING,
101
+ code="SM014",
102
+ message=(
103
+ f"Locale '{locale}' in namespace '{namespace}' is missing keys: "
104
+ f"{', '.join(sorted(missing))}"
105
+ ),
106
+ module_name=module_name,
107
+ )
108
+ )
109
+ if extra:
110
+ findings.append(
111
+ Diagnostic(
112
+ level=DiagnosticLevel.WARNING,
113
+ code="SM015",
114
+ message=(
115
+ f"Locale '{locale}' in namespace '{namespace}' has keys not in "
116
+ f"default: {', '.join(sorted(extra))}"
117
+ ),
118
+ module_name=module_name,
119
+ )
120
+ )
121
+ return findings
@@ -0,0 +1,73 @@
1
+ """SM018: flag Inertia ``router.{post,patch,put,delete}('/api/...')`` calls.
2
+
3
+ Inertia's client-side router (``@inertiajs/react``'s ``router.*``) expects
4
+ Inertia-shaped responses or redirects — a raw JSON body from the REST
5
+ API layer triggers ``All Inertia requests must receive a valid Inertia
6
+ response``. The fix is to point the call at a module **view** endpoint
7
+ that returns ``RedirectResponse(..., status_code=303)``.
8
+
9
+ This check regex-scans each module's ``pages/**/*.tsx`` files for the
10
+ anti-pattern. It is textual (not AST) because a full TSX parser in
11
+ Python is not worth the dependency — the pattern is unambiguous enough
12
+ that string matching catches it reliably.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
22
+
23
+ if TYPE_CHECKING:
24
+ from simple_module_core.module import ModuleBase
25
+
26
+ # Matches calls like:
27
+ # router.post('/api/datasets/', ...)
28
+ # router.patch(`/api/datasets/${id}`, ...)
29
+ # router.delete("/api/datasets/" + id)
30
+ # The quote character is captured so we only match string-literal URLs
31
+ # (backtick, single, or double) — variable URLs don't count.
32
+ _ROUTER_API_CALL = re.compile(
33
+ r"router\.(?P<method>post|patch|put|delete)\s*\(\s*[`'\"]/api/",
34
+ )
35
+
36
+
37
+ def check_inertia_api_calls(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]:
38
+ """Warn when a page uses Inertia's router against a JSON API endpoint."""
39
+ pages_dir = src_dir / "pages"
40
+ if not pages_dir.exists():
41
+ return []
42
+
43
+ diags: list[Diagnostic] = []
44
+ for tsx in sorted(pages_dir.rglob("*.tsx")):
45
+ try:
46
+ source = tsx.read_text()
47
+ except OSError:
48
+ continue
49
+ for lineno, line in enumerate(source.splitlines(), start=1):
50
+ match = _ROUTER_API_CALL.search(line)
51
+ if not match:
52
+ continue
53
+ method = match.group("method").upper()
54
+ diags.append(
55
+ Diagnostic(
56
+ level=DiagnosticLevel.WARNING,
57
+ code="SM018",
58
+ message=(
59
+ f"router.{method.lower()}() targets a JSON API endpoint — "
60
+ "Inertia will reject the response"
61
+ ),
62
+ module_name=mod.meta.name,
63
+ file=f"{tsx}:{lineno}",
64
+ suggestion=(
65
+ "Point the call at a view endpoint (e.g. '/"
66
+ f"{mod.meta.name.lower()}/...') that returns "
67
+ "RedirectResponse(..., status_code=303). Or, if you "
68
+ "really need the JSON payload, use fetch() instead of "
69
+ "Inertia's router."
70
+ ),
71
+ )
72
+ )
73
+ return diags
@@ -0,0 +1,35 @@
1
+ """SM017: warn modules shipping .tsx pages but missing npm workspace files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
9
+
10
+ if TYPE_CHECKING:
11
+ from simple_module_core.module import ModuleBase
12
+
13
+
14
+ def check_js_workspace_files(mod: ModuleBase, src_dir: Path) -> list[Diagnostic]:
15
+ """Warn when a module ships .tsx pages but is missing npm workspace files."""
16
+ pages_dir = src_dir / "pages"
17
+ if not pages_dir.exists() or not any(pages_dir.rglob("*.tsx")):
18
+ return []
19
+ module_dir = src_dir.parent
20
+ return [
21
+ Diagnostic(
22
+ level=DiagnosticLevel.WARNING,
23
+ code="SM017",
24
+ message=f"Module ships pages/*.tsx but has no {fn}",
25
+ module_name=mod.meta.name,
26
+ file=str(module_dir / fn),
27
+ suggestion=(
28
+ f"Create {module_dir / fn} — without it npm won't treat the "
29
+ "module as a workspace member and Vite may fail to resolve "
30
+ "@simple-module-py/ui subpath imports"
31
+ ),
32
+ )
33
+ for fn in ("package.json", "tsconfig.json")
34
+ if not (module_dir / fn).exists()
35
+ ]
@@ -0,0 +1,45 @@
1
+ """Alembic migration-state diagnostics (SM010, SM011)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
6
+
7
+
8
+ class MigrationDiagnostics:
9
+ """Validates database migration state."""
10
+
11
+ def check_revision_mismatch(
12
+ self,
13
+ current_revision: str | None,
14
+ head_revision: str | None,
15
+ ) -> list[Diagnostic]:
16
+ """SM010: Error if database is not at the migration head."""
17
+ if current_revision == head_revision:
18
+ return []
19
+ return [
20
+ Diagnostic(
21
+ level=DiagnosticLevel.ERROR,
22
+ code="SM010",
23
+ message=(f"Database at revision {current_revision!r}, expected {head_revision!r}"),
24
+ module_name="migrations",
25
+ suggestion="Run: make migrate",
26
+ )
27
+ ]
28
+
29
+ def check_table_coverage(
30
+ self,
31
+ module_tables: set[str],
32
+ migrated_tables: set[str],
33
+ ) -> list[Diagnostic]:
34
+ """SM011: Warning if module tables are missing from migration history."""
35
+ missing = module_tables - migrated_tables
36
+ return [
37
+ Diagnostic(
38
+ level=DiagnosticLevel.WARNING,
39
+ code="SM011",
40
+ message=f"Table '{table}' declared in models but not found in migration history",
41
+ module_name="migrations",
42
+ suggestion=f'Run: make migration msg="add {table}"',
43
+ )
44
+ for table in sorted(missing)
45
+ ]