simple-module-cli 0.0.8__tar.gz → 0.0.9__tar.gz

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 (88) hide show
  1. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/PKG-INFO +1 -1
  2. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/pyproject.toml +1 -1
  3. simple_module_cli-0.0.9/simple_module_cli/app_project.py +264 -0
  4. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/recipes.py +13 -6
  5. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/scaffolding.py +35 -4
  6. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/Makefile +5 -1
  7. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml +34 -0
  8. simple_module_cli-0.0.9/simple_module_cli/templates/host/_optional/background_tasks/host.Dockerfile +44 -0
  9. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/package.json.tpl +3 -2
  10. simple_module_cli-0.0.9/simple_module_cli/templates/host/client_app/vite.config.ts +152 -0
  11. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -6
  12. simple_module_cli-0.0.9/simple_module_cli/templates/host/templates/index.html +24 -0
  13. simple_module_cli-0.0.9/simple_module_cli/templates/workspace/.env.example +19 -0
  14. simple_module_cli-0.0.9/simple_module_cli/templates/workspace/.gitignore +20 -0
  15. simple_module_cli-0.0.9/simple_module_cli/templates/workspace/Makefile +42 -0
  16. simple_module_cli-0.0.9/simple_module_cli/templates/workspace/README.md.tpl +62 -0
  17. simple_module_cli-0.0.9/simple_module_cli/templates/workspace/package.json.tpl +13 -0
  18. simple_module_cli-0.0.9/simple_module_cli/templates/workspace/pyproject.toml.tpl +17 -0
  19. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_cli_new.py +57 -28
  20. simple_module_cli-0.0.8/simple_module_cli/app_project.py +0 -179
  21. simple_module_cli-0.0.8/simple_module_cli/templates/host/client_app/vite.config.ts +0 -60
  22. simple_module_cli-0.0.8/simple_module_cli/templates/host/templates/index.html +0 -12
  23. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/.gitignore +0 -0
  24. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/LICENSE +0 -0
  25. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/README.md +0 -0
  26. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/__init__.py +0 -0
  27. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/_env.py +0 -0
  28. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/case.py +0 -0
  29. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/catalog.py +0 -0
  30. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/cli.py +0 -0
  31. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/new.py +0 -0
  32. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/package_update.py +0 -0
  33. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/plugins.py +0 -0
  34. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/README.md +0 -0
  35. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  36. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  37. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  38. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  39. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  40. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  41. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  42. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  43. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  44. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  45. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/skills_cmd.py +0 -0
  46. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/.env.example +0 -0
  47. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/.gitignore +0 -0
  48. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/README.md.tpl +0 -0
  49. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  50. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  51. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile +0 -0
  52. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/alembic.ini +0 -0
  53. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  54. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  55. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  56. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  57. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  58. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  59. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/main.py +0 -0
  60. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/migrations/env.py +0 -0
  61. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  62. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  63. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  64. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  65. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/.gitignore +0 -0
  66. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/README.md.tpl +0 -0
  67. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  68. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  69. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  70. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  71. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep +0 -0
  72. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  73. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  74. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/package.json.tpl +0 -0
  75. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  76. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/tests/test_module.py.tpl +0 -0
  77. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  78. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/simple_module_cli/wizard.py +0 -0
  79. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_build_packaging.py +0 -0
  80. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_cli_catalog.py +0 -0
  81. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_cli_package_update.py +0 -0
  82. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_cli_recipes.py +0 -0
  83. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_cli_wizard.py +0 -0
  84. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_no_framework_deps.py +0 -0
  85. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_plugin_discovery.py +0 -0
  86. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_scaffolding_host.py +0 -0
  87. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_scaffolding_module.py +0 -0
  88. {simple_module_cli-0.0.8 → simple_module_cli-0.0.9}/tests/test_skills_cmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_cli
3
- Version: 0.0.8
3
+ Version: 0.0.9
4
4
  Summary: Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host.
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.8"
3
+ version = "0.0.9"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,264 @@
1
+ """Greenfield ``simple-module new`` scaffolding.
2
+
3
+ Wraps :func:`simple_module_cli.scaffolding.create_host` (and, in
4
+ workspace mode, :func:`create_workspace`) with the opinionated bits —
5
+ module-list resolution from the CLI catalog, secret generation, DB URL
6
+ selection, ``pyproject.toml`` / ``package.json`` rewriting, and
7
+ post-scaffold recipe application.
8
+
9
+ Lives in its own module to keep ``scaffolding.py`` under the per-file
10
+ line cap and to make the surface area of "host scaffold" vs "app
11
+ scaffold" obvious to readers.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json as _json
17
+ import secrets as _secrets
18
+ from collections.abc import Sequence
19
+ from importlib.metadata import PackageNotFoundError
20
+ from importlib.metadata import version as _pkg_version
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from simple_module_cli._env import set_env_key
25
+ from simple_module_cli.case import to_kebab_case, to_pascal_case
26
+ from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
27
+ from simple_module_cli.recipes import RECIPES, ScaffoldCtx
28
+ from simple_module_cli.scaffolding import (
29
+ _module_to_pypi_name,
30
+ create_host,
31
+ create_module,
32
+ create_workspace,
33
+ )
34
+
35
+ __all__ = ["create_app_project"]
36
+
37
+ _SAMPLE_MODULE_NAME = "hello"
38
+ _SAMPLE_MODULE_PKG = _module_to_pypi_name(_SAMPLE_MODULE_NAME)
39
+
40
+
41
+ def _resolve_framework_version() -> str:
42
+ """Resolve the framework version to pin scaffolded apps against.
43
+
44
+ The CLI ships in lockstep with the rest of the framework (one
45
+ ``bump_version.py`` rewrites every ``pyproject.toml`` in the repo), so
46
+ its own installed version is the source of truth. Falling back to a
47
+ placeholder lets editable installs without dist-info still scaffold —
48
+ but that path should never be reached in a release wheel.
49
+ """
50
+ try:
51
+ return _pkg_version("simple_module_cli")
52
+ except PackageNotFoundError:
53
+ return "0.0.0"
54
+
55
+
56
+ _FRAMEWORK_VERSION = _resolve_framework_version()
57
+
58
+ _APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"]
59
+
60
+ _APP_NPM_DEPS = {
61
+ "@simple-module-py/ui": _FRAMEWORK_VERSION,
62
+ "@simple-module-py/i18n": _FRAMEWORK_VERSION,
63
+ "react": "^19.0.0",
64
+ "react-dom": "^19.0.0",
65
+ "@inertiajs/react": "^2.0.0",
66
+ }
67
+ _APP_NPM_DEV_DEPS = {
68
+ "@simple-module-py/tsconfig": _FRAMEWORK_VERSION,
69
+ "@vitejs/plugin-react": "^5.0.0",
70
+ "typescript": "^5.6.0",
71
+ "vite": "^8.0.0",
72
+ }
73
+
74
+ # Files the host template ships that the workspace template re-emits at
75
+ # the project root. Host copies are stripped in workspace mode.
76
+ _HOST_FILES_OWNED_BY_WORKSPACE = (
77
+ ".env.example",
78
+ ".gitignore",
79
+ "README.md",
80
+ "Makefile",
81
+ )
82
+
83
+
84
+ def create_app_project(
85
+ target: Path,
86
+ *,
87
+ name: str,
88
+ db: str = "sqlite",
89
+ tenancy: bool = False,
90
+ selected: Sequence[str] | None = None,
91
+ flat: bool = False,
92
+ ) -> None:
93
+ """Greenfield ``simple-module new`` scaffold.
94
+
95
+ In workspace mode (the default), lays down a uv + npm workspace at
96
+ ``target/`` with the host under ``target/host/`` and a sample module
97
+ under ``target/modules/hello/``. In flat mode (``flat=True``), keeps
98
+ the legacy single-host layout: host files at ``target/`` with no
99
+ ``modules/`` directory or workspace plumbing.
100
+
101
+ Generates a secret, picks a DB URL, rewrites the host's
102
+ ``pyproject.toml`` / the relevant ``package.json`` to pin exact
103
+ framework versions, and applies any matching post-scaffold recipes
104
+ (e.g. the ``background_tasks`` recipe drops a Celery worker stack).
105
+ """
106
+ if target.exists() and any(target.iterdir()):
107
+ raise FileExistsError(
108
+ f"Destination {target} already exists and is non-empty; "
109
+ "choose a new path or remove its contents first."
110
+ )
111
+
112
+ chosen = list(selected) if selected is not None else list(PRESETS["standard"])
113
+ resolved, _added = expand_deps(chosen)
114
+
115
+ display_names = [to_pascal_case(CATALOG[m].display) for m in resolved]
116
+ host_dir = target if flat else target / "host"
117
+ if not flat:
118
+ target.mkdir(parents=True, exist_ok=True)
119
+ create_workspace(target, name=name)
120
+ create_host(host_dir, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION)
121
+ if not flat:
122
+ _strip_workspace_owned_files(host_dir)
123
+
124
+ py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [
125
+ f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved
126
+ ]
127
+
128
+ workspace_sources: list[str] = []
129
+ if not flat:
130
+ _scaffold_sample_module(target)
131
+ py_deps.append(_SAMPLE_MODULE_PKG)
132
+ workspace_sources.append(_SAMPLE_MODULE_PKG)
133
+
134
+ env_path = target / ".env.example"
135
+ env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
136
+ env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
137
+ env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat))
138
+ env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
139
+ env_path.write_text(env_text, encoding="utf-8")
140
+
141
+ host_pyproject = host_dir / "pyproject.toml"
142
+ text = host_pyproject.read_text(encoding="utf-8")
143
+ # Workspace mode needs the host's [project].name distinct from the
144
+ # workspace root's, otherwise uv refuses with "two workspace members
145
+ # are both named ...". Flat mode keeps the user's exact name.
146
+ project_name = None if flat else f"{to_kebab_case(name)}-host"
147
+ text = _rewrite_pyproject(
148
+ text, py_deps, _APP_PY_DEV_DEPS, sources=workspace_sources, project_name=project_name
149
+ )
150
+ host_pyproject.write_text(text, encoding="utf-8")
151
+
152
+ if flat:
153
+ # The workspace template already emits a top-level package.json
154
+ # with workspaces; flat mode has none, so seed one with the
155
+ # framework npm pins so `npm install` resolves at the root.
156
+ pkg_path = target / "package.json"
157
+ pkg_data: dict[str, Any] = (
158
+ _json.loads(pkg_path.read_text(encoding="utf-8"))
159
+ if pkg_path.exists()
160
+ else {"name": to_kebab_case(name), "private": True, "type": "module"}
161
+ )
162
+ pkg_data.setdefault("dependencies", {}).update(_APP_NPM_DEPS)
163
+ pkg_data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS)
164
+ pkg_path.write_text(_json.dumps(pkg_data, indent=2) + "\n", encoding="utf-8")
165
+
166
+ ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved))
167
+ for mod_name in resolved:
168
+ recipe_key = CATALOG[mod_name].recipe
169
+ if recipe_key is not None and recipe_key in RECIPES:
170
+ RECIPES[recipe_key].apply(target, ctx)
171
+
172
+
173
+ def _strip_workspace_owned_files(host_dir: Path) -> None:
174
+ """Drop host copies of files the workspace root owns in workspace mode."""
175
+ for relpath in _HOST_FILES_OWNED_BY_WORKSPACE:
176
+ (host_dir / relpath).unlink(missing_ok=True)
177
+
178
+
179
+ def _scaffold_sample_module(target: Path) -> None:
180
+ sample_dest = target / "modules" / _SAMPLE_MODULE_NAME
181
+ if sample_dest.exists():
182
+ return
183
+ create_module(sample_dest, name=_SAMPLE_MODULE_NAME)
184
+ _pin_sample_module_deps(sample_dest)
185
+ _seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")
186
+
187
+
188
+ def _seed_static_dist_placeholder(static_dist: Path) -> None:
189
+ # Hatch's force-include resolves at build time even for editable installs;
190
+ # an empty placeholder keeps `uv sync` from failing before vite build runs.
191
+ static_dist.mkdir(parents=True, exist_ok=True)
192
+ (static_dist / ".gitkeep").touch()
193
+
194
+
195
+ def _pin_sample_module_deps(sample_dest: Path) -> None:
196
+ """Replace the module template's future-API range pins with exact pins.
197
+
198
+ The shared ``sm create-module`` template ships ``>=1.0,<2.0`` against the
199
+ framework's eventual stable line, but the workspace-bundled sample has to
200
+ resolve against whatever the framework version actually is today (``==X``
201
+ in pre-1.0). Without rewriting, ``uv sync`` can't satisfy the workspace.
202
+ """
203
+ import tomlkit
204
+
205
+ pyproject = sample_dest / "pyproject.toml"
206
+ doc = tomlkit.parse(pyproject.read_text(encoding="utf-8"))
207
+ project = doc.setdefault("project", tomlkit.table())
208
+ project["dependencies"] = [_pin_or_keep(dep) for dep in project.get("dependencies", [])]
209
+ optional = project.get("optional-dependencies")
210
+ if optional is not None:
211
+ for extra, deps in list(optional.items()):
212
+ optional[extra] = [_pin_or_keep(dep) for dep in deps]
213
+ pyproject.write_text(tomlkit.dumps(doc), encoding="utf-8")
214
+
215
+
216
+ def _pin_or_keep(dep: str) -> str:
217
+ """Pin a ``simple_module_*`` requirement to the framework version; pass through otherwise."""
218
+ pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip()
219
+ if pkg.startswith(("simple_module_", "simple-module-")):
220
+ return f"{pkg}=={_FRAMEWORK_VERSION}"
221
+ return dep
222
+
223
+
224
+ def _db_url(db: str, slug: str, *, flat: bool) -> str:
225
+ if db == "postgres":
226
+ return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
227
+ # Workspace mode keeps the SQLite file next to host/'s alembic.ini so
228
+ # `cd host && uvicorn` and `cd host && alembic` resolve the same path.
229
+ return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db"
230
+
231
+
232
+ def _rewrite_pyproject(
233
+ text: str,
234
+ deps: list[str],
235
+ dev_deps: list[str],
236
+ *,
237
+ sources: Sequence[str] = (),
238
+ project_name: str | None = None,
239
+ ) -> str:
240
+ """Replace deps in a host ``pyproject.toml`` and pin workspace sources.
241
+
242
+ ``sources`` lists ``simple_module_*`` packages that should resolve from
243
+ the uv workspace (``modules/*``) instead of PyPI. Emits a
244
+ ``[tool.uv.sources]`` block per entry. Empty in flat mode.
245
+
246
+ ``project_name`` overrides ``[project].name`` — set in workspace mode
247
+ so the host's package name differs from the workspace root's.
248
+ """
249
+ import tomlkit
250
+
251
+ doc = tomlkit.parse(text)
252
+ project = doc.setdefault("project", tomlkit.table())
253
+ if project_name is not None:
254
+ project["name"] = project_name
255
+ project["dependencies"] = list(deps)
256
+ groups = doc.setdefault("dependency-groups", tomlkit.table())
257
+ groups["dev"] = list(dev_deps)
258
+ if sources:
259
+ tool = doc.setdefault("tool", tomlkit.table())
260
+ uv_table = tool.setdefault("uv", tomlkit.table())
261
+ uv_sources = uv_table.setdefault("sources", tomlkit.table())
262
+ for src in sources:
263
+ uv_sources[src] = {"workspace": True}
264
+ return tomlkit.dumps(doc)
@@ -49,16 +49,22 @@ def _optional_template_root(name: str) -> Path:
49
49
 
50
50
 
51
51
  class BackgroundTasksRecipe:
52
- """Lays down run_worker.py + compose + Dockerfile + Make targets."""
52
+ """Lays down run_worker.py + compose + Dockerfiles + Make targets."""
53
53
 
54
54
  def apply(self, target: Path, ctx: ScaffoldCtx) -> None:
55
55
  templates = _optional_template_root("background_tasks")
56
56
 
57
57
  run_worker_dest = target / "scripts" / "run_worker.py"
58
58
  compose_dest = target / "docker-compose.yml"
59
- dockerfile_dest = target / "docker" / "worker.Dockerfile"
60
-
61
- for path in (run_worker_dest, compose_dest, dockerfile_dest):
59
+ host_dockerfile_dest = target / "docker" / "host.Dockerfile"
60
+ worker_dockerfile_dest = target / "docker" / "worker.Dockerfile"
61
+
62
+ for path in (
63
+ run_worker_dest,
64
+ compose_dest,
65
+ host_dockerfile_dest,
66
+ worker_dockerfile_dest,
67
+ ):
62
68
  if path.exists():
63
69
  raise FileExistsError(
64
70
  f"{path} already exists — refusing to clobber. "
@@ -70,8 +76,9 @@ class BackgroundTasksRecipe:
70
76
 
71
77
  shutil.copy2(templates / "docker-compose.yml", compose_dest)
72
78
 
73
- dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
74
- shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest)
79
+ host_dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
80
+ shutil.copy2(templates / "host.Dockerfile", host_dockerfile_dest)
81
+ shutil.copy2(templates / "worker.Dockerfile", worker_dockerfile_dest)
75
82
 
76
83
  env_path = target / ".env.example"
77
84
  env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
@@ -1,9 +1,12 @@
1
1
  """Host + module scaffolding via package-data templates.
2
2
 
3
+ * :func:`create_workspace` materializes the project-root workspace shell
4
+ (top-level ``pyproject.toml`` / ``package.json`` / ``Makefile``) from
5
+ ``simple_module_cli/templates/workspace/``.
3
6
  * :func:`create_host` materializes a new host project from the templates
4
- under ``simple_module/templates/host/``.
7
+ under ``simple_module_cli/templates/host/``.
5
8
  * :func:`create_module` materializes a new module package from
6
- ``simple_module/templates/module/``.
9
+ ``simple_module_cli/templates/module/``.
7
10
 
8
11
  The frontend pages manifest + per-module JS dep discovery live in
9
12
  :mod:`simple_module_hosting.manifest` (those need module-discovery and
@@ -20,7 +23,7 @@ from pathlib import Path
20
23
 
21
24
  from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case
22
25
 
23
- __all__ = ["create_host", "create_module"]
26
+ __all__ = ["create_host", "create_module", "create_workspace"]
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
 
@@ -80,11 +83,35 @@ def _apply_template_files(
80
83
  shutil.copy2(src, target)
81
84
 
82
85
 
86
+ def create_workspace(
87
+ dest: Path,
88
+ name: str,
89
+ template_root: Path | None = None,
90
+ ) -> Path:
91
+ """Materialize the workspace-root shell at ``dest``.
92
+
93
+ Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json``
94
+ (npm workspace), ``Makefile`` (delegates to host), ``.env.example``,
95
+ ``.gitignore``, and ``README.md``. Does NOT create the host or any
96
+ modules — those go under ``dest/host`` and ``dest/modules/`` afterwards.
97
+ """
98
+ dest = Path(dest)
99
+ dest.mkdir(parents=True, exist_ok=True)
100
+ _apply_template_files(
101
+ _resolve_template_root("workspace", template_root),
102
+ dest,
103
+ {"{{HOST_NAME}}": to_kebab_case(name)},
104
+ )
105
+ logger.info("Scaffolded workspace root at %s", dest)
106
+ return dest
107
+
108
+
83
109
  def create_host(
84
110
  dest: Path,
85
111
  name: str,
86
112
  modules: Sequence[str],
87
113
  template_root: Path | None = None,
114
+ framework_version: str = "*",
88
115
  ) -> Path:
89
116
  dest = Path(dest)
90
117
  _require_empty_dest(dest)
@@ -92,7 +119,11 @@ def create_host(
92
119
  _apply_template_files(
93
120
  _resolve_template_root("host", template_root),
94
121
  dest,
95
- {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines},
122
+ {
123
+ "{{HOST_NAME}}": name,
124
+ "{{MODULE_DEPS}}": module_dep_lines,
125
+ "{{FRAMEWORK_VERSION}}": framework_version,
126
+ },
96
127
  )
97
128
  logger.info(
98
129
  "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "<none>"
@@ -1,4 +1,4 @@
1
- .PHONY: install dev dev-api dev-ui build migrate gen-pages sync-js-deps
1
+ .PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-js-deps
2
2
 
3
3
  install:
4
4
  uv sync
@@ -21,6 +21,10 @@ build:
21
21
  migrate:
22
22
  uv run alembic upgrade head
23
23
 
24
+ migration:
25
+ @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1)
26
+ uv run alembic revision --autogenerate -m "$(msg)"
27
+
24
28
  gen-pages:
25
29
  uv run python -m simple_module_hosting gen-pages --host-dir=client_app
26
30
 
@@ -1,4 +1,20 @@
1
1
  services:
2
+ postgres:
3
+ image: postgres:16
4
+ environment:
5
+ POSTGRES_DB: simple_module
6
+ POSTGRES_USER: sm
7
+ POSTGRES_PASSWORD: sm
8
+ ports:
9
+ - "5432:5432"
10
+ volumes:
11
+ - pgdata:/var/lib/postgresql/data
12
+ healthcheck:
13
+ test: ["CMD-SHELL", "pg_isready -U sm -d simple_module"]
14
+ interval: 5s
15
+ timeout: 5s
16
+ retries: 10
17
+
2
18
  redis:
3
19
  image: redis:7-alpine
4
20
  ports:
@@ -11,6 +27,19 @@ services:
11
27
  timeout: 3s
12
28
  retries: 10
13
29
 
30
+ host:
31
+ build:
32
+ context: .
33
+ dockerfile: docker/host.Dockerfile
34
+ env_file: .env
35
+ environment:
36
+ SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module}
37
+ ports:
38
+ - "8000:8000"
39
+ depends_on:
40
+ postgres:
41
+ condition: service_healthy
42
+
14
43
  worker:
15
44
  build:
16
45
  context: .
@@ -19,9 +48,12 @@ services:
19
48
  environment:
20
49
  SM_BG_TASKS_BROKER_URL: redis://redis:6379/0
21
50
  SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1
51
+ SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module}
22
52
  depends_on:
23
53
  redis:
24
54
  condition: service_healthy
55
+ postgres:
56
+ condition: service_healthy
25
57
  command:
26
58
  - "uv"
27
59
  - "run"
@@ -41,6 +73,7 @@ services:
41
73
  environment:
42
74
  SM_BG_TASKS_BROKER_URL: redis://redis:6379/0
43
75
  SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1
76
+ SM_DATABASE_URL: ${SM_DATABASE_URL:-postgresql+asyncpg://sm:sm@postgres:5432/simple_module}
44
77
  depends_on:
45
78
  redis:
46
79
  condition: service_healthy
@@ -57,4 +90,5 @@ services:
57
90
  - "info"
58
91
 
59
92
  volumes:
93
+ pgdata:
60
94
  redisdata:
@@ -0,0 +1,44 @@
1
+ # FastAPI host image. Multi-stage: Node builds the Vite client bundle,
2
+ # Python serves uvicorn. Migrations run on container start.
3
+
4
+ FROM node:22-slim AS frontend
5
+ WORKDIR /app
6
+ COPY package.json package-lock.json* ./
7
+ COPY host/client_app/package.json host/client_app/
8
+ COPY modules/ modules/
9
+ RUN npm ci --workspaces --include-workspace-root
10
+ COPY host/ host/
11
+ RUN npm --workspace host/client_app run build
12
+
13
+ FROM python:3.12-slim AS runtime
14
+
15
+ ENV PYTHONDONTWRITEBYTECODE=1 \
16
+ PYTHONUNBUFFERED=1 \
17
+ UV_LINK_MODE=copy \
18
+ UV_COMPILE_BYTECODE=1 \
19
+ UV_SYSTEM_PYTHON=1
20
+
21
+ RUN apt-get update \
22
+ && apt-get install -y --no-install-recommends curl ca-certificates build-essential \
23
+ && rm -rf /var/lib/apt/lists/* \
24
+ && pip install --no-cache-dir uv
25
+
26
+ WORKDIR /app
27
+
28
+ COPY pyproject.toml uv.lock* ./
29
+ COPY host/pyproject.toml host/
30
+ COPY modules/ modules/
31
+ RUN uv sync --all-packages --no-dev
32
+
33
+ COPY host/ host/
34
+ COPY --from=frontend /app/host/static/dist host/static/dist
35
+
36
+ RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin app \
37
+ && chown -R app:app /app
38
+ USER app
39
+
40
+ EXPOSE 8000
41
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
42
+ CMD curl -fsS http://localhost:8000/health || exit 1
43
+
44
+ CMD ["sh", "-c", "cd host && uv run alembic upgrade head && uv run uvicorn main:app --host 0.0.0.0 --port 8000"]
@@ -9,12 +9,13 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@inertiajs/react": "^2.0.0",
12
- "@simple-module-py/i18n": "^0.0.3",
13
- "@simple-module-py/ui": "^0.0.3",
12
+ "@simple-module-py/i18n": "{{FRAMEWORK_VERSION}}",
13
+ "@simple-module-py/ui": "{{FRAMEWORK_VERSION}}",
14
14
  "react": "^19.0.0",
15
15
  "react-dom": "^19.0.0"
16
16
  },
17
17
  "devDependencies": {
18
+ "@simple-module-py/tsconfig": "{{FRAMEWORK_VERSION}}",
18
19
  "@tailwindcss/vite": "^4.0.0",
19
20
  "@types/node": "^22.0.0",
20
21
  "@types/react": "^19.0.0",