Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 137 additions & 52 deletions framework/cli/simple_module_cli/app_project.py
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
"""Greenfield ``simple-module new`` scaffolding.

Wraps :func:`simple_module_hosting.scaffolding.create_host` with the
opinionated bits — module-list resolution from the CLI catalog, secret
generation, DB URL selection, ``pyproject.toml`` / ``package.json``
rewriting, and post-scaffold recipe application.
Wraps :func:`simple_module_cli.scaffolding.create_host` (and, in
workspace mode, :func:`create_workspace`) with the opinionated bits —
module-list resolution from the CLI catalog, secret generation, DB URL
selection, ``pyproject.toml`` / ``package.json`` rewriting, and
post-scaffold recipe application.

Lives in its own module to keep ``scaffolding.py`` under the per-file
line cap and to make the surface area of "host scaffold" vs "app
Expand All@@ -24,7 +25,12 @@
from simple_module_cli.case import to_kebab_case, to_pascal_case
from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps
from simple_module_cli.recipes import RECIPES, ScaffoldCtx
from simple_module_cli.scaffolding import _module_to_pypi_name, create_host, create_module
from simple_module_cli.scaffolding import (
_module_to_pypi_name,
create_host,
create_module,
create_workspace,
)

__all__ = ["create_app_project"]

Expand DownExpand Up@@ -56,7 +62,7 @@ def _resolve_framework_version() -> str:
"@simple-module-py/i18n": _FRAMEWORK_VERSION,
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@inertiajs/react": "^1.0.0",
"@inertiajs/react": "^2.0.0",
}
_APP_NPM_DEV_DEPS = {
"@simple-module-py/tsconfig": _FRAMEWORK_VERSION,
Expand All@@ -65,6 +71,15 @@ def _resolve_framework_version() -> str:
"vite": "^8.0.0",
}

# Files the host template ships that the workspace template re-emits at
# the project root. Host copies are stripped in workspace mode.
_HOST_FILES_OWNED_BY_WORKSPACE = (
".env.example",
".gitignore",
"README.md",
"Makefile",
)


def create_app_project(
target: Path,
Expand All@@ -77,9 +92,14 @@ def create_app_project(
) -> None:
"""Greenfield ``simple-module new`` scaffold.

Wraps :func:`create_host` with a chosen module list (defaults to the
``standard`` preset), generates a secret, picks a DB URL, rewrites
the generated ``package.json`` / ``pyproject.toml`` to pin exact
In workspace mode (the default), lays down a uv + npm workspace at
``target/`` with the host under ``target/host/`` and a sample module
under ``target/modules/hello/``. In flat mode (``flat=True``), keeps
the legacy single-host layout: host files at ``target/`` with no
``modules/`` directory or workspace plumbing.

Generates a secret, picks a DB URL, rewrites the host's
``pyproject.toml`` / the relevant ``package.json`` to pin exact
framework versions, and applies any matching post-scaffold recipes
(e.g. the ``background_tasks`` recipe drops a Celery worker stack).
"""
Expand All@@ -93,40 +113,55 @@ def create_app_project(
resolved, _added = expand_deps(chosen)

display_names = [to_pascal_case(CATALOG[m].display) for m in resolved]
create_host(target, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION)
host_dir = target if flat else target / "host"
if not flat:
target.mkdir(parents=True, exist_ok=True)
create_workspace(target, name=name)
create_host(host_dir, name=name, modules=display_names, framework_version=_FRAMEWORK_VERSION)
if not flat:
_strip_workspace_owned_files(host_dir)

py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [
f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved
]

workspace_sources: list[str] = []
if not flat:
_scaffold_sample_module(target)
py_deps.append(_SAMPLE_MODULE_PKG)
workspace_sources.append(_SAMPLE_MODULE_PKG)

env_path = target / ".env.example"
env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
env_text = set_env_key(env_text, "SM_SECRET_KEY", _secrets.token_urlsafe(32))
env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name)))
env_text = set_env_key(env_text, "SM_DATABASE_URL", _db_url(db, to_kebab_case(name), flat=flat))
env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
env_path.write_text(env_text, encoding="utf-8")

if not flat:
_scaffold_sample_module(target)
py_deps.append(_SAMPLE_MODULE_PKG)
host_pyproject = host_dir / "pyproject.toml"
text = host_pyproject.read_text(encoding="utf-8")
# Workspace mode needs the host's [project].name distinct from the
# workspace root's, otherwise uv refuses with "two workspace members
# are both named ...". Flat mode keeps the user's exact name.
project_name = None if flat else f"{to_kebab_case(name)}-host"
text = _rewrite_pyproject(
text, py_deps, _APP_PY_DEV_DEPS, sources=workspace_sources, project_name=project_name
)
host_pyproject.write_text(text, encoding="utf-8")

pyproject = target / "pyproject.toml"
if pyproject.exists():
text = pyproject.read_text(encoding="utf-8")
text = _rewrite_pyproject(text, py_deps, _APP_PY_DEV_DEPS, flat=flat)
pyproject.write_text(text, encoding="utf-8")

pkg_path = target / "package.json"
data: dict[str, Any]
if pkg_path.exists():
data = _json.loads(pkg_path.read_text(encoding="utf-8"))
else:
data = {"name": to_kebab_case(name), "private": True, "type": "module"}
data.setdefault("dependencies", {}).update(_APP_NPM_DEPS)
data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS)
if not flat:
data["workspaces"] = ["client_app", "modules/*"]
pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8")
if flat:
# The workspace template already emits a top-level package.json
# with workspaces; flat mode has none, so seed one with the
# framework npm pins so `npm install` resolves at the root.
pkg_path = target / "package.json"
pkg_data: dict[str, Any] = (
_json.loads(pkg_path.read_text(encoding="utf-8"))
if pkg_path.exists()
else {"name": to_kebab_case(name), "private": True, "type": "module"}
)
pkg_data.setdefault("dependencies", {}).update(_APP_NPM_DEPS)
pkg_data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS)
pkg_path.write_text(_json.dumps(pkg_data, indent=2) + "\n", encoding="utf-8")

ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved))
for mod_name in resolved:
Expand All@@ -135,45 +170,95 @@ def create_app_project(
RECIPES[recipe_key].apply(target, ctx)


def _scaffold_sample_module(target: Path) -> None:
"""Give the user a place to copy when they want to add a feature module.
def _strip_workspace_owned_files(host_dir: Path) -> None:
"""Drop host copies of files the workspace root owns in workspace mode."""
for relpath in _HOST_FILES_OWNED_BY_WORKSPACE:
(host_dir / relpath).unlink(missing_ok=True)

The alternative is reverse-engineering one of the wheel-installed
framework modules from ``.venv/site-packages/``.
"""

def _scaffold_sample_module(target: Path) -> None:
sample_dest = target / "modules" / _SAMPLE_MODULE_NAME
if sample_dest.exists():
return
create_module(sample_dest, name=_SAMPLE_MODULE_NAME)
_pin_sample_module_deps(sample_dest)
_seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")


def _seed_static_dist_placeholder(static_dist: Path) -> None:
# Hatch's force-include resolves at build time even for editable installs;
# an empty placeholder keeps `uv sync` from failing before vite build runs.
static_dist.mkdir(parents=True, exist_ok=True)
(static_dist / ".gitkeep").touch()


def _pin_sample_module_deps(sample_dest: Path) -> None:
"""Replace the module template's future-API range pins with exact pins.

The shared ``sm create-module`` template ships ``>=1.0,<2.0`` against the
framework's eventual stable line, but the workspace-bundled sample has to
resolve against whatever the framework version actually is today (``==X``
in pre-1.0). Without rewriting, ``uv sync`` can't satisfy the workspace.
"""
import tomlkit

pyproject = sample_dest / "pyproject.toml"
doc = tomlkit.parse(pyproject.read_text(encoding="utf-8"))
project = doc.setdefault("project", tomlkit.table())
project["dependencies"] = [_pin_or_keep(dep) for dep in project.get("dependencies", [])]
optional = project.get("optional-dependencies")
if optional is not None:
for extra, deps in list(optional.items()):
optional[extra] = [_pin_or_keep(dep) for dep in deps]
pyproject.write_text(tomlkit.dumps(doc), encoding="utf-8")


def _pin_or_keep(dep: str) -> str:
"""Pin a ``simple_module_*`` requirement to the framework version; pass through otherwise."""
pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip()
if pkg.startswith(("simple_module_", "simple-module-")):
return f"{pkg}=={_FRAMEWORK_VERSION}"
return dep

def _db_url(db: str, slug: str) -> str:

def _db_url(db: str, slug: str, *, flat: bool) -> str:
if db == "postgres":
return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
return "sqlite+aiosqlite:///./app.db"
# Workspace mode keeps the SQLite file next to host/'s alembic.ini so
# `cd host && uvicorn` and `cd host && alembic` resolve the same path.
return "sqlite+aiosqlite:///./app.db" if flat else "sqlite+aiosqlite:///./host/app.db"


def _rewrite_pyproject(
text: str,
deps: list[str],
dev_deps: list[str],
*,
sources: Sequence[str] = (),
project_name: str | None = None,
) -> str:
"""Replace deps in a host ``pyproject.toml`` and pin workspace sources.

def _rewrite_pyproject(text: str, deps: list[str], dev_deps: list[str], *, flat: bool) -> str:
"""Replace deps + wire uv workspace based on ``flat`` mode.
``sources`` lists ``simple_module_*`` packages that should resolve from
the uv workspace (``modules/*``) instead of PyPI. Emits a
``[tool.uv.sources]`` block per entry. Empty in flat mode.

Workspace mode (``flat=False``) adds a ``[tool.uv.sources]`` entry so uv
resolves the bundled sample module from the workspace, not PyPI. Flat
mode strips the static ``[tool.uv.workspace]`` block inherited from the
template — there is no ``modules/`` tree for it to point at.
``project_name`` overrides ``[project].name`` — set in workspace mode
so the host's package name differs from the workspace root's.
"""
import tomlkit

doc = tomlkit.parse(text)
project = doc.setdefault("project", tomlkit.table())
if project_name is not None:
project["name"] = project_name
project["dependencies"] = list(deps)
groups = doc.setdefault("dependency-groups", tomlkit.table())
groups["dev"] = list(dev_deps)
tool = doc.setdefault("tool", tomlkit.table())
uv_table = tool.setdefault("uv", tomlkit.table())
if flat:
if "workspace" in uv_table:
del uv_table["workspace"]
else:
sources = uv_table.setdefault("sources", tomlkit.table())
sources[_SAMPLE_MODULE_PKG] = {"workspace": True}
if sources:
tool = doc.setdefault("tool", tomlkit.table())
uv_table = tool.setdefault("uv", tomlkit.table())
uv_sources = uv_table.setdefault("sources", tomlkit.table())
for src in sources:
uv_sources[src] = {"workspace": True}
return tomlkit.dumps(doc)
19 changes: 13 additions & 6 deletions framework/cli/simple_module_cli/recipes.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,16 +49,22 @@ def _optional_template_root(name: str) -> Path:


class BackgroundTasksRecipe:
"""Lays down run_worker.py + compose + Dockerfile + Make targets."""
"""Lays down run_worker.py + compose + Dockerfiles + Make targets."""

def apply(self, target: Path, ctx: ScaffoldCtx) -> None:
templates = _optional_template_root("background_tasks")

run_worker_dest = target / "scripts" / "run_worker.py"
compose_dest = target / "docker-compose.yml"
dockerfile_dest = target / "docker" / "worker.Dockerfile"

for path in (run_worker_dest, compose_dest, dockerfile_dest):
host_dockerfile_dest = target / "docker" / "host.Dockerfile"
worker_dockerfile_dest = target / "docker" / "worker.Dockerfile"

for path in (
run_worker_dest,
compose_dest,
host_dockerfile_dest,
worker_dockerfile_dest,
):
if path.exists():
raise FileExistsError(
f"{path} already exists — refusing to clobber. "
Expand All@@ -70,8 +76,9 @@ def apply(self, target: Path, ctx: ScaffoldCtx) -> None:

shutil.copy2(templates / "docker-compose.yml", compose_dest)

dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest)
host_dockerfile_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(templates / "host.Dockerfile", host_dockerfile_dest)
shutil.copy2(templates / "worker.Dockerfile", worker_dockerfile_dest)

env_path = target / ".env.example"
env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
Expand Down
32 changes: 29 additions & 3 deletions framework/cli/simple_module_cli/scaffolding.py
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
"""Host + module scaffolding via package-data templates.

* :func:`create_workspace` materializes the project-root workspace shell
(top-level ``pyproject.toml`` / ``package.json`` / ``Makefile``) from
``simple_module_cli/templates/workspace/``.
* :func:`create_host` materializes a new host project from the templates
under ``simple_module/templates/host/``.
under ``simple_module_cli/templates/host/``.
* :func:`create_module` materializes a new module package from
``simple_module/templates/module/``.
``simple_module_cli/templates/module/``.

The frontend pages manifest + per-module JS dep discovery live in
:mod:`simple_module_hosting.manifest` (those need module-discovery and
Expand All@@ -20,7 +23,7 @@

from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case

__all__ = ["create_host", "create_module"]
__all__ = ["create_host", "create_module", "create_workspace"]

logger = logging.getLogger(__name__)

Expand DownExpand Up@@ -80,6 +83,29 @@ def _apply_template_files(
shutil.copy2(src, target)


def create_workspace(
dest: Path,
name: str,
template_root: Path | None = None,
) -> Path:
"""Materialize the workspace-root shell at ``dest``.

Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json``
(npm workspace), ``Makefile`` (delegates to host), ``.env.example``,
``.gitignore``, and ``README.md``. Does NOT create the host or any
modules — those go under ``dest/host`` and ``dest/modules/`` afterwards.
"""
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
_apply_template_files(
_resolve_template_root("workspace", template_root),
dest,
{"{{HOST_NAME}}": to_kebab_case(name)},
)
logger.info("Scaffolded workspace root at %s", dest)
return dest


def create_host(
dest: Path,
name: str,
Expand Down
6 changes: 5 additions & 1 deletion framework/cli/simple_module_cli/templates/host/Makefile
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
.PHONY: install dev dev-api dev-ui build migrate gen-pages sync-js-deps
.PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-js-deps

install:
uv sync
Expand All@@ -21,6 +21,10 @@ build:
migrate:
uv run alembic upgrade head

migration:
@test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1)
uv run alembic revision --autogenerate -m "$(msg)"

gen-pages:
uv run python -m simple_module_hosting gen-pages --host-dir=client_app

Expand Down
Loading
Loading