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,89 @@
|
|
|
1
|
+
"""Menu system — modules contribute menu items, filtered by user roles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
MenuItemMethod = Literal["get", "post"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MenuSection(StrEnum):
|
|
13
|
+
"""Where the menu item appears in the UI."""
|
|
14
|
+
|
|
15
|
+
SIDEBAR = "sidebar"
|
|
16
|
+
ADMIN_SIDEBAR = "adminSidebar"
|
|
17
|
+
NAVBAR = "navbar"
|
|
18
|
+
USER_DROPDOWN = "userDropdown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class MenuItem:
|
|
23
|
+
"""A single navigation entry."""
|
|
24
|
+
|
|
25
|
+
label: str
|
|
26
|
+
url: str
|
|
27
|
+
icon: str = ""
|
|
28
|
+
order: int = 0
|
|
29
|
+
section: MenuSection = MenuSection.SIDEBAR
|
|
30
|
+
requires_auth: bool = True
|
|
31
|
+
roles: list[str] = field(default_factory=list)
|
|
32
|
+
"""Empty list = visible to all authenticated users."""
|
|
33
|
+
method: MenuItemMethod = "get"
|
|
34
|
+
"""HTTP method used when the item is activated. ``"post"`` renders as an
|
|
35
|
+
Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MenuRegistry:
|
|
39
|
+
"""Collects menu items from all modules and filters them per-request."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._items: list[MenuItem] = []
|
|
43
|
+
self._sorted: list[MenuItem] | None = None
|
|
44
|
+
|
|
45
|
+
def _invalidate(self) -> None:
|
|
46
|
+
self._sorted = None
|
|
47
|
+
|
|
48
|
+
def add(self, item: MenuItem) -> None:
|
|
49
|
+
self._items.append(item)
|
|
50
|
+
self._invalidate()
|
|
51
|
+
|
|
52
|
+
def add_many(self, items: list[MenuItem]) -> None:
|
|
53
|
+
self._items.extend(items)
|
|
54
|
+
self._invalidate()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def all_items(self) -> list[MenuItem]:
|
|
58
|
+
if self._sorted is None:
|
|
59
|
+
self._sorted = sorted(self._items, key=lambda i: i.order)
|
|
60
|
+
return self._sorted
|
|
61
|
+
|
|
62
|
+
def get_for_user(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
is_authenticated: bool,
|
|
66
|
+
roles: list[str] | None = None,
|
|
67
|
+
) -> dict[str, list[dict]]:
|
|
68
|
+
"""Return menu items grouped by section, filtered by auth/roles.
|
|
69
|
+
|
|
70
|
+
Returns a dict ready to be serialized into Inertia shared props.
|
|
71
|
+
"""
|
|
72
|
+
roles = roles or []
|
|
73
|
+
result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}
|
|
74
|
+
|
|
75
|
+
for item in self.all_items:
|
|
76
|
+
if item.requires_auth and not is_authenticated:
|
|
77
|
+
continue
|
|
78
|
+
if item.roles and not any(r in item.roles for r in roles):
|
|
79
|
+
continue
|
|
80
|
+
result[item.section.value].append(
|
|
81
|
+
{
|
|
82
|
+
"label": item.label,
|
|
83
|
+
"url": item.url,
|
|
84
|
+
"icon": item.icon,
|
|
85
|
+
"method": item.method,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return result
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Module base class and metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fastapi import APIRouter, FastAPI
|
|
12
|
+
|
|
13
|
+
from simple_module_core.events import EventBus
|
|
14
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
15
|
+
from simple_module_core.health import HealthRegistry
|
|
16
|
+
from simple_module_core.menu import MenuRegistry
|
|
17
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ModuleMeta:
|
|
22
|
+
"""Metadata describing a module."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
route_prefix: str = ""
|
|
26
|
+
view_prefix: str = ""
|
|
27
|
+
depends_on: list[str] = field(default_factory=list)
|
|
28
|
+
version: str = "1.0.0"
|
|
29
|
+
requires_framework: str | None = None
|
|
30
|
+
"""PEP 440 specifier for the framework API version this module supports.
|
|
31
|
+
|
|
32
|
+
Example: ``">=1.0,<2.0"``. When set, the module is rejected at boot if the
|
|
33
|
+
installed ``simple_module_core.FRAMEWORK_API_VERSION`` does not satisfy it.
|
|
34
|
+
When ``None``, no compatibility check is performed (legacy modules).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ModuleBase(ABC):
|
|
39
|
+
"""Base class for all modules.
|
|
40
|
+
|
|
41
|
+
Subclasses override only the methods they need.
|
|
42
|
+
Every method has a default no-op implementation.
|
|
43
|
+
|
|
44
|
+
Hook execution order during app boot:
|
|
45
|
+
|
|
46
|
+
Phase 1: Bootstrap
|
|
47
|
+
- Load framework Settings
|
|
48
|
+
- Discover & topological-sort modules
|
|
49
|
+
- Run diagnostics (dev only)
|
|
50
|
+
|
|
51
|
+
Phase 2: App creation
|
|
52
|
+
- Create FastAPI app
|
|
53
|
+
- Store framework registries + settings on app.state
|
|
54
|
+
|
|
55
|
+
Phase 3: Module settings
|
|
56
|
+
- register_settings(app)
|
|
57
|
+
|
|
58
|
+
Phase 4: Module registrations
|
|
59
|
+
- register_menu_items(registry)
|
|
60
|
+
- register_permissions(registry)
|
|
61
|
+
- register_feature_flags(registry)
|
|
62
|
+
- register_event_handlers(bus)
|
|
63
|
+
- register_health_checks(registry)
|
|
64
|
+
|
|
65
|
+
Phase 5: Database
|
|
66
|
+
- init_db() -> DatabaseState
|
|
67
|
+
- register_listeners(db_state)
|
|
68
|
+
|
|
69
|
+
Phase 6: App wiring
|
|
70
|
+
- Inertia setup
|
|
71
|
+
- register_exception_handlers(app)
|
|
72
|
+
- Middleware pipeline (register_middleware per module)
|
|
73
|
+
- register_routes(api_router, view_router)
|
|
74
|
+
- Mount health router + static files
|
|
75
|
+
|
|
76
|
+
Phase 7: Runtime (async)
|
|
77
|
+
- on_startup(app) — per module, in dependency order
|
|
78
|
+
- on_shutdown(app) — per module, reverse order
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
meta: ModuleMeta
|
|
82
|
+
|
|
83
|
+
# ── Settings ─────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
86
|
+
"""Load module-specific settings and store on ``app.state``.
|
|
87
|
+
|
|
88
|
+
Called before all other registration hooks. Convention:
|
|
89
|
+
store as ``app.state.<module_name_lower>_settings``.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
# ── Service Registration ──────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def register_routes(
|
|
95
|
+
self,
|
|
96
|
+
api_router: APIRouter,
|
|
97
|
+
view_router: APIRouter,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Register API endpoints and Inertia view routes."""
|
|
100
|
+
|
|
101
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
102
|
+
"""Contribute menu items visible in the UI."""
|
|
103
|
+
|
|
104
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
105
|
+
"""Declare permissions this module uses."""
|
|
106
|
+
|
|
107
|
+
def register_feature_flags(self, registry: FeatureFlagRegistry) -> None:
|
|
108
|
+
"""Declare feature flags this module exposes."""
|
|
109
|
+
|
|
110
|
+
def register_event_handlers(self, bus: EventBus) -> None:
|
|
111
|
+
"""Subscribe to events published by other modules."""
|
|
112
|
+
|
|
113
|
+
def register_health_checks(self, registry: HealthRegistry) -> None:
|
|
114
|
+
"""Contribute health checks for the ``/health/ready`` endpoint."""
|
|
115
|
+
|
|
116
|
+
def register_middleware(self, app: FastAPI) -> None:
|
|
117
|
+
"""Add middleware to the application.
|
|
118
|
+
|
|
119
|
+
Called after core middleware (session, security headers) positioning
|
|
120
|
+
is established but before the app starts. Modules that need to
|
|
121
|
+
inject middleware (e.g. auth) override this method.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def register_exception_handlers(self, app: FastAPI) -> None:
|
|
125
|
+
"""Register custom exception handlers on the application.
|
|
126
|
+
|
|
127
|
+
Called after framework exception handlers are set up.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
# ── Asset contribution (templates & static files) ─────────
|
|
131
|
+
|
|
132
|
+
def template_dirs(self) -> list[Path]:
|
|
133
|
+
"""Return Jinja2 template directories this module contributes.
|
|
134
|
+
|
|
135
|
+
The host aggregates all modules' template dirs (plus its own) into the
|
|
136
|
+
Jinja2 loader, so templates in ``<module>/templates/`` become resolvable
|
|
137
|
+
from any module. Return an empty list (default) to contribute none.
|
|
138
|
+
|
|
139
|
+
Each path is typically computed via
|
|
140
|
+
``importlib.resources.files(__package__) / "templates"`` so it resolves
|
|
141
|
+
correctly when the module is installed as a PyPI package.
|
|
142
|
+
"""
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
def static_mounts(self) -> dict[str, Path]:
|
|
146
|
+
"""Return static file mounts this module contributes.
|
|
147
|
+
|
|
148
|
+
Mapping of URL prefix → filesystem directory. The host mounts each entry
|
|
149
|
+
via ``StaticFiles`` during app boot. Convention: prefix with
|
|
150
|
+
``/modules/<name>/static`` to avoid collisions with the host's own
|
|
151
|
+
``/static`` mount. Return an empty dict (default) to contribute none.
|
|
152
|
+
"""
|
|
153
|
+
return {}
|
|
154
|
+
|
|
155
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
156
|
+
"""Return ``{namespace: directory}`` mapping for locale JSON files.
|
|
157
|
+
|
|
158
|
+
Default returns an empty dict. Override to contribute a module's
|
|
159
|
+
locales::
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"products": importlib.resources.files(__package__) / "locales"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
The namespace becomes the key prefix in the merged i18n registry.
|
|
166
|
+
A file ``locales/en.json`` containing ``{"browse": {"title": "X"}}``
|
|
167
|
+
becomes the key ``products.browse.title`` at runtime.
|
|
168
|
+
|
|
169
|
+
Convention: use the module's lowercase name as the namespace.
|
|
170
|
+
"""
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
# ── Lifecycle ─────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
176
|
+
"""Called after all modules are registered, during app startup."""
|
|
177
|
+
|
|
178
|
+
async def on_shutdown(self, app: FastAPI) -> None:
|
|
179
|
+
"""Called during app shutdown."""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Permission registry — modules declare the permissions they use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
WILDCARD = "*"
|
|
8
|
+
|
|
9
|
+
# Default role→permission mapping. Admin gets all permissions via the wildcard.
|
|
10
|
+
# Additional mappings are added at registration time via PermissionRegistry.map_role.
|
|
11
|
+
DEFAULT_ROLE_PERMISSIONS: dict[str, list[str]] = {
|
|
12
|
+
"admin": [WILDCARD],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PermissionGroup:
|
|
18
|
+
"""A named group of related permissions (typically one per module)."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
permissions: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PermissionRegistry:
|
|
25
|
+
"""Central registry of all permissions across all modules.
|
|
26
|
+
|
|
27
|
+
Effectively immutable after module-registration (boot phase). The computed
|
|
28
|
+
views ``all_permissions`` and ``role_map`` are read on every authenticated
|
|
29
|
+
request by ``InertiaLayoutDataMiddleware`` — cache them and invalidate on
|
|
30
|
+
every mutation.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._groups: dict[str, PermissionGroup] = {}
|
|
35
|
+
self._role_map: dict[str, set[str]] = {}
|
|
36
|
+
self._all_permissions_cache: list[str] | None = None
|
|
37
|
+
self._role_map_cache: dict[str, list[str]] | None = None
|
|
38
|
+
|
|
39
|
+
def _invalidate(self) -> None:
|
|
40
|
+
self._all_permissions_cache = None
|
|
41
|
+
self._role_map_cache = None
|
|
42
|
+
|
|
43
|
+
def add_group(self, name: str, permissions: list[str]) -> None:
|
|
44
|
+
"""Register a group of related permissions."""
|
|
45
|
+
if name in self._groups:
|
|
46
|
+
self._groups[name].permissions.extend(permissions)
|
|
47
|
+
else:
|
|
48
|
+
self._groups[name] = PermissionGroup(name=name, permissions=list(permissions))
|
|
49
|
+
self._invalidate()
|
|
50
|
+
|
|
51
|
+
def add(self, permission: str) -> None:
|
|
52
|
+
"""Register a single permission (auto-grouped by prefix before '.')."""
|
|
53
|
+
group_name = permission.split(".")[0] if "." in permission else "general"
|
|
54
|
+
if group_name not in self._groups:
|
|
55
|
+
self._groups[group_name] = PermissionGroup(name=group_name)
|
|
56
|
+
if permission not in self._groups[group_name].permissions:
|
|
57
|
+
self._groups[group_name].permissions.append(permission)
|
|
58
|
+
self._invalidate()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def all_permissions(self) -> list[str]:
|
|
62
|
+
"""All registered permission strings, sorted."""
|
|
63
|
+
if self._all_permissions_cache is None:
|
|
64
|
+
perms: set[str] = set()
|
|
65
|
+
for group in self._groups.values():
|
|
66
|
+
perms.update(group.permissions)
|
|
67
|
+
self._all_permissions_cache = sorted(perms)
|
|
68
|
+
return self._all_permissions_cache
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def groups(self) -> list[PermissionGroup]:
|
|
72
|
+
return list(self._groups.values())
|
|
73
|
+
|
|
74
|
+
def has(self, permission: str) -> bool:
|
|
75
|
+
return any(permission in g.permissions for g in self._groups.values())
|
|
76
|
+
|
|
77
|
+
def map_role(self, role: str, permissions: list[str]) -> None:
|
|
78
|
+
"""Register a role→permission mapping.
|
|
79
|
+
|
|
80
|
+
Merges *permissions* into the existing set for *role* so that multiple
|
|
81
|
+
calls from different modules accumulate rather than overwrite.
|
|
82
|
+
"""
|
|
83
|
+
if role not in self._role_map:
|
|
84
|
+
self._role_map[role] = set()
|
|
85
|
+
self._role_map[role].update(permissions)
|
|
86
|
+
self._invalidate()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def role_map(self) -> dict[str, list[str]]:
|
|
90
|
+
"""Merged role→permission mapping (``DEFAULT_ROLE_PERMISSIONS`` + module maps)."""
|
|
91
|
+
if self._role_map_cache is None:
|
|
92
|
+
merged: dict[str, list[str]] = {
|
|
93
|
+
role: list(perms) for role, perms in DEFAULT_ROLE_PERMISSIONS.items()
|
|
94
|
+
}
|
|
95
|
+
for role, perms in self._role_map.items():
|
|
96
|
+
if role in merged:
|
|
97
|
+
merged[role] = list(set(merged[role]) | perms)
|
|
98
|
+
else:
|
|
99
|
+
merged[role] = list(perms)
|
|
100
|
+
self._role_map_cache = merged
|
|
101
|
+
return self._role_map_cache
|
|
102
|
+
|
|
103
|
+
def get_permissions_for_roles(
|
|
104
|
+
self,
|
|
105
|
+
roles: list[str],
|
|
106
|
+
role_permission_map: dict[str, list[str]] | None = None,
|
|
107
|
+
) -> set[str]:
|
|
108
|
+
"""Resolve permissions for a set of roles.
|
|
109
|
+
|
|
110
|
+
If role_permission_map is None, 'admin' gets all permissions,
|
|
111
|
+
other roles get none. Override for richer mapping.
|
|
112
|
+
"""
|
|
113
|
+
if role_permission_map is None:
|
|
114
|
+
if "admin" in roles:
|
|
115
|
+
return set(self.all_permissions)
|
|
116
|
+
return set()
|
|
117
|
+
|
|
118
|
+
result: set[str] = set()
|
|
119
|
+
for role in roles:
|
|
120
|
+
result.update(role_permission_map.get(role, []))
|
|
121
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Framework-scoped singleton container.
|
|
2
|
+
|
|
3
|
+
Stored as ``app.state.sm`` during :func:`create_app`. Consumers read
|
|
4
|
+
``request.app.state.sm.<field>`` instead of reaching for loose
|
|
5
|
+
``app.state`` attributes — gives us typing, discoverability, and a
|
|
6
|
+
single place to see what the framework owns.
|
|
7
|
+
|
|
8
|
+
Frozen + slotted by design: Services is built once at boot and never
|
|
9
|
+
mutated. Slots reject attribute additions, which is how the previous
|
|
10
|
+
``app.state`` shape grew unbounded.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from inertia import InertiaConfig
|
|
20
|
+
from simple_module_db.session import DatabaseState
|
|
21
|
+
from simple_module_hosting.settings import Settings
|
|
22
|
+
|
|
23
|
+
from simple_module_core.events import EventBus
|
|
24
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
25
|
+
from simple_module_core.health import HealthRegistry
|
|
26
|
+
from simple_module_core.i18n import I18nRegistry
|
|
27
|
+
from simple_module_core.menu import MenuRegistry
|
|
28
|
+
from simple_module_core.module import ModuleBase
|
|
29
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Services:
|
|
34
|
+
"""Framework singletons. One slot per owner, read-only after boot."""
|
|
35
|
+
|
|
36
|
+
settings: Settings
|
|
37
|
+
db: DatabaseState
|
|
38
|
+
event_bus: EventBus
|
|
39
|
+
menu_registry: MenuRegistry
|
|
40
|
+
permissions: PermissionRegistry
|
|
41
|
+
feature_flags: FeatureFlagRegistry
|
|
42
|
+
health_registry: HealthRegistry
|
|
43
|
+
i18n_registry: I18nRegistry
|
|
44
|
+
inertia_config: InertiaConfig
|
|
45
|
+
modules: tuple[ModuleBase, ...]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Framework API version compatibility checks for modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
|
|
11
|
+
from simple_module_core.exceptions import FrameworkVersionError
|
|
12
|
+
from simple_module_core.module import ModuleBase
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
FRAMEWORK_API_VERSION = "1.0.0"
|
|
17
|
+
"""Current public API version of simple_module_core.
|
|
18
|
+
|
|
19
|
+
Modules declare a compatible range via ``ModuleMeta.requires_framework``
|
|
20
|
+
(PEP 440 specifier, e.g. ``">=1.0,<2.0"``). Bumping this constant's major
|
|
21
|
+
component signals a breaking change in the module contract — ``ModuleBase``,
|
|
22
|
+
registries, the event bus, ``create_module_base``, or the model mixins.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Pre-parsed for the default compatibility check so boot doesn't re-parse
|
|
26
|
+
# this constant on every call. The custom-version path below still parses
|
|
27
|
+
# caller-supplied strings lazily.
|
|
28
|
+
_FRAMEWORK_VERSION = Version(FRAMEWORK_API_VERSION)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_framework_compatibility(
|
|
32
|
+
modules: Sequence[ModuleBase],
|
|
33
|
+
framework_version: str = FRAMEWORK_API_VERSION,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Fail fast if any module's ``requires_framework`` spec rejects the framework version.
|
|
36
|
+
|
|
37
|
+
Modules with ``requires_framework=None`` are skipped (unversioned modules pass).
|
|
38
|
+
Malformed specifiers are reported as failures rather than letting a cryptic
|
|
39
|
+
``InvalidSpecifier`` bubble up from deep inside ``packaging``.
|
|
40
|
+
"""
|
|
41
|
+
current = (
|
|
42
|
+
_FRAMEWORK_VERSION
|
|
43
|
+
if framework_version == FRAMEWORK_API_VERSION
|
|
44
|
+
else Version(framework_version)
|
|
45
|
+
)
|
|
46
|
+
failures: list[tuple[str, str, str]] = []
|
|
47
|
+
|
|
48
|
+
for mod in modules:
|
|
49
|
+
spec_str = mod.meta.requires_framework
|
|
50
|
+
if spec_str is None:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
spec = SpecifierSet(spec_str)
|
|
54
|
+
except InvalidSpecifier as exc:
|
|
55
|
+
failures.append((mod.meta.name, spec_str, f"invalid version specifier ({exc})"))
|
|
56
|
+
continue
|
|
57
|
+
if current not in spec:
|
|
58
|
+
failures.append(
|
|
59
|
+
(
|
|
60
|
+
mod.meta.name,
|
|
61
|
+
spec_str,
|
|
62
|
+
f"framework {framework_version} does not satisfy '{spec_str}'",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if failures:
|
|
67
|
+
raise FrameworkVersionError(framework_version, failures)
|
|
@@ -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,29 @@
|
|
|
1
|
+
simple_module_core/__init__.py,sha256=e1Sf0Hy2YgOt_bl4Fs8-6qwSXWwAfbartRXQr5Z1vss,2087
|
|
2
|
+
simple_module_core/__main__.py,sha256=98rf_b9GsbcYoHvU9q3aondBuHvxVO-VMJPuohb0XNc,3096
|
|
3
|
+
simple_module_core/discovery.py,sha256=oeib4kJJXwqe0y5HczyMIRbubPHXerHv4RYRTYsuR3s,6776
|
|
4
|
+
simple_module_core/dotenv.py,sha256=ubQ-hS-yMnBXkW1Sh0yTgLzlcHPOZ1P78VJXCJ7N2fM,1461
|
|
5
|
+
simple_module_core/environments.py,sha256=OY7Hy9FcGjit3RRnROxUJHynMi5Wq9G1_zrjwYb0Jfw,663
|
|
6
|
+
simple_module_core/events.py,sha256=3klVq41t08hHs6rp59FolnH0gJQjiyvTiuWAaBEgm7k,3015
|
|
7
|
+
simple_module_core/exceptions.py,sha256=X8MEs-NodkS5tTcaO24BjOeAkmWXdODRGukE6ys1q64,2034
|
|
8
|
+
simple_module_core/feature_flags.py,sha256=I2nkIX6dEvG8in9N4JR3VrDnCTG8y1pWzM3KJJK3FiU,6666
|
|
9
|
+
simple_module_core/health.py,sha256=UPeWmjh607JnKG1XXnOiGKa4HHxd5x51EZR8HfuyRyU,982
|
|
10
|
+
simple_module_core/i18n.py,sha256=shpcg7k8IVNofiwxo30jhZ-tBzRG8XWiz2PP20CPFdw,10247
|
|
11
|
+
simple_module_core/menu.py,sha256=toaNvXzO-gL0bvpckSOGRWRavVy70BPH0lOQd_ZCfFE,2586
|
|
12
|
+
simple_module_core/module.py,sha256=CiaaAjFbf3dp-BJEti9W_Y3cPULG5paNAUk45TCxd-E,6588
|
|
13
|
+
simple_module_core/permissions.py,sha256=Z_QRC6WL1IyKjbeWWxj-RbyGJLwZU2H2NwBNrpwdHTg,4517
|
|
14
|
+
simple_module_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
simple_module_core/services.py,sha256=_BmZCT18ZaXMT1qif0CCt7Fi6mB7i2MqbniYleC5RBs,1585
|
|
16
|
+
simple_module_core/versioning.py,sha256=ii7gF5gcO1FV7qw0J86ksRS49kh3GMhUcoyX0Nrm0_g,2361
|
|
17
|
+
simple_module_core/diagnostics/__init__.py,sha256=t2RX37e9rIlBST-KJ2jysuniAZ0Eq-SuV2PvUnIdbio,874
|
|
18
|
+
simple_module_core/diagnostics/_coupling.py,sha256=xrS5yteuLIobxwBahxCRkCw3WopIG8WYdGZfkBAsB3Q,3023
|
|
19
|
+
simple_module_core/diagnostics/_i18n.py,sha256=DVthcWRee-zlR1BLemV3HYxFYZXTkVL9Kj2dqoE3iUQ,4852
|
|
20
|
+
simple_module_core/diagnostics/_inertia_api.py,sha256=OuvxaR7CKen-a5RVaKoPNobxrbIHuyU2SQ7ZBlLfUCw,2839
|
|
21
|
+
simple_module_core/diagnostics/_js_workspace.py,sha256=004aYAFHbP31j3Iy8x-0l_1Sj1f4CeIR35LO1sfxEIM,1250
|
|
22
|
+
simple_module_core/diagnostics/_migration.py,sha256=7D1kE88kDy7bZgu4y9oAJexVg3zb6i6fsZQJEJJsZpk,1528
|
|
23
|
+
simple_module_core/diagnostics/_module.py,sha256=fQTBZ1y0NajB6b8mhOEu-IknTjnRaVdYtYCo-CeTOr0,10165
|
|
24
|
+
simple_module_core/diagnostics/_runner.py,sha256=E9NraAj_3i5CCoRA-5PcpWxf-Tk0GdXtx_jEcbWrfmI,2892
|
|
25
|
+
simple_module_core/diagnostics/_types.py,sha256=EQucVU24ktuxqupot3Gf10HolFmdmM-QDwPL6tolJJc,886
|
|
26
|
+
simple_module_core-0.0.1.dist-info/METADATA,sha256=1G2stmMayQBX87RItlTYgGOFy5AbsD7uWC-t2LUPI1k,3377
|
|
27
|
+
simple_module_core-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
28
|
+
simple_module_core-0.0.1.dist-info/licenses/LICENSE,sha256=Yn66lhLklsF5p7pa85_ksQrJ79Q-FgOaUAHevLBjer4,1068
|
|
29
|
+
simple_module_core-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|