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,45 @@
|
|
|
1
|
+
"""Shared helpers for validating redirect targets.
|
|
2
|
+
|
|
3
|
+
A raw ``Referer`` header is attacker-controlled — a crafted form on a third
|
|
4
|
+
party site can set it to any value. Any endpoint that 303s back to the
|
|
5
|
+
referring page must validate the URL is same-origin before trusting it, or
|
|
6
|
+
become a reflected open-redirect.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from fastapi import Request
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def safe_referer_or_root(request: Request) -> str:
|
|
17
|
+
"""Return the Referer iff it's same-origin; otherwise fall back to ``/``.
|
|
18
|
+
|
|
19
|
+
Only honors references that (a) resolve to the same scheme+host as the
|
|
20
|
+
current request, or (b) are relative paths that don't try to escape to a
|
|
21
|
+
protocol-relative URL (``//evil.example``).
|
|
22
|
+
"""
|
|
23
|
+
referer = request.headers.get("referer")
|
|
24
|
+
if not referer:
|
|
25
|
+
return "/"
|
|
26
|
+
|
|
27
|
+
# Protocol-relative URLs like "//evil.example/foo" resolve against the
|
|
28
|
+
# origin in browsers but leave the site — reject them.
|
|
29
|
+
if referer.startswith("//"):
|
|
30
|
+
return "/"
|
|
31
|
+
|
|
32
|
+
parsed = urlsplit(referer)
|
|
33
|
+
# Relative path with no scheme+host → same-origin by construction.
|
|
34
|
+
if not parsed.scheme and not parsed.netloc:
|
|
35
|
+
return referer if referer.startswith("/") else "/"
|
|
36
|
+
|
|
37
|
+
# Absolute URL → must match the current request's origin.
|
|
38
|
+
current = request.url
|
|
39
|
+
if parsed.scheme == current.scheme and parsed.netloc == current.netloc:
|
|
40
|
+
path = parsed.path or "/"
|
|
41
|
+
if parsed.query:
|
|
42
|
+
path = f"{path}?{parsed.query}"
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
return "/"
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Host + module scaffolding via package-data templates.
|
|
2
|
+
|
|
3
|
+
* :func:`create_host` materializes a new host project from the templates
|
|
4
|
+
under ``simple_module_hosting/templates/host/``.
|
|
5
|
+
* :func:`create_module` materializes a new module package from
|
|
6
|
+
``simple_module_hosting/templates/module/``.
|
|
7
|
+
|
|
8
|
+
The frontend pages manifest + per-module JS dep discovery used to live
|
|
9
|
+
here as well; both moved to :mod:`simple_module_hosting.manifest` to
|
|
10
|
+
keep this file under the project's per-file line cap. They're re-exported
|
|
11
|
+
below so existing import sites keep working.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib.resources
|
|
17
|
+
import json as _json
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
import secrets as _secrets
|
|
21
|
+
import shutil
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from simple_module_hosting.manifest import (
|
|
26
|
+
collect_module_js_deps,
|
|
27
|
+
compute_module_pages,
|
|
28
|
+
read_module_package_json,
|
|
29
|
+
repo_root_from_client_app,
|
|
30
|
+
write_module_pages_manifest,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"collect_module_js_deps",
|
|
35
|
+
"compute_module_pages",
|
|
36
|
+
"create_app_project",
|
|
37
|
+
"create_host",
|
|
38
|
+
"create_module",
|
|
39
|
+
"read_module_package_json",
|
|
40
|
+
"repo_root_from_client_app",
|
|
41
|
+
"write_module_pages_manifest",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# Templates ship as package data under simple_module_hosting/templates/{host,module}/.
|
|
47
|
+
_TEMPLATES_PACKAGE = "simple_module_hosting.templates"
|
|
48
|
+
|
|
49
|
+
# Path-segment substitution token used by create_module.
|
|
50
|
+
_PACKAGE_PATH_TOKEN = "__PACKAGE__"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _module_to_pypi_name(name: str) -> str:
|
|
54
|
+
"""'Products' -> 'simple_module_products'. Matches the publishing convention."""
|
|
55
|
+
return f"simple_module_{name.lower()}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _iter_template_files(template_root: Path):
|
|
59
|
+
"""Yield every file under ``template_root``, preserving relative paths."""
|
|
60
|
+
for path in template_root.rglob("*"):
|
|
61
|
+
if path.is_file():
|
|
62
|
+
yield path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _require_empty_dest(dest: Path) -> None:
|
|
66
|
+
"""Raise if ``dest`` is an existing non-empty directory — never clobber files."""
|
|
67
|
+
if dest.exists() and any(dest.iterdir()):
|
|
68
|
+
raise FileExistsError(
|
|
69
|
+
f"Destination {dest} already exists and is non-empty. "
|
|
70
|
+
"Choose a new path or remove the contents first."
|
|
71
|
+
)
|
|
72
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_template_root(subdir: str, override: Path | None) -> Path:
|
|
76
|
+
"""Return the scaffold template root, either from package data or an override."""
|
|
77
|
+
if override is not None:
|
|
78
|
+
return Path(override)
|
|
79
|
+
return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _apply_template_files(
|
|
83
|
+
src_root: Path,
|
|
84
|
+
dest: Path,
|
|
85
|
+
substitutions: Mapping[str, str],
|
|
86
|
+
*,
|
|
87
|
+
path_rewrites: Mapping[str, str] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Copy every file under ``src_root`` to ``dest``, applying substitutions.
|
|
90
|
+
|
|
91
|
+
Files ending in ``.tpl`` are read as text, placeholders replaced, and
|
|
92
|
+
written without the suffix. Every other file is copied verbatim. If
|
|
93
|
+
``path_rewrites`` is given, each key is replaced by its value anywhere
|
|
94
|
+
in relative paths (used by :func:`create_module` to rename the
|
|
95
|
+
``__PACKAGE__`` directory placeholder).
|
|
96
|
+
"""
|
|
97
|
+
for src in _iter_template_files(src_root):
|
|
98
|
+
rel_str = str(src.relative_to(src_root))
|
|
99
|
+
for old, new in (path_rewrites or {}).items():
|
|
100
|
+
rel_str = rel_str.replace(old, new)
|
|
101
|
+
rel_str = rel_str.removesuffix(".tpl")
|
|
102
|
+
target = dest / rel_str
|
|
103
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
if src.suffix == ".tpl":
|
|
106
|
+
text = src.read_text(encoding="utf-8")
|
|
107
|
+
for placeholder, value in substitutions.items():
|
|
108
|
+
text = text.replace(placeholder, value)
|
|
109
|
+
target.write_text(text, encoding="utf-8")
|
|
110
|
+
else:
|
|
111
|
+
shutil.copy2(src, target)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_host(
|
|
115
|
+
dest: Path,
|
|
116
|
+
name: str,
|
|
117
|
+
modules: Sequence[str],
|
|
118
|
+
template_root: Path | None = None,
|
|
119
|
+
) -> Path:
|
|
120
|
+
"""Materialize a SimpleModule host scaffold at ``dest``.
|
|
121
|
+
|
|
122
|
+
Modules listed in ``modules`` become PyPI dependencies in the scaffolded
|
|
123
|
+
``pyproject.toml`` (e.g. ``"simple_module_products>=0.1,<1.0"``). Raises
|
|
124
|
+
:class:`FileExistsError` if ``dest`` is an existing non-empty directory.
|
|
125
|
+
"""
|
|
126
|
+
dest = Path(dest)
|
|
127
|
+
_require_empty_dest(dest)
|
|
128
|
+
|
|
129
|
+
module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules)
|
|
130
|
+
_apply_template_files(
|
|
131
|
+
_resolve_template_root("host", template_root),
|
|
132
|
+
dest,
|
|
133
|
+
{"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
logger.info(
|
|
137
|
+
"Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "<none>"
|
|
138
|
+
)
|
|
139
|
+
return dest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _to_snake_case(name: str) -> str:
|
|
143
|
+
"""'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'."""
|
|
144
|
+
s = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
|
|
145
|
+
s = re.sub(r"[\s\-]+", "_", s)
|
|
146
|
+
return s.lower()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _to_kebab_case(name: str) -> str:
|
|
150
|
+
"""'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug)."""
|
|
151
|
+
return _to_snake_case(name).replace("_", "-")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _to_pascal_case(name: str) -> str:
|
|
155
|
+
"""'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta)."""
|
|
156
|
+
snake = _to_snake_case(name)
|
|
157
|
+
return "".join(part.capitalize() for part in snake.split("_") if part)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def create_module(
|
|
161
|
+
dest: Path,
|
|
162
|
+
name: str,
|
|
163
|
+
template_root: Path | None = None,
|
|
164
|
+
) -> Path:
|
|
165
|
+
"""Materialize a publishable module package at ``dest``.
|
|
166
|
+
|
|
167
|
+
``name`` is accepted in any case style (``MyFeature``, ``my-feature``,
|
|
168
|
+
``my_feature``) and normalized to three forms:
|
|
169
|
+
|
|
170
|
+
* ``MODULE_NAME`` — ``PascalCase``, appears in ``Meta(name=...)``
|
|
171
|
+
* ``MODULE_SLUG`` — ``kebab-case``, used in the PyPI distribution name
|
|
172
|
+
* ``PACKAGE_NAME`` — ``snake_case``, the importable Python package and
|
|
173
|
+
the entry_point key
|
|
174
|
+
"""
|
|
175
|
+
dest = Path(dest)
|
|
176
|
+
_require_empty_dest(dest)
|
|
177
|
+
|
|
178
|
+
display_name = _to_pascal_case(name)
|
|
179
|
+
slug = _to_kebab_case(name)
|
|
180
|
+
package_name = _to_snake_case(name)
|
|
181
|
+
|
|
182
|
+
_apply_template_files(
|
|
183
|
+
_resolve_template_root("module", template_root),
|
|
184
|
+
dest,
|
|
185
|
+
substitutions={
|
|
186
|
+
"{{MODULE_NAME}}": display_name,
|
|
187
|
+
"{{MODULE_SLUG}}": slug,
|
|
188
|
+
"{{PACKAGE_NAME}}": package_name,
|
|
189
|
+
},
|
|
190
|
+
path_rewrites={_PACKAGE_PATH_TOKEN: package_name},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name)
|
|
194
|
+
return dest
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------
|
|
198
|
+
# create_app_project — used by `sm new` / `simple-module new`
|
|
199
|
+
# ---------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
_FRAMEWORK_VERSION = "0.0.1"
|
|
202
|
+
|
|
203
|
+
_APP_PY_DEPS = [
|
|
204
|
+
f"simple_module_hosting=={_FRAMEWORK_VERSION}",
|
|
205
|
+
f"simple_module_users=={_FRAMEWORK_VERSION}",
|
|
206
|
+
f"simple_module_dashboard=={_FRAMEWORK_VERSION}",
|
|
207
|
+
f"simple_module_permissions=={_FRAMEWORK_VERSION}",
|
|
208
|
+
]
|
|
209
|
+
_APP_PY_DEV_DEPS = [f"simple_module_testing=={_FRAMEWORK_VERSION}", "pytest>=8.0"]
|
|
210
|
+
|
|
211
|
+
_APP_NPM_DEPS = {
|
|
212
|
+
"@simple-module-py/ui": _FRAMEWORK_VERSION,
|
|
213
|
+
"@simple-module-py/i18n": _FRAMEWORK_VERSION,
|
|
214
|
+
"react": "^19.0.0",
|
|
215
|
+
"react-dom": "^19.0.0",
|
|
216
|
+
"@inertiajs/react": "^1.0.0",
|
|
217
|
+
}
|
|
218
|
+
_APP_NPM_DEV_DEPS = {
|
|
219
|
+
"@simple-module-py/tsconfig": _FRAMEWORK_VERSION,
|
|
220
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
221
|
+
"typescript": "^5.6.0",
|
|
222
|
+
"vite": "^8.0.0",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def create_app_project(
|
|
227
|
+
target: Path,
|
|
228
|
+
*,
|
|
229
|
+
name: str,
|
|
230
|
+
db: str = "sqlite",
|
|
231
|
+
tenancy: bool = False,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Greenfield ``simple-module new`` scaffold.
|
|
234
|
+
|
|
235
|
+
Wraps :func:`create_host` with opinionated pre-wired modules (users +
|
|
236
|
+
dashboard + permissions), generates a secret, picks a DB URL, and rewrites
|
|
237
|
+
the generated package.json / pyproject.toml to pin exact framework
|
|
238
|
+
versions.
|
|
239
|
+
"""
|
|
240
|
+
if target.exists() and any(target.iterdir()):
|
|
241
|
+
raise FileExistsError(
|
|
242
|
+
f"Destination {target} already exists and is non-empty; "
|
|
243
|
+
"choose a new path or remove its contents first."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
create_host(target, name=name, modules=["users", "dashboard", "permissions"])
|
|
247
|
+
|
|
248
|
+
env_path = target / ".env.example"
|
|
249
|
+
env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
250
|
+
env_text = _set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
|
|
251
|
+
env_text = _set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, _to_kebab_case(name)))
|
|
252
|
+
env_text = _set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
|
|
253
|
+
env_path.write_text(env_text, encoding="utf-8")
|
|
254
|
+
|
|
255
|
+
pyproject = target / "pyproject.toml"
|
|
256
|
+
if pyproject.exists():
|
|
257
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
258
|
+
text = _inject_py_deps(text, _APP_PY_DEPS, _APP_PY_DEV_DEPS)
|
|
259
|
+
pyproject.write_text(text, encoding="utf-8")
|
|
260
|
+
|
|
261
|
+
pkg_path = target / "package.json"
|
|
262
|
+
if pkg_path.exists():
|
|
263
|
+
data = _json.loads(pkg_path.read_text(encoding="utf-8"))
|
|
264
|
+
else:
|
|
265
|
+
data = {"name": _to_kebab_case(name), "private": True, "type": "module"}
|
|
266
|
+
data.setdefault("dependencies", {}).update(_APP_NPM_DEPS)
|
|
267
|
+
data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS)
|
|
268
|
+
pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _set_env_key(text: str, key: str, value: str) -> str:
|
|
272
|
+
lines = text.splitlines()
|
|
273
|
+
prefix = f"{key}="
|
|
274
|
+
out = [ln for ln in lines if not ln.startswith(prefix)]
|
|
275
|
+
out.append(f"{key}={value}")
|
|
276
|
+
return "\n".join(out) + "\n"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _db_url(db: str, slug: str) -> str:
|
|
280
|
+
if db == "postgres":
|
|
281
|
+
return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
|
|
282
|
+
return "sqlite+aiosqlite:///./app.db"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str:
|
|
286
|
+
"""Replace project.dependencies + dependency-groups.dev in a pyproject.toml."""
|
|
287
|
+
import tomlkit
|
|
288
|
+
|
|
289
|
+
doc = tomlkit.parse(text)
|
|
290
|
+
project = doc.setdefault("project", tomlkit.table())
|
|
291
|
+
project["dependencies"] = list(deps)
|
|
292
|
+
groups = doc.setdefault("dependency-groups", tomlkit.table())
|
|
293
|
+
groups["dev"] = list(dev_deps)
|
|
294
|
+
return tomlkit.dumps(doc)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Back-compat shim — prefer BootstrapSettings + HostSettings directly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from simple_module_hosting.bootstrap_settings import BootstrapSettings
|
|
6
|
+
from simple_module_hosting.host_settings import HostSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(HostSettings, BootstrapSettings):
|
|
10
|
+
"""Combined bootstrap + host settings for legacy import sites."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Database — defaults to a local SQLite file.
|
|
2
|
+
# For PostgreSQL use: postgresql+asyncpg://user:pass@host:5432/dbname
|
|
3
|
+
SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
|
|
4
|
+
|
|
5
|
+
# Environment: development | production
|
|
6
|
+
SM_ENVIRONMENT=development
|
|
7
|
+
|
|
8
|
+
# Secret key for session middleware — change before deploying.
|
|
9
|
+
SM_SECRET_KEY=change-me-in-production
|
|
10
|
+
|
|
11
|
+
# Vite dev server URL (only used in development).
|
|
12
|
+
SM_VITE_DEV_URL=http://localhost:5050
|
|
13
|
+
|
|
14
|
+
# Optional: JSON array to restrict which installed modules load at boot.
|
|
15
|
+
# SM_MODULES_ENABLED=["Auth","Products"]
|
|
16
|
+
|
|
17
|
+
# First-boot admin seed (optional). Only applied when the users table is empty.
|
|
18
|
+
# Leave unset and use `uv run sm-users create-admin` instead if you prefer.
|
|
19
|
+
# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
|
|
20
|
+
# SM_USERS_BOOTSTRAP_PASSWORD=changeme
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.venv/
|
|
5
|
+
|
|
6
|
+
uv.lock
|
|
7
|
+
|
|
8
|
+
node_modules/
|
|
9
|
+
|
|
10
|
+
.env
|
|
11
|
+
|
|
12
|
+
*.db
|
|
13
|
+
*.sqlite3
|
|
14
|
+
|
|
15
|
+
static/dist/
|
|
16
|
+
|
|
17
|
+
# Auto-generated by the host at boot / `sm gen-pages`.
|
|
18
|
+
client_app/modules.manifest.json
|
|
19
|
+
client_app/modules.generated.ts
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.PHONY: install dev dev-api dev-ui build migrate gen-pages
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
uv sync
|
|
5
|
+
cd client_app && npm install
|
|
6
|
+
|
|
7
|
+
dev: gen-pages
|
|
8
|
+
@echo "Starting API and UI dev servers..."
|
|
9
|
+
$(MAKE) -j2 dev-api dev-ui
|
|
10
|
+
|
|
11
|
+
dev-api:
|
|
12
|
+
uv run uvicorn main:app --reload --port 8000
|
|
13
|
+
|
|
14
|
+
dev-ui:
|
|
15
|
+
cd client_app && npm run dev
|
|
16
|
+
|
|
17
|
+
build:
|
|
18
|
+
cd client_app && npm run build
|
|
19
|
+
|
|
20
|
+
migrate:
|
|
21
|
+
uv run alembic upgrade head
|
|
22
|
+
|
|
23
|
+
gen-pages:
|
|
24
|
+
uv run sm gen-pages --host-dir=client_app
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# {{HOST_NAME}}
|
|
2
|
+
|
|
3
|
+
A SimpleModule host application, generated by `sm create-host`.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install Python deps (framework + any modules listed in pyproject.toml)
|
|
9
|
+
uv sync
|
|
10
|
+
|
|
11
|
+
# Copy example env and adjust
|
|
12
|
+
cp .env.example .env
|
|
13
|
+
|
|
14
|
+
# First time only — initialize the migration history for the modules you
|
|
15
|
+
# picked. Inspect the generated file, then apply it.
|
|
16
|
+
alembic revision --autogenerate -m "initial schema"
|
|
17
|
+
alembic upgrade head
|
|
18
|
+
|
|
19
|
+
# Run the API
|
|
20
|
+
python main.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Dev UI + API together (when you have a `client_app/` alongside this file):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
make dev # if you ship a Makefile; otherwise run vite + uvicorn in two shells
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Adding a module
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1) install the module package (e.g. from PyPI)
|
|
33
|
+
uv add simple_module_my_module
|
|
34
|
+
|
|
35
|
+
# 2) generate & apply the migration (new tables + any schema changes)
|
|
36
|
+
alembic revision --autogenerate -m "add my-module"
|
|
37
|
+
alembic upgrade head
|
|
38
|
+
|
|
39
|
+
# 3) restart the host — the module's routes, menu items, permissions,
|
|
40
|
+
# feature flags, events, and health checks register automatically
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Disabling a module without uninstalling it
|
|
44
|
+
|
|
45
|
+
Set `SM_MODULES_ENABLED` to a JSON array of the modules you want loaded:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
SM_MODULES_ENABLED=["Auth","Products"]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Unlisted modules stay installed (so the existing DB schema is untouched)
|
|
52
|
+
but don't contribute any routes, registries, or lifecycle work.
|
|
53
|
+
|
|
54
|
+
## Upgrading the framework
|
|
55
|
+
|
|
56
|
+
Modules declare `requires_framework` in their `Meta`. If a module can't
|
|
57
|
+
work with your installed `simple_module_core` version, the host refuses
|
|
58
|
+
to boot with `FrameworkVersionError` listing the offending modules.
|
|
59
|
+
Upgrade or pin as appropriate.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
sqlalchemy.url =
|
|
4
|
+
|
|
5
|
+
[loggers]
|
|
6
|
+
keys = root,sqlalchemy,alembic
|
|
7
|
+
|
|
8
|
+
[handlers]
|
|
9
|
+
keys = console
|
|
10
|
+
|
|
11
|
+
[formatters]
|
|
12
|
+
keys = generic
|
|
13
|
+
|
|
14
|
+
[logger_root]
|
|
15
|
+
level = WARN
|
|
16
|
+
handlers = console
|
|
17
|
+
|
|
18
|
+
[logger_sqlalchemy]
|
|
19
|
+
level = WARN
|
|
20
|
+
handlers =
|
|
21
|
+
qualname = sqlalchemy.engine
|
|
22
|
+
|
|
23
|
+
[logger_alembic]
|
|
24
|
+
level = INFO
|
|
25
|
+
handlers =
|
|
26
|
+
qualname = alembic
|
|
27
|
+
|
|
28
|
+
[handler_console]
|
|
29
|
+
class = StreamHandler
|
|
30
|
+
args = (sys.stderr,)
|
|
31
|
+
level = NOTSET
|
|
32
|
+
formatter = generic
|
|
33
|
+
|
|
34
|
+
[formatter_generic]
|
|
35
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
36
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createInertiaApp } from '@inertiajs/react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { resolvePage } from './pages';
|
|
4
|
+
|
|
5
|
+
createInertiaApp({
|
|
6
|
+
resolve: async (name) => {
|
|
7
|
+
return await resolvePage(name);
|
|
8
|
+
},
|
|
9
|
+
setup({ el, App, props }) {
|
|
10
|
+
createRoot(el).render(<App {...props} />);
|
|
11
|
+
},
|
|
12
|
+
progress: {
|
|
13
|
+
color: '#4B5563',
|
|
14
|
+
delay: 150,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{HOST_NAME}}-client-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "tsc && vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@inertiajs/react": "^2.0.0",
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.0.0",
|
|
17
|
+
"@types/react": "^19.0.0",
|
|
18
|
+
"@types/react-dom": "^19.0.0",
|
|
19
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
20
|
+
"typescript": "^5.7.0",
|
|
21
|
+
"vite": "^6.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type ErrorProps = {
|
|
2
|
+
status: number;
|
|
3
|
+
message?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export default function Error({ status, message }: ErrorProps) {
|
|
7
|
+
return (
|
|
8
|
+
<main style={{ padding: '2rem', maxWidth: '40rem', margin: '0 auto' }}>
|
|
9
|
+
<h1>{status}</h1>
|
|
10
|
+
<p>{message || 'Something went wrong.'}</p>
|
|
11
|
+
</main>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inertia page resolver.
|
|
3
|
+
*
|
|
4
|
+
* Module pages are discovered via a generated file (modules.generated.ts)
|
|
5
|
+
* emitted by the Python host at boot, or manually via `sm gen-pages`. Each
|
|
6
|
+
* installed module contributes an import.meta.glob() call with an absolute
|
|
7
|
+
* path, so pages shipped inside pip-installed module wheels resolve.
|
|
8
|
+
*
|
|
9
|
+
* Host-level pages live in client_app/pages/{PageName}.tsx and are
|
|
10
|
+
* registered under just "{PageName}" (e.g. "Error").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { moduleGlobs } from './modules.generated';
|
|
14
|
+
|
|
15
|
+
type PageModule = { default: React.ComponentType<Record<string, unknown>> };
|
|
16
|
+
type PageLoader = () => Promise<PageModule>;
|
|
17
|
+
|
|
18
|
+
const hostPages = import.meta.glob<PageModule>('./pages/*.tsx');
|
|
19
|
+
|
|
20
|
+
const pages: Record<string, PageLoader> = {};
|
|
21
|
+
|
|
22
|
+
for (const [moduleName, globEntries] of Object.entries(moduleGlobs)) {
|
|
23
|
+
for (const [filePath, loader] of Object.entries(globEntries)) {
|
|
24
|
+
const match = filePath.match(/\/pages\/(\w+)\.tsx$/);
|
|
25
|
+
if (match) {
|
|
26
|
+
pages[`${moduleName}/${match[1]}`] = loader;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const [filePath, loader] of Object.entries(hostPages)) {
|
|
32
|
+
const match = filePath.match(/\.\/pages\/(\w+)\.tsx$/);
|
|
33
|
+
if (match) {
|
|
34
|
+
pages[match[1]] = loader;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolvePage(
|
|
39
|
+
name: string,
|
|
40
|
+
): Promise<React.ComponentType<Record<string, unknown>>> {
|
|
41
|
+
const loader = pages[name];
|
|
42
|
+
if (!loader) {
|
|
43
|
+
throw new Error(`Page "${name}" not found. Available pages: ${Object.keys(pages).join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
const module = await loader();
|
|
46
|
+
return module.default;
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"types": ["vite/client", "node"],
|
|
13
|
+
"allowImportingTsExtensions": false
|
|
14
|
+
},
|
|
15
|
+
"include": ["**/*.ts", "**/*.tsx"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import { defineConfig } from 'vite';
|
|
5
|
+
|
|
6
|
+
const projectRoot = path.resolve(__dirname, '..');
|
|
7
|
+
|
|
8
|
+
// Load the module pages manifest written by the Python host at boot.
|
|
9
|
+
// Each entry points at an absolute pages/ directory — typically inside a
|
|
10
|
+
// pip-installed module wheel. Vite needs these in server.fs.allow so the
|
|
11
|
+
// dev server can read files outside the host root.
|
|
12
|
+
const manifestPath = path.resolve(__dirname, 'modules.manifest.json');
|
|
13
|
+
const moduleFsAllow: string[] = [];
|
|
14
|
+
if (fs.existsSync(manifestPath)) {
|
|
15
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, string>;
|
|
16
|
+
for (const pagesDir of Object.values(manifest)) {
|
|
17
|
+
moduleFsAllow.push(path.dirname(pagesDir));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
plugins: [react()],
|
|
23
|
+
root: __dirname,
|
|
24
|
+
build: {
|
|
25
|
+
outDir: '../static/dist',
|
|
26
|
+
manifest: true,
|
|
27
|
+
rollupOptions: {
|
|
28
|
+
input: path.resolve(__dirname, 'main.tsx'),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
server: {
|
|
32
|
+
port: 5050,
|
|
33
|
+
strictPort: true,
|
|
34
|
+
origin: 'http://localhost:5050',
|
|
35
|
+
fs: {
|
|
36
|
+
allow: [projectRoot, ...moduleFsAllow],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|