simple-module-hosting 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_hosting/__init__.py +7 -0
- simple_module_hosting/_error_handlers.py +54 -0
- simple_module_hosting/_hydrate_step.py +39 -0
- simple_module_hosting/_inertia_setup.py +73 -0
- simple_module_hosting/_inertia_shared.py +61 -0
- simple_module_hosting/_observability.py +108 -0
- simple_module_hosting/_phase_helpers.py +160 -0
- simple_module_hosting/app_builder.py +281 -0
- simple_module_hosting/bootstrap_settings.py +55 -0
- simple_module_hosting/cli.py +292 -0
- simple_module_hosting/health.py +79 -0
- simple_module_hosting/host_settings.py +33 -0
- simple_module_hosting/i18n_deps.py +25 -0
- simple_module_hosting/i18n_manifest.py +202 -0
- simple_module_hosting/i18n_middleware.py +95 -0
- simple_module_hosting/inertia_deps.py +27 -0
- simple_module_hosting/inertia_utils.py +31 -0
- simple_module_hosting/logging.py +91 -0
- simple_module_hosting/manifest.py +250 -0
- simple_module_hosting/middleware.py +272 -0
- simple_module_hosting/migrations.py +65 -0
- simple_module_hosting/permissions.py +75 -0
- simple_module_hosting/py.typed +0 -0
- simple_module_hosting/redirects.py +45 -0
- simple_module_hosting/scaffolding.py +294 -0
- simple_module_hosting/settings.py +10 -0
- simple_module_hosting/templates/host/.env.example +20 -0
- simple_module_hosting/templates/host/.gitignore +19 -0
- simple_module_hosting/templates/host/Makefile +24 -0
- simple_module_hosting/templates/host/README.md.tpl +59 -0
- simple_module_hosting/templates/host/alembic.ini +36 -0
- simple_module_hosting/templates/host/client_app/app.tsx +16 -0
- simple_module_hosting/templates/host/client_app/main.tsx +2 -0
- simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
- simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
- simple_module_hosting/templates/host/client_app/pages.ts +47 -0
- simple_module_hosting/templates/host/client_app/styles.css +7 -0
- simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
- simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
- simple_module_hosting/templates/host/main.py +27 -0
- simple_module_hosting/templates/host/migrations/env.py +80 -0
- simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
- simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
- simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
- simple_module_hosting/templates/host/templates/index.html +12 -0
- simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
- simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
- simple_module_hosting/templates/module/.gitignore +14 -0
- simple_module_hosting/templates/module/README.md.tpl +82 -0
- simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
- simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
- simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
- simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
- simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
- simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
- simple_module_hosting/templates/module/package.json.tpl +16 -0
- simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
- simple_module_hosting/templates/module/tests/__init__.py +0 -0
- simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
- simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
- simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
- simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
- simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
- simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
- simple_module_hosting-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Application builder — discovers modules, wires everything, returns a FastAPI app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, FastAPI
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
|
|
14
|
+
from simple_module_core.discovery import discover_modules, topological_sort
|
|
15
|
+
from simple_module_core.events import EventBus
|
|
16
|
+
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
17
|
+
from simple_module_core.health import HealthRegistry
|
|
18
|
+
from simple_module_core.menu import MenuRegistry
|
|
19
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
20
|
+
from simple_module_core.services import Services
|
|
21
|
+
from simple_module_db.listeners import register_listeners
|
|
22
|
+
from simple_module_db.session import init_db
|
|
23
|
+
|
|
24
|
+
from simple_module_hosting._inertia_setup import setup_inertia
|
|
25
|
+
from simple_module_hosting._phase_helpers import (
|
|
26
|
+
check_settings_registration,
|
|
27
|
+
install_middleware,
|
|
28
|
+
mount_module_static_dirs,
|
|
29
|
+
register_exception_handlers,
|
|
30
|
+
)
|
|
31
|
+
from simple_module_hosting.health import router as health_router
|
|
32
|
+
from simple_module_hosting.i18n_manifest import build_i18n_registry, emit_frontend_types
|
|
33
|
+
from simple_module_hosting.migrations import check_migrations
|
|
34
|
+
from simple_module_hosting.settings import Settings
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_APP_TITLE = "SimpleModule"
|
|
39
|
+
_APP_VERSION = "0.1.0"
|
|
40
|
+
_DOCS_URL = "/api/docs"
|
|
41
|
+
_REDOC_URL = "/api/redoc"
|
|
42
|
+
_STATIC_MOUNT_PATH = "/static"
|
|
43
|
+
_STATIC_DIR_NAME = "static"
|
|
44
|
+
_ENV_PROJECT_ROOT = "SM_PROJECT_ROOT"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_project_root() -> Path:
|
|
48
|
+
"""Return the project root directory.
|
|
49
|
+
|
|
50
|
+
Prefers the ``SM_PROJECT_ROOT`` environment variable (set by
|
|
51
|
+
``host/main.py``) so the framework works even when installed from a
|
|
52
|
+
wheel into ``site-packages`` — in that layout the fallback walk-up
|
|
53
|
+
below would escape the package into ``site-packages/..`` and miss
|
|
54
|
+
``host/static`` entirely.
|
|
55
|
+
|
|
56
|
+
Falls back to ``parents[3]`` for the workspace-install dev loop
|
|
57
|
+
(simple_module_hosting/ → hosting/ → framework/ → project root).
|
|
58
|
+
"""
|
|
59
|
+
override = os.environ.get(_ENV_PROJECT_ROOT)
|
|
60
|
+
if override:
|
|
61
|
+
return Path(override)
|
|
62
|
+
return Path(__file__).resolve().parents[3]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_PROJECT_ROOT = _resolve_project_root()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def wire_module_routes(app: FastAPI, module) -> None:
|
|
69
|
+
"""Attach a module's API + view routers to ``app`` using its Meta prefixes.
|
|
70
|
+
|
|
71
|
+
The single canonical implementation so ``create_app`` and the test harness
|
|
72
|
+
in ``simple_module_testing`` stay in lockstep if ``ModuleBase`` ever gains
|
|
73
|
+
a new router type.
|
|
74
|
+
"""
|
|
75
|
+
api_router = APIRouter(prefix=module.meta.route_prefix, tags=[module.meta.name])
|
|
76
|
+
view_router = APIRouter(prefix=module.meta.view_prefix, tags=[f"{module.meta.name} Views"])
|
|
77
|
+
module.register_routes(api_router, view_router)
|
|
78
|
+
app.include_router(api_router)
|
|
79
|
+
app.include_router(view_router)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
83
|
+
"""Build and configure the full FastAPI application.
|
|
84
|
+
|
|
85
|
+
Boot sequence:
|
|
86
|
+
1. Load settings & discover modules
|
|
87
|
+
2. Run diagnostics (dev only)
|
|
88
|
+
3. Create FastAPI app & store framework state
|
|
89
|
+
4. Module settings (register_settings)
|
|
90
|
+
5. Module registrations (menu, permissions, flags, events, health)
|
|
91
|
+
6. Initialize database
|
|
92
|
+
7. Inertia setup & exception handlers (register_exception_handlers)
|
|
93
|
+
8. Middleware pipeline (register_middleware)
|
|
94
|
+
9. Routes (register_routes), health endpoints, static files
|
|
95
|
+
"""
|
|
96
|
+
settings = settings or Settings()
|
|
97
|
+
|
|
98
|
+
# ── Phase 1: Discover modules ──────────────────────────
|
|
99
|
+
# Production: any bad module (import error, missing meta, wrong base
|
|
100
|
+
# class) fails the boot immediately with a clear message — better than
|
|
101
|
+
# silently shipping a partial app. Dev keeps the lenient default.
|
|
102
|
+
modules = discover_modules(
|
|
103
|
+
enabled=settings.modules_enabled,
|
|
104
|
+
strict=not settings.is_development,
|
|
105
|
+
)
|
|
106
|
+
modules = topological_sort(modules)
|
|
107
|
+
logger.info(
|
|
108
|
+
"Loaded %d module(s): %s",
|
|
109
|
+
len(modules),
|
|
110
|
+
", ".join(m.meta.name for m in modules),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Build the i18n registry up front so diagnostics can validate key parity
|
|
114
|
+
# against host/ui locales, not just module-contributed ones.
|
|
115
|
+
i18n_registry, i18n_extra = build_i18n_registry(settings, modules, _PROJECT_ROOT)
|
|
116
|
+
|
|
117
|
+
# ── Phase 2: Run diagnostics (dev only) ────────────────
|
|
118
|
+
if settings.is_development:
|
|
119
|
+
diagnostics = run_diagnostics(
|
|
120
|
+
modules,
|
|
121
|
+
i18n_supported_locales=settings.i18n_supported_locales,
|
|
122
|
+
i18n_default_locale=settings.i18n_default_locale,
|
|
123
|
+
i18n_extra_sources=i18n_extra,
|
|
124
|
+
)
|
|
125
|
+
errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
|
|
126
|
+
if diagnostics:
|
|
127
|
+
print_diagnostics(diagnostics)
|
|
128
|
+
if errors:
|
|
129
|
+
raise SystemExit(f"Module diagnostics: {len(errors)} error(s). Fix before continuing.")
|
|
130
|
+
|
|
131
|
+
# Emit frontend module-pages manifest so Vite can find pages shipped
|
|
132
|
+
# inside pip-installed module wheels. See scaffolding.py.
|
|
133
|
+
try:
|
|
134
|
+
from simple_module_hosting.scaffolding import write_module_pages_manifest
|
|
135
|
+
|
|
136
|
+
client_app = _PROJECT_ROOT / "host" / "client_app"
|
|
137
|
+
if client_app.is_dir():
|
|
138
|
+
write_module_pages_manifest(modules, client_app)
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.exception("Failed to write module pages manifest — frontend may miss pages")
|
|
141
|
+
|
|
142
|
+
emit_frontend_types(i18n_registry, _PROJECT_ROOT)
|
|
143
|
+
|
|
144
|
+
# ── Phase 3: Create FastAPI app ────────────────────────
|
|
145
|
+
menu_registry = MenuRegistry()
|
|
146
|
+
perm_registry = PermissionRegistry()
|
|
147
|
+
ff_registry = FeatureFlagRegistry()
|
|
148
|
+
event_bus = EventBus()
|
|
149
|
+
health_registry = HealthRegistry()
|
|
150
|
+
|
|
151
|
+
@asynccontextmanager
|
|
152
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
153
|
+
app.state.migration = await check_migrations(app.state.sm.db.engine)
|
|
154
|
+
|
|
155
|
+
# Hydrate all registered settings from DB before any module
|
|
156
|
+
# on_startup hook runs, so startup code sees DB-backed values.
|
|
157
|
+
# Importlib keeps plugin names out of the framework AST (SM009).
|
|
158
|
+
if hasattr(app.state, "settings"):
|
|
159
|
+
import importlib
|
|
160
|
+
|
|
161
|
+
from simple_module_hosting._hydrate_step import hydrate_all
|
|
162
|
+
|
|
163
|
+
service_cls = importlib.import_module("settings.service").SettingService
|
|
164
|
+
store_cls = importlib.import_module("settings.store").SettingsStore
|
|
165
|
+
|
|
166
|
+
async with app.state.sm.db.session_factory() as session:
|
|
167
|
+
await hydrate_all(app, store_cls(service_cls(session)))
|
|
168
|
+
|
|
169
|
+
for mod in modules:
|
|
170
|
+
await mod.on_startup(app)
|
|
171
|
+
yield
|
|
172
|
+
for mod in reversed(modules):
|
|
173
|
+
await mod.on_shutdown(app)
|
|
174
|
+
await app.state.sm.db.engine.dispose()
|
|
175
|
+
|
|
176
|
+
app = FastAPI(
|
|
177
|
+
title=_APP_TITLE,
|
|
178
|
+
version=_APP_VERSION,
|
|
179
|
+
docs_url=_DOCS_URL if settings.is_development else None,
|
|
180
|
+
redoc_url=_REDOC_URL if settings.is_development else None,
|
|
181
|
+
lifespan=lifespan,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# ── Phase 4: Module settings ───────────────────────────
|
|
185
|
+
for mod in modules:
|
|
186
|
+
mod.register_settings(app)
|
|
187
|
+
|
|
188
|
+
# Register host-level settings under package="host" (DB-backed). The
|
|
189
|
+
# Settings module must already have run register_settings (topo order
|
|
190
|
+
# puts it early; its meta.depends_on = [] so it's among the first).
|
|
191
|
+
# When the Settings module isn't enabled, there's no registry to
|
|
192
|
+
# register against — skip quietly.
|
|
193
|
+
#
|
|
194
|
+
# We resolve `settings.registration` via importlib rather than a plain
|
|
195
|
+
# `from settings.registration import ...`: the SM009 coupling check is
|
|
196
|
+
# AST-based and forbids any static import of a plugin package name
|
|
197
|
+
# from within framework/* code. Dynamic resolution keeps the framework
|
|
198
|
+
# AST plugin-free while still hitting the real helper at runtime.
|
|
199
|
+
if hasattr(app.state, "settings"):
|
|
200
|
+
import importlib
|
|
201
|
+
from dataclasses import dataclass as _dataclass
|
|
202
|
+
|
|
203
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
204
|
+
|
|
205
|
+
_register_module_settings = importlib.import_module(
|
|
206
|
+
"settings.registration"
|
|
207
|
+
).register_module_settings
|
|
208
|
+
|
|
209
|
+
@_dataclass
|
|
210
|
+
class _HostServices:
|
|
211
|
+
settings: HostSettings
|
|
212
|
+
|
|
213
|
+
_register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s))
|
|
214
|
+
|
|
215
|
+
if settings.is_development:
|
|
216
|
+
settings_diagnostics = check_settings_registration(app, modules)
|
|
217
|
+
if settings_diagnostics:
|
|
218
|
+
print_diagnostics(settings_diagnostics)
|
|
219
|
+
|
|
220
|
+
# ── Phase 5: Module registrations ──────────────────────
|
|
221
|
+
for mod in modules:
|
|
222
|
+
mod.register_menu_items(menu_registry)
|
|
223
|
+
mod.register_permissions(perm_registry)
|
|
224
|
+
mod.register_feature_flags(ff_registry)
|
|
225
|
+
mod.register_event_handlers(event_bus)
|
|
226
|
+
mod.register_health_checks(health_registry)
|
|
227
|
+
|
|
228
|
+
logger.info(
|
|
229
|
+
"Registered %d menu items, %d permissions, %d feature flags, %d health checks",
|
|
230
|
+
len(menu_registry.all_items),
|
|
231
|
+
len(perm_registry.all_permissions),
|
|
232
|
+
len(ff_registry.all_flags),
|
|
233
|
+
len(health_registry.all_checks),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# ── Phase 6: Initialize database ───────────────────────
|
|
237
|
+
db_state = init_db(
|
|
238
|
+
settings.database_url,
|
|
239
|
+
echo=settings.debug,
|
|
240
|
+
pool_size=settings.db_pool_size,
|
|
241
|
+
max_overflow=settings.db_max_overflow,
|
|
242
|
+
pool_pre_ping=settings.db_pool_pre_ping,
|
|
243
|
+
pool_recycle=settings.db_pool_recycle,
|
|
244
|
+
)
|
|
245
|
+
register_listeners(db_state)
|
|
246
|
+
|
|
247
|
+
# ── Phase 7: Inertia + exception handlers ──────────────
|
|
248
|
+
inertia_config = setup_inertia(app, settings, modules, _PROJECT_ROOT)
|
|
249
|
+
if inertia_config is None:
|
|
250
|
+
raise RuntimeError("Inertia not configured — no template directories available")
|
|
251
|
+
register_exception_handlers(app, modules)
|
|
252
|
+
|
|
253
|
+
# ── Phase 8: Middleware pipeline ───────────────────────
|
|
254
|
+
install_middleware(app, settings, modules, menu_registry, perm_registry)
|
|
255
|
+
|
|
256
|
+
# ── Phase 9: Routes, health, static files ──────────────
|
|
257
|
+
for mod in modules:
|
|
258
|
+
wire_module_routes(app, mod)
|
|
259
|
+
|
|
260
|
+
app.include_router(health_router)
|
|
261
|
+
|
|
262
|
+
static_dir = _PROJECT_ROOT / "host" / _STATIC_DIR_NAME
|
|
263
|
+
if static_dir.is_dir():
|
|
264
|
+
app.mount(_STATIC_MOUNT_PATH, StaticFiles(directory=static_dir), name=_STATIC_DIR_NAME)
|
|
265
|
+
|
|
266
|
+
mount_module_static_dirs(app, modules)
|
|
267
|
+
|
|
268
|
+
app.state.sm = Services(
|
|
269
|
+
settings=settings,
|
|
270
|
+
db=db_state,
|
|
271
|
+
event_bus=event_bus,
|
|
272
|
+
menu_registry=menu_registry,
|
|
273
|
+
permissions=perm_registry,
|
|
274
|
+
feature_flags=ff_registry,
|
|
275
|
+
health_registry=health_registry,
|
|
276
|
+
i18n_registry=i18n_registry,
|
|
277
|
+
inertia_config=inertia_config,
|
|
278
|
+
modules=tuple(modules),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return app
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Env-only settings read before the database is available.
|
|
2
|
+
|
|
3
|
+
Everything here is needed either to connect to the DB, sign session cookies,
|
|
4
|
+
or configure the Python process (logging, Vite asset URLs, module allowlist).
|
|
5
|
+
These values stay in ``.env`` — all other settings live in the DB.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import model_validator
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
15
|
+
|
|
16
|
+
_PLACEHOLDER_SECRET_KEY = "change-me-in-production"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BootstrapSettings(BaseSettings):
|
|
20
|
+
"""Pre-DB bootstrap environment knobs."""
|
|
21
|
+
|
|
22
|
+
model_config = SettingsConfigDict(env_prefix="SM_", env_file=".env", extra="ignore")
|
|
23
|
+
|
|
24
|
+
database_url: str = "sqlite+aiosqlite:///./app.db"
|
|
25
|
+
db_pool_size: int = 10
|
|
26
|
+
db_max_overflow: int = 20
|
|
27
|
+
db_pool_pre_ping: bool = True
|
|
28
|
+
db_pool_recycle: int = 1800
|
|
29
|
+
|
|
30
|
+
environment: str = "development"
|
|
31
|
+
secret_key: str = _PLACEHOLDER_SECRET_KEY
|
|
32
|
+
vite_dev_url: str = "http://localhost:5050"
|
|
33
|
+
debug: bool = False
|
|
34
|
+
|
|
35
|
+
log_level: str = "INFO"
|
|
36
|
+
log_format: Literal["json", "text"] = "json"
|
|
37
|
+
|
|
38
|
+
modules_enabled: list[str] | None = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_development(self) -> bool:
|
|
42
|
+
return self.environment == "development"
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="after")
|
|
45
|
+
def _forbid_placeholder_secret_in_production(self) -> BootstrapSettings:
|
|
46
|
+
if (
|
|
47
|
+
self.environment not in NON_PROD_ENVIRONMENTS
|
|
48
|
+
and self.secret_key == _PLACEHOLDER_SECRET_KEY
|
|
49
|
+
):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"SM_SECRET_KEY must be set to a non-default value when "
|
|
52
|
+
f"SM_ENVIRONMENT={self.environment!r}. Generate one with "
|
|
53
|
+
"`python -c 'import secrets; print(secrets.token_urlsafe(48))'`."
|
|
54
|
+
)
|
|
55
|
+
return self
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""SimpleModule CLI — `sm` console script.
|
|
2
|
+
|
|
3
|
+
Currently exposes:
|
|
4
|
+
|
|
5
|
+
* ``sm create-host <name>`` — scaffold a new host directory.
|
|
6
|
+
* ``sm create-module <name>`` — scaffold a new module package.
|
|
7
|
+
* ``sm gen-pages`` — regenerate the frontend pages manifest + Tailwind CSS.
|
|
8
|
+
* ``sm sync-js-deps`` — install JS deps declared by installed modules.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
from simple_module_core import discover_modules
|
|
21
|
+
|
|
22
|
+
from simple_module_hosting.scaffolding import (
|
|
23
|
+
_to_kebab_case,
|
|
24
|
+
collect_module_js_deps,
|
|
25
|
+
create_module,
|
|
26
|
+
repo_root_from_client_app,
|
|
27
|
+
write_module_pages_manifest,
|
|
28
|
+
)
|
|
29
|
+
from simple_module_hosting.scaffolding import (
|
|
30
|
+
create_host as _create_host,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group()
|
|
35
|
+
def main() -> None:
|
|
36
|
+
"""SimpleModule developer CLI."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@main.command("new")
|
|
40
|
+
@click.argument("name")
|
|
41
|
+
@click.option(
|
|
42
|
+
"--dest",
|
|
43
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
44
|
+
default=None,
|
|
45
|
+
help="Destination directory. Defaults to ./<name>.",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--db",
|
|
49
|
+
type=click.Choice(["sqlite", "postgres"]),
|
|
50
|
+
default="sqlite",
|
|
51
|
+
show_default=True,
|
|
52
|
+
help="Database backend to configure in .env.example.",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--tenancy/--no-tenancy",
|
|
56
|
+
default=False,
|
|
57
|
+
show_default=True,
|
|
58
|
+
help="Enable the multi-tenant middleware by default.",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--yes",
|
|
62
|
+
"-y",
|
|
63
|
+
is_flag=True,
|
|
64
|
+
default=False,
|
|
65
|
+
help="Skip interactive prompts; accept all defaults.",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--no-install",
|
|
69
|
+
is_flag=True,
|
|
70
|
+
default=False,
|
|
71
|
+
help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.",
|
|
72
|
+
)
|
|
73
|
+
def new_project(
|
|
74
|
+
name: str,
|
|
75
|
+
dest: Path | None,
|
|
76
|
+
db: str,
|
|
77
|
+
tenancy: bool,
|
|
78
|
+
yes: bool,
|
|
79
|
+
no_install: bool,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Scaffold a new SimpleModule app — pre-wired with users, dashboard, permissions."""
|
|
82
|
+
target = dest or Path.cwd() / name
|
|
83
|
+
if not yes:
|
|
84
|
+
db = click.prompt(
|
|
85
|
+
"Database backend",
|
|
86
|
+
default=db,
|
|
87
|
+
type=click.Choice(["sqlite", "postgres"]),
|
|
88
|
+
)
|
|
89
|
+
tenancy = click.confirm("Enable multi-tenancy?", default=tenancy)
|
|
90
|
+
|
|
91
|
+
from simple_module_hosting.scaffolding import create_app_project
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
create_app_project(target, name=name, db=db, tenancy=tenancy)
|
|
95
|
+
except FileExistsError as exc:
|
|
96
|
+
click.echo(f"ERROR: {exc}", err=True)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
click.echo(f"Created app '{name}' at {target}")
|
|
100
|
+
click.echo("\nPre-wired modules: users, dashboard, permissions")
|
|
101
|
+
click.echo("\nNext steps:")
|
|
102
|
+
click.echo(f" cd {target}")
|
|
103
|
+
if no_install:
|
|
104
|
+
click.echo(" uv sync")
|
|
105
|
+
click.echo(" npm install")
|
|
106
|
+
click.echo(" alembic upgrade head")
|
|
107
|
+
click.echo(" make dev")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
click.echo("Installing dependencies...")
|
|
111
|
+
for cmd in (["uv", "sync"], ["npm", "install"]):
|
|
112
|
+
result = subprocess.run(cmd, cwd=target, check=False)
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
click.echo(
|
|
115
|
+
f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); "
|
|
116
|
+
f"finish setup manually.",
|
|
117
|
+
err=True,
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False)
|
|
122
|
+
click.echo("\nSetup complete. Run `make dev` in the new directory.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@main.command("create-host")
|
|
126
|
+
@click.argument("name")
|
|
127
|
+
@click.option(
|
|
128
|
+
"--dest",
|
|
129
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
130
|
+
default=None,
|
|
131
|
+
help="Destination directory. Defaults to ./<name>.",
|
|
132
|
+
)
|
|
133
|
+
@click.option(
|
|
134
|
+
"--with",
|
|
135
|
+
"modules",
|
|
136
|
+
default="",
|
|
137
|
+
help="Comma-separated module names to declare as deps (e.g. --with=Auth,Products).",
|
|
138
|
+
)
|
|
139
|
+
def create_host(name: str, dest: Path | None, modules: str) -> None:
|
|
140
|
+
"""Scaffold a new SimpleModule host project at ./<NAME>."""
|
|
141
|
+
target = dest or Path.cwd() / name
|
|
142
|
+
selected = [m.strip() for m in modules.split(",") if m.strip()]
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
_create_host(target, name=name, modules=selected)
|
|
146
|
+
except FileExistsError as exc:
|
|
147
|
+
click.echo(f"ERROR: {exc}", err=True)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
click.echo(f"Created host '{name}' at {target}")
|
|
151
|
+
if selected:
|
|
152
|
+
click.echo(f"Declared modules: {', '.join(selected)}")
|
|
153
|
+
click.echo("\nNext steps:")
|
|
154
|
+
click.echo(f" cd {target}")
|
|
155
|
+
click.echo(" uv sync")
|
|
156
|
+
click.echo(" cp .env.example .env")
|
|
157
|
+
click.echo(' alembic revision --autogenerate -m "initial schema"')
|
|
158
|
+
click.echo(" alembic upgrade head")
|
|
159
|
+
click.echo(" python main.py")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@main.command("create-module")
|
|
163
|
+
@click.argument("name")
|
|
164
|
+
@click.option(
|
|
165
|
+
"--dest",
|
|
166
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
167
|
+
default=None,
|
|
168
|
+
help="Destination directory. Defaults to ./simple_module_<package>.",
|
|
169
|
+
)
|
|
170
|
+
def create_module_cmd(name: str, dest: Path | None) -> None:
|
|
171
|
+
"""Scaffold a publishable SimpleModule module package."""
|
|
172
|
+
slug = _to_kebab_case(name)
|
|
173
|
+
package = slug.replace("-", "_")
|
|
174
|
+
target = dest or Path.cwd() / f"simple_module_{package}"
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
create_module(target, name=name)
|
|
178
|
+
except FileExistsError as exc:
|
|
179
|
+
click.echo(f"ERROR: {exc}", err=True)
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
click.echo(f"Created module 'simple_module_{package}' at {target}")
|
|
183
|
+
click.echo("\nNext steps:")
|
|
184
|
+
click.echo(f" cd {target}")
|
|
185
|
+
click.echo(" uv sync --extra dev")
|
|
186
|
+
click.echo(" uv run pytest")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@main.command("gen-pages")
|
|
190
|
+
@click.option(
|
|
191
|
+
"--host-dir",
|
|
192
|
+
type=click.Path(file_okay=False, exists=True, path_type=Path),
|
|
193
|
+
default=None,
|
|
194
|
+
help="Path to the host's client_app directory. Defaults to ./client_app.",
|
|
195
|
+
)
|
|
196
|
+
def gen_pages(host_dir: Path | None) -> None:
|
|
197
|
+
"""Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}."""
|
|
198
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
199
|
+
output = host_dir or Path.cwd() / "client_app"
|
|
200
|
+
if not output.is_dir():
|
|
201
|
+
click.echo(f"ERROR: client_app directory not found at {output}", err=True)
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
modules = discover_modules()
|
|
205
|
+
written = write_module_pages_manifest(modules, output)
|
|
206
|
+
click.echo(
|
|
207
|
+
f"Wrote {written['manifest'].name}, {written['generated'].name}, "
|
|
208
|
+
f"{written['css'].name} to {output}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@main.command("sync-js-deps")
|
|
213
|
+
@click.option(
|
|
214
|
+
"--host-client-app",
|
|
215
|
+
type=click.Path(file_okay=False, exists=True, path_type=Path),
|
|
216
|
+
default=None,
|
|
217
|
+
help="Path to host/client_app. Defaults to ./client_app.",
|
|
218
|
+
)
|
|
219
|
+
@click.option(
|
|
220
|
+
"--dry-run",
|
|
221
|
+
is_flag=True,
|
|
222
|
+
default=False,
|
|
223
|
+
help="Print the npm install command without running it.",
|
|
224
|
+
)
|
|
225
|
+
def sync_js_deps(host_client_app: Path | None, dry_run: bool) -> None:
|
|
226
|
+
"""Install JS deps declared by installed modules into host's node_modules.
|
|
227
|
+
|
|
228
|
+
Walks every discovered module, reads its package.json, and runs a single
|
|
229
|
+
``npm install --workspace host/client_app --save=false <specs>``. Use
|
|
230
|
+
this after ``pip install``-ing a module wheel that declares JS deps;
|
|
231
|
+
in-repo modules already flow through npm workspaces and need nothing.
|
|
232
|
+
"""
|
|
233
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
234
|
+
|
|
235
|
+
output = host_client_app or Path.cwd() / "client_app"
|
|
236
|
+
if not output.is_dir():
|
|
237
|
+
click.echo(f"ERROR: client_app directory not found at {output}", err=True)
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
modules = discover_modules()
|
|
241
|
+
by_module = collect_module_js_deps(modules)
|
|
242
|
+
if not by_module:
|
|
243
|
+
click.echo("No module JS dependencies declared.")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
# Flatten into a single spec list. npm's own resolver handles conflicts.
|
|
247
|
+
specs: list[str] = []
|
|
248
|
+
for mod_name in sorted(by_module):
|
|
249
|
+
for dep, rng in sorted(by_module[mod_name].items()):
|
|
250
|
+
specs.append(f"{dep}@{rng}")
|
|
251
|
+
# Dedupe while preserving first-seen order.
|
|
252
|
+
deduped: list[str] = []
|
|
253
|
+
seen: set[str] = set()
|
|
254
|
+
for spec in specs:
|
|
255
|
+
if spec not in seen:
|
|
256
|
+
seen.add(spec)
|
|
257
|
+
deduped.append(spec)
|
|
258
|
+
|
|
259
|
+
npm = shutil.which("npm")
|
|
260
|
+
if npm is None:
|
|
261
|
+
click.echo("ERROR: npm not found on PATH.", err=True)
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
# Workspace path is relative to the repo root — derive it from output.
|
|
265
|
+
repo_root = repo_root_from_client_app(output)
|
|
266
|
+
try:
|
|
267
|
+
workspace = str(output.resolve().relative_to(repo_root))
|
|
268
|
+
except ValueError:
|
|
269
|
+
workspace = str(output.resolve())
|
|
270
|
+
|
|
271
|
+
cmd = [
|
|
272
|
+
npm,
|
|
273
|
+
"install",
|
|
274
|
+
"--workspace",
|
|
275
|
+
workspace,
|
|
276
|
+
"--save=false",
|
|
277
|
+
"--no-audit",
|
|
278
|
+
"--no-fund",
|
|
279
|
+
*deduped,
|
|
280
|
+
]
|
|
281
|
+
click.echo("Installing module JS deps:")
|
|
282
|
+
for spec in deduped:
|
|
283
|
+
click.echo(f" {spec}")
|
|
284
|
+
if dry_run:
|
|
285
|
+
click.echo("(dry-run) " + " ".join(cmd))
|
|
286
|
+
return
|
|
287
|
+
result = subprocess.run(cmd, cwd=repo_root, check=False)
|
|
288
|
+
sys.exit(result.returncode)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
main()
|