simple-module-core 0.0.1__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 (44) hide show
  1. simple_module_core-0.0.1/.gitignore +59 -0
  2. simple_module_core-0.0.1/LICENSE +21 -0
  3. simple_module_core-0.0.1/PKG-INFO +85 -0
  4. simple_module_core-0.0.1/README.md +54 -0
  5. simple_module_core-0.0.1/pyproject.toml +40 -0
  6. simple_module_core-0.0.1/simple_module_core/__init__.py +76 -0
  7. simple_module_core-0.0.1/simple_module_core/__main__.py +96 -0
  8. simple_module_core-0.0.1/simple_module_core/diagnostics/__init__.py +24 -0
  9. simple_module_core-0.0.1/simple_module_core/diagnostics/_coupling.py +81 -0
  10. simple_module_core-0.0.1/simple_module_core/diagnostics/_i18n.py +121 -0
  11. simple_module_core-0.0.1/simple_module_core/diagnostics/_inertia_api.py +73 -0
  12. simple_module_core-0.0.1/simple_module_core/diagnostics/_js_workspace.py +35 -0
  13. simple_module_core-0.0.1/simple_module_core/diagnostics/_migration.py +45 -0
  14. simple_module_core-0.0.1/simple_module_core/diagnostics/_module.py +252 -0
  15. simple_module_core-0.0.1/simple_module_core/diagnostics/_runner.py +81 -0
  16. simple_module_core-0.0.1/simple_module_core/diagnostics/_types.py +33 -0
  17. simple_module_core-0.0.1/simple_module_core/discovery.py +195 -0
  18. simple_module_core-0.0.1/simple_module_core/dotenv.py +38 -0
  19. simple_module_core-0.0.1/simple_module_core/environments.py +15 -0
  20. simple_module_core-0.0.1/simple_module_core/events.py +91 -0
  21. simple_module_core-0.0.1/simple_module_core/exceptions.py +56 -0
  22. simple_module_core-0.0.1/simple_module_core/feature_flags.py +187 -0
  23. simple_module_core-0.0.1/simple_module_core/health.py +46 -0
  24. simple_module_core-0.0.1/simple_module_core/i18n.py +258 -0
  25. simple_module_core-0.0.1/simple_module_core/menu.py +89 -0
  26. simple_module_core-0.0.1/simple_module_core/module.py +179 -0
  27. simple_module_core-0.0.1/simple_module_core/permissions.py +121 -0
  28. simple_module_core-0.0.1/simple_module_core/py.typed +0 -0
  29. simple_module_core-0.0.1/simple_module_core/services.py +45 -0
  30. simple_module_core-0.0.1/simple_module_core/versioning.py +67 -0
  31. simple_module_core-0.0.1/tests/test_diagnostics.py +74 -0
  32. simple_module_core-0.0.1/tests/test_discovery.py +248 -0
  33. simple_module_core-0.0.1/tests/test_events.py +175 -0
  34. simple_module_core-0.0.1/tests/test_feature_flags.py +196 -0
  35. simple_module_core-0.0.1/tests/test_feature_flags_decorator.py +150 -0
  36. simple_module_core-0.0.1/tests/test_health_registry.py +48 -0
  37. simple_module_core-0.0.1/tests/test_i18n.py +188 -0
  38. simple_module_core-0.0.1/tests/test_i18n_diagnostics.py +101 -0
  39. simple_module_core-0.0.1/tests/test_menu.py +102 -0
  40. simple_module_core-0.0.1/tests/test_module_base.py +164 -0
  41. simple_module_core-0.0.1/tests/test_module_diagnostics.py +146 -0
  42. simple_module_core-0.0.1/tests/test_permissions.py +115 -0
  43. simple_module_core-0.0.1/tests/test_services.py +61 -0
  44. simple_module_core-0.0.1/tests/test_versioning.py +92 -0
@@ -0,0 +1,59 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # Auto-generated frontend module manifest (regenerated by the host at boot
40
+ # or via `make gen-pages`).
41
+ host/client_app/modules.manifest.json
42
+ host/client_app/modules.generated.ts
43
+ host/client_app/modules.generated.css
44
+
45
+ # Worktrees
46
+ .worktrees/
47
+
48
+ # Performance profiles
49
+ .memray/
50
+ .benchmarks/
51
+
52
+ # OS
53
+ .DS_Store
54
+ Thumbs.db
55
+
56
+ .playwright-cli/*
57
+ .playwright-mcp/*
58
+ host/client_app/.playwright-cli/*
59
+ .superpowers/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anto Subash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_core
3
+ Version: 0.0.1
4
+ Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
9
+ Author-email: Anto Subash <antosubash@live.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: fastapi,modular-monolith,module-discovery,plugin-system,simple-module
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: babel>=2.14
25
+ Requires-Dist: fastapi>=0.115
26
+ Requires-Dist: packaging>=23.0
27
+ Requires-Dist: pydantic-settings>=2.0
28
+ Requires-Dist: pydantic>=2.0
29
+ Requires-Dist: pyee>=12.0
30
+ Description-Content-Type: text/markdown
31
+
32
+ # simple_module_core
33
+
34
+ Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.
35
+
36
+ This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM017`) used by `make doctor`.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install simple_module_core
42
+ ```
43
+
44
+ You usually don't install this directly — it's pulled in by `simple_module_hosting` and every `simple_module_*` module.
45
+
46
+ ## What it provides
47
+
48
+ - `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
49
+ - `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
50
+ - `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
51
+ - Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and ~ten others.
52
+ - Tiny event-bus (`pyee`) for decoupled module-to-module communication.
53
+
54
+ ## Usage
55
+
56
+ ```python
57
+ # modules/orders/orders/module.py
58
+ from simple_module_core import ModuleBase, ModuleMeta
59
+
60
+
61
+ class OrdersModule(ModuleBase):
62
+ meta = ModuleMeta(name="orders", depends_on=["users"])
63
+
64
+ def register_routes(self, api_router, view_router):
65
+ from .endpoints import api, views
66
+ api_router.include_router(api.router)
67
+ view_router.include_router(views.router)
68
+ ```
69
+
70
+ And in `pyproject.toml`:
71
+
72
+ ```toml
73
+ [project.entry-points.simple_module]
74
+ orders = "orders.module:OrdersModule"
75
+ ```
76
+
77
+ The host's `discover_modules()` call picks this up automatically at boot.
78
+
79
+ ## Depends on
80
+
81
+ - `fastapi`, `pydantic`, `pydantic-settings`, `pyee`, `babel`, `packaging`
82
+
83
+ ## License
84
+
85
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,54 @@
1
+ # simple_module_core
2
+
3
+ Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.
4
+
5
+ This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM017`) used by `make doctor`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install simple_module_core
11
+ ```
12
+
13
+ You usually don't install this directly — it's pulled in by `simple_module_hosting` and every `simple_module_*` module.
14
+
15
+ ## What it provides
16
+
17
+ - `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
18
+ - `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
19
+ - `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
20
+ - Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and ~ten others.
21
+ - Tiny event-bus (`pyee`) for decoupled module-to-module communication.
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ # modules/orders/orders/module.py
27
+ from simple_module_core import ModuleBase, ModuleMeta
28
+
29
+
30
+ class OrdersModule(ModuleBase):
31
+ meta = ModuleMeta(name="orders", depends_on=["users"])
32
+
33
+ def register_routes(self, api_router, view_router):
34
+ from .endpoints import api, views
35
+ api_router.include_router(api.router)
36
+ view_router.include_router(views.router)
37
+ ```
38
+
39
+ And in `pyproject.toml`:
40
+
41
+ ```toml
42
+ [project.entry-points.simple_module]
43
+ orders = "orders.module:OrdersModule"
44
+ ```
45
+
46
+ The host's `discover_modules()` call picks this up automatically at boot.
47
+
48
+ ## Depends on
49
+
50
+ - `fastapi`, `pydantic`, `pydantic-settings`, `pyee`, `babel`, `packaging`
51
+
52
+ ## License
53
+
54
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "simple_module_core"
3
+ version = "0.0.1"
4
+ description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
10
+ keywords = ["simple-module", "fastapi", "modular-monolith", "plugin-system", "module-discovery"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: FastAPI",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "babel>=2.14",
25
+ "fastapi>=0.115",
26
+ "packaging>=23.0",
27
+ "pydantic>=2.0",
28
+ "pydantic-settings>=2.0",
29
+ "pyee>=12.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/antosubash/simple_module_python"
34
+ Repository = "https://github.com/antosubash/simple_module_python"
35
+ Issues = "https://github.com/antosubash/simple_module_python/issues"
36
+ Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
@@ -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