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.
Files changed (65) hide show
  1. simple_module_hosting/__init__.py +7 -0
  2. simple_module_hosting/_error_handlers.py +54 -0
  3. simple_module_hosting/_hydrate_step.py +39 -0
  4. simple_module_hosting/_inertia_setup.py +73 -0
  5. simple_module_hosting/_inertia_shared.py +61 -0
  6. simple_module_hosting/_observability.py +108 -0
  7. simple_module_hosting/_phase_helpers.py +160 -0
  8. simple_module_hosting/app_builder.py +281 -0
  9. simple_module_hosting/bootstrap_settings.py +55 -0
  10. simple_module_hosting/cli.py +292 -0
  11. simple_module_hosting/health.py +79 -0
  12. simple_module_hosting/host_settings.py +33 -0
  13. simple_module_hosting/i18n_deps.py +25 -0
  14. simple_module_hosting/i18n_manifest.py +202 -0
  15. simple_module_hosting/i18n_middleware.py +95 -0
  16. simple_module_hosting/inertia_deps.py +27 -0
  17. simple_module_hosting/inertia_utils.py +31 -0
  18. simple_module_hosting/logging.py +91 -0
  19. simple_module_hosting/manifest.py +250 -0
  20. simple_module_hosting/middleware.py +272 -0
  21. simple_module_hosting/migrations.py +65 -0
  22. simple_module_hosting/permissions.py +75 -0
  23. simple_module_hosting/py.typed +0 -0
  24. simple_module_hosting/redirects.py +45 -0
  25. simple_module_hosting/scaffolding.py +294 -0
  26. simple_module_hosting/settings.py +10 -0
  27. simple_module_hosting/templates/host/.env.example +20 -0
  28. simple_module_hosting/templates/host/.gitignore +19 -0
  29. simple_module_hosting/templates/host/Makefile +24 -0
  30. simple_module_hosting/templates/host/README.md.tpl +59 -0
  31. simple_module_hosting/templates/host/alembic.ini +36 -0
  32. simple_module_hosting/templates/host/client_app/app.tsx +16 -0
  33. simple_module_hosting/templates/host/client_app/main.tsx +2 -0
  34. simple_module_hosting/templates/host/client_app/package.json.tpl +23 -0
  35. simple_module_hosting/templates/host/client_app/pages/Error.tsx +13 -0
  36. simple_module_hosting/templates/host/client_app/pages.ts +47 -0
  37. simple_module_hosting/templates/host/client_app/styles.css +7 -0
  38. simple_module_hosting/templates/host/client_app/tsconfig.json +16 -0
  39. simple_module_hosting/templates/host/client_app/vite.config.ts +39 -0
  40. simple_module_hosting/templates/host/main.py +27 -0
  41. simple_module_hosting/templates/host/migrations/env.py +80 -0
  42. simple_module_hosting/templates/host/migrations/script.py.mako +26 -0
  43. simple_module_hosting/templates/host/migrations/versions/.gitkeep +1 -0
  44. simple_module_hosting/templates/host/pyproject.toml.tpl +17 -0
  45. simple_module_hosting/templates/host/templates/index.html +12 -0
  46. simple_module_hosting/templates/module/.github/workflows/ci.yml +32 -0
  47. simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl +52 -0
  48. simple_module_hosting/templates/module/.gitignore +14 -0
  49. simple_module_hosting/templates/module/README.md.tpl +82 -0
  50. simple_module_hosting/templates/module/__PACKAGE__/__init__.py +0 -0
  51. simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  52. simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl +11 -0
  53. simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl +46 -0
  54. simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep +1 -0
  55. simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl +22 -0
  56. simple_module_hosting/templates/module/package.json.tpl +16 -0
  57. simple_module_hosting/templates/module/pyproject.toml.tpl +39 -0
  58. simple_module_hosting/templates/module/tests/__init__.py +0 -0
  59. simple_module_hosting/templates/module/tests/test_module.py.tpl +27 -0
  60. simple_module_hosting/templates/module/tsconfig.json.tpl +11 -0
  61. simple_module_hosting-0.0.1.dist-info/METADATA +93 -0
  62. simple_module_hosting-0.0.1.dist-info/RECORD +65 -0
  63. simple_module_hosting-0.0.1.dist-info/WHEEL +4 -0
  64. simple_module_hosting-0.0.1.dist-info/entry_points.txt +3 -0
  65. 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,2 @@
1
+ import './styles.css';
2
+ import './app';
@@ -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,7 @@
1
+ body {
2
+ margin: 0;
3
+ font-family:
4
+ system-ui,
5
+ -apple-system,
6
+ sans-serif;
7
+ }
@@ -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
+ });