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
70 changes: 19 additions & 51 deletions framework/cli/simple_module_cli/app_project.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,8 +17,6 @@
import secrets as _secrets
import shutil as _shutil
from collections.abc import Sequence
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from pathlib import Path
from typing import Any

Expand All@@ -32,6 +30,7 @@
create_host,
create_module,
create_workspace,
resolve_framework_version,
)

__all__ = ["create_app_project"]
Expand All@@ -40,30 +39,21 @@
_SAMPLE_MODULE_PKG = _module_to_pypi_name(_SAMPLE_MODULE_NAME)


def _resolve_framework_version() -> str:
"""Resolve the framework version to pin scaffolded apps against.

The CLI ships in lockstep with the rest of the framework (one
``bump_version.py`` rewrites every ``pyproject.toml`` in the repo), so
its own installed version is the source of truth. Falling back to a
placeholder lets editable installs without dist-info still scaffold —
but that path should never be reached in a release wheel.
"""
try:
return _pkg_version("simple_module_cli")
except PackageNotFoundError:
return "0.0.0"


_FRAMEWORK_VERSION = _resolve_framework_version()
_FRAMEWORK_VERSION = resolve_framework_version()

# Pin ``simple_module_cli`` as a dev dep so ``uv run smpy`` resolves to the
# project venv. The global ``uv tool`` install runs in its own isolated venv
# that can't see the project's plugin entry points (issue #134).
# that can't see the project's plugin entry points (issue #134). The lint /
# test tooling (ruff, ty, pytest-*) backs the generated `make lint`/`make
# test` targets so a fresh app can run its own quality gates.
_APP_PY_DEV_DEPS = [
f"simple_module_test=={_FRAMEWORK_VERSION}",
f"simple_module_cli=={_FRAMEWORK_VERSION}",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-playwright>=0.7.2",
"ruff>=0.8",
"ty>=0.0.29",
]

_APP_NPM_DEPS = {
Expand DownExpand Up@@ -120,7 +110,12 @@ def create_app_project(
preserved: list[Path] = []
if not flat:
preserved.extend(
create_workspace(target, name=name, preserve_existing=SAFE_PRESERVED_NAMES)
create_workspace(
target,
name=name,
framework_version=_FRAMEWORK_VERSION,
preserve_existing=SAFE_PRESERVED_NAMES,
)
)
preserved.extend(
create_host(
Expand DownExpand Up@@ -195,11 +190,13 @@ 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 the sample's framework deps to the exact framework version so the
# workspace resolves (the template's >=1.0,<2.0 ranges don't exist on PyPI
# pre-1.0). See GH #195.
create_module(sample_dest, name=_SAMPLE_MODULE_NAME, framework_version=_FRAMEWORK_VERSION)
# GitHub only reads workflows from the repo root, so the template's
# .github/ is dead inside a workspace.
_shutil.rmtree(sample_dest / ".github")
_pin_sample_module_deps(sample_dest)
_seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist")


Expand All@@ -210,35 +207,6 @@ def _seed_static_dist_placeholder(static_dist: Path) -> None:
(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 ``smpy 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, *, flat: bool) -> str:
if db == "postgres":
return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}"
Expand Down
8 changes: 6 additions & 2 deletions framework/cli/simple_module_cli/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
from simple_module_cli.plugins import discover_and_mount
from simple_module_cli.scaffolding import create_host as _create_host
from simple_module_cli.scaffolding import create_module as _create_module
from simple_module_cli.scaffolding import resolve_framework_version
from simple_module_cli.skills_cmd import app as skills_app

app = typer.Typer(
Expand DownExpand Up@@ -68,7 +69,7 @@ def create_host(
typer.echo(" uv sync")
typer.echo(" cp .env.example .env")
typer.echo(' alembic revision --autogenerate -m "initial schema"')
typer.echo(" alembic upgrade head")
typer.echo(" alembic upgrade heads")
typer.echo(" python main.py")


Expand All@@ -85,7 +86,10 @@ def create_module(
package = slug.replace("-", "_")
target = dest or Path.cwd() / f"simple_module_{package}"
try:
_create_module(target, name=name)
# Pin framework deps to the installed framework version so the module
# resolves against the app that created it (the template's >=1.0,<2.0
# ranges don't exist on PyPI pre-1.0). See GH #195.
_create_module(target, name=name, framework_version=resolve_framework_version())
except FileExistsError as exc:
typer.echo(f"ERROR: {exc}", err=True)
raise typer.Exit(code=1) from exc
Expand Down
4 changes: 3 additions & 1 deletion framework/cli/simple_module_cli/new.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,7 +164,9 @@ def new_project(
return

_bootstrap_initial_migration(host_dir)
subprocess.run([*_ALEMBIC, "upgrade", "head"], cwd=host_dir, check=False)
# `heads` (plural) applies every per-module branch head; `head` (singular)
# errors once a second module ships its own migration branch label.
subprocess.run([*_ALEMBIC, "upgrade", "heads"], cwd=host_dir, check=False)
typer.echo("\nSetup complete. Run `make dev` in the new directory.")
if "background_tasks" in resolved:
typer.echo("For background jobs, also run: docker compose up -d redis worker beat")
Expand Down
81 changes: 77 additions & 4 deletions framework/cli/simple_module_cli/scaffolding.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,8 @@
"create_host",
"create_module",
"create_workspace",
"pin_framework_deps",
"resolve_framework_version",
]

logger = logging.getLogger(__name__)
Expand All@@ -55,6 +57,59 @@ def _module_to_pypi_name(name: str) -> str:
return f"simple_module_{name.lower()}"


def resolve_framework_version() -> str:
"""Resolve the framework version that scaffolded apps should pin against.

The CLI ships in lockstep with the rest of the framework (one
``bump_version.py`` rewrites every ``pyproject.toml``), so its own
installed distribution version is the source of truth. Falls back to a
placeholder for editable installs lacking dist-info — never reached from a
release wheel.
"""
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version

try:
return pkg_version("simple_module_cli")
except PackageNotFoundError:
return "0.0.0"


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


def pin_framework_deps(pyproject_path: Path, version: str) -> None:
"""Pin every ``simple_module_*`` requirement in a pyproject to ``==version``.

The module template ships forward-looking ranges (``>=1.0,<2.0``) against
the framework's eventual stable line, but the published distributions are
pre-1.0 (``0.0.x``), so those ranges resolve to nothing on PyPI. Rewriting
to an exact pin lets a freshly created module resolve against the framework
version that created it — e.g. ``uv add ./modules/<name>`` into the same
workspace. Both ``dependencies`` and every ``optional-dependencies`` extra
(the ``dev`` extra pins ``simple_module_test``) are rewritten. See GH #195.
"""
import tomlkit

doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8"))
project = doc.get("project")
if project is None:
return
deps = project.get("dependencies")
if deps is not None:
project["dependencies"] = [_pin_one(dep, version) for dep in deps]
optional = project.get("optional-dependencies")
if optional is not None:
for extra, items in list(optional.items()):
optional[extra] = [_pin_one(dep, version) for dep in items]
pyproject_path.write_text(tomlkit.dumps(doc), encoding="utf-8")


def _iter_template_files(template_root: Path):
"""Yield every file under ``template_root``. Skips ``_optional/`` paths."""
for path in template_root.rglob("*"):
Expand DownExpand Up@@ -124,15 +179,20 @@ def create_workspace(
dest: Path,
name: str,
template_root: Path | None = None,
framework_version: str = "*",
*,
preserve_existing: frozenset[str] = frozenset(),
) -> list[Path]:
"""Materialize the workspace-root shell at ``dest``; return preserved paths.

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.
Lays down the top-level ``pyproject.toml`` (uv workspace + dev tooling for
``make test``/``lint``), ``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.

``framework_version`` pins ``simple_module_test`` in the root dev group;
defaults to ``"*"`` for callers that don't need an exact pin.

``preserve_existing`` lists top-level entry names that may already exist
in ``dest``; the scaffold's copy is skipped and the preserved path is
Expand All@@ -147,6 +207,7 @@ def create_workspace(
{
"{{HOST_NAME}}": validate_scaffold_name(name),
"{{HOST_PYPI_NAME}}": to_kebab_case(name),
"{{FRAMEWORK_VERSION}}": framework_version,
},
preserve_existing=preserve_existing,
)
Expand DownExpand Up@@ -191,7 +252,17 @@ def create_module(
dest: Path,
name: str,
template_root: Path | None = None,
*,
framework_version: str | None = None,
) -> Path:
"""Scaffold a module package at ``dest``.

When ``framework_version`` is given, the template's forward-looking
``simple_module_*`` ranges are rewritten to an exact pin so the module
resolves against that framework version (e.g. ``uv add`` into the workspace
that created it). Left as ``None``, the template's ranges are kept verbatim.
See GH #195.
"""
dest = Path(dest)
existed_before = dest.exists()
_require_empty_dest(dest)
Expand All@@ -210,6 +281,8 @@ def create_module(
},
path_rewrites={_PACKAGE_PATH_TOKEN: package_name},
)
if framework_version is not None:
pin_framework_deps(dest / "pyproject.toml", framework_version)
except Exception:
# Rollback so a half-scaffolded directory doesn't leave the user
# with an unparseable Python package and the impression that a
Expand Down
35 changes: 29 additions & 6 deletions 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 migration gen-pages sync-js-deps
.PHONY: install dev dev-api dev-ui build test test-py test-js lint doctor migrate migration gen-pages sync-js-deps

install:
uv sync
Expand All@@ -18,15 +18,38 @@ dev-ui:
build:
cd client_app && npm run build

migrate:
uv run alembic upgrade head
# Testing
test: test-py test-js

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

# --if-present is a no-op until you add a "test" script (e.g. vitest) to package.json.
test-js:
cd client_app && npm run test --if-present

# Lint + typecheck (Python). Mirrors the framework's own quality gate.
lint:
uv run ruff format --check .
uv run ruff check .
uv run ty check

# Module diagnostics — the same checks that run at prod boot (orphan pages,
# coupling violations, migration drift, locale issues).
doctor:
uv run python -m simple_module_core

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

sync-js-deps:
uv run python -m simple_module_hosting sync-js-deps --host-client-app=client_app

# `upgrade heads` (plural) applies every per-module branch head; `upgrade head`
# (singular) errors once a second module adds its own migration branch label.
migrate:
uv run alembic upgrade heads

migration:
@test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1)
uv run alembic revision --autogenerate -m "$(msg)"
33 changes: 23 additions & 10 deletions framework/cli/simple_module_cli/templates/host/main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,25 +5,38 @@
"""

import os
import sys
from pathlib import Path

from simple_module_core.dotenv import load_dotenv_into_environ

# Pin this host directory on ``sys.path`` as an ABSOLUTE path *before* the
# chdir below, so ``from routes import ...`` resolves no matter what the cwd
# is. The scaffolded Makefile launches the app as ``cd host && uvicorn
# main:app``; uvicorn puts the launch cwd on ``sys.path`` as the empty string
# ``''`` (resolved lazily against the *current* cwd). Once we chdir to the
# repo root, that ``''`` entry points at the wrong directory and the sibling
# ``routes`` module is no longer importable — and the ``--reload`` subprocess
# re-imports via the same path, so it breaks there too. See GH #194.
_HOST_DIR = Path(__file__).resolve().parent
_REPO_ROOT = _HOST_DIR.parent
if str(_HOST_DIR) not in sys.path:
sys.path.insert(0, str(_HOST_DIR))

# Resolve the workspace root from this file's location so the web process
# behaves the same regardless of where uvicorn was launched (the scaffolded
# Makefile uses ``cd host && uvicorn main:app``, but ``uv run --project host``
# or a wheel deployment may run from elsewhere). chdir up front so cwd-relative
# paths in ``.env`` (e.g. ``sqlite+aiosqlite:///./host/app.db``) resolve
# consistently; load ``.env`` into ``os.environ`` so framework code reading
# ``os.environ.get("SM_…")`` directly sees the same values pydantic does.
_REPO_ROOT = Path(__file__).resolve().parent.parent
# behaves the same regardless of where uvicorn was launched (``uv run
# --project host`` or a wheel deployment may run from elsewhere). chdir up
# front so cwd-relative paths in ``.env`` (e.g.
# ``sqlite+aiosqlite:///./host/app.db``) resolve consistently; load ``.env``
# into ``os.environ`` so framework code reading ``os.environ.get("SM_…")``
# directly sees the same values pydantic does.
os.chdir(_REPO_ROOT)
load_dotenv_into_environ(_REPO_ROOT / ".env")

from simple_module_hosting import Settings, create_app
from simple_module_hosting.logging import setup_logging
from simple_module_hosting import Settings, create_app # noqa: E402
from simple_module_hosting.logging import setup_logging # noqa: E402

from routes import router as host_router
from routes import router as host_router # noqa: E402

settings = Settings()

Expand Down
Loading
Loading