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.
- simple_module_core/__init__.py +76 -0
- simple_module_core/__main__.py +96 -0
- simple_module_core/diagnostics/__init__.py +24 -0
- simple_module_core/diagnostics/_coupling.py +81 -0
- simple_module_core/diagnostics/_i18n.py +121 -0
- simple_module_core/diagnostics/_inertia_api.py +73 -0
- simple_module_core/diagnostics/_js_workspace.py +35 -0
- simple_module_core/diagnostics/_migration.py +45 -0
- simple_module_core/diagnostics/_module.py +252 -0
- simple_module_core/diagnostics/_runner.py +81 -0
- simple_module_core/diagnostics/_types.py +33 -0
- simple_module_core/discovery.py +195 -0
- simple_module_core/dotenv.py +38 -0
- simple_module_core/environments.py +15 -0
- simple_module_core/events.py +91 -0
- simple_module_core/exceptions.py +56 -0
- simple_module_core/feature_flags.py +187 -0
- simple_module_core/health.py +46 -0
- simple_module_core/i18n.py +258 -0
- simple_module_core/menu.py +89 -0
- simple_module_core/module.py +179 -0
- simple_module_core/permissions.py +121 -0
- simple_module_core/py.typed +0 -0
- simple_module_core/services.py +45 -0
- simple_module_core/versioning.py +67 -0
- simple_module_core-0.0.1.dist-info/METADATA +85 -0
- simple_module_core-0.0.1.dist-info/RECORD +29 -0
- simple_module_core-0.0.1.dist-info/WHEEL +4 -0
- simple_module_core-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Structural diagnostics that validate discovered modules against invariants."""
|
|
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._coupling import check_framework_module_coupling
|
|
11
|
+
from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
|
|
12
|
+
from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
|
|
13
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from simple_module_core.module import ModuleBase
|
|
17
|
+
|
|
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
|
+
class ModuleDiagnostics:
|
|
48
|
+
"""Validates module structure and configuration."""
|
|
49
|
+
|
|
50
|
+
def run(self, modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
51
|
+
diagnostics: list[Diagnostic] = []
|
|
52
|
+
diagnostics.extend(self._check_duplicate_names(modules))
|
|
53
|
+
diagnostics.extend(self._check_schema_conflicts(modules))
|
|
54
|
+
diagnostics.extend(self._check_empty_modules(modules))
|
|
55
|
+
diagnostics.extend(self._check_missing_meta(modules))
|
|
56
|
+
diagnostics.extend(check_framework_module_coupling(modules))
|
|
57
|
+
|
|
58
|
+
# File-based checks (need to find module source directories)
|
|
59
|
+
for mod in modules:
|
|
60
|
+
src_dir = self._find_source_dir(mod)
|
|
61
|
+
if src_dir:
|
|
62
|
+
rendered_pages = self._find_render_calls(mod, src_dir)
|
|
63
|
+
diagnostics.extend(self._check_orphan_pages(mod, src_dir, rendered_pages))
|
|
64
|
+
diagnostics.extend(self._check_phantom_renders(mod, src_dir, rendered_pages))
|
|
65
|
+
diagnostics.extend(check_js_workspace_files(mod, src_dir))
|
|
66
|
+
diagnostics.extend(check_inertia_api_calls(mod, src_dir))
|
|
67
|
+
|
|
68
|
+
return diagnostics
|
|
69
|
+
|
|
70
|
+
def _check_duplicate_names(self, modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
71
|
+
seen: dict[str, str] = {}
|
|
72
|
+
diags: list[Diagnostic] = []
|
|
73
|
+
for mod in modules:
|
|
74
|
+
name = mod.meta.name
|
|
75
|
+
cls_name = type(mod).__qualname__
|
|
76
|
+
if name in seen:
|
|
77
|
+
diags.append(
|
|
78
|
+
Diagnostic(
|
|
79
|
+
level=DiagnosticLevel.ERROR,
|
|
80
|
+
code="SM008",
|
|
81
|
+
message=f"Duplicate module name '{name}' (also in {seen[name]})",
|
|
82
|
+
module_name=cls_name,
|
|
83
|
+
suggestion="Each module must have a unique meta.name",
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
seen[name] = cls_name
|
|
87
|
+
return diags
|
|
88
|
+
|
|
89
|
+
def _check_schema_conflicts(self, modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
90
|
+
"""Check for modules that would create conflicting DB schemas."""
|
|
91
|
+
prefixes: dict[str, str] = {}
|
|
92
|
+
diags: list[Diagnostic] = []
|
|
93
|
+
for mod in modules:
|
|
94
|
+
prefix = mod.meta.name.lower()
|
|
95
|
+
if prefix in prefixes:
|
|
96
|
+
diags.append(
|
|
97
|
+
Diagnostic(
|
|
98
|
+
level=DiagnosticLevel.ERROR,
|
|
99
|
+
code="SM008",
|
|
100
|
+
message=(
|
|
101
|
+
f"Schema/table prefix '{prefix}' conflicts "
|
|
102
|
+
f"with module '{prefixes[prefix]}'"
|
|
103
|
+
),
|
|
104
|
+
module_name=mod.meta.name,
|
|
105
|
+
suggestion="Use unique module names to avoid DB schema conflicts",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
prefixes[prefix] = mod.meta.name
|
|
109
|
+
return diags
|
|
110
|
+
|
|
111
|
+
def _check_empty_modules(self, modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
112
|
+
diags: list[Diagnostic] = []
|
|
113
|
+
for mod in modules:
|
|
114
|
+
cls = type(mod)
|
|
115
|
+
overridden = [
|
|
116
|
+
name
|
|
117
|
+
for name in (
|
|
118
|
+
"register_routes",
|
|
119
|
+
"register_menu_items",
|
|
120
|
+
"register_permissions",
|
|
121
|
+
"register_feature_flags",
|
|
122
|
+
"register_event_handlers",
|
|
123
|
+
"register_middleware",
|
|
124
|
+
"register_health_checks",
|
|
125
|
+
"register_exception_handlers",
|
|
126
|
+
"register_settings",
|
|
127
|
+
"template_dirs",
|
|
128
|
+
"static_mounts",
|
|
129
|
+
"locale_dirs",
|
|
130
|
+
"on_startup",
|
|
131
|
+
"on_shutdown",
|
|
132
|
+
)
|
|
133
|
+
if name in cls.__dict__
|
|
134
|
+
]
|
|
135
|
+
if not overridden:
|
|
136
|
+
diags.append(
|
|
137
|
+
Diagnostic(
|
|
138
|
+
level=DiagnosticLevel.INFO,
|
|
139
|
+
code="SM007",
|
|
140
|
+
message="Module exists but overrides no registration methods",
|
|
141
|
+
module_name=mod.meta.name,
|
|
142
|
+
suggestion="Override register_routes() or other methods to add functionality", # noqa: E501
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return diags
|
|
146
|
+
|
|
147
|
+
def _check_missing_meta(self, modules: list[ModuleBase]) -> list[Diagnostic]:
|
|
148
|
+
diags: list[Diagnostic] = []
|
|
149
|
+
for mod in modules:
|
|
150
|
+
if not hasattr(mod, "meta"):
|
|
151
|
+
diags.append(
|
|
152
|
+
Diagnostic(
|
|
153
|
+
level=DiagnosticLevel.ERROR,
|
|
154
|
+
code="SM001",
|
|
155
|
+
message="Module missing 'meta' class attribute",
|
|
156
|
+
module_name=type(mod).__qualname__,
|
|
157
|
+
suggestion="Add: meta = ModuleMeta(name='YourModule')",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return diags
|
|
161
|
+
|
|
162
|
+
def _find_package_dir(self, package_name: str) -> Path | None:
|
|
163
|
+
"""Locate the source directory for a top-level package."""
|
|
164
|
+
spec = importlib.util.find_spec(package_name)
|
|
165
|
+
if spec and spec.submodule_search_locations:
|
|
166
|
+
locations = list(spec.submodule_search_locations)
|
|
167
|
+
if locations:
|
|
168
|
+
return Path(locations[0])
|
|
169
|
+
return None
|
|
170
|
+
|
|
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
|
+
def _find_source_dir(self, mod: ModuleBase) -> Path | None:
|
|
250
|
+
"""Locate the source directory for a module's package."""
|
|
251
|
+
pkg_name = type(mod).__module__.rsplit(".", 1)[0]
|
|
252
|
+
return self._find_package_dir(pkg_name)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Entry points that assemble and print diagnostic output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from simple_module_core.diagnostics._migration import MigrationDiagnostics
|
|
11
|
+
from simple_module_core.diagnostics._module import ModuleDiagnostics
|
|
12
|
+
from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from simple_module_core.module import ModuleBase
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_diagnostics(
|
|
21
|
+
modules: list[ModuleBase],
|
|
22
|
+
*,
|
|
23
|
+
migration_state: dict | None = None,
|
|
24
|
+
module_tables: set[str] | None = None,
|
|
25
|
+
migrated_tables: set[str] | None = None,
|
|
26
|
+
i18n_supported_locales: list[str] | None = None,
|
|
27
|
+
i18n_default_locale: str | None = None,
|
|
28
|
+
i18n_extra_sources: list[tuple[str, str, Path]] | None = None,
|
|
29
|
+
) -> list[Diagnostic]:
|
|
30
|
+
"""Convenience function to run all diagnostics.
|
|
31
|
+
|
|
32
|
+
When ``migration_state`` is provided, also runs migration diagnostics.
|
|
33
|
+
When ``i18n_supported_locales`` and ``i18n_default_locale`` are provided,
|
|
34
|
+
also runs i18n locale coverage diagnostics. ``i18n_extra_sources`` lets
|
|
35
|
+
callers include host/ui locale dirs that aren't owned by a ``ModuleBase``.
|
|
36
|
+
"""
|
|
37
|
+
diagnostics = ModuleDiagnostics().run(modules)
|
|
38
|
+
|
|
39
|
+
if i18n_supported_locales and i18n_default_locale:
|
|
40
|
+
from simple_module_core.diagnostics._i18n import I18nDiagnostics
|
|
41
|
+
|
|
42
|
+
diagnostics.extend(
|
|
43
|
+
I18nDiagnostics(
|
|
44
|
+
supported_locales=i18n_supported_locales,
|
|
45
|
+
default_locale=i18n_default_locale,
|
|
46
|
+
extra_sources=i18n_extra_sources,
|
|
47
|
+
).run(modules)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if migration_state is not None:
|
|
51
|
+
migration_diag = MigrationDiagnostics()
|
|
52
|
+
diagnostics.extend(
|
|
53
|
+
migration_diag.check_revision_mismatch(
|
|
54
|
+
current_revision=migration_state.get("current_revision"),
|
|
55
|
+
head_revision=migration_state.get("head_revision"),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if module_tables is not None and migrated_tables is not None:
|
|
59
|
+
diagnostics.extend(migration_diag.check_table_coverage(module_tables, migrated_tables))
|
|
60
|
+
|
|
61
|
+
return diagnostics
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_diagnostics(diagnostics: list[Diagnostic]) -> None:
|
|
65
|
+
"""Pretty-print diagnostics to stderr."""
|
|
66
|
+
if not diagnostics:
|
|
67
|
+
logger.info("\u2713 No module diagnostics issues found")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
71
|
+
warnings = [d for d in diagnostics if d.level == DiagnosticLevel.WARNING]
|
|
72
|
+
infos = [d for d in diagnostics if d.level == DiagnosticLevel.INFO]
|
|
73
|
+
|
|
74
|
+
for d in diagnostics:
|
|
75
|
+
print(str(d), file=sys.stderr)
|
|
76
|
+
print(file=sys.stderr)
|
|
77
|
+
|
|
78
|
+
print(
|
|
79
|
+
f"Results: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Core diagnostic types: level enum and finding dataclass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DiagnosticLevel(StrEnum):
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
WARNING = "warning"
|
|
12
|
+
INFO = "info"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Diagnostic:
|
|
17
|
+
"""A single diagnostic finding."""
|
|
18
|
+
|
|
19
|
+
level: DiagnosticLevel
|
|
20
|
+
code: str
|
|
21
|
+
message: str
|
|
22
|
+
module_name: str
|
|
23
|
+
file: str | None = None
|
|
24
|
+
suggestion: str | None = None
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
prefix = {"error": "\u2717", "warning": "\u26a0", "info": "\u2139"}[self.level]
|
|
28
|
+
parts = [f"{prefix} {self.code} [{self.level.upper()}] {self.module_name}: {self.message}"]
|
|
29
|
+
if self.file:
|
|
30
|
+
parts.append(f" \u21b3 {self.file}")
|
|
31
|
+
if self.suggestion:
|
|
32
|
+
parts.append(f" \u21b3 Suggestion: {self.suggestion}")
|
|
33
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Module discovery via Python entry_points and dependency ordering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from importlib.metadata import entry_points
|
|
8
|
+
|
|
9
|
+
from simple_module_core.exceptions import CircularDependencyError, InvalidModuleError
|
|
10
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
ENTRY_POINT_GROUP = "simple_module"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_module_package_name(module: ModuleBase) -> str:
|
|
18
|
+
"""Return the top-level Python package a module instance belongs to.
|
|
19
|
+
|
|
20
|
+
Used by Alembic env.py, the frontend manifest generator, and diagnostics
|
|
21
|
+
to locate a module's files (models, pages, templates) on disk.
|
|
22
|
+
"""
|
|
23
|
+
return type(module).__module__.split(".")[0]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover_modules(
|
|
27
|
+
enabled: Sequence[str] | None = None,
|
|
28
|
+
*,
|
|
29
|
+
strict: bool = False,
|
|
30
|
+
) -> list[ModuleBase]:
|
|
31
|
+
"""Discover all installed modules via ``[project.entry-points.simple_module]``.
|
|
32
|
+
|
|
33
|
+
Returns instantiated module objects (unsorted). Raises
|
|
34
|
+
:class:`FrameworkVersionError` if any discovered module's
|
|
35
|
+
``requires_framework`` spec rejects the current framework API version.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
enabled:
|
|
40
|
+
Optional allowlist of module names (case-insensitive matched against
|
|
41
|
+
``ModuleMeta.name``). When ``None`` (default), every installed module
|
|
42
|
+
is loaded. When a list, only modules whose name appears in it are
|
|
43
|
+
loaded. Names that don't match any installed module log a warning.
|
|
44
|
+
An empty list loads nothing.
|
|
45
|
+
strict:
|
|
46
|
+
When ``True``, any invalid module (failed to load, not a
|
|
47
|
+
``ModuleBase`` subclass, missing/invalid ``meta``) raises
|
|
48
|
+
:class:`InvalidModuleError` immediately. Callers in production
|
|
49
|
+
should pass ``strict=True`` so a broken deployment fails loudly
|
|
50
|
+
at boot rather than silently losing a feature.
|
|
51
|
+
|
|
52
|
+
When ``False`` (the default — preserves dev ergonomics), invalid
|
|
53
|
+
modules are logged and skipped.
|
|
54
|
+
"""
|
|
55
|
+
# Imported here to avoid a circular import at module load time.
|
|
56
|
+
from simple_module_core.versioning import check_framework_compatibility
|
|
57
|
+
|
|
58
|
+
def fail(msg: str, exc: BaseException | None = None) -> None:
|
|
59
|
+
if strict:
|
|
60
|
+
raise InvalidModuleError(msg) from exc
|
|
61
|
+
if exc is not None:
|
|
62
|
+
logger.exception("%s — skipping", msg)
|
|
63
|
+
else:
|
|
64
|
+
logger.error("%s — skipping", msg)
|
|
65
|
+
|
|
66
|
+
eps = entry_points(group=ENTRY_POINT_GROUP)
|
|
67
|
+
modules: list[ModuleBase] = []
|
|
68
|
+
allowlist_lower = {name.lower() for name in enabled} if enabled is not None else None
|
|
69
|
+
|
|
70
|
+
for ep in eps:
|
|
71
|
+
try:
|
|
72
|
+
module_cls = ep.load()
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
fail(f"Failed to load module entry point '{ep.name}': {exc}", exc)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
instance = module_cls()
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
fail(
|
|
81
|
+
f"Failed to instantiate module '{ep.name}' ({module_cls!r}): {exc}",
|
|
82
|
+
exc,
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if not isinstance(instance, ModuleBase):
|
|
87
|
+
fail(
|
|
88
|
+
f"Entry point '{ep.name}' loaded {module_cls!r} which is not a ModuleBase subclass"
|
|
89
|
+
)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
meta = getattr(instance, "meta", None)
|
|
93
|
+
if not isinstance(meta, ModuleMeta):
|
|
94
|
+
fail(
|
|
95
|
+
f"Module {module_cls.__qualname__!r} (entry point '{ep.name}') "
|
|
96
|
+
"is missing 'meta = ModuleMeta(...)'"
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if allowlist_lower is not None and meta.name.lower() not in allowlist_lower:
|
|
101
|
+
logger.info(
|
|
102
|
+
"Module '%s' is installed but not in modules_enabled — skipping",
|
|
103
|
+
meta.name,
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
modules.append(instance)
|
|
108
|
+
logger.info("Discovered module: %s (v%s)", meta.name, meta.version)
|
|
109
|
+
|
|
110
|
+
# Warn about allowlist entries that didn't resolve to an installed module.
|
|
111
|
+
if allowlist_lower is not None:
|
|
112
|
+
loaded = {m.meta.name.lower() for m in modules}
|
|
113
|
+
missing = allowlist_lower - loaded
|
|
114
|
+
for name in missing:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"modules_enabled references '%s' which is not installed — ignoring",
|
|
117
|
+
name,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
check_framework_compatibility(modules)
|
|
121
|
+
return modules
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def topological_sort(modules: Sequence[ModuleBase]) -> list[ModuleBase]:
|
|
125
|
+
"""Sort modules so dependencies come before dependents.
|
|
126
|
+
|
|
127
|
+
Raises ``CircularDependencyError`` if a cycle is detected.
|
|
128
|
+
"""
|
|
129
|
+
by_name: dict[str, ModuleBase] = {m.meta.name: m for m in modules}
|
|
130
|
+
|
|
131
|
+
# Kahn's algorithm
|
|
132
|
+
in_degree: dict[str, int] = dict.fromkeys(by_name, 0)
|
|
133
|
+
dependents: dict[str, list[str]] = {name: [] for name in by_name}
|
|
134
|
+
|
|
135
|
+
for mod in modules:
|
|
136
|
+
for dep_name in mod.meta.depends_on:
|
|
137
|
+
if dep_name not in by_name:
|
|
138
|
+
logger.warning(
|
|
139
|
+
"Module '%s' depends on '%s' which is not installed — ignoring",
|
|
140
|
+
mod.meta.name,
|
|
141
|
+
dep_name,
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
in_degree[mod.meta.name] += 1
|
|
145
|
+
dependents[dep_name].append(mod.meta.name)
|
|
146
|
+
|
|
147
|
+
queue: list[str] = [name for name, deg in in_degree.items() if deg == 0]
|
|
148
|
+
result: list[str] = []
|
|
149
|
+
|
|
150
|
+
while queue:
|
|
151
|
+
# Sort queue for deterministic ordering
|
|
152
|
+
queue.sort()
|
|
153
|
+
current = queue.pop(0)
|
|
154
|
+
result.append(current)
|
|
155
|
+
for dependent in dependents[current]:
|
|
156
|
+
in_degree[dependent] -= 1
|
|
157
|
+
if in_degree[dependent] == 0:
|
|
158
|
+
queue.append(dependent)
|
|
159
|
+
|
|
160
|
+
if len(result) != len(by_name):
|
|
161
|
+
# Find the cycle for a helpful error message
|
|
162
|
+
remaining = set(by_name.keys()) - set(result)
|
|
163
|
+
cycle = _find_cycle(remaining, {n: m.meta.depends_on for n, m in by_name.items()})
|
|
164
|
+
raise CircularDependencyError(cycle)
|
|
165
|
+
|
|
166
|
+
return [by_name[name] for name in result]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _find_cycle(nodes: set[str], deps: dict[str, list[str]]) -> list[str]:
|
|
170
|
+
"""Find one cycle in the dependency graph for error reporting."""
|
|
171
|
+
visited: set[str] = set()
|
|
172
|
+
path: list[str] = []
|
|
173
|
+
|
|
174
|
+
def dfs(node: str) -> list[str] | None:
|
|
175
|
+
if node in visited:
|
|
176
|
+
idx = path.index(node) if node in path else 0
|
|
177
|
+
return [*path[idx:], node]
|
|
178
|
+
visited.add(node)
|
|
179
|
+
path.append(node)
|
|
180
|
+
for dep in deps.get(node, []):
|
|
181
|
+
if dep in nodes:
|
|
182
|
+
result = dfs(dep)
|
|
183
|
+
if result:
|
|
184
|
+
return result
|
|
185
|
+
path.pop()
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
for node in nodes:
|
|
189
|
+
visited.clear()
|
|
190
|
+
path.clear()
|
|
191
|
+
cycle = dfs(node)
|
|
192
|
+
if cycle:
|
|
193
|
+
return cycle
|
|
194
|
+
|
|
195
|
+
return list(nodes) # fallback
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Minimal ``.env`` parser — dependency-free.
|
|
2
|
+
|
|
3
|
+
Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
|
|
4
|
+
diagnostics CLI runs before the host package is imported; the users-module
|
|
5
|
+
bootstrap runs after settings are constructed and needs to read values that
|
|
6
|
+
``UsersSettings`` deliberately omits from ``env_file``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_dotenv(path: Path | None = None) -> dict[str, str]:
|
|
16
|
+
"""Parse a ``.env`` file into a dict. Empty dict if the file is missing.
|
|
17
|
+
|
|
18
|
+
Values surrounded by matching single or double quotes have the quotes
|
|
19
|
+
stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
|
|
20
|
+
values — keep the file simple. Does *not* mutate ``os.environ``; the
|
|
21
|
+
caller decides whether to merge.
|
|
22
|
+
|
|
23
|
+
Without ``path``, looks up ``$SM_PROJECT_ROOT/.env`` (falling back to
|
|
24
|
+
``$CWD/.env``) — the convention used by every tool in this repo.
|
|
25
|
+
"""
|
|
26
|
+
if path is None:
|
|
27
|
+
root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
|
|
28
|
+
path = root / ".env"
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
return {}
|
|
31
|
+
parsed: dict[str, str] = {}
|
|
32
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
33
|
+
line = raw.strip()
|
|
34
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
35
|
+
continue
|
|
36
|
+
key, _, value = line.partition("=")
|
|
37
|
+
parsed[key.strip()] = value.strip().strip('"').strip("'")
|
|
38
|
+
return parsed
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared environment-classification constants.
|
|
2
|
+
|
|
3
|
+
Both the host and module settings validators need to know which ``SM_ENVIRONMENT``
|
|
4
|
+
values are "non-prod" — anything outside this set is treated as production
|
|
5
|
+
and subject to stricter defaults (e.g. placeholder-secret rejection).
|
|
6
|
+
|
|
7
|
+
Duplicating this constant in each settings module would mean an operator who
|
|
8
|
+
adds ``"staging"`` to one list but forgets the other gets inconsistent
|
|
9
|
+
validation. Lives in ``simple_module_core`` because both the host package and
|
|
10
|
+
module settings already depend on it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
NON_PROD_ENVIRONMENTS: frozenset[str] = frozenset({"development", "testing"})
|