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.
- simple_module_core-0.0.1/.gitignore +59 -0
- simple_module_core-0.0.1/LICENSE +21 -0
- simple_module_core-0.0.1/PKG-INFO +85 -0
- simple_module_core-0.0.1/README.md +54 -0
- simple_module_core-0.0.1/pyproject.toml +40 -0
- simple_module_core-0.0.1/simple_module_core/__init__.py +76 -0
- simple_module_core-0.0.1/simple_module_core/__main__.py +96 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/__init__.py +24 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_coupling.py +81 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_i18n.py +121 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_inertia_api.py +73 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_js_workspace.py +35 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_migration.py +45 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_module.py +252 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_runner.py +81 -0
- simple_module_core-0.0.1/simple_module_core/diagnostics/_types.py +33 -0
- simple_module_core-0.0.1/simple_module_core/discovery.py +195 -0
- simple_module_core-0.0.1/simple_module_core/dotenv.py +38 -0
- simple_module_core-0.0.1/simple_module_core/environments.py +15 -0
- simple_module_core-0.0.1/simple_module_core/events.py +91 -0
- simple_module_core-0.0.1/simple_module_core/exceptions.py +56 -0
- simple_module_core-0.0.1/simple_module_core/feature_flags.py +187 -0
- simple_module_core-0.0.1/simple_module_core/health.py +46 -0
- simple_module_core-0.0.1/simple_module_core/i18n.py +258 -0
- simple_module_core-0.0.1/simple_module_core/menu.py +89 -0
- simple_module_core-0.0.1/simple_module_core/module.py +179 -0
- simple_module_core-0.0.1/simple_module_core/permissions.py +121 -0
- simple_module_core-0.0.1/simple_module_core/py.typed +0 -0
- simple_module_core-0.0.1/simple_module_core/services.py +45 -0
- simple_module_core-0.0.1/simple_module_core/versioning.py +67 -0
- simple_module_core-0.0.1/tests/test_diagnostics.py +74 -0
- simple_module_core-0.0.1/tests/test_discovery.py +248 -0
- simple_module_core-0.0.1/tests/test_events.py +175 -0
- simple_module_core-0.0.1/tests/test_feature_flags.py +196 -0
- simple_module_core-0.0.1/tests/test_feature_flags_decorator.py +150 -0
- simple_module_core-0.0.1/tests/test_health_registry.py +48 -0
- simple_module_core-0.0.1/tests/test_i18n.py +188 -0
- simple_module_core-0.0.1/tests/test_i18n_diagnostics.py +101 -0
- simple_module_core-0.0.1/tests/test_menu.py +102 -0
- simple_module_core-0.0.1/tests/test_module_base.py +164 -0
- simple_module_core-0.0.1/tests/test_module_diagnostics.py +146 -0
- simple_module_core-0.0.1/tests/test_permissions.py +115 -0
- simple_module_core-0.0.1/tests/test_services.py +61 -0
- 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
|