From 8adf03d1aac37cfd31e66a0a63d72c7d6e3db462 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 19:53:39 +0200 Subject: [PATCH 01/24] docs(spec): CLI project setup with modules and background jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designs an upgraded \`sm new\` that scaffolds a project with any chosen subset of modules (preset or custom) and lands a runnable Celery worker + beat + Redis stack when background_tasks is selected — no manual Makefile / docker-compose / run_worker.py editing required. --- ...26-04-26-cli-modules-and-bg-jobs-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md diff --git a/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md b/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md new file mode 100644 index 00000000..ed75c67e --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md @@ -0,0 +1,220 @@ +# CLI: project setup with modules and background jobs + +**Date:** 2026-04-26 +**Status:** Draft, pending implementation + +## Problem + +`sm new ` today only pre-wires three modules (`users`, `dashboard`, `permissions`) and ignores the cost of opting in to anything else. To stand up a project that uses `background_tasks`, the user has to: + +1. Add `simple_module_background_tasks` to `pyproject.toml` by hand. +2. Set `SM_BG_TASKS_BROKER_URL` in `.env`. +3. Author a `scripts/run_worker.py` that builds the Celery app. +4. Add `worker` / `beat` / `worker-docker` targets to `Makefile`. +5. Author a `docker-compose.yml` with `redis`, `worker`, and `beat` services and a `worker.Dockerfile`. + +That is enough friction that "I want background jobs" turns into a half-day yak-shave. The same goes, to a lesser extent, for any module outside the hardcoded standard three. We want one command that scaffolds a project with any chosen subset of modules — including background jobs — and produces a runnable project. + +## Goals + +- `sm new` accepts an explicit module list via flags, or runs an interactive wizard when no flags are given. +- Selecting `background_tasks` lands a runnable Celery worker + beat + Redis stack via `docker compose up`, plus host Make targets and `scripts/run_worker.py`, with no manual editing required. +- Module dependencies are resolved transitively and added silently (with a printed note). +- The CLI catalog is hardcoded — adding a new module to the catalog is a CLI code change. +- The framework layer remains free of devex concerns (Makefile / compose). Recipes live in the CLI package. + +## Non-goals + +- Third-party module registration. The catalog is closed for this change. +- A TUI library (`questionary`, `inquirer`, etc.). Wizard uses `click.prompt` and `click.confirm`. +- Worker queue configuration, autoscaling, beat-only deployments. +- Replacing or deprecating `sm create-host` — it remains the lower-level "deps-only" command. + +## Design + +### File layout + +Replace `framework/hosting/simple_module_hosting/cli.py` with a package: + +``` +framework/hosting/simple_module_hosting/cli/ +├── __init__.py # click group; re-exports existing commands +├── new.py # `sm new` — flags + wizard, calls into catalog/wizard/recipes +├── catalog.py # ModuleEntry, CATALOG, PRESETS, expand_deps() +├── wizard.py # interactive prompts (db, tenancy, preset, custom-pick) +└── recipes.py # Recipe protocol + per-module post-scaffold actions +``` + +Each file has one responsibility and stays under the 300-line cap. Existing commands (`create-host`, `create-module`, `gen-pages`, `sync-js-deps`) move into `cli/__init__.py` (or thin per-command modules) so the `sm` console script keeps working. + +### Catalog + +```python +# cli/catalog.py +from dataclasses import dataclass + +@dataclass(frozen=True) +class ModuleEntry: + name: str # "background_tasks" — snake_case key + package: str # "simple_module_background_tasks" + display: str # "Background Tasks" + requires: tuple[str, ...] = () # other catalog keys + recipe: str | None = None # key into RECIPES, or None + +CATALOG: dict[str, ModuleEntry] = { + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry("permissions", "simple_module_permissions", "Permissions", requires=("auth", "users")), + "dashboard": ModuleEntry("dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products")), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry("file_storage", "simple_module_file_storage", "File Storage", requires=("settings",)), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "datasets": ModuleEntry("datasets", "simple_module_datasets", "Datasets", requires=("file_storage", "background_tasks")), + "background_tasks": ModuleEntry("background_tasks", "simple_module_background_tasks", "Background Tasks", requires=("users",), recipe="background_tasks"), +} + +PRESETS: dict[str, tuple[str, ...]] = { + "minimal": ("users",), + "standard": ("users", "dashboard", "permissions"), + "full": tuple(CATALOG), +} + +def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: + """Return (resolved_topo_order, auto_added_pairs). + auto_added_pairs is [(added_module, required_by), ...] for printing.""" +``` + +`requires=` values mirror each module's real `ModuleMeta.depends_on`, transcribed to the snake_case catalog key. + +`expand_deps` is the only non-trivial function: BFS over `requires`, append-only, preserves topo order so the resulting list is always loadable. Unknown name raises `KeyError("unknown module: ; available: ...")`. + +### `sm new` interface + +``` +sm new + --dest + --db sqlite|postgres (existing) + --tenancy/--no-tenancy (existing) + --preset minimal|standard|full NEW + --with mod1,mod2,... NEW — added on top of preset + --yes/-y (existing — skips wizard, uses defaults) + --no-install (existing) +``` + +Resolution rule: `selected = preset_modules ∪ --with`, then `expand_deps(selected)`. For each `(added, required_by)` pair, print `Added (required by )`. + +Defaults when `--yes` is given without `--preset`/`--with`: preset `standard` (matches today's pre-wired set, no behavior change for existing scripts). + +Conflict rule: if `--with` includes an unknown module name, exit 1 with the available list. If both `--preset` and `--with` are given, both are honored (union). + +### Wizard + +When `--yes` is absent and neither `--preset` nor `--with` is given, prompt in this order using `click.prompt` / `click.confirm`: + +1. `Database backend [sqlite/postgres]:` (default sqlite) +2. `Enable multi-tenancy? [y/N]:` +3. `Preset: [1] minimal [2] standard [3] full [4] custom` (default 2) +4. If choice 4: per-module `Include ? [y/N]:` loop in catalog order. +5. Resolve deps. Print: `Selected modules: ` and any `Added X (required by Y)` lines. +6. `Proceed? [Y/n]:` + +If `--yes` and any of `--preset`/`--with` is given, skip the wizard entirely. + +### Recipes + +```python +# cli/recipes.py +from typing import Protocol +from dataclasses import dataclass +from pathlib import Path + +@dataclass +class ScaffoldCtx: + name: str + db: str + tenancy: bool + selected: tuple[str, ...] + +class Recipe(Protocol): + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + +RECIPES: dict[str, Recipe] = { + "background_tasks": BackgroundTasksRecipe(), +} +``` + +`BackgroundTasksRecipe.apply` performs: + +1. `_set_env_key(target/".env.example", "SM_BG_TASKS_BROKER_URL", "redis://redis:6379/0")`. +2. Copy `templates/host/_optional/background_tasks/run_worker.py` to `target/scripts/run_worker.py`. +3. Append the `Makefile` snippet (worker / beat / worker-docker targets) — idempotent, skipped if any target already present. +4. Write `target/docker-compose.yml` with `redis`, `worker`, `beat` services. The host template does not ship a `docker-compose.yml`, so the recipe owns the file outright — no YAML merge needed, no `pyyaml` dependency. The compose file is shipped as a static template. +5. Copy `templates/host/_optional/background_tasks/worker.Dockerfile` to `target/docker/worker.Dockerfile`. + +The recipe receives `ScaffoldCtx`. `name` is reused for the compose project name; `db`/`tenancy` are unused for `background_tasks` today but are part of the protocol so future recipes can read them. + +### Templates + +New package-data files alongside the existing host templates: + +``` +framework/hosting/simple_module_hosting/templates/host/_optional/ +└── background_tasks/ + ├── run_worker.py + ├── docker-compose.yml + ├── Makefile.snippet + └── worker.Dockerfile +``` + +The `_optional/` segment is filtered out by `_apply_template_files` so it's not copied during the default `create_host` pass — it's only consumed by recipes. Add an explicit skip for any path containing `/_optional/` in the host-template walker. + +`run_worker.py` is the same shape as the in-tree `scripts/run_worker.py`: + +```python +from background_tasks.celery_app import build_celery +from background_tasks.settings import BackgroundTasksSettings +celery = build_celery(BackgroundTasksSettings()) +``` + +### Wiring into `create_app_project` + +`create_app_project` gains a `selected: Sequence[str] | None = None` parameter (default `None` → `PRESETS["standard"]`). Internally: + +1. Resolve `selected` via `expand_deps`. +2. Build `_APP_PY_DEPS` by mapping each catalog entry to `f"{entry.package}=={_FRAMEWORK_VERSION}"` instead of the current hardcoded list. +3. Call `create_host(target, name=name, modules=[entry.display for entry in resolved])` so the dependency declarations match. +4. After scaffolding, for each entry with a `recipe`, look it up in `RECIPES` and call `apply(target, ctx)`. + +The two existing kwargs — `db` and `tenancy` — keep their current behavior. + +### Backward compat + +- `sm create-host --with=` keeps current "deps-only" behavior unchanged. +- `sm new --yes` with no other flags → standard preset → same outcome as today. +- `create_app_project` callers passing only `name`, `db`, `tenancy` keep working (default `selected=None`). + +## Tests + +- `framework/hosting/tests/test_cli_catalog.py` — `expand_deps` returns transitive closure; auto-add list correct; unknown name raises with available-list message; idempotent on already-resolved input. +- `framework/hosting/tests/test_cli_wizard.py` — `CliRunner` driving each preset path (1/2/3/4) and the custom checkbox loop; verifies dep auto-add notice prints; verifies `--yes` skips prompts. +- `framework/hosting/tests/test_cli_recipes.py` — `BackgroundTasksRecipe.apply` against a fresh tempdir already populated by `create_host`. Asserts `.env.example` contains `SM_BG_TASKS_BROKER_URL`, `scripts/run_worker.py` exists with the expected import, `Makefile` contains `worker:` / `beat:` targets, `docker-compose.yml` parses to YAML with `redis`, `worker`, `beat` keys under `services`. +- `framework/hosting/tests/test_cli_new.py` — end-to-end smoke: `sm new demo --yes --preset=full --no-install --dest=` produces a project that contains the union of all modules, a runnable compose file, Make targets, and `pyproject.toml` listing every package. + +## Failure modes + +- **Existing files when applying recipes.** `Makefile` and `.env.example` are edited idempotently (skip if marker present). `docker-compose.yml`, `scripts/run_worker.py`, and `docker/worker.Dockerfile` are written outright; if any already exist when the recipe runs, the recipe errors out — collision means user-authored content from a previous scaffold attempt and we don't clobber. +- **Catalog drift.** A unit test loads every package referenced in the catalog (by import name) and asserts the Python distribution exists in the workspace. Catches typos and renames at CI time, not at user-runtime. +- **`--with` typos.** Print available module list with the closest match (`difflib.get_close_matches`). + +## Migration + +This change is additive: + +- `cli.py` becomes `cli/__init__.py` plus four new files. The `sm` console-script entry point in `framework/hosting/pyproject.toml` continues to point at `simple_module_hosting.cli:main` — `__init__.py` exposes `main` from the new package. +- No template changes that affect existing host scaffolds. The new `_optional/` tree is additive. +- `create_app_project`'s positional/kwarg signature is unchanged; only the new `selected=` kwarg is added. + +## Open questions + +None blocking. The catalog `requires=` mappings will be re-verified against each module's real `ModuleMeta.depends_on` during implementation; the values in the Catalog section above are the current best mapping and may be tightened. From 7880bf0b8ab1ed63920f2c3487834ebe8f8d93f2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 19:58:14 +0200 Subject: [PATCH 02/24] docs(plan): CLI project setup with modules and background jobs Implementation plan for the spec at docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md. Seven TDD tasks: package split, catalog + expand_deps, wizard, background_tasks recipe + opt-in templates, create_app_project refactor, sm new flag wiring, lint/typecheck pass. --- .../2026-04-26-cli-modules-and-bg-jobs.md | 1392 +++++++++++++++++ 1 file changed, 1392 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md diff --git a/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md b/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md new file mode 100644 index 00000000..ea0bbc4d --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-cli-modules-and-bg-jobs.md @@ -0,0 +1,1392 @@ +# CLI: project setup with modules and background jobs — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade `sm new` so it scaffolds a SimpleModule project with any chosen subset of modules (presets or custom) and lands a runnable Celery worker + beat + Redis stack when `background_tasks` is selected — no manual editing required. + +**Architecture:** Replace the single-file `framework/hosting/simple_module_hosting/cli.py` with a `cli/` package containing a hardcoded module **catalog** (with transitive-dep resolution), an interactive **wizard**, and per-module **recipes** that perform post-scaffold actions. The `background_tasks` recipe writes `scripts/run_worker.py`, appends Make targets, and emits a `docker-compose.yml` + `worker.Dockerfile`. + +**Tech Stack:** Python 3.12, `click` (existing dep), Click `CliRunner` for tests, `pytest`. No new runtime deps. Templates ship as package data under `simple_module_hosting/templates/host/_optional/background_tasks/`. + +**Spec:** `docs/superpowers/specs/2026-04-26-cli-modules-and-bg-jobs-design.md` + +--- + +## File Structure (created or modified) + +| Path | Role | +|---|---| +| `framework/hosting/simple_module_hosting/cli.py` | **Deleted** — replaced by package below | +| `framework/hosting/simple_module_hosting/cli/__init__.py` | Click group; existing commands `create-host`, `create-module`, `gen-pages`, `sync-js-deps` live here. Re-exports `main`. | +| `framework/hosting/simple_module_hosting/cli/catalog.py` | `ModuleEntry`, `CATALOG`, `PRESETS`, `expand_deps()` | +| `framework/hosting/simple_module_hosting/cli/wizard.py` | `run_wizard(default_db, default_tenancy)` returning `(db, tenancy, selected)` | +| `framework/hosting/simple_module_hosting/cli/recipes.py` | `Recipe` protocol, `ScaffoldCtx`, `RECIPES` dict, `BackgroundTasksRecipe` | +| `framework/hosting/simple_module_hosting/cli/new.py` | The upgraded `new_project` command | +| `framework/hosting/simple_module_hosting/scaffolding.py` | `create_app_project` gains `selected: Sequence[str] \| None` param | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py` | Static template (no `.tpl` suffix; verbatim copy by recipe — bypass `_apply_template_files`) | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml` | Compose stack: redis + worker + beat | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet` | `worker` / `beat` / `worker-docker` targets | +| `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile` | Celery image for worker + beat | +| `framework/hosting/tests/test_cli_catalog.py` | New | +| `framework/hosting/tests/test_cli_wizard.py` | New | +| `framework/hosting/tests/test_cli_recipes.py` | New | +| `framework/hosting/tests/test_cli_new.py` | Extended with preset + recipe tests | + +`framework/hosting/pyproject.toml` does **not** change — the entry point `sm = "simple_module_hosting.cli:main"` resolves the same after `cli.py` becomes `cli/__init__.py` because `main` is re-exported. + +--- + +## Task 1: Convert `cli.py` → `cli/` package (no behavior change) + +**Files:** +- Delete: `framework/hosting/simple_module_hosting/cli.py` +- Create: `framework/hosting/simple_module_hosting/cli/__init__.py` +- Test: `framework/hosting/tests/test_cli_new.py` (existing — must keep passing) + +This task is a pure refactor. We move every existing command into `cli/__init__.py` verbatim so the test suite continues to pass before any behavior change. + +- [ ] **Step 1: Run existing CLI tests baseline** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 2: Create the package directory** + +```bash +mkdir -p framework/hosting/simple_module_hosting/cli +git mv framework/hosting/simple_module_hosting/cli.py framework/hosting/simple_module_hosting/cli/__init__.py +``` + +- [ ] **Step 3: Run tests to confirm move is transparent** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py -v` +Expected: PASS (3 tests). Python imports `cli/__init__.py` for `simple_module_hosting.cli` exactly the same as `cli.py`, so `main` is still findable. + +- [ ] **Step 4: Verify `sm` console script still resolves** + +Run: `uv run sm --help` +Expected: Lists `new`, `create-host`, `create-module`, `gen-pages`, `sync-js-deps` — same as before. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/__init__.py +git rm framework/hosting/simple_module_hosting/cli.py +git commit -m "refactor(cli): move cli.py to cli/__init__.py (no behavior change) + +Preparing the file for split into a package — catalog, wizard, recipes, +new will land in dedicated modules. Console-script entry point unchanged." +``` + +--- + +## Task 2: Build the module catalog with transitive dep resolution + +**Files:** +- Create: `framework/hosting/simple_module_hosting/cli/catalog.py` +- Create: `framework/hosting/tests/test_cli_catalog.py` + +The catalog is a pure data + one pure function (`expand_deps`). TDD it. + +- [ ] **Step 1: Write failing tests for `expand_deps`** + +```python +# framework/hosting/tests/test_cli_catalog.py +"""Tests for the module catalog and dependency expansion.""" + +from __future__ import annotations + +import pytest +from simple_module_hosting.cli.catalog import ( + CATALOG, + PRESETS, + ModuleEntry, + expand_deps, +) + + +def test_catalog_keys_match_entry_names() -> None: + for key, entry in CATALOG.items(): + assert key == entry.name, f"catalog key {key!r} != entry.name {entry.name!r}" + + +def test_every_requires_value_is_a_known_catalog_key() -> None: + for entry in CATALOG.values(): + for required in entry.requires: + assert required in CATALOG, ( + f"{entry.name} requires unknown module {required!r}" + ) + + +def test_presets_only_reference_known_modules() -> None: + for name, mods in PRESETS.items(): + for m in mods: + assert m in CATALOG, f"preset {name!r} references unknown module {m!r}" + + +def test_expand_deps_returns_input_when_no_requires() -> None: + resolved, added = expand_deps(["auth"]) + assert resolved == ["auth"] + assert added == [] + + +def test_expand_deps_pulls_in_transitive_dep() -> None: + # users requires auth (per ModuleMeta.depends_on) + resolved, added = expand_deps(["users"]) + assert set(resolved) == {"auth", "users"} + assert added == [("auth", "users")] + + +def test_expand_deps_pulls_in_chain() -> None: + # datasets -> file_storage -> settings AND datasets -> background_tasks -> users -> auth + resolved, added = expand_deps(["datasets"]) + assert set(resolved) == { + "datasets", + "file_storage", + "settings", + "background_tasks", + "users", + "auth", + } + # Every added pair points to a real requirer in the input or already-added set. + added_names = {a for a, _ in added} + assert added_names == {"file_storage", "settings", "background_tasks", "users", "auth"} + + +def test_expand_deps_idempotent_when_input_already_complete() -> None: + resolved1, _ = expand_deps(["users"]) + resolved2, added2 = expand_deps(resolved1) + assert sorted(resolved1) == sorted(resolved2) + assert added2 == [] + + +def test_expand_deps_unknown_name_raises_with_available_list() -> None: + with pytest.raises(KeyError) as exc: + expand_deps(["does_not_exist"]) + msg = str(exc.value) + assert "does_not_exist" in msg + assert "auth" in msg # one of the available names in the message + + +def test_expand_deps_preserves_load_order_dep_before_dependent() -> None: + """The resolved list must be in topo order: a module's deps appear before it.""" + resolved, _ = expand_deps(["dashboard"]) + for i, name in enumerate(resolved): + for required in CATALOG[name].requires: + assert resolved.index(required) < i, ( + f"{required} must appear before {name} in {resolved}" + ) + + +def test_module_entry_is_frozen() -> None: + entry = ModuleEntry(name="x", package="simple_module_x", display="X") + with pytest.raises(Exception): # FrozenInstanceError or AttributeError + entry.name = "y" # type: ignore[misc] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_catalog.py -v` +Expected: All tests fail with `ModuleNotFoundError: No module named 'simple_module_hosting.cli.catalog'`. + +- [ ] **Step 3: Implement `cli/catalog.py`** + +```python +# framework/hosting/simple_module_hosting/cli/catalog.py +"""Hardcoded catalog of installable SimpleModule modules. + +Each :class:`ModuleEntry` declares the PyPI package name, a human display +name, transitive `requires` (other catalog keys), and an optional `recipe` +key for post-scaffold actions handled by :mod:`.recipes`. + +`expand_deps` takes a user-selected subset and returns a topologically +ordered superset including every transitive requirement, plus the list +of `(added, required_by)` pairs for printing back to the user. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +__all__ = ["CATALOG", "PRESETS", "ModuleEntry", "expand_deps"] + + +@dataclass(frozen=True) +class ModuleEntry: + name: str + package: str + display: str + requires: tuple[str, ...] = field(default_factory=tuple) + recipe: str | None = None + + +# Keys are snake_case; values mirror each module's real +# ``ModuleMeta.depends_on`` (transcribed to catalog keys). +CATALOG: dict[str, ModuleEntry] = { + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), + "permissions": ModuleEntry("permissions", "simple_module_permissions", "Permissions", requires=("auth", "users")), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "dashboard": ModuleEntry("dashboard", "simple_module_dashboard", "Dashboard", requires=("users", "products")), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), + "file_storage": ModuleEntry("file_storage", "simple_module_file_storage", "File Storage", requires=("settings",)), + "background_tasks": ModuleEntry("background_tasks", "simple_module_background_tasks", "Background Tasks", requires=("users",), recipe="background_tasks"), + "datasets": ModuleEntry("datasets", "simple_module_datasets", "Datasets", requires=("file_storage", "background_tasks")), +} + + +PRESETS: dict[str, tuple[str, ...]] = { + "minimal": ("users",), + "standard": ("users", "dashboard", "permissions"), + "full": tuple(CATALOG), +} + + +def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: + """Return (topo-ordered resolved list, [(added, required_by), ...]). + + Raises ``KeyError`` if any input name is not in the catalog. The + error message lists the available catalog keys. + """ + selected_list = list(selected) + for name in selected_list: + if name not in CATALOG: + available = ", ".join(sorted(CATALOG)) + raise KeyError( + f"unknown module: {name!r}; available: {available}" + ) + + explicit = set(selected_list) + resolved: list[str] = [] + in_resolved: set[str] = set() + added: list[tuple[str, str]] = [] + + def _visit(name: str, required_by: str | None) -> None: + if name in in_resolved: + return + for dep in CATALOG[name].requires: + _visit(dep, required_by=name) + resolved.append(name) + in_resolved.add(name) + if required_by is not None and name not in explicit: + added.append((name, required_by)) + + for name in selected_list: + _visit(name, required_by=None) + return resolved, added +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest framework/hosting/tests/test_cli_catalog.py -v` +Expected: All 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/catalog.py framework/hosting/tests/test_cli_catalog.py +git commit -m "feat(cli): module catalog with transitive dep expansion + +Adds CATALOG, PRESETS, and expand_deps() — pure data + one pure +function. Will be wired into 'sm new' in a follow-up." +``` + +--- + +## Task 3: Implement the interactive wizard + +**Files:** +- Create: `framework/hosting/simple_module_hosting/cli/wizard.py` +- Create: `framework/hosting/tests/test_cli_wizard.py` + +Wizard owns the prompt sequence: db → tenancy → preset (or custom checkbox loop) → confirmation. Returns the resolved tuple `(db, tenancy, selected_topo_ordered)`. + +- [ ] **Step 1: Write failing tests for the wizard** + +```python +# framework/hosting/tests/test_cli_wizard.py +"""Tests for the `sm new` interactive wizard.""" + +from __future__ import annotations + +import click +from click.testing import CliRunner +from simple_module_hosting.cli.wizard import run_wizard + + +def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: + """Run the wizard with stdin pre-fed; return (db, tenancy, selected, output).""" + + captured: dict = {} + + @click.command() + def wrapper() -> None: + db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) + captured["db"] = db + captured["tenancy"] = tenancy + captured["selected"] = selected + + runner = CliRunner() + result = runner.invoke(wrapper, input="\n".join(answers) + "\n") + assert result.exit_code == 0, result.output + return captured["db"], captured["tenancy"], captured["selected"], result.output + + +def test_wizard_standard_preset_default_path() -> None: + # Answers: db=, tenancy=, preset=, confirm= + db, tenancy, selected, out = _drive(["", "", "", ""]) + assert db == "sqlite" + assert tenancy is False + assert "users" in selected and "dashboard" in selected and "permissions" in selected + # auth auto-added because users/permissions require it + assert "auth" in selected + assert "Added auth (required by" in out + + +def test_wizard_postgres_with_tenancy() -> None: + db, tenancy, _selected, _out = _drive(["postgres", "y", "", ""]) + assert db == "postgres" + assert tenancy is True + + +def test_wizard_minimal_preset() -> None: + _, _, selected, _ = _drive(["", "", "1", ""]) + # minimal = users; users requires auth + assert set(selected) == {"users", "auth"} + + +def test_wizard_full_preset_includes_background_tasks() -> None: + _, _, selected, _ = _drive(["", "", "3", ""]) + assert "background_tasks" in selected + assert "datasets" in selected + assert len(selected) >= 10 + + +def test_wizard_custom_picks_only_yes_answers() -> None: + # preset=4 (custom). Per-module loop walks CATALOG order: + # auth, users, permissions, products, dashboard, settings, feature_flags, + # file_storage, background_tasks, datasets — answer y to background_tasks only. + answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""] + _, _, selected, out = _drive(answers) + # background_tasks pulls in users (its only require), which pulls in auth. + assert set(selected) == {"background_tasks", "users", "auth"} + assert "Added users (required by background_tasks)" in out + assert "Added auth (required by users)" in out + + +def test_wizard_aborts_on_confirm_no() -> None: + captured: dict = {} + + @click.command() + def wrapper() -> None: + try: + run_wizard(default_db="sqlite", default_tenancy=False) + except click.Abort: + captured["aborted"] = True + raise + + runner = CliRunner() + result = runner.invoke(wrapper, input="\n".join(["", "", "", "n"]) + "\n") + assert result.exit_code != 0 + assert captured.get("aborted") is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_wizard.py -v` +Expected: All 6 tests fail with `ModuleNotFoundError: No module named 'simple_module_hosting.cli.wizard'`. + +- [ ] **Step 3: Implement `cli/wizard.py`** + +```python +# framework/hosting/simple_module_hosting/cli/wizard.py +"""Interactive prompt sequence for `sm new`. + +Returns the user's choices as ``(db, tenancy, selected)`` where ``selected`` +is the topologically resolved module list (already includes transitive +requires). All prompts use ``click`` — no extra TUI dep. +""" + +from __future__ import annotations + +import click + +from .catalog import CATALOG, PRESETS, expand_deps + +__all__ = ["run_wizard"] + + +_PRESET_CHOICES = ("minimal", "standard", "full", "custom") + + +def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: + db = click.prompt( + "Database backend", + default=default_db, + type=click.Choice(["sqlite", "postgres"]), + ) + tenancy = click.confirm("Enable multi-tenancy?", default=default_tenancy) + + click.echo("\nPreset:") + click.echo(" [1] minimal — users only") + click.echo(" [2] standard — users, dashboard, permissions (default)") + click.echo(" [3] full — every module") + click.echo(" [4] custom — pick modules one by one") + choice = click.prompt( + "Choose", + default="2", + type=click.Choice(["1", "2", "3", "4"]), + show_choices=False, + ) + preset_name = _PRESET_CHOICES[int(choice) - 1] + + if preset_name == "custom": + picked = [ + name for name in CATALOG + if click.confirm(f"Include {CATALOG[name].display}?", default=False) + ] + else: + picked = list(PRESETS[preset_name]) + + resolved, added = expand_deps(picked) + for name, required_by in added: + click.echo(f"Added {name} (required by {required_by})") + click.echo(f"Selected modules: {', '.join(resolved)}") + + if not click.confirm("Proceed?", default=True): + raise click.Abort() + return db, tenancy, resolved +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest framework/hosting/tests/test_cli_wizard.py -v` +Expected: All 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/wizard.py framework/hosting/tests/test_cli_wizard.py +git commit -m "feat(cli): interactive wizard for sm new + +db -> tenancy -> preset (or custom checkbox loop) -> confirm. Auto-adds +required deps with a printed note. No new TUI dependency." +``` + +--- + +## Task 4: Add `background_tasks` recipe + templates + +**Files:** +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py` +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml` +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet` +- Create: `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile` +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py:58-72` — make `_iter_template_files` skip `_optional/` +- Create: `framework/hosting/simple_module_hosting/cli/recipes.py` +- Create: `framework/hosting/tests/test_cli_recipes.py` + +### Step group A — templates + +- [ ] **Step 1: Write the worker run script template** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py`: + +```python +"""Entry point for the Celery worker and beat services. + +Both the web process and the worker go through the same +``background_tasks.celery_app.build_celery`` factory so the broker +config, autodiscovered tasks, and signal handlers stay in lockstep. + +Run locally: + uv run celery -A scripts.run_worker:celery worker -l info + uv run celery -A scripts.run_worker:celery beat -l info +""" + +from __future__ import annotations + +from background_tasks.celery_app import build_celery +from background_tasks.settings import BackgroundTasksSettings + +celery = build_celery(BackgroundTasksSettings()) +``` + +- [ ] **Step 2: Write the docker-compose template** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml`: + +```yaml +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + worker: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "worker" + - "-l" + - "info" + - "--concurrency=4" + + beat: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + worker: + condition: service_started + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "beat" + - "-l" + - "info" + +volumes: + redisdata: +``` + +- [ ] **Step 3: Write the Makefile snippet** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet`: + +```make +# --- background_tasks ---------------------------------------------------- +.PHONY: worker beat worker-docker + +worker: ## Run a Celery worker locally against $(SM_BG_TASKS_BROKER_URL) + uv run celery -A scripts.run_worker:celery worker -l info + +beat: ## Run the Celery beat scheduler locally + uv run celery -A scripts.run_worker:celery beat -l info + +worker-docker: ## Build + run the worker + beat services in docker + docker compose up --build worker beat +# --- end background_tasks ------------------------------------------------ +``` + +- [ ] **Step 4: Write the worker Dockerfile template** + +Create `framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile`: + +```dockerfile +# Celery worker image for the BackgroundTasks module. +# Serves both the worker and beat services in docker-compose — they +# differ only by command. + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY scripts/ scripts/ +COPY client_app/ client_app/ + +RUN uv sync --frozen --no-dev + +RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin worker \ + && chown -R worker:worker /app +USER worker + +ENV CELERY_APP=scripts.run_worker:celery +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD uv run celery -A $CELERY_APP inspect ping -d celery@$HOSTNAME || exit 1 + +CMD ["uv", "run", "celery", "-A", "scripts.run_worker:celery", "worker", "-l", "info"] +``` + +- [ ] **Step 5: Update `_iter_template_files` to skip `_optional/`** + +In `framework/hosting/simple_module_hosting/scaffolding.py`, replace the `_iter_template_files` function (lines ~58-62): + +```python +def _iter_template_files(template_root: Path): + """Yield every file under ``template_root``, preserving relative paths. + + Skips any path under an ``_optional/`` segment — those are recipe-managed + templates consumed by :mod:`simple_module_hosting.cli.recipes`, not by + the default scaffolding pass. + """ + for path in template_root.rglob("*"): + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path +``` + +- [ ] **Step 6: Run existing scaffolding tests to confirm `_optional/` is excluded** + +Run: `uv run pytest framework/hosting/tests/test_scaffolding_host.py framework/hosting/tests/test_cli_new.py -v` +Expected: All existing tests still pass — the `_optional/` files do **not** leak into the scaffolded host. + +### Step group B — recipe + +- [ ] **Step 7: Write failing tests for `BackgroundTasksRecipe`** + +```python +# framework/hosting/tests/test_cli_recipes.py +"""Tests for per-module post-scaffold recipes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from simple_module_hosting.cli.recipes import ( + RECIPES, + BackgroundTasksRecipe, + ScaffoldCtx, +) +from simple_module_hosting.scaffolding import create_host + + +def _scaffold_minimal_host(target: Path) -> None: + create_host(target, name="demo", modules=["Users"]) + + +def test_background_tasks_recipe_registered() -> None: + assert "background_tasks" in RECIPES + assert isinstance(RECIPES["background_tasks"], BackgroundTasksRecipe) + + +def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + script = tmp_path / "scripts" / "run_worker.py" + assert script.is_file() + text = script.read_text() + assert "from background_tasks.celery_app import build_celery" in text + assert "celery = build_celery(BackgroundTasksSettings())" in text + + +def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + compose = (tmp_path / "docker-compose.yml").read_text() + assert "redis:" in compose + assert "worker:" in compose + assert "beat:" in compose + assert "scripts.run_worker:celery" in compose + + +def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + dockerfile = (tmp_path / "docker" / "worker.Dockerfile").read_text() + assert "FROM python:3.12-slim" in dockerfile + assert "scripts.run_worker:celery" in dockerfile + + +def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + makefile = (tmp_path / "Makefile").read_text() + assert "worker:" in makefile + assert "beat:" in makefile + assert "worker-docker:" in makefile + + +def test_recipe_sets_broker_url_env_var(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply( + tmp_path, ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + ) + env_text = (tmp_path / ".env.example").read_text() + assert "SM_BG_TASKS_BROKER_URL=redis://redis:6379/0" in env_text + + +def test_recipe_makefile_snippet_idempotent(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + ctx = ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) + BackgroundTasksRecipe().apply(tmp_path, ctx) + first = (tmp_path / "Makefile").read_text() + # Re-running on the same target must error or be idempotent — collisions + # on run_worker.py / compose / Dockerfile must raise. + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply(tmp_path, ctx) + assert (tmp_path / "Makefile").read_text() == first + + +def test_recipe_errors_on_existing_run_worker(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + (tmp_path / "scripts").mkdir(exist_ok=True) + (tmp_path / "scripts" / "run_worker.py").write_text("# user-authored\n") + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply( + tmp_path, + ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)), + ) +``` + +- [ ] **Step 8: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_recipes.py -v` +Expected: All tests fail with `ModuleNotFoundError: No module named 'simple_module_hosting.cli.recipes'`. + +- [ ] **Step 9: Implement `cli/recipes.py`** + +```python +# framework/hosting/simple_module_hosting/cli/recipes.py +"""Per-module post-scaffold recipes. + +A recipe is invoked by ``sm new`` after the base host scaffold lands. It +performs module-specific actions (write helper scripts, append Make +targets, drop a docker-compose stack). The framework layer is kept free +of devex concerns — recipes know about Makefiles and compose, framework +scaffolding does not. +""" + +from __future__ import annotations + +import importlib.resources +import shutil +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +__all__ = [ + "BackgroundTasksRecipe", + "RECIPES", + "Recipe", + "ScaffoldCtx", +] + +_OPTIONAL_PACKAGE = "simple_module_hosting.templates.host._optional" +_BG_BROKER_ENV_KEY = "SM_BG_TASKS_BROKER_URL" +_BG_BROKER_DEFAULT = "redis://redis:6379/0" +_MAKEFILE_MARKER = "# --- background_tasks --" + + +@dataclass(frozen=True) +class ScaffoldCtx: + name: str + db: str + tenancy: bool + selected: Sequence[str] + + +class Recipe(Protocol): + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + + +def _optional_template_root(name: str) -> Path: + return Path(str(importlib.resources.files(_OPTIONAL_PACKAGE) / name)) + + +def _set_env_key(text: str, key: str, value: str) -> str: + """Replace or append ``KEY=VALUE`` in an env-style file body.""" + lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")] + lines.append(f"{key}={value}") + return "\n".join(lines) + "\n" + + +class BackgroundTasksRecipe: + """Lays down run_worker.py + compose + Dockerfile + 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): + if path.exists(): + raise FileExistsError( + f"{path} already exists — refusing to clobber. " + "Remove the file or run `sm new` against an empty directory." + ) + + run_worker_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "run_worker.py", run_worker_dest) + + shutil.copy2(templates / "docker-compose.yml", compose_dest) + + dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest) + + env_path = target / ".env.example" + env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + env_path.write_text( + _set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT), + encoding="utf-8", + ) + + makefile_path = target / "Makefile" + snippet = (templates / "Makefile.snippet").read_text(encoding="utf-8") + existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" + if _MAKEFILE_MARKER not in existing: + sep = "" if existing.endswith("\n") or not existing else "\n" + makefile_path.write_text(existing + sep + snippet, encoding="utf-8") + + +RECIPES: dict[str, Recipe] = { + "background_tasks": BackgroundTasksRecipe(), +} +``` + +- [ ] **Step 10: Run tests to verify they pass** + +Run: `uv run pytest framework/hosting/tests/test_cli_recipes.py -v` +Expected: All 8 tests pass. + +- [ ] **Step 11: Commit** + +```bash +git add framework/hosting/simple_module_hosting/templates/host/_optional/ \ + framework/hosting/simple_module_hosting/scaffolding.py \ + framework/hosting/simple_module_hosting/cli/recipes.py \ + framework/hosting/tests/test_cli_recipes.py +git commit -m "feat(cli): background_tasks recipe + opt-in templates + +Recipes lay down post-scaffold artifacts (run_worker.py, docker-compose +with redis/worker/beat, worker.Dockerfile, Makefile targets, env var) +without touching framework scaffolding. Templates live under +templates/host/_optional/ and are skipped by the default copy walker." +``` + +--- + +## Task 5: Refactor `create_app_project` to accept a `selected=` list + +**Files:** +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py:198-268` +- Test: `framework/hosting/tests/test_cli_new.py` (add new test cases) + +`create_app_project` currently hardcodes `["users", "dashboard", "permissions"]` and a hardcoded `_APP_PY_DEPS`. Make both come from the catalog so the wizard / flags can drive it. + +- [ ] **Step 1: Add failing tests for the new `selected=` parameter** + +Append to `framework/hosting/tests/test_cli_new.py`: + +```python +def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=["users", "background_tasks"]) + + pyproject = (target / "pyproject.toml").read_text() + # background_tasks selected -> dep listed + assert "simple_module_background_tasks" in pyproject + # auth auto-added (users requires auth) + assert "simple_module_auth" in pyproject + # dashboard NOT requested -> NOT listed + assert "simple_module_dashboard" not in pyproject + + +def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=["background_tasks"]) + + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + assert (target / "docker" / "worker.Dockerfile").is_file() + makefile_text = (target / "Makefile").read_text() + assert "worker:" in makefile_text + + +def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False) + pyproject = (target / "pyproject.toml").read_text() + for required in ("simple_module_users", "simple_module_dashboard", "simple_module_permissions"): + assert required in pyproject +``` + +- [ ] **Step 2: Run new tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py::test_create_app_project_with_selected_kwarg framework/hosting/tests/test_cli_new.py::test_create_app_project_runs_recipe_for_background_tasks -v` +Expected: FAIL — `selected` is an unexpected kwarg. + +- [ ] **Step 3: Refactor `create_app_project`** + +Replace the function body in `framework/hosting/simple_module_hosting/scaffolding.py` (around lines 198-268). Drop the hardcoded `_APP_PY_DEPS` constant; build deps from the catalog. Add `selected: Sequence[str] | None = None`. + +```python +# Near the top of scaffolding.py (after existing imports), add: +from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps +from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx +``` + +(Keep these imports at module bottom-of-imports to avoid circulars — `cli/recipes.py` imports nothing from `scaffolding.py` at module scope.) + +Then replace `create_app_project`: + +```python +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> 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 framework + versions, and applies any matching post-scaffold recipes. + """ + if target.exists() and any(target.iterdir()): + raise FileExistsError( + f"Destination {target} already exists and is non-empty; " + "choose a new path or remove its contents first." + ) + + chosen = list(selected) if selected is not None else list(PRESETS["standard"]) + resolved, _added = expand_deps(chosen) + + # create_host expects display names (PascalCase) for the {{MODULE_DEPS}} template. + display_names = [CATALOG[m].display.replace(" ", "") for m in resolved] + create_host(target, name=name, modules=display_names) + + py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ + f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved + ] + + 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_MULTI_TENANT", "true" if tenancy else "false") + env_path.write_text(env_text, encoding="utf-8") + + pyproject = target / "pyproject.toml" + if pyproject.exists(): + text = pyproject.read_text(encoding="utf-8") + text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) + pyproject.write_text(text, encoding="utf-8") + + pkg_path = target / "package.json" + 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) + pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") + + ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) + for mod_name in resolved: + recipe_key = CATALOG[mod_name].recipe + if recipe_key is not None and recipe_key in RECIPES: + RECIPES[recipe_key].apply(target, ctx) +``` + +Delete the now-unused `_APP_PY_DEPS` constant. + +- [ ] **Step 4: Run all CLI/scaffolding tests** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py framework/hosting/tests/test_cli_catalog.py framework/hosting/tests/test_cli_wizard.py framework/hosting/tests/test_cli_recipes.py framework/hosting/tests/test_scaffolding_host.py -v` +Expected: All pass — including the three new tests from Step 1 and the back-compat test. + +- [ ] **Step 5: Commit** + +```bash +git add framework/hosting/simple_module_hosting/scaffolding.py framework/hosting/tests/test_cli_new.py +git commit -m "feat(scaffolding): create_app_project accepts selected= module list + +Default 'standard' preset preserves existing behavior. Selected modules +drive both Python deps (from catalog) and post-scaffold recipes." +``` + +--- + +## Task 6: Wire `sm new` to use catalog + wizard + recipes + +**Files:** +- Create: `framework/hosting/simple_module_hosting/cli/new.py` +- Modify: `framework/hosting/simple_module_hosting/cli/__init__.py` — register `new_project` command from `new.py`, drop the old inline definition +- Test: `framework/hosting/tests/test_cli_new.py` — add flag-driven coverage + +- [ ] **Step 1: Write failing tests for the new flag interface** + +Append to `framework/hosting/tests/test_cli_new.py`: + +```python +def test_sm_new_with_preset_full_includes_background_tasks(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--preset", "full", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_background_tasks" in pyproject + + +def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + [ + "new", "demo", + "--yes", + "--preset", "minimal", + "--with", "background_tasks", + "--no-install", + "--dest", str(target), + ], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + # users (from minimal) + background_tasks + transitively auth + assert "simple_module_users" in pyproject + assert "simple_module_background_tasks" in pyproject + assert "simple_module_auth" in pyproject + + +def test_sm_new_unknown_with_module_errors(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--with", "does_not_exist", "--no-install", "--dest", str(target)], + ) + assert result.exit_code != 0 + assert "does_not_exist" in result.output + assert "available" in result.output.lower() + + +def test_sm_new_yes_with_no_flags_uses_standard_preset(tmp_path: Path) -> None: + """Back-compat: --yes alone keeps today's pre-wired set.""" + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + for required in ("simple_module_users", "simple_module_dashboard", "simple_module_permissions"): + assert required in pyproject + # background_tasks not in standard preset + assert "simple_module_background_tasks" not in pyproject + + +def test_sm_new_interactive_full_preset(tmp_path: Path) -> None: + """Wizard path: db, tenancy, preset=3 (full), proceed.""" + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--no-install", "--dest", str(target)], + input="\n".join(["", "", "3", ""]) + "\n", + ) + assert result.exit_code == 0, result.output + assert (target / "docker-compose.yml").is_file() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py -v` +Expected: New tests fail (current `new` command doesn't accept `--preset` / `--with`). + +- [ ] **Step 3: Create `cli/new.py`** + +```python +# framework/hosting/simple_module_hosting/cli/new.py +"""The upgraded `sm new` command. + +Combines flag-driven non-interactive use (`--preset` / `--with`) with the +interactive wizard. All paths converge on +:func:`simple_module_hosting.scaffolding.create_app_project` with a +resolved module list. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import click + +from simple_module_hosting.scaffolding import create_app_project + +from .catalog import PRESETS, expand_deps +from .wizard import run_wizard + +__all__ = ["new_project"] + + +@click.command("new") +@click.argument("name") +@click.option( + "--dest", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Destination directory. Defaults to ./.", +) +@click.option( + "--db", + type=click.Choice(["sqlite", "postgres"]), + default="sqlite", + show_default=True, + help="Database backend to configure in .env.example.", +) +@click.option( + "--tenancy/--no-tenancy", + default=False, + show_default=True, + help="Enable the multi-tenant middleware by default.", +) +@click.option( + "--preset", + type=click.Choice(["minimal", "standard", "full"]), + default=None, + help="Module preset. Mutually compatible with --with (union).", +) +@click.option( + "--with", + "extra", + default="", + help="Comma-separated extra module names to include (e.g. background_tasks,file_storage).", +) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip interactive prompts; accept defaults.", +) +@click.option( + "--no-install", + is_flag=True, + default=False, + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", +) +def new_project( + name: str, + dest: Path | None, + db: str, + tenancy: bool, + preset: str | None, + extra: str, + yes: bool, + no_install: bool, +) -> None: + """Scaffold a new SimpleModule app, optionally with background jobs.""" + target = dest or Path.cwd() / name + + extra_list = [m.strip() for m in extra.split(",") if m.strip()] + flag_driven = preset is not None or bool(extra_list) + + if yes or flag_driven: + chosen = list(PRESETS[preset or "standard"]) + extra_list + try: + resolved, added = expand_deps(chosen) + except KeyError as exc: + click.echo(f"ERROR: {exc}", err=True) + sys.exit(1) + for added_name, required_by in added: + click.echo(f"Added {added_name} (required by {required_by})") + else: + try: + db, tenancy, resolved = run_wizard(default_db=db, default_tenancy=tenancy) + except click.Abort: + click.echo("Aborted.", err=True) + sys.exit(1) + + try: + create_app_project(target, name=name, db=db, tenancy=tenancy, selected=resolved) + except FileExistsError as exc: + click.echo(f"ERROR: {exc}", err=True) + sys.exit(1) + + click.echo(f"Created app '{name}' at {target}") + click.echo(f"Modules: {', '.join(resolved)}") + click.echo("\nNext steps:") + click.echo(f" cd {target}") + if no_install: + click.echo(" uv sync") + click.echo(" npm install") + click.echo(" alembic upgrade head") + click.echo(" make dev") + if "background_tasks" in resolved: + click.echo(" docker compose up -d redis worker beat # background jobs") + return + + click.echo("Installing dependencies...") + for cmd in (["uv", "sync"], ["npm", "install"]): + result = subprocess.run(cmd, cwd=target, check=False) + if result.returncode != 0: + click.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + "finish setup manually.", + err=True, + ) + return + + subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) + click.echo("\nSetup complete. Run `make dev` in the new directory.") + if "background_tasks" in resolved: + click.echo("For background jobs, also run: docker compose up -d redis worker beat") +``` + +- [ ] **Step 4: Strip the old `new_project` from `cli/__init__.py` and import from `new.py`** + +In `framework/hosting/simple_module_hosting/cli/__init__.py`: + +1. Remove the entire existing `@main.command("new")`-decorated `new_project` function. +2. Add the import + registration at the bottom of the file (after `main` is defined): + +```python +from .new import new_project as _new_project +main.add_command(_new_project) +``` + +- [ ] **Step 5: Run the full CLI test suite** + +Run: `uv run pytest framework/hosting/tests/test_cli_new.py framework/hosting/tests/test_cli_catalog.py framework/hosting/tests/test_cli_wizard.py framework/hosting/tests/test_cli_recipes.py framework/hosting/tests/test_scaffolding_host.py -v` +Expected: All tests pass — both the existing (`--yes --db sqlite`) tests and the five new flag/wizard tests from Step 1. + +- [ ] **Step 6: Manual smoke check** + +Run: +```bash +TMP=$(mktemp -d) && uv run sm new demo --yes --preset full --no-install --dest "$TMP/demo" +ls "$TMP/demo/scripts/run_worker.py" "$TMP/demo/docker-compose.yml" "$TMP/demo/docker/worker.Dockerfile" +grep -E '^(worker|beat|worker-docker):' "$TMP/demo/Makefile" +grep SM_BG_TASKS_BROKER_URL "$TMP/demo/.env.example" +``` +Expected: every file/grep matches; no errors. + +- [ ] **Step 7: Commit** + +```bash +git add framework/hosting/simple_module_hosting/cli/new.py \ + framework/hosting/simple_module_hosting/cli/__init__.py \ + framework/hosting/tests/test_cli_new.py +git commit -m "feat(cli): sm new with --preset, --with, and wizard + +Scaffolds a project with any chosen subset of modules. Selecting +background_tasks lands a runnable Celery worker + beat + Redis stack +via docker compose, host Make targets, and scripts/run_worker.py — no +manual editing required." +``` + +--- + +## Task 7: Lint, type-check, and final verification + +**Files:** none + +- [ ] **Step 1: Run the project lint suite** + +Run: `make lint` +Expected: PASS. `ty` and `ruff` are happy with the new package; per-file 300-line cap is not breached. + +- [ ] **Step 2: Run the full Python test suite** + +Run: `uv run pytest` +Expected: All tests pass — including the existing scaffolding/host tests, which must not regress. + +- [ ] **Step 3: Verify `make doctor` on a generated project** + +Run: +```bash +TMP=$(mktemp -d) && uv run sm new demo --yes --preset full --no-install --dest "$TMP/demo" +cd "$TMP/demo" && uv sync && uv run sm doctor 2>&1 || true +cd - +``` +Expected: Exits clean (no SM001/SM008/SM009 errors). SM010 may surface because the freshly-scaffolded project has no migration history yet — that's existing behavior. + +- [ ] **Step 4: Final commit if any cleanup applied** + +If lint or tests required follow-up edits: +```bash +git add -p +git commit -m "chore(cli): post-implementation cleanup" +``` + +Otherwise skip. + +--- + +## Self-Review (run after writing the plan) + +**Spec coverage:** Every section of the spec is mapped to a task — +- Catalog → Task 2 +- Wizard → Task 3 +- `sm new` flags → Task 6 +- Recipes + templates → Task 4 +- `create_app_project` refactor → Task 5 +- File-layout reorg into `cli/` package → Task 1 +- Tests for catalog / wizard / recipes / smoke → Tasks 2, 3, 4, 6 +- Lint / line-cap → Task 7 + +**No placeholders:** every code block is complete, every command shown. + +**Type consistency:** `ScaffoldCtx`, `Recipe`, `ModuleEntry`, `expand_deps` signatures are identical across catalog.py / recipes.py / new.py / scaffolding.py. `selected=` keyword is the same in `create_app_project` and `ScaffoldCtx`. + +**Out of scope:** third-party catalog extension; YAML merging; new TUI deps. From bea9ab0d909d1cbcccac4694f1d449496ecf6007 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 19:59:05 +0200 Subject: [PATCH 03/24] refactor(cli): move cli.py to cli/__init__.py (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparing the file for split into a package — catalog, wizard, recipes, new will land in dedicated modules. Console-script entry point unchanged. --- .../hosting/simple_module_hosting/{cli.py => cli/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename framework/hosting/simple_module_hosting/{cli.py => cli/__init__.py} (100%) diff --git a/framework/hosting/simple_module_hosting/cli.py b/framework/hosting/simple_module_hosting/cli/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/cli.py rename to framework/hosting/simple_module_hosting/cli/__init__.py From c0418aaa1d4d94063dda0962f08f3050543191a8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:00:01 +0200 Subject: [PATCH 04/24] feat(cli): module catalog with transitive dep expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CATALOG, PRESETS, and expand_deps() — pure data + one pure function. Will be wired into 'sm new' in a follow-up. --- .../simple_module_hosting/cli/catalog.py | 111 ++++++++++++++++++ framework/hosting/tests/test_cli_catalog.py | 87 ++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 framework/hosting/simple_module_hosting/cli/catalog.py create mode 100644 framework/hosting/tests/test_cli_catalog.py diff --git a/framework/hosting/simple_module_hosting/cli/catalog.py b/framework/hosting/simple_module_hosting/cli/catalog.py new file mode 100644 index 00000000..7e432eb8 --- /dev/null +++ b/framework/hosting/simple_module_hosting/cli/catalog.py @@ -0,0 +1,111 @@ +"""Hardcoded catalog of installable SimpleModule modules. + +Each :class:`ModuleEntry` declares the PyPI package name, a human display +name, transitive ``requires`` (other catalog keys), and an optional +``recipe`` key for post-scaffold actions handled by :mod:`.recipes`. + +:func:`expand_deps` takes a user-selected subset and returns a +topologically ordered superset including every transitive requirement, +plus the list of ``(added, required_by)`` pairs for printing back to the +user. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +__all__ = ["CATALOG", "PRESETS", "ModuleEntry", "expand_deps"] + + +@dataclass(frozen=True) +class ModuleEntry: + name: str + package: str + display: str + requires: tuple[str, ...] = field(default_factory=tuple) + recipe: str | None = None + + +CATALOG: dict[str, ModuleEntry] = { + "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), + "users": ModuleEntry( + "users", "simple_module_users", "Users", requires=("auth",) + ), + "permissions": ModuleEntry( + "permissions", + "simple_module_permissions", + "Permissions", + requires=("auth", "users"), + ), + "products": ModuleEntry("products", "simple_module_products", "Products"), + "dashboard": ModuleEntry( + "dashboard", + "simple_module_dashboard", + "Dashboard", + requires=("users", "products"), + ), + "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), + "feature_flags": ModuleEntry( + "feature_flags", "simple_module_feature_flags", "Feature Flags" + ), + "file_storage": ModuleEntry( + "file_storage", + "simple_module_file_storage", + "File Storage", + requires=("settings",), + ), + "background_tasks": ModuleEntry( + "background_tasks", + "simple_module_background_tasks", + "Background Tasks", + requires=("users",), + recipe="background_tasks", + ), + "datasets": ModuleEntry( + "datasets", + "simple_module_datasets", + "Datasets", + requires=("file_storage", "background_tasks"), + ), +} + + +PRESETS: dict[str, tuple[str, ...]] = { + "minimal": ("users",), + "standard": ("users", "dashboard", "permissions"), + "full": tuple(CATALOG), +} + + +def expand_deps(selected: Iterable[str]) -> tuple[list[str], list[tuple[str, str]]]: + """Return ``(topo-ordered resolved list, [(added, required_by), ...])``. + + Raises :class:`KeyError` if any input name is missing from the + catalog. The error message lists the available catalog keys so a + user typo (`--with=does_not_exist`) is self-correcting. + """ + selected_list = list(selected) + for name in selected_list: + if name not in CATALOG: + available = ", ".join(sorted(CATALOG)) + raise KeyError(f"unknown module: {name!r}; available: {available}") + + explicit = set(selected_list) + resolved: list[str] = [] + in_resolved: set[str] = set() + added: list[tuple[str, str]] = [] + + def _visit(name: str, required_by: str | None) -> None: + if name in in_resolved: + return + for dep in CATALOG[name].requires: + _visit(dep, required_by=name) + resolved.append(name) + in_resolved.add(name) + if required_by is not None and name not in explicit: + added.append((name, required_by)) + + for name in selected_list: + _visit(name, required_by=None) + return resolved, added diff --git a/framework/hosting/tests/test_cli_catalog.py b/framework/hosting/tests/test_cli_catalog.py new file mode 100644 index 00000000..17036d57 --- /dev/null +++ b/framework/hosting/tests/test_cli_catalog.py @@ -0,0 +1,87 @@ +"""Tests for the module catalog and dependency expansion.""" + +from __future__ import annotations + +import pytest + +from simple_module_hosting.cli.catalog import ( + CATALOG, + PRESETS, + ModuleEntry, + expand_deps, +) + + +def test_catalog_keys_match_entry_names() -> None: + for key, entry in CATALOG.items(): + assert key == entry.name, f"catalog key {key!r} != entry.name {entry.name!r}" + + +def test_every_requires_value_is_a_known_catalog_key() -> None: + for entry in CATALOG.values(): + for required in entry.requires: + assert required in CATALOG, ( + f"{entry.name} requires unknown module {required!r}" + ) + + +def test_presets_only_reference_known_modules() -> None: + for name, mods in PRESETS.items(): + for m in mods: + assert m in CATALOG, f"preset {name!r} references unknown module {m!r}" + + +def test_expand_deps_returns_input_when_no_requires() -> None: + resolved, added = expand_deps(["auth"]) + assert resolved == ["auth"] + assert added == [] + + +def test_expand_deps_pulls_in_transitive_dep() -> None: + resolved, added = expand_deps(["users"]) + assert set(resolved) == {"auth", "users"} + assert added == [("auth", "users")] + + +def test_expand_deps_pulls_in_chain() -> None: + resolved, added = expand_deps(["datasets"]) + assert set(resolved) == { + "datasets", + "file_storage", + "settings", + "background_tasks", + "users", + "auth", + } + added_names = {a for a, _ in added} + assert added_names == {"file_storage", "settings", "background_tasks", "users", "auth"} + + +def test_expand_deps_idempotent_when_input_already_complete() -> None: + resolved1, _ = expand_deps(["users"]) + resolved2, added2 = expand_deps(resolved1) + assert sorted(resolved1) == sorted(resolved2) + assert added2 == [] + + +def test_expand_deps_unknown_name_raises_with_available_list() -> None: + with pytest.raises(KeyError) as exc: + expand_deps(["does_not_exist"]) + msg = str(exc.value) + assert "does_not_exist" in msg + assert "auth" in msg + + +def test_expand_deps_preserves_load_order_dep_before_dependent() -> None: + resolved, _ = expand_deps(["dashboard"]) + for i, name in enumerate(resolved): + for required in CATALOG[name].requires: + assert resolved.index(required) < i, ( + f"{required} must appear before {name} in {resolved}" + ) + + +def test_module_entry_is_frozen() -> None: + entry = ModuleEntry(name="x", package="simple_module_x", display="X") + with pytest.raises(Exception): + entry.name = "y" # type: ignore[misc] From 8c5f9ed728f9a1b193e982bd77f9899932ce4986 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:00:47 +0200 Subject: [PATCH 05/24] feat(cli): interactive wizard for sm new db -> tenancy -> preset (or custom checkbox loop) -> confirm. Auto-adds required deps with a printed note. No new TUI dependency. --- .../simple_module_hosting/cli/wizard.py | 59 ++++++++++++++ framework/hosting/tests/test_cli_wizard.py | 81 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 framework/hosting/simple_module_hosting/cli/wizard.py create mode 100644 framework/hosting/tests/test_cli_wizard.py diff --git a/framework/hosting/simple_module_hosting/cli/wizard.py b/framework/hosting/simple_module_hosting/cli/wizard.py new file mode 100644 index 00000000..5be12d68 --- /dev/null +++ b/framework/hosting/simple_module_hosting/cli/wizard.py @@ -0,0 +1,59 @@ +"""Interactive prompt sequence for ``sm new``. + +Returns the user's choices as ``(db, tenancy, selected)`` where +``selected`` is the topologically resolved module list (already includes +transitive requires). All prompts use ``click`` — no extra TUI dep. +""" + +from __future__ import annotations + +import click + +from .catalog import CATALOG, PRESETS, expand_deps + +__all__ = ["run_wizard"] + + +_PRESET_CHOICES = ("minimal", "standard", "full", "custom") + + +def run_wizard( + *, default_db: str, default_tenancy: bool +) -> tuple[str, bool, list[str]]: + db = click.prompt( + "Database backend", + default=default_db, + type=click.Choice(["sqlite", "postgres"]), + ) + tenancy = click.confirm("Enable multi-tenancy?", default=default_tenancy) + + click.echo("\nPreset:") + click.echo(" [1] minimal — users only") + click.echo(" [2] standard — users, dashboard, permissions (default)") + click.echo(" [3] full — every module") + click.echo(" [4] custom — pick modules one by one") + choice = click.prompt( + "Choose", + default="2", + type=click.Choice(["1", "2", "3", "4"]), + show_choices=False, + ) + preset_name = _PRESET_CHOICES[int(choice) - 1] + + if preset_name == "custom": + picked = [ + name + for name in CATALOG + if click.confirm(f"Include {CATALOG[name].display}?", default=False) + ] + else: + picked = list(PRESETS[preset_name]) + + resolved, added = expand_deps(picked) + for name, required_by in added: + click.echo(f"Added {name} (required by {required_by})") + click.echo(f"Selected modules: {', '.join(resolved)}") + + if not click.confirm("Proceed?", default=True): + raise click.Abort() + return db, tenancy, resolved diff --git a/framework/hosting/tests/test_cli_wizard.py b/framework/hosting/tests/test_cli_wizard.py new file mode 100644 index 00000000..38736aae --- /dev/null +++ b/framework/hosting/tests/test_cli_wizard.py @@ -0,0 +1,81 @@ +"""Tests for the `sm new` interactive wizard.""" + +from __future__ import annotations + +import click +from click.testing import CliRunner + +from simple_module_hosting.cli.wizard import run_wizard + + +def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: + """Run the wizard with stdin pre-fed; return (db, tenancy, selected, output).""" + + captured: dict = {} + + @click.command() + def wrapper() -> None: + db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) + captured["db"] = db + captured["tenancy"] = tenancy + captured["selected"] = selected + + runner = CliRunner() + result = runner.invoke(wrapper, input="\n".join(answers) + "\n") + assert result.exit_code == 0, result.output + return captured["db"], captured["tenancy"], captured["selected"], result.output + + +def test_wizard_standard_preset_default_path() -> None: + db, tenancy, selected, out = _drive(["", "", "", ""]) + assert db == "sqlite" + assert tenancy is False + assert "users" in selected and "dashboard" in selected and "permissions" in selected + assert "auth" in selected + assert "Added auth (required by" in out + + +def test_wizard_postgres_with_tenancy() -> None: + db, tenancy, _selected, _out = _drive(["postgres", "y", "", ""]) + assert db == "postgres" + assert tenancy is True + + +def test_wizard_minimal_preset() -> None: + _, _, selected, _ = _drive(["", "", "1", ""]) + assert set(selected) == {"users", "auth"} + + +def test_wizard_full_preset_includes_background_tasks() -> None: + _, _, selected, _ = _drive(["", "", "3", ""]) + assert "background_tasks" in selected + assert "datasets" in selected + assert len(selected) >= 10 + + +def test_wizard_custom_picks_only_yes_answers() -> None: + # CATALOG order is: auth, users, permissions, products, dashboard, settings, + # feature_flags, file_storage, background_tasks, datasets — answer y to + # background_tasks only. + answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""] + _, _, selected, out = _drive(answers) + assert set(selected) == {"background_tasks", "users", "auth"} + assert "Added users (required by background_tasks)" in out + assert "Added auth (required by users)" in out + + +def test_wizard_aborts_on_confirm_no() -> None: + captured: dict = {} + + @click.command() + def wrapper() -> None: + try: + run_wizard(default_db="sqlite", default_tenancy=False) + except click.Abort: + captured["aborted"] = True + raise + + runner = CliRunner() + result = runner.invoke(wrapper, input="\n".join(["", "", "", "n"]) + "\n") + assert result.exit_code != 0 + assert captured.get("aborted") is True From 97e38f4ca764e271d9659172b3c3048bc5ce895d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:02:53 +0200 Subject: [PATCH 06/24] feat(cli): background_tasks recipe + opt-in templates Recipes lay down post-scaffold artifacts (run_worker.py, docker-compose with redis/worker/beat, worker.Dockerfile, Makefile targets, env var) without touching framework scaffolding. Templates live under templates/host/_optional/ and are skipped by the default copy walker. --- .../simple_module_hosting/cli/recipes.py | 100 ++++++++++++++++++ .../simple_module_hosting/scaffolding.py | 14 ++- .../background_tasks/Makefile.snippet | 12 +++ .../background_tasks/docker-compose.yml | 60 +++++++++++ .../_optional/background_tasks/run_worker.py | 17 +++ .../background_tasks/worker.Dockerfile | 37 +++++++ framework/hosting/tests/test_cli_recipes.py | 90 ++++++++++++++++ 7 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 framework/hosting/simple_module_hosting/cli/recipes.py create mode 100644 framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet create mode 100644 framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml create mode 100644 framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py create mode 100644 framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile create mode 100644 framework/hosting/tests/test_cli_recipes.py diff --git a/framework/hosting/simple_module_hosting/cli/recipes.py b/framework/hosting/simple_module_hosting/cli/recipes.py new file mode 100644 index 00000000..4039c97d --- /dev/null +++ b/framework/hosting/simple_module_hosting/cli/recipes.py @@ -0,0 +1,100 @@ +"""Per-module post-scaffold recipes. + +A recipe is invoked by ``sm new`` after the base host scaffold lands. It +performs module-specific actions (write helper scripts, append Make +targets, drop a docker-compose stack). The framework layer is kept free +of devex concerns — recipes know about Makefiles and compose, framework +scaffolding does not. +""" + +from __future__ import annotations + +import importlib.resources +import shutil +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +__all__ = [ + "BackgroundTasksRecipe", + "RECIPES", + "Recipe", + "ScaffoldCtx", +] + +_BG_BROKER_ENV_KEY = "SM_BG_TASKS_BROKER_URL" +_BG_BROKER_DEFAULT = "redis://redis:6379/0" +_MAKEFILE_MARKER = "# --- background_tasks --" + + +@dataclass(frozen=True) +class ScaffoldCtx: + name: str + db: str + tenancy: bool + selected: Sequence[str] + + +class Recipe(Protocol): + def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... + + +def _optional_template_root(name: str) -> Path: + """Resolve ``templates/host/_optional//`` from package data.""" + base = importlib.resources.files("simple_module_hosting") + return Path(str(base / "templates" / "host" / "_optional" / name)) + + +def _set_env_key(text: str, key: str, value: str) -> str: + """Replace or append ``KEY=VALUE`` in an env-style file body.""" + lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")] + lines.append(f"{key}={value}") + return "\n".join(lines) + "\n" + + +class BackgroundTasksRecipe: + """Lays down run_worker.py + compose + Dockerfile + 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): + if path.exists(): + raise FileExistsError( + f"{path} already exists — refusing to clobber. " + "Remove the file or run `sm new` against an empty directory." + ) + + run_worker_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "run_worker.py", run_worker_dest) + + shutil.copy2(templates / "docker-compose.yml", compose_dest) + + dockerfile_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(templates / "worker.Dockerfile", dockerfile_dest) + + env_path = target / ".env.example" + env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + env_path.write_text( + _set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT), + encoding="utf-8", + ) + + makefile_path = target / "Makefile" + snippet = (templates / "Makefile.snippet").read_text(encoding="utf-8") + existing = ( + makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" + ) + if _MAKEFILE_MARKER not in existing: + sep = "" if existing.endswith("\n") or not existing else "\n" + makefile_path.write_text(existing + sep + snippet, encoding="utf-8") + + +RECIPES: dict[str, Recipe] = { + "background_tasks": BackgroundTasksRecipe(), +} diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py index a5892ebf..38523566 100644 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ b/framework/hosting/simple_module_hosting/scaffolding.py @@ -56,10 +56,18 @@ def _module_to_pypi_name(name: str) -> str: def _iter_template_files(template_root: Path): - """Yield every file under ``template_root``, preserving relative paths.""" + """Yield every file under ``template_root``, preserving relative paths. + + Skips any path under an ``_optional/`` segment — those are recipe-managed + templates consumed by :mod:`simple_module_hosting.cli.recipes`, not by + the default scaffolding pass. + """ for path in template_root.rglob("*"): - if path.is_file(): - yield path + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path def _require_empty_dest(dest: Path) -> None: diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet new file mode 100644 index 00000000..43e2827b --- /dev/null +++ b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet @@ -0,0 +1,12 @@ +# --- background_tasks ---------------------------------------------------- +.PHONY: worker beat worker-docker + +worker: ## Run a Celery worker locally against $(SM_BG_TASKS_BROKER_URL) + uv run celery -A scripts.run_worker:celery worker -l info + +beat: ## Run the Celery beat scheduler locally + uv run celery -A scripts.run_worker:celery beat -l info + +worker-docker: ## Build + run the worker + beat services in docker + docker compose up --build worker beat +# --- end background_tasks ------------------------------------------------ diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml new file mode 100644 index 00000000..46a1493f --- /dev/null +++ b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml @@ -0,0 +1,60 @@ +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + worker: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "worker" + - "-l" + - "info" + - "--concurrency=4" + + beat: + build: + context: . + dockerfile: docker/worker.Dockerfile + env_file: .env + environment: + SM_BG_TASKS_BROKER_URL: redis://redis:6379/0 + SM_BG_TASKS_RESULT_BACKEND: redis://redis:6379/1 + depends_on: + redis: + condition: service_healthy + worker: + condition: service_started + command: + - "uv" + - "run" + - "celery" + - "-A" + - "scripts.run_worker:celery" + - "beat" + - "-l" + - "info" + +volumes: + redisdata: diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py new file mode 100644 index 00000000..82788b90 --- /dev/null +++ b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py @@ -0,0 +1,17 @@ +"""Entry point for the Celery worker and beat services. + +Both the web process and the worker go through the same +``background_tasks.celery_app.build_celery`` factory so the broker +config, autodiscovered tasks, and signal handlers stay in lockstep. + +Run locally: + uv run celery -A scripts.run_worker:celery worker -l info + uv run celery -A scripts.run_worker:celery beat -l info +""" + +from __future__ import annotations + +from background_tasks.celery_app import build_celery +from background_tasks.settings import BackgroundTasksSettings + +celery = build_celery(BackgroundTasksSettings()) diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile new file mode 100644 index 00000000..120e96f3 --- /dev/null +++ b/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile @@ -0,0 +1,37 @@ +# Celery worker image for the BackgroundTasks module. +# Serves both the worker and beat services in docker-compose — they +# differ only by command. + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_SYSTEM_PYTHON=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +COPY scripts/ scripts/ +COPY client_app/ client_app/ + +RUN uv sync --frozen --no-dev + +RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin worker \ + && chown -R worker:worker /app +USER worker + +ENV CELERY_APP=scripts.run_worker:celery +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD uv run celery -A $CELERY_APP inspect ping -d celery@$HOSTNAME || exit 1 + +CMD ["uv", "run", "celery", "-A", "scripts.run_worker:celery", "worker", "-l", "info"] diff --git a/framework/hosting/tests/test_cli_recipes.py b/framework/hosting/tests/test_cli_recipes.py new file mode 100644 index 00000000..c6b52eaf --- /dev/null +++ b/framework/hosting/tests/test_cli_recipes.py @@ -0,0 +1,90 @@ +"""Tests for per-module post-scaffold recipes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from simple_module_hosting.cli.recipes import ( + RECIPES, + BackgroundTasksRecipe, + ScaffoldCtx, +) +from simple_module_hosting.scaffolding import create_host + + +def _scaffold_minimal_host(target: Path) -> None: + create_host(target, name="demo", modules=["Users"]) + + +def _ctx() -> ScaffoldCtx: + return ScaffoldCtx( + name="demo", db="sqlite", tenancy=False, selected=("background_tasks",) + ) + + +def test_background_tasks_recipe_registered() -> None: + assert "background_tasks" in RECIPES + assert isinstance(RECIPES["background_tasks"], BackgroundTasksRecipe) + + +def test_recipe_writes_run_worker_script(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + script = tmp_path / "scripts" / "run_worker.py" + assert script.is_file() + text = script.read_text() + assert "from background_tasks.celery_app import build_celery" in text + assert "celery = build_celery(BackgroundTasksSettings())" in text + + +def test_recipe_writes_compose_with_redis_worker_beat(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + compose = (tmp_path / "docker-compose.yml").read_text() + assert "redis:" in compose + assert "worker:" in compose + assert "beat:" in compose + assert "scripts.run_worker:celery" in compose + + +def test_recipe_writes_worker_dockerfile(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + dockerfile = (tmp_path / "docker" / "worker.Dockerfile").read_text() + assert "FROM python:3.12-slim" in dockerfile + assert "scripts.run_worker:celery" in dockerfile + + +def test_recipe_appends_makefile_targets(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + makefile = (tmp_path / "Makefile").read_text() + assert "worker:" in makefile + assert "beat:" in makefile + assert "worker-docker:" in makefile + + +def test_recipe_sets_broker_url_env_var(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + env_text = (tmp_path / ".env.example").read_text() + assert "SM_BG_TASKS_BROKER_URL=redis://redis:6379/0" in env_text + + +def test_recipe_makefile_snippet_idempotent(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + first = (tmp_path / "Makefile").read_text() + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply(tmp_path, _ctx()) + assert (tmp_path / "Makefile").read_text() == first + + +def test_recipe_errors_on_existing_run_worker(tmp_path: Path) -> None: + _scaffold_minimal_host(tmp_path) + (tmp_path / "scripts").mkdir(exist_ok=True) + (tmp_path / "scripts" / "run_worker.py").write_text("# user-authored\n") + with pytest.raises(FileExistsError): + BackgroundTasksRecipe().apply(tmp_path, _ctx()) From 499f4b7d8d8e5ba141cdfb549b9baf4f2362707a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:03:57 +0200 Subject: [PATCH 07/24] feat(scaffolding): create_app_project accepts selected= module list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default 'standard' preset preserves existing behavior. Selected modules drive both Python deps (from catalog) and post-scaffold recipes — so selecting background_tasks now lays down a runnable Celery stack. --- .../simple_module_hosting/scaffolding.py | 40 ++++++++++----- framework/hosting/tests/test_cli_new.py | 51 +++++++++++++++++++ 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py index 38523566..0e3d60ff 100644 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ b/framework/hosting/simple_module_hosting/scaffolding.py @@ -208,12 +208,6 @@ def create_module( _FRAMEWORK_VERSION = "0.0.1" -_APP_PY_DEPS = [ - f"simple_module_hosting=={_FRAMEWORK_VERSION}", - f"simple_module_users=={_FRAMEWORK_VERSION}", - f"simple_module_dashboard=={_FRAMEWORK_VERSION}", - f"simple_module_permissions=={_FRAMEWORK_VERSION}", -] _APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"] _APP_NPM_DEPS = { @@ -237,21 +231,37 @@ def create_app_project( name: str, db: str = "sqlite", tenancy: bool = False, + selected: Sequence[str] | None = None, ) -> None: """Greenfield ``simple-module new`` scaffold. - Wraps :func:`create_host` with opinionated pre-wired modules (users + - dashboard + permissions), generates a secret, picks a DB URL, and rewrites - the generated package.json / pyproject.toml to pin exact framework - versions. + 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 + framework versions, and applies any matching post-scaffold recipes + (e.g. the ``background_tasks`` recipe drops a Celery worker stack). """ + # Imports are local to avoid a circular import: cli.recipes is allowed + # to import from scaffolding in the future, but scaffolding itself + # only needs catalog/recipes at call time. + from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps + from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx + if target.exists() and any(target.iterdir()): raise FileExistsError( f"Destination {target} already exists and is non-empty; " "choose a new path or remove its contents first." ) - create_host(target, name=name, modules=["users", "dashboard", "permissions"]) + chosen = list(selected) if selected is not None else list(PRESETS["standard"]) + resolved, _added = expand_deps(chosen) + + display_names = [CATALOG[m].display.replace(" ", "") for m in resolved] + create_host(target, name=name, modules=display_names) + + py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ + f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved + ] env_path = target / ".env.example" env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" @@ -263,7 +273,7 @@ def create_app_project( pyproject = target / "pyproject.toml" if pyproject.exists(): text = pyproject.read_text(encoding="utf-8") - text = _inject_py_deps(text, _APP_PY_DEPS, _APP_PY_DEV_DEPS) + text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) pyproject.write_text(text, encoding="utf-8") pkg_path = target / "package.json" @@ -275,6 +285,12 @@ def create_app_project( data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") + ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) + for mod_name in resolved: + recipe_key = CATALOG[mod_name].recipe + if recipe_key is not None and recipe_key in RECIPES: + RECIPES[recipe_key].apply(target, ctx) + def _set_env_key(text: str, key: str, value: str) -> str: lines = text.splitlines() diff --git a/framework/hosting/tests/test_cli_new.py b/framework/hosting/tests/test_cli_new.py index 7276d84b..c6d2e64f 100644 --- a/framework/hosting/tests/test_cli_new.py +++ b/framework/hosting/tests/test_cli_new.py @@ -64,6 +64,57 @@ def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: assert len(secret_line.split("=", 1)[1]) >= 20 +def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project( + target, + name="demo", + db="sqlite", + tenancy=False, + selected=["users", "background_tasks"], + ) + + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_background_tasks" in pyproject + assert "simple_module_auth" in pyproject # auto-added (users requires auth) + assert "simple_module_dashboard" not in pyproject + + +def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project( + target, + name="demo", + db="sqlite", + tenancy=False, + selected=["background_tasks"], + ) + + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + assert (target / "docker" / "worker.Dockerfile").is_file() + makefile_text = (target / "Makefile").read_text() + assert "worker:" in makefile_text + + +def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) -> None: + from simple_module_hosting.scaffolding import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False) + pyproject = (target / "pyproject.toml").read_text() + for required in ( + "simple_module_users", + "simple_module_dashboard", + "simple_module_permissions", + ): + assert required in pyproject + + def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: target = tmp_path / "my-app" target.mkdir() From 15ca47b424f8b27a938b897cb9f946df6c6bcdb1 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:05:36 +0200 Subject: [PATCH 08/24] feat(cli): sm new with --preset, --with, and wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds a project with any chosen subset of modules. Selecting background_tasks lands a runnable Celery worker + beat + Redis stack via docker compose, host Make targets, and scripts/run_worker.py — no manual editing required. Wizard path (sm new ) prompts: db -> tenancy -> preset -> confirm. Flag path (sm new --yes --preset full --with file_storage) skips prompts. Auto-adds transitive deps with a printed note in both modes. --- .../simple_module_hosting/cli/__init__.py | 101 ++----------- .../hosting/simple_module_hosting/cli/new.py | 140 ++++++++++++++++++ framework/hosting/tests/test_cli_new.py | 77 ++++++++++ 3 files changed, 229 insertions(+), 89 deletions(-) create mode 100644 framework/hosting/simple_module_hosting/cli/new.py diff --git a/framework/hosting/simple_module_hosting/cli/__init__.py b/framework/hosting/simple_module_hosting/cli/__init__.py index bd11abda..5ab4ea1f 100644 --- a/framework/hosting/simple_module_hosting/cli/__init__.py +++ b/framework/hosting/simple_module_hosting/cli/__init__.py @@ -1,9 +1,12 @@ """SimpleModule CLI — `sm` console script. -Currently exposes: +Exposes: -* ``sm create-host `` — scaffold a new host directory. -* ``sm create-module `` — scaffold a new module package. +* ``sm new `` — scaffold a runnable app with selected modules + (and a Celery stack when background_tasks is opted in). Defined in + :mod:`.new`. +* ``sm create-host `` — scaffold a bare host directory. +* ``sm create-module `` — scaffold a publishable module package. * ``sm gen-pages`` — regenerate the frontend pages manifest + Tailwind CSS. * ``sm sync-js-deps`` — install JS deps declared by installed modules. """ @@ -36,92 +39,6 @@ def main() -> None: """SimpleModule developer CLI.""" -@main.command("new") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./.", -) -@click.option( - "--db", - type=click.Choice(["sqlite", "postgres"]), - default="sqlite", - show_default=True, - help="Database backend to configure in .env.example.", -) -@click.option( - "--tenancy/--no-tenancy", - default=False, - show_default=True, - help="Enable the multi-tenant middleware by default.", -) -@click.option( - "--yes", - "-y", - is_flag=True, - default=False, - help="Skip interactive prompts; accept all defaults.", -) -@click.option( - "--no-install", - is_flag=True, - default=False, - help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", -) -def new_project( - name: str, - dest: Path | None, - db: str, - tenancy: bool, - yes: bool, - no_install: bool, -) -> None: - """Scaffold a new SimpleModule app — pre-wired with users, dashboard, permissions.""" - target = dest or Path.cwd() / name - if not yes: - db = click.prompt( - "Database backend", - default=db, - type=click.Choice(["sqlite", "postgres"]), - ) - tenancy = click.confirm("Enable multi-tenancy?", default=tenancy) - - from simple_module_hosting.scaffolding import create_app_project - - try: - create_app_project(target, name=name, db=db, tenancy=tenancy) - except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) - - click.echo(f"Created app '{name}' at {target}") - click.echo("\nPre-wired modules: users, dashboard, permissions") - click.echo("\nNext steps:") - click.echo(f" cd {target}") - if no_install: - click.echo(" uv sync") - click.echo(" npm install") - click.echo(" alembic upgrade head") - click.echo(" make dev") - return - - click.echo("Installing dependencies...") - for cmd in (["uv", "sync"], ["npm", "install"]): - result = subprocess.run(cmd, cwd=target, check=False) - if result.returncode != 0: - click.echo( - f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " - f"finish setup manually.", - err=True, - ) - return - - subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) - click.echo("\nSetup complete. Run `make dev` in the new directory.") - - @main.command("create-host") @click.argument("name") @click.option( @@ -288,5 +205,11 @@ def sync_js_deps(host_client_app: Path | None, dry_run: bool) -> None: sys.exit(result.returncode) +# `sm new` lives in its own module (catalog + wizard + recipes wiring). +from .new import new_project as _new_project # noqa: E402 + +main.add_command(_new_project) + + if __name__ == "__main__": main() diff --git a/framework/hosting/simple_module_hosting/cli/new.py b/framework/hosting/simple_module_hosting/cli/new.py new file mode 100644 index 00000000..84772a92 --- /dev/null +++ b/framework/hosting/simple_module_hosting/cli/new.py @@ -0,0 +1,140 @@ +"""The upgraded ``sm new`` command. + +Combines flag-driven non-interactive use (``--preset`` / ``--with``) with +the interactive wizard. All paths converge on +:func:`simple_module_hosting.scaffolding.create_app_project` with a +resolved module list. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import click + +from simple_module_hosting.scaffolding import create_app_project + +from .catalog import PRESETS, expand_deps +from .wizard import run_wizard + +__all__ = ["new_project"] + + +@click.command("new") +@click.argument("name") +@click.option( + "--dest", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Destination directory. Defaults to ./.", +) +@click.option( + "--db", + type=click.Choice(["sqlite", "postgres"]), + default="sqlite", + show_default=True, + help="Database backend to configure in .env.example.", +) +@click.option( + "--tenancy/--no-tenancy", + default=False, + show_default=True, + help="Enable the multi-tenant middleware by default.", +) +@click.option( + "--preset", + type=click.Choice(["minimal", "standard", "full"]), + default=None, + help="Module preset. Combine with --with to add modules on top.", +) +@click.option( + "--with", + "extra", + default="", + help="Comma-separated extra module names (e.g. background_tasks,file_storage).", +) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip interactive prompts; accept defaults.", +) +@click.option( + "--no-install", + is_flag=True, + default=False, + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", +) +def new_project( + name: str, + dest: Path | None, + db: str, + tenancy: bool, + preset: str | None, + extra: str, + yes: bool, + no_install: bool, +) -> None: + """Scaffold a new SimpleModule app, optionally with background jobs.""" + target = dest or Path.cwd() / name + + extra_list = [m.strip() for m in extra.split(",") if m.strip()] + flag_driven = preset is not None or bool(extra_list) + + if yes or flag_driven: + chosen = list(PRESETS[preset or "standard"]) + extra_list + try: + resolved, added = expand_deps(chosen) + except KeyError as exc: + click.echo(f"ERROR: {exc}", err=True) + sys.exit(1) + for added_name, required_by in added: + click.echo(f"Added {added_name} (required by {required_by})") + else: + try: + db, tenancy, resolved = run_wizard( + default_db=db, default_tenancy=tenancy + ) + except click.Abort: + click.echo("Aborted.", err=True) + sys.exit(1) + + try: + create_app_project( + target, name=name, db=db, tenancy=tenancy, selected=resolved + ) + except FileExistsError as exc: + click.echo(f"ERROR: {exc}", err=True) + sys.exit(1) + + click.echo(f"Created app '{name}' at {target}") + click.echo(f"Modules: {', '.join(resolved)}") + click.echo("\nNext steps:") + click.echo(f" cd {target}") + if no_install: + click.echo(" uv sync") + click.echo(" npm install") + click.echo(" alembic upgrade head") + click.echo(" make dev") + if "background_tasks" in resolved: + click.echo(" docker compose up -d redis worker beat # background jobs") + return + + click.echo("Installing dependencies...") + for cmd in (["uv", "sync"], ["npm", "install"]): + result = subprocess.run(cmd, cwd=target, check=False) + if result.returncode != 0: + click.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + "finish setup manually.", + err=True, + ) + return + + subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) + click.echo("\nSetup complete. Run `make dev` in the new directory.") + if "background_tasks" in resolved: + click.echo("For background jobs, also run: docker compose up -d redis worker beat") diff --git a/framework/hosting/tests/test_cli_new.py b/framework/hosting/tests/test_cli_new.py index c6d2e64f..a7e1ccb1 100644 --- a/framework/hosting/tests/test_cli_new.py +++ b/framework/hosting/tests/test_cli_new.py @@ -115,6 +115,83 @@ def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) - assert required in pyproject +def test_sm_new_with_preset_full_includes_background_tasks(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--preset", "full", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert (target / "scripts" / "run_worker.py").is_file() + assert (target / "docker-compose.yml").is_file() + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_background_tasks" in pyproject + + +def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + [ + "new", "demo", + "--yes", + "--preset", "minimal", + "--with", "background_tasks", + "--no-install", + "--dest", str(target), + ], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + assert "simple_module_users" in pyproject + assert "simple_module_background_tasks" in pyproject + assert "simple_module_auth" in pyproject + + +def test_sm_new_unknown_with_module_errors(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--with", "does_not_exist", "--no-install", "--dest", str(target)], + ) + assert result.exit_code != 0 + assert "does_not_exist" in result.output + assert "available" in result.output.lower() + + +def test_sm_new_yes_with_no_flags_uses_standard_preset(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + pyproject = (target / "pyproject.toml").read_text() + for required in ( + "simple_module_users", + "simple_module_dashboard", + "simple_module_permissions", + ): + assert required in pyproject + assert "simple_module_background_tasks" not in pyproject + + +def test_sm_new_interactive_full_preset(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + result = runner.invoke( + main, + ["new", "demo", "--no-install", "--dest", str(target)], + input="\n".join(["", "", "3", ""]) + "\n", + ) + assert result.exit_code == 0, result.output + assert (target / "docker-compose.yml").is_file() + + def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: target = tmp_path / "my-app" target.mkdir() From 82c794421d6690c2e3e421a2ce0cbbf3d3f6feaa Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:11:20 +0200 Subject: [PATCH 09/24] chore(cli): lint, file-size, and SM009 fixes for new CLI surface - Split create_app_project from scaffolding.py into app_project.py to stay under the 300-line cap. - SM009 (framework -> plugin coupling) now skips templates/* paths; the new background_tasks run_worker.py template intentionally imports from the background_tasks package once it has been copied into a host project, where it is no longer framework code. - ruff format + import-sort fixes, FrozenInstanceError specificity in test_cli_catalog. --- .../diagnostics/_coupling.py | 5 + .../simple_module_hosting/app_project.py | 132 ++++++++++++++++++ .../simple_module_hosting/cli/catalog.py | 8 +- .../hosting/simple_module_hosting/cli/new.py | 8 +- .../simple_module_hosting/cli/recipes.py | 6 +- .../simple_module_hosting/cli/wizard.py | 4 +- .../simple_module_hosting/scaffolding.py | 119 +--------------- framework/hosting/tests/test_cli_catalog.py | 11 +- framework/hosting/tests/test_cli_new.py | 12 +- framework/hosting/tests/test_cli_recipes.py | 5 +- framework/hosting/tests/test_cli_wizard.py | 1 - 11 files changed, 159 insertions(+), 152 deletions(-) create mode 100644 framework/hosting/simple_module_hosting/app_project.py diff --git a/framework/core/simple_module_core/diagnostics/_coupling.py b/framework/core/simple_module_core/diagnostics/_coupling.py index 0119b519..4bb67163 100644 --- a/framework/core/simple_module_core/diagnostics/_coupling.py +++ b/framework/core/simple_module_core/diagnostics/_coupling.py @@ -54,6 +54,11 @@ def check_framework_module_coupling(modules: list[ModuleBase]) -> list[Diagnosti diags: list[Diagnostic] = [] for fw_pkg, fw_dir in framework_dirs: for py_file in fw_dir.rglob("*.py"): + # `templates/` ships as package data — its `.py` files are not + # framework code that runs at import time. They land in the + # scaffolded user project and import plugins legitimately there. + if "templates" in py_file.relative_to(fw_dir).parts: + continue try: tree = ast.parse(py_file.read_text(), filename=str(py_file)) except SyntaxError: diff --git a/framework/hosting/simple_module_hosting/app_project.py b/framework/hosting/simple_module_hosting/app_project.py new file mode 100644 index 00000000..70fdb7c5 --- /dev/null +++ b/framework/hosting/simple_module_hosting/app_project.py @@ -0,0 +1,132 @@ +"""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. + +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 +scaffold" obvious to readers. +""" + +from __future__ import annotations + +import json as _json +import secrets as _secrets +from collections.abc import Sequence +from pathlib import Path + +__all__ = ["create_app_project"] + +_FRAMEWORK_VERSION = "0.0.1" + +_APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"] + +_APP_NPM_DEPS = { + "@simple-module-py/ui": _FRAMEWORK_VERSION, + "@simple-module-py/i18n": _FRAMEWORK_VERSION, + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^1.0.0", +} +_APP_NPM_DEV_DEPS = { + "@simple-module-py/tsconfig": _FRAMEWORK_VERSION, + "@vitejs/plugin-react": "^5.0.0", + "typescript": "^5.6.0", + "vite": "^8.0.0", +} + + +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> 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 + framework versions, and applies any matching post-scaffold recipes + (e.g. the ``background_tasks`` recipe drops a Celery worker stack). + """ + # Imports are local to break the import cycle: scaffolding.py + # re-exports ``create_app_project`` from this module, so this module + # cannot import from scaffolding at module-load time. + from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps + from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx + from simple_module_hosting.scaffolding import _to_kebab_case, create_host + + if target.exists() and any(target.iterdir()): + raise FileExistsError( + f"Destination {target} already exists and is non-empty; " + "choose a new path or remove its contents first." + ) + + chosen = list(selected) if selected is not None else list(PRESETS["standard"]) + resolved, _added = expand_deps(chosen) + + display_names = [CATALOG[m].display.replace(" ", "") for m in resolved] + create_host(target, name=name, modules=display_names) + + py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ + f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved + ] + + 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_MULTI_TENANT", "true" if tenancy else "false") + env_path.write_text(env_text, encoding="utf-8") + + pyproject = target / "pyproject.toml" + if pyproject.exists(): + text = pyproject.read_text(encoding="utf-8") + text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) + pyproject.write_text(text, encoding="utf-8") + + pkg_path = target / "package.json" + 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) + pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") + + ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) + for mod_name in resolved: + recipe_key = CATALOG[mod_name].recipe + if recipe_key is not None and recipe_key in RECIPES: + RECIPES[recipe_key].apply(target, ctx) + + +def _set_env_key(text: str, key: str, value: str) -> str: + lines = text.splitlines() + prefix = f"{key}=" + out = [ln for ln in lines if not ln.startswith(prefix)] + out.append(f"{key}={value}") + return "\n".join(out) + "\n" + + +def _db_url(db: str, slug: str) -> str: + if db == "postgres": + return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" + return "sqlite+aiosqlite:///./app.db" + + +def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str: + """Replace project.dependencies + dependency-groups.dev in a pyproject.toml.""" + import tomlkit + + doc = tomlkit.parse(text) + project = doc.setdefault("project", tomlkit.table()) + project["dependencies"] = list(deps) + groups = doc.setdefault("dependency-groups", tomlkit.table()) + groups["dev"] = list(dev_deps) + return tomlkit.dumps(doc) diff --git a/framework/hosting/simple_module_hosting/cli/catalog.py b/framework/hosting/simple_module_hosting/cli/catalog.py index 7e432eb8..ad436ff5 100644 --- a/framework/hosting/simple_module_hosting/cli/catalog.py +++ b/framework/hosting/simple_module_hosting/cli/catalog.py @@ -29,9 +29,7 @@ class ModuleEntry: CATALOG: dict[str, ModuleEntry] = { "auth": ModuleEntry("auth", "simple_module_auth", "Auth"), - "users": ModuleEntry( - "users", "simple_module_users", "Users", requires=("auth",) - ), + "users": ModuleEntry("users", "simple_module_users", "Users", requires=("auth",)), "permissions": ModuleEntry( "permissions", "simple_module_permissions", @@ -46,9 +44,7 @@ class ModuleEntry: requires=("users", "products"), ), "settings": ModuleEntry("settings", "simple_module_settings", "Settings"), - "feature_flags": ModuleEntry( - "feature_flags", "simple_module_feature_flags", "Feature Flags" - ), + "feature_flags": ModuleEntry("feature_flags", "simple_module_feature_flags", "Feature Flags"), "file_storage": ModuleEntry( "file_storage", "simple_module_file_storage", diff --git a/framework/hosting/simple_module_hosting/cli/new.py b/framework/hosting/simple_module_hosting/cli/new.py index 84772a92..8736276e 100644 --- a/framework/hosting/simple_module_hosting/cli/new.py +++ b/framework/hosting/simple_module_hosting/cli/new.py @@ -95,17 +95,13 @@ def new_project( click.echo(f"Added {added_name} (required by {required_by})") else: try: - db, tenancy, resolved = run_wizard( - default_db=db, default_tenancy=tenancy - ) + db, tenancy, resolved = run_wizard(default_db=db, default_tenancy=tenancy) except click.Abort: click.echo("Aborted.", err=True) sys.exit(1) try: - create_app_project( - target, name=name, db=db, tenancy=tenancy, selected=resolved - ) + create_app_project(target, name=name, db=db, tenancy=tenancy, selected=resolved) except FileExistsError as exc: click.echo(f"ERROR: {exc}", err=True) sys.exit(1) diff --git a/framework/hosting/simple_module_hosting/cli/recipes.py b/framework/hosting/simple_module_hosting/cli/recipes.py index 4039c97d..a241bd44 100644 --- a/framework/hosting/simple_module_hosting/cli/recipes.py +++ b/framework/hosting/simple_module_hosting/cli/recipes.py @@ -17,8 +17,8 @@ from typing import Protocol __all__ = [ - "BackgroundTasksRecipe", "RECIPES", + "BackgroundTasksRecipe", "Recipe", "ScaffoldCtx", ] @@ -87,9 +87,7 @@ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: makefile_path = target / "Makefile" snippet = (templates / "Makefile.snippet").read_text(encoding="utf-8") - existing = ( - makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" - ) + existing = makefile_path.read_text(encoding="utf-8") if makefile_path.exists() else "" if _MAKEFILE_MARKER not in existing: sep = "" if existing.endswith("\n") or not existing else "\n" makefile_path.write_text(existing + sep + snippet, encoding="utf-8") diff --git a/framework/hosting/simple_module_hosting/cli/wizard.py b/framework/hosting/simple_module_hosting/cli/wizard.py index 5be12d68..0a138313 100644 --- a/framework/hosting/simple_module_hosting/cli/wizard.py +++ b/framework/hosting/simple_module_hosting/cli/wizard.py @@ -17,9 +17,7 @@ _PRESET_CHOICES = ("minimal", "standard", "full", "custom") -def run_wizard( - *, default_db: str, default_tenancy: bool -) -> tuple[str, bool, list[str]]: +def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: db = click.prompt( "Database backend", default=default_db, diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py index 0e3d60ff..783d552b 100644 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ b/framework/hosting/simple_module_hosting/scaffolding.py @@ -14,14 +14,13 @@ from __future__ import annotations import importlib.resources -import json as _json import logging import re -import secrets as _secrets import shutil from collections.abc import Mapping, Sequence from pathlib import Path +from simple_module_hosting.app_project import create_app_project as create_app_project from simple_module_hosting.manifest import ( collect_module_js_deps, compute_module_pages, @@ -200,119 +199,3 @@ def create_module( logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) return dest - - -# --------------------------------------------------------------- -# create_app_project — used by `sm new` / `simple-module new` -# --------------------------------------------------------------- - -_FRAMEWORK_VERSION = "0.0.1" - -_APP_PY_DEV_DEPS = [f"simple_module_test=={_FRAMEWORK_VERSION}", "pytest>=8.0"] - -_APP_NPM_DEPS = { - "@simple-module-py/ui": _FRAMEWORK_VERSION, - "@simple-module-py/i18n": _FRAMEWORK_VERSION, - "react": "^19.0.0", - "react-dom": "^19.0.0", - "@inertiajs/react": "^1.0.0", -} -_APP_NPM_DEV_DEPS = { - "@simple-module-py/tsconfig": _FRAMEWORK_VERSION, - "@vitejs/plugin-react": "^5.0.0", - "typescript": "^5.6.0", - "vite": "^8.0.0", -} - - -def create_app_project( - target: Path, - *, - name: str, - db: str = "sqlite", - tenancy: bool = False, - selected: Sequence[str] | None = None, -) -> 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 - framework versions, and applies any matching post-scaffold recipes - (e.g. the ``background_tasks`` recipe drops a Celery worker stack). - """ - # Imports are local to avoid a circular import: cli.recipes is allowed - # to import from scaffolding in the future, but scaffolding itself - # only needs catalog/recipes at call time. - from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps - from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx - - if target.exists() and any(target.iterdir()): - raise FileExistsError( - f"Destination {target} already exists and is non-empty; " - "choose a new path or remove its contents first." - ) - - chosen = list(selected) if selected is not None else list(PRESETS["standard"]) - resolved, _added = expand_deps(chosen) - - display_names = [CATALOG[m].display.replace(" ", "") for m in resolved] - create_host(target, name=name, modules=display_names) - - py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ - f"{CATALOG[m].package}=={_FRAMEWORK_VERSION}" for m in resolved - ] - - 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_MULTI_TENANT", "true" if tenancy else "false") - env_path.write_text(env_text, encoding="utf-8") - - pyproject = target / "pyproject.toml" - if pyproject.exists(): - text = pyproject.read_text(encoding="utf-8") - text = _inject_py_deps(text, py_deps, _APP_PY_DEV_DEPS) - pyproject.write_text(text, encoding="utf-8") - - pkg_path = target / "package.json" - 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) - pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") - - ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved)) - for mod_name in resolved: - recipe_key = CATALOG[mod_name].recipe - if recipe_key is not None and recipe_key in RECIPES: - RECIPES[recipe_key].apply(target, ctx) - - -def _set_env_key(text: str, key: str, value: str) -> str: - lines = text.splitlines() - prefix = f"{key}=" - out = [ln for ln in lines if not ln.startswith(prefix)] - out.append(f"{key}={value}") - return "\n".join(out) + "\n" - - -def _db_url(db: str, slug: str) -> str: - if db == "postgres": - return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" - return "sqlite+aiosqlite:///./app.db" - - -def _inject_py_deps(text: str, deps: list[str], dev_deps: list[str]) -> str: - """Replace project.dependencies + dependency-groups.dev in a pyproject.toml.""" - import tomlkit - - doc = tomlkit.parse(text) - project = doc.setdefault("project", tomlkit.table()) - project["dependencies"] = list(deps) - groups = doc.setdefault("dependency-groups", tomlkit.table()) - groups["dev"] = list(dev_deps) - return tomlkit.dumps(doc) diff --git a/framework/hosting/tests/test_cli_catalog.py b/framework/hosting/tests/test_cli_catalog.py index 17036d57..a32bda2d 100644 --- a/framework/hosting/tests/test_cli_catalog.py +++ b/framework/hosting/tests/test_cli_catalog.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from simple_module_hosting.cli.catalog import ( CATALOG, PRESETS, @@ -20,9 +19,7 @@ def test_catalog_keys_match_entry_names() -> None: def test_every_requires_value_is_a_known_catalog_key() -> None: for entry in CATALOG.values(): for required in entry.requires: - assert required in CATALOG, ( - f"{entry.name} requires unknown module {required!r}" - ) + assert required in CATALOG, f"{entry.name} requires unknown module {required!r}" def test_presets_only_reference_known_modules() -> None: @@ -82,6 +79,8 @@ def test_expand_deps_preserves_load_order_dep_before_dependent() -> None: def test_module_entry_is_frozen() -> None: + from dataclasses import FrozenInstanceError + entry = ModuleEntry(name="x", package="simple_module_x", display="X") - with pytest.raises(Exception): - entry.name = "y" # type: ignore[misc] + with pytest.raises(FrozenInstanceError): + entry.name = "y" # ty: ignore[invalid-assignment] noqa: B010 diff --git a/framework/hosting/tests/test_cli_new.py b/framework/hosting/tests/test_cli_new.py index a7e1ccb1..da1adf06 100644 --- a/framework/hosting/tests/test_cli_new.py +++ b/framework/hosting/tests/test_cli_new.py @@ -135,12 +135,16 @@ def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: result = runner.invoke( main, [ - "new", "demo", + "new", + "demo", "--yes", - "--preset", "minimal", - "--with", "background_tasks", + "--preset", + "minimal", + "--with", + "background_tasks", "--no-install", - "--dest", str(target), + "--dest", + str(target), ], ) assert result.exit_code == 0, result.output diff --git a/framework/hosting/tests/test_cli_recipes.py b/framework/hosting/tests/test_cli_recipes.py index c6b52eaf..b9e950d5 100644 --- a/framework/hosting/tests/test_cli_recipes.py +++ b/framework/hosting/tests/test_cli_recipes.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest - from simple_module_hosting.cli.recipes import ( RECIPES, BackgroundTasksRecipe, @@ -19,9 +18,7 @@ def _scaffold_minimal_host(target: Path) -> None: def _ctx() -> ScaffoldCtx: - return ScaffoldCtx( - name="demo", db="sqlite", tenancy=False, selected=("background_tasks",) - ) + return ScaffoldCtx(name="demo", db="sqlite", tenancy=False, selected=("background_tasks",)) def test_background_tasks_recipe_registered() -> None: diff --git a/framework/hosting/tests/test_cli_wizard.py b/framework/hosting/tests/test_cli_wizard.py index 38736aae..006b778f 100644 --- a/framework/hosting/tests/test_cli_wizard.py +++ b/framework/hosting/tests/test_cli_wizard.py @@ -4,7 +4,6 @@ import click from click.testing import CliRunner - from simple_module_hosting.cli.wizard import run_wizard From d675258e361be9cca25a221bd2992548b7e9c84c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:16:25 +0200 Subject: [PATCH 10/24] refactor(cli): extract set_env_key helper, use _to_pascal_case - Move duplicated _set_env_key from app_project.py and cli/recipes.py into simple_module_hosting/_env.py. - Use scaffolding._to_pascal_case for CATALOG.display normalization instead of ad-hoc display.replace(' ', ''). - Replace direct attribute assignment with __setattr__ in the frozen-dataclass test so it doesn't need ty/ruff suppressions. --- .../hosting/simple_module_hosting/_env.py | 12 ++++++++++ .../simple_module_hosting/app_project.py | 24 +++++++++---------- .../simple_module_hosting/cli/recipes.py | 11 +++------ framework/hosting/tests/test_cli_catalog.py | 2 +- 4 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 framework/hosting/simple_module_hosting/_env.py diff --git a/framework/hosting/simple_module_hosting/_env.py b/framework/hosting/simple_module_hosting/_env.py new file mode 100644 index 00000000..58cf7548 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_env.py @@ -0,0 +1,12 @@ +"""Shared helpers for editing dotenv-style files at scaffold time.""" + +from __future__ import annotations + +__all__ = ["set_env_key"] + + +def set_env_key(text: str, key: str, value: str) -> str: + """Replace or append ``KEY=VALUE`` in an env-style file body.""" + lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")] + lines.append(f"{key}={value}") + return "\n".join(lines) + "\n" diff --git a/framework/hosting/simple_module_hosting/app_project.py b/framework/hosting/simple_module_hosting/app_project.py index 70fdb7c5..7c9e185c 100644 --- a/framework/hosting/simple_module_hosting/app_project.py +++ b/framework/hosting/simple_module_hosting/app_project.py @@ -17,6 +17,8 @@ from collections.abc import Sequence from pathlib import Path +from simple_module_hosting._env import set_env_key + __all__ = ["create_app_project"] _FRAMEWORK_VERSION = "0.0.1" @@ -59,7 +61,11 @@ def create_app_project( # cannot import from scaffolding at module-load time. from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx - from simple_module_hosting.scaffolding import _to_kebab_case, create_host + from simple_module_hosting.scaffolding import ( + _to_kebab_case, + _to_pascal_case, + create_host, + ) if target.exists() and any(target.iterdir()): raise FileExistsError( @@ -70,7 +76,7 @@ def create_app_project( chosen = list(selected) if selected is not None else list(PRESETS["standard"]) resolved, _added = expand_deps(chosen) - display_names = [CATALOG[m].display.replace(" ", "") for m in resolved] + display_names = [_to_pascal_case(CATALOG[m].display) for m in resolved] create_host(target, name=name, modules=display_names) py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ @@ -79,9 +85,9 @@ def create_app_project( 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_MULTI_TENANT", "true" if tenancy else "false") + 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_MULTI_TENANT", "true" if tenancy else "false") env_path.write_text(env_text, encoding="utf-8") pyproject = target / "pyproject.toml" @@ -106,14 +112,6 @@ def create_app_project( RECIPES[recipe_key].apply(target, ctx) -def _set_env_key(text: str, key: str, value: str) -> str: - lines = text.splitlines() - prefix = f"{key}=" - out = [ln for ln in lines if not ln.startswith(prefix)] - out.append(f"{key}={value}") - return "\n".join(out) + "\n" - - def _db_url(db: str, slug: str) -> str: if db == "postgres": return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" diff --git a/framework/hosting/simple_module_hosting/cli/recipes.py b/framework/hosting/simple_module_hosting/cli/recipes.py index a241bd44..7b508b7d 100644 --- a/framework/hosting/simple_module_hosting/cli/recipes.py +++ b/framework/hosting/simple_module_hosting/cli/recipes.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Protocol +from simple_module_hosting._env import set_env_key + __all__ = [ "RECIPES", "BackgroundTasksRecipe", @@ -46,13 +48,6 @@ def _optional_template_root(name: str) -> Path: return Path(str(base / "templates" / "host" / "_optional" / name)) -def _set_env_key(text: str, key: str, value: str) -> str: - """Replace or append ``KEY=VALUE`` in an env-style file body.""" - lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")] - lines.append(f"{key}={value}") - return "\n".join(lines) + "\n" - - class BackgroundTasksRecipe: """Lays down run_worker.py + compose + Dockerfile + Make targets.""" @@ -81,7 +76,7 @@ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: env_path = target / ".env.example" env_text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" env_path.write_text( - _set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT), + set_env_key(env_text, _BG_BROKER_ENV_KEY, _BG_BROKER_DEFAULT), encoding="utf-8", ) diff --git a/framework/hosting/tests/test_cli_catalog.py b/framework/hosting/tests/test_cli_catalog.py index a32bda2d..71027a8a 100644 --- a/framework/hosting/tests/test_cli_catalog.py +++ b/framework/hosting/tests/test_cli_catalog.py @@ -83,4 +83,4 @@ def test_module_entry_is_frozen() -> None: entry = ModuleEntry(name="x", package="simple_module_x", display="X") with pytest.raises(FrozenInstanceError): - entry.name = "y" # ty: ignore[invalid-assignment] noqa: B010 + entry.__setattr__("name", "y") From 7f528360623b5f3da1e278deb0e4000770364fbc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:47:39 +0200 Subject: [PATCH 11/24] docs(spec): standalone simple-module CLI distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carves the scaffolder out of simple_module_hosting into a new PyPI distribution called simple-module with deps {typer, tomlkit} only — no framework runtime deps. Single 'sm' console script; plugin subcommands (host gen-pages, users create-admin, etc.) discovered via the simple_module.cli_plugins entry-point group. All sm-* sibling scripts go away. --- ...026-04-26-standalone-cli-package-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md diff --git a/docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md b/docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md new file mode 100644 index 00000000..bf0f8c3a --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md @@ -0,0 +1,201 @@ +# Standalone `simple-module` CLI distribution + +**Date:** 2026-04-26 +**Status:** Draft, pending implementation + +## Problem + +`sm new` is currently shipped inside `simple_module_hosting`, which means `pip install simple-module-hosting` (or anything on top of it like `pipx install`) drags in FastAPI, Starlette, Inertia, Uvicorn, SQLModel, and the rest of the runtime — even when the user only wants to scaffold a new project. There is also no single CLI surface: hosting registers `sm`, `simple_module_hosting`'s helpers (`gen-pages`, `sync-js-deps`) are crammed into the same binary, and individual modules register their own `sm-users`, `sm-settings`, … console scripts. A new user who runs `pip install simple-module` should get a small, runnable scaffolder; a developer working inside a project should get one consolidated `sm` whose subcommand surface grows as plugins are installed. + +## Goals + +- A new PyPI distribution `simple-module` whose only runtime dependencies are `typer` and `tomlkit`. No framework-runtime deps. +- A single `sm` console script. All today's `sm-*` scripts (`sm-host`, `sm-users`, `sm-settings`) go away. +- Built-in (always-available) commands: `sm new`, `sm create-host`, `sm create-module`. +- Plugin commands provided via Python entry points: `simple_module_hosting` contributes `sm host gen-pages` and `sm host sync-js-deps`; the `users` and `settings` modules contribute `sm users …` and `sm settings …`. +- `pip install simple-module` works on a machine with no other framework packages installed and gives the user a working scaffolder. + +## Non-goals + +- Bumping versions, cutting a release, or interacting with `release.yml` (covered by the existing public-release spec). +- Reorganizing internal module-CLI behavior beyond the necessary `Typer` shape changes. +- Documentation rewrites beyond the README install snippet and the Makefile. +- A namespace or grouping convention for future module CLIs beyond what entry points + Typer naturally give. + +## Design + +### Distribution layout + +``` +framework/cli/ ← NEW workspace member +├── pyproject.toml name="simple-module" +├── README.md +├── LICENSE +└── simple_module/ importable package + ├── __init__.py + ├── _env.py set_env_key + ├── case.py _to_snake_case / _to_kebab_case / _to_pascal_case + ├── scaffolding.py create_host, create_module, _apply_template_files + ├── app_project.py create_app_project + helpers + ├── catalog.py ModuleEntry, CATALOG, PRESETS, expand_deps + ├── wizard.py run_wizard + ├── recipes.py Recipe protocol, BackgroundTasksRecipe, RECIPES + ├── new.py `sm new` Typer command + ├── plugins.py entry-point discovery + mounting + ├── cli.py root Typer app, mounts plugins, exposes `main` + └── templates/ package data + ├── host/ + ├── module/ + └── host/_optional/background_tasks/ +``` + +`framework/cli/pyproject.toml`: + +```toml +[project] +name = "simple-module" +version = "0.0.1" +description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +keywords = ["simple-module", "scaffolding", "cli"] +dependencies = [ + "typer>=0.12", + "tomlkit>=0.13", +] + +[project.scripts] +sm = "simple_module.cli:main" +simple-module = "simple_module.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] +``` + +### Plugin entry-point contract + +Group: `simple_module.cli_plugins`. + +Each plugin's `pyproject.toml` adds an entry under that group whose **key** becomes the subcommand namespace and whose **value** is a `module:attr` reference to a `typer.Typer` instance: + +```toml +# framework/hosting/pyproject.toml +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" + +# modules/users/pyproject.toml +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" + +# modules/settings/pyproject.toml +[project.entry-points."simple_module.cli_plugins"] +settings = "settings.cli:app" +``` + +At startup, `simple_module.plugins.discover()` calls `importlib.metadata.entry_points(group="simple_module.cli_plugins")`, loads each entry, and mounts it on the root app via `root.add_typer(plugin_app, name=entry.name)`. A failed entry-point load (broken import, missing attribute) prints a single warning and is skipped — `sm` itself keeps working. Discovery is unconditional but cheap; loading is eager so `--help` shows the full surface. + +Resulting CLI surface: + +``` +sm new … (built-in) +sm create-host … (built-in) +sm create-module … (built-in) +sm host gen-pages … (when simple_module_hosting installed) +sm host sync-js-deps … (when simple_module_hosting installed) +sm users create-admin … (when users module installed) +sm settings import-from-env … (when settings module installed) +``` + +### File moves + +| From | To | +|---|---| +| `framework/hosting/simple_module_hosting/_env.py` | `framework/cli/simple_module/_env.py` | +| `framework/hosting/simple_module_hosting/scaffolding.py` (`create_host`, `create_module`, `_apply_template_files`, `_iter_template_files`, `_resolve_template_root`, etc.) | `framework/cli/simple_module/scaffolding.py` | +| `_to_snake_case` / `_to_kebab_case` / `_to_pascal_case` (currently in `scaffolding.py`) | `framework/cli/simple_module/case.py` | +| `framework/hosting/simple_module_hosting/app_project.py` | `framework/cli/simple_module/app_project.py` | +| `framework/hosting/simple_module_hosting/cli/{catalog,wizard,recipes,new}.py` | `framework/cli/simple_module/{catalog,wizard,recipes,new}.py` | +| `framework/hosting/simple_module_hosting/cli/__init__.py` (only the `create-host` and `create-module` halves) | `framework/cli/simple_module/cli.py` | +| `framework/hosting/simple_module_hosting/cli/__init__.py` (only the `gen-pages` and `sync-js-deps` halves) | `framework/hosting/simple_module_hosting/host_cli.py` (Typer `app`) | +| `framework/hosting/simple_module_hosting/templates/{host,module,host/_optional}/` | `framework/cli/simple_module/templates/{host,module,host/_optional}/` | +| `framework/hosting/tests/test_cli_*.py` | `framework/cli/tests/test_cli_*.py` (imports update from `simple_module_hosting.cli.*` → `simple_module.*`) | +| `framework/hosting/tests/test_scaffolding_host.py` | `framework/cli/tests/test_scaffolding.py` | + +The `simple_module_hosting` package keeps its **runtime** (`app_builder.py`, middleware, `Settings`, `health.py`, manifest helpers, etc.) and gains `host_cli.py`. It loses `cli/`, `app_project.py`, `scaffolding.py`, `_env.py`, and the `templates/` tree. + +### Code-level changes + +Every Click decorator in the moved code is rewritten to Typer's signature style. Concretely: + +- `@click.command(...)` + `@click.option(...)` decorators → `def cmd(arg: Annotated[T, typer.Option(...)] = default): ...` registered via `@app.command()`. +- `click.Choice(["sqlite", "postgres"])` → string `Enum` (e.g. `class Db(str, Enum): sqlite = "sqlite"; postgres = "postgres"`) referenced as the parameter type. Typer renders the same shell-completion + help. +- `click.prompt`, `click.confirm`, `click.echo`, `click.Abort` → `typer.prompt`, `typer.confirm`, `typer.echo`, `typer.Abort` (re-exports of the Click implementations; behavior identical). +- Tests: `from click.testing import CliRunner` → `from typer.testing import CliRunner`. Same `runner.invoke(...)` / `input=` API. + +The interactive wizard logic, `expand_deps`, the recipe protocol, the catalog data, and the templates are unchanged. + +### Module-CLI updates + +`modules/users/users/cli.py` is already a Typer app exporting `app`; only the `pyproject.toml` script declaration changes: + +```toml +# Before +[project.scripts] +sm-users = "users.cli:app" + +# After +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" +``` + +`modules/settings/settings/cli.py` is currently Click-style with a `main` function. Convert to a Typer app named `app`. Same pyproject.toml swap (`sm-settings` script → entry-point under `simple_module.cli_plugins`). + +`framework/hosting/pyproject.toml` drops `sm = "simple_module_hosting.cli:main"` and gains: + +```toml +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" +``` + +Note: the `simple-module` console-script alias is dropped from `simple_module_hosting` too — it now lives only in the `simple-module` package. + +### Workspace + Makefile wiring + +- Root `pyproject.toml`: add `framework/cli` to workspace members. +- `[tool.uv.sources]`: add `simple-module = { workspace = true }` so in-tree dev resolves to the local copy. +- `host/pyproject.toml`: add `simple-module==0.0.1` as a dependency. (Hosting and module packages do **not** depend on `simple-module`.) +- `Makefile`: rename `sm gen-pages` → `sm host gen-pages` and `sm sync-js-deps` → `sm host sync-js-deps` in the `gen-pages` and `sync-module-deps` targets. The `make new-module` target keeps invoking `sm create-module`. +- Existing CI (`.github/workflows/pr.yml`) needs no change — `make lint` and `make test` will pick up the new workspace member automatically. +- Release matrix (`.github/workflows/release.yml`): one new entry for the `simple-module` package alongside the existing 14. + +### Test strategy + +- The 47 CLI tests move from `framework/hosting/tests/test_cli_*.py` → `framework/cli/tests/test_cli_*.py`. Imports update mechanically (find/replace on `simple_module_hosting.cli` → `simple_module`, `simple_module_hosting.scaffolding` → `simple_module.scaffolding`). The Click `CliRunner` import becomes `from typer.testing import CliRunner`. The Click `result.output` / `result.exit_code` API is identical under Typer's runner. +- `framework/cli/tests/test_no_framework_deps.py` (new): asserts that `importlib.metadata.requires("simple-module")` is exactly `["typer>=0.12", "tomlkit>=0.13"]`. Guards against accidental dep drift introducing `simple_module_core` / `simple_module_hosting` deps. +- `framework/cli/tests/test_plugin_discovery.py` (new): exercises `simple_module.plugins.discover()` against a fake entry-point group injected via `monkeypatch.setattr(importlib.metadata, "entry_points", ...)`. Asserts that valid plugins mount, broken plugins are skipped with a warning, and the resulting Typer app's `--help` lists the expected subgroups. +- `framework/hosting/tests/test_host_cli.py` (new): smoke-tests `sm-host`'s `gen-pages` and `sync-js-deps` happy paths after the move. The existing `test_scaffolding_host.py` becomes `framework/cli/tests/test_scaffolding.py`. +- `framework/hosting/tests/test_app.py` etc. unchanged. + +### Failure modes + +- **A plugin's entry point references a missing module or non-Typer object.** Log a warning (`sm` keeps working with whatever else is installed). Test covers this. +- **Two plugins claim the same subgroup name** (e.g. two installs both register `host`). `add_typer` raises; we catch the second registration and warn, keeping the first. Test covers this. +- **A user has `simple-module` installed but no plugins.** Built-in commands work; `sm --help` shows only `new` / `create-host` / `create-module`. No errors. +- **Templates package-data shipping.** The `_optional/` directory tree is copied as `package-data` under the wheel; verified by `framework/cli/tests/test_cli_recipes.py` reading the package-data path at runtime (same pattern that works today). + +### Backward compat + +This is a pre-`0.0.1` framework with no external consumers. There are no shims or aliases: + +- `simple_module_hosting.cli` package, `simple_module_hosting.scaffolding`, and `simple_module_hosting.app_project` are **deleted**, not re-exported. +- All in-tree callers are updated mechanically: tests, the `Makefile`, `host/pyproject.toml`, the per-module `pyproject.toml` files. +- The `sm-users` and `sm-settings` console scripts are deleted. Anyone running `sm-users create-admin` switches to `sm users create-admin`. + +## Open questions + +None blocking. The dep guard test (`test_no_framework_deps.py`) is intentionally narrow: it pins the declared dep list to exactly `typer` and `tomlkit`. If we ever decide to relax to `typer-slim` to drop `rich`, that's a single-line change with the test caught the moment. From cf15795227202068b801b50d34e1f534a7295061 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:56:06 +0200 Subject: [PATCH 12/24] docs(plan): standalone simple-module CLI package implementation 9-task TDD plan: bootstrap distribution, move utilities and scaffolding, move catalog/wizard/recipes/new/app_project, port Click to Typer, implement plugin discovery, carve sm host plugin, convert users + settings to plugins, final dep-guard + cleanup. --- .../2026-04-26-standalone-cli-package.md | 2044 +++++++++++++++++ 1 file changed, 2044 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-standalone-cli-package.md diff --git a/docs/superpowers/plans/2026-04-26-standalone-cli-package.md b/docs/superpowers/plans/2026-04-26-standalone-cli-package.md new file mode 100644 index 00000000..dfd8ac20 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-standalone-cli-package.md @@ -0,0 +1,2044 @@ +# Standalone `simple-module` CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Carve the scaffolder out of `simple_module_hosting` into a new PyPI distribution `simple-module` whose only deps are `typer` + `tomlkit`. Single `sm` console script; plugin subcommands (`sm host gen-pages`, `sm users create-admin`, …) discovered via Python entry points. All `sm-*` sibling scripts go away. + +**Architecture:** New workspace member `framework/cli/` containing the importable package `simple_module`. Click → Typer 1:1 port at the decorator layer; logic, templates, and tests are copies. `simple_module_hosting` keeps only its runtime + a new `host_cli.py` Typer app registered as a plugin. Modules `users` and `settings` swap their `sm-*` console-script entries for `simple_module.cli_plugins` entry-point entries. + +**Tech Stack:** Python 3.12, `typer>=0.12`, `tomlkit>=0.13`, `pytest`, `typer.testing.CliRunner`, `importlib.metadata.entry_points`, hatchling, uv workspaces. + +**Spec:** [docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md](docs/superpowers/specs/2026-04-26-standalone-cli-package-design.md) + +--- + +## File-structure summary + +| Path | Role | +|---|---| +| `framework/cli/pyproject.toml` | New distribution `simple-module`, deps: `typer`, `tomlkit`. | +| `framework/cli/simple_module/__init__.py` | Empty, marks the package. | +| `framework/cli/simple_module/_env.py` | `set_env_key` (moved from `simple_module_hosting/_env.py`). | +| `framework/cli/simple_module/case.py` | `_to_snake_case`, `_to_kebab_case`, `_to_pascal_case` (moved from `scaffolding.py`). | +| `framework/cli/simple_module/scaffolding.py` | `create_host`, `create_module`, template walker (moved). | +| `framework/cli/simple_module/app_project.py` | `create_app_project` + helpers (moved). | +| `framework/cli/simple_module/catalog.py` | `ModuleEntry`, `CATALOG`, `PRESETS`, `expand_deps` (moved). | +| `framework/cli/simple_module/wizard.py` | `run_wizard` (moved + Typer port). | +| `framework/cli/simple_module/recipes.py` | `Recipe`, `BackgroundTasksRecipe`, `RECIPES` (moved). | +| `framework/cli/simple_module/new.py` | `sm new` Typer command (moved + ported). | +| `framework/cli/simple_module/cli.py` | Root Typer app + `create-host` / `create-module` commands + plugin mount + `main`. | +| `framework/cli/simple_module/plugins.py` | Entry-point discovery + mounting. | +| `framework/cli/simple_module/templates/` | All template files (moved from hosting). | +| `framework/cli/tests/test_*.py` | Migrated CLI tests (`test_cli_catalog`, `test_cli_wizard`, `test_cli_recipes`, `test_cli_new`, `test_scaffolding_host`, `test_scaffolding_module`, plus new `test_plugin_discovery`, `test_no_framework_deps`). | +| `framework/hosting/simple_module_hosting/host_cli.py` | New: Typer app with `gen-pages` + `sync-js-deps`. | +| `framework/hosting/simple_module_hosting/cli/` | **Deleted** — entire package. | +| `framework/hosting/simple_module_hosting/scaffolding.py` | **Deleted**. | +| `framework/hosting/simple_module_hosting/app_project.py` | **Deleted**. | +| `framework/hosting/simple_module_hosting/_env.py` | **Deleted**. | +| `framework/hosting/simple_module_hosting/templates/` | **Deleted** (moved). | +| `framework/hosting/simple_module_hosting/manifest.py` | Stays — used by `host_cli.py`. | +| `framework/hosting/pyproject.toml` | Drops `sm`/`simple-module` scripts; adds `simple_module.cli_plugins` entry. | +| `modules/users/pyproject.toml` | Drops `sm-users` script; adds `simple_module.cli_plugins` entry. | +| `modules/settings/pyproject.toml` | Drops `sm-settings` script; adds `simple_module.cli_plugins` entry. | +| `modules/settings/settings/cli.py` | Rewritten as Typer app. | +| `Makefile` | `sm gen-pages` → `sm host gen-pages`; `sm sync-js-deps` → `sm host sync-js-deps`. | +| `pyproject.toml` (root) | Workspace `members = ["framework/*", …]` already covers `framework/cli`; verify ruff `extend-exclude` updated to point at the new templates path. | +| `README.md` | `sm-users` / `sm-settings` snippets updated to `sm users …` / `sm settings …`. | + +--- + +## Task 1: Bootstrap the `simple-module` distribution + +**Files:** +- Create: `framework/cli/pyproject.toml` +- Create: `framework/cli/README.md` +- Create: `framework/cli/LICENSE` (copy of root `LICENSE`) +- Create: `framework/cli/simple_module/__init__.py` (empty) +- Modify: `pyproject.toml` (root) — verify `framework/cli` is covered by `framework/*` workspace glob. + +- [ ] **Step 1: Create the distribution directory and minimal Python package** + +```bash +mkdir -p framework/cli/simple_module framework/cli/tests +touch framework/cli/simple_module/__init__.py +touch framework/cli/tests/__init__.py +``` + +- [ ] **Step 2: Write `framework/cli/pyproject.toml`** + +Create `framework/cli/pyproject.toml`: + +```toml +[project] +name = "simple-module" +version = "0.0.1" +description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "scaffolding", "cli", "fastapi", "modular-monolith"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "typer>=0.12", + "tomlkit>=0.13", +] + +[project.scripts] +sm = "simple_module.cli:main" +simple-module = "simple_module.cli:main" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] +``` + +- [ ] **Step 3: Stub `simple_module/cli.py` so the entry point resolves** + +Create `framework/cli/simple_module/cli.py`: + +```python +"""Root `sm` command — scaffolders + plugin mount. + +This file gets fleshed out in Task 5 (Typer port) and Task 6 (plugin +discovery). For now it exists only so the ``sm = simple_module.cli:main`` +console-script entry point resolves cleanly during workspace install. +""" + +from __future__ import annotations + + +def main() -> int: + raise SystemExit( + "simple-module CLI is being installed but its commands have not " + "been wired up yet. Re-install once Task 5 lands." + ) +``` + +- [ ] **Step 4: Copy the LICENSE and write a README** + +```bash +cp LICENSE framework/cli/LICENSE +``` + +Create `framework/cli/README.md`: + +````markdown +# simple-module + +Standalone scaffolder for the [SimpleModule framework](https://github.com/antosubash/simple_module_python). + +```bash +pip install simple-module # or: pipx install simple-module +sm new my-app # interactive wizard +sm new my-app --yes --preset full +``` + +Provides three built-in commands: `sm new`, `sm create-host`, `sm create-module`. + +When other framework packages are installed, they contribute additional subcommands via the `simple_module.cli_plugins` entry-point group: + +| Package | Commands | +|---|---| +| `simple_module_hosting` | `sm host gen-pages`, `sm host sync-js-deps` | +| `simple_module_users` | `sm users create-admin` | +| `simple_module_settings` | `sm settings import-from-env` | + +## License + +MIT — see [LICENSE](LICENSE). +```` + +- [ ] **Step 5: Confirm workspace + uv sync resolves** + +The root `pyproject.toml` declares `members = ["framework/*", "modules/*", "host"]`. The new `framework/cli/` is automatically picked up. + +Run: `uv sync --all-packages` +Expected: succeeds; the new `simple-module` distribution appears in `uv pip list`. Run `uv pip list 2>&1 | grep simple-module` and expect a row. + +- [ ] **Step 6: Verify the stub console script resolves** + +Run: `uv run sm --help 2>&1 | head -3` +Expected: prints the SystemExit message from Step 3 (proves the entry point + package import works). + +- [ ] **Step 7: Commit** + +```bash +git add framework/cli/ pyproject.toml +git commit -m "feat(cli): bootstrap simple-module distribution + +$(cat <<'EOF' +New workspace member framework/cli/ shipping the simple-module PyPI +distribution. Contains stub cli.py for the sm entry point; real +commands land in subsequent commits. +EOF +)" +``` + +--- + +## Task 2: Move `_env.py` and case helpers + +Two pure-utility modules with no Click/Typer surface, no template I/O. Move first to establish the import-rewrite pattern. + +**Files:** +- Create: `framework/cli/simple_module/_env.py` +- Create: `framework/cli/simple_module/case.py` +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py:148-164` (remove `_to_snake_case`, `_to_kebab_case`, `_to_pascal_case`). +- Delete: `framework/hosting/simple_module_hosting/_env.py` +- Modify: `framework/hosting/simple_module_hosting/app_project.py` (update import). +- Modify: `framework/hosting/simple_module_hosting/cli/recipes.py` (update import). + +- [ ] **Step 1: Create `framework/cli/simple_module/_env.py`** + +```python +"""Shared helpers for editing dotenv-style files at scaffold time.""" + +from __future__ import annotations + +__all__ = ["set_env_key"] + + +def set_env_key(text: str, key: str, value: str) -> str: + """Replace or append ``KEY=VALUE`` in an env-style file body.""" + lines = [ln for ln in text.splitlines() if not ln.startswith(f"{key}=")] + lines.append(f"{key}={value}") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 2: Create `framework/cli/simple_module/case.py`** + +```python +"""Identifier case-conversion helpers used by every scaffolder. + +Module/host names are accepted in any case style and normalized to the +three forms the templates need: snake_case (Python package + entry-point +key), kebab-case (PyPI slug), and PascalCase (display name in Meta). +""" + +from __future__ import annotations + +import re + +__all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"] + + +def to_snake_case(name: str) -> str: + """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'.""" + s = re.sub(r"(? str: + """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug).""" + return to_snake_case(name).replace("_", "-") + + +def to_pascal_case(name: str) -> str: + """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" + snake = to_snake_case(name) + return "".join(part.capitalize() for part in snake.split("_") if part) +``` + +(Note: leading `_` removed — these are now public helpers under `simple_module.case`.) + +- [ ] **Step 3: Add `simple-module` as a workspace dep of `simple_module_hosting`** + +Edit `framework/hosting/pyproject.toml` — add to `dependencies`: + +```toml +dependencies = [ + "click>=8.1", + "fastapi>=0.115", + "fastapi-inertia>=1.0", + "httpx>=0.27", + "jinja2>=3.1", + "simple_module==0.0.1", # NEW + "simple_module_core==0.0.1", + "simple_module_db==0.0.1", + "starlette>=0.44", + "tomlkit>=0.13", + "uvicorn[standard]>=0.34", +] +``` + +And add to `[tool.uv.sources]`: + +```toml +[tool.uv.sources] +simple_module = { workspace = true } +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +``` + +(Keep this dep through the rest of the migration so `simple_module_hosting` can re-import freely from `simple_module`. It's removed in Task 9.) + +- [ ] **Step 4: Run `uv sync --all-packages`** + +Run: `uv sync --all-packages` +Expected: succeeds. + +- [ ] **Step 5: Update `simple_module_hosting/app_project.py` to import from `simple_module`** + +Replace the existing imports (currently at top of file + a local import inside `create_app_project`): + +```python +# At top of framework/hosting/simple_module_hosting/app_project.py +from simple_module._env import set_env_key +from simple_module.case import to_kebab_case, to_pascal_case +``` + +Inside `create_app_project`, drop the local import of `_to_kebab_case`, `_to_pascal_case`, `create_host` from `simple_module_hosting.scaffolding` — keep only the `create_host` local import (case helpers are now top-level): + +```python +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> None: + from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps + from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx + from simple_module_hosting.scaffolding import create_host + ... +``` + +Replace the two call sites: +- `_to_kebab_case(name)` → `to_kebab_case(name)` +- `_to_pascal_case(CATALOG[m].display)` → `to_pascal_case(CATALOG[m].display)` + +- [ ] **Step 6: Update `simple_module_hosting/cli/recipes.py` to import from `simple_module`** + +Replace `from simple_module_hosting._env import set_env_key` with: + +```python +from simple_module._env import set_env_key +``` + +- [ ] **Step 7: Delete the case helpers from `scaffolding.py`** + +Edit `framework/hosting/simple_module_hosting/scaffolding.py`. Remove the three definitions: + +```python +def _to_snake_case(name: str) -> str: ... +def _to_kebab_case(name: str) -> str: ... +def _to_pascal_case(name: str) -> str: ... +``` + +(They lived around lines 148-164; they're no longer needed.) `create_module` calls them; update it to import from `simple_module.case`: + +```python +# Top of scaffolding.py +from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case +``` + +Inside `create_module`, replace `_to_pascal_case(name)` → `to_pascal_case(name)`, `_to_kebab_case(name)` → `to_kebab_case(name)`, `_to_snake_case(name)` → `to_snake_case(name)`. + +- [ ] **Step 8: Delete `framework/hosting/simple_module_hosting/_env.py`** + +```bash +git rm framework/hosting/simple_module_hosting/_env.py +``` + +- [ ] **Step 9: Run the full hosting test suite** + +Run: `uv run pytest framework/hosting/ -q` +Expected: all 144 tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add framework/cli/ framework/hosting/ pyproject.toml +git commit -m "refactor(cli): move _env and case helpers to simple_module package + +$(cat <<'EOF' +Pure utilities; first pieces of the simple-module distribution. Hosting +keeps temporary workspace dep on simple-module so existing modules can +import from both during the migration. +EOF +)" +``` + +--- + +## Task 3: Move `scaffolding.py` (`create_host`, `create_module`) and templates + +**Files:** +- Create: `framework/cli/simple_module/scaffolding.py` +- Move: `framework/hosting/simple_module_hosting/templates/` → `framework/cli/simple_module/templates/` +- Modify: `framework/cli/pyproject.toml` (declare templates as package data via hatch). +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py` (becomes a re-export shim). +- Move: `framework/hosting/tests/test_scaffolding_host.py` → `framework/cli/tests/test_scaffolding_host.py` +- Move: `framework/hosting/tests/test_scaffolding_module.py` → `framework/cli/tests/test_scaffolding_module.py` +- Update: import sites in moved tests + in `simple_module_hosting/cli/recipes.py` + in `simple_module_hosting/app_project.py`. +- Modify: root `pyproject.toml` `[tool.ruff] extend-exclude` to point at the new templates path. + +- [ ] **Step 1: Move templates wholesale** + +```bash +git mv framework/hosting/simple_module_hosting/templates framework/cli/simple_module/templates +``` + +- [ ] **Step 2: Update root `pyproject.toml` ruff exclude** + +Edit `pyproject.toml` (root). Replace: + +```toml +extend-exclude = ["framework/hosting/simple_module_hosting/templates"] +``` + +with: + +```toml +extend-exclude = ["framework/cli/simple_module/templates"] +``` + +- [ ] **Step 3: Create `framework/cli/simple_module/scaffolding.py`** + +This is a near-verbatim copy of the current `framework/hosting/simple_module_hosting/scaffolding.py`, **minus** `create_app_project` (still in hosting; moves in Task 4) and minus the case helpers (moved in Task 2). + +```python +"""Host + module scaffolding via package-data templates. + +* :func:`create_host` materializes a new host project from the templates + under ``simple_module/templates/host/``. +* :func:`create_module` materializes a new module package from + ``simple_module/templates/module/``. + +The frontend pages manifest + per-module JS dep discovery live in +:mod:`simple_module_hosting.manifest` (those need module-discovery and +stay in hosting). +""" + +from __future__ import annotations + +import importlib.resources +import logging +import re +import shutil +from collections.abc import Mapping, Sequence +from pathlib import Path + +from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case + +__all__ = ["create_host", "create_module"] + +logger = logging.getLogger(__name__) + +_TEMPLATES_PACKAGE = "simple_module.templates" +_PACKAGE_PATH_TOKEN = "__PACKAGE__" + + +def _module_to_pypi_name(name: str) -> str: + return f"simple_module_{name.lower()}" + + +def _iter_template_files(template_root: Path): + """Yield every file under ``template_root``. Skips ``_optional/`` paths.""" + for path in template_root.rglob("*"): + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path + + +def _require_empty_dest(dest: Path) -> None: + if dest.exists() and any(dest.iterdir()): + raise FileExistsError( + f"Destination {dest} already exists and is non-empty. " + "Choose a new path or remove the contents first." + ) + dest.mkdir(parents=True, exist_ok=True) + + +def _resolve_template_root(subdir: str, override: Path | None) -> Path: + if override is not None: + return Path(override) + return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir)) + + +def _apply_template_files( + src_root: Path, + dest: Path, + substitutions: Mapping[str, str], + *, + path_rewrites: Mapping[str, str] | None = None, +) -> None: + for src in _iter_template_files(src_root): + rel_str = str(src.relative_to(src_root)) + for old, new in (path_rewrites or {}).items(): + rel_str = rel_str.replace(old, new) + rel_str = rel_str.removesuffix(".tpl") + target = dest / rel_str + target.parent.mkdir(parents=True, exist_ok=True) + if src.suffix == ".tpl": + text = src.read_text(encoding="utf-8") + for placeholder, value in substitutions.items(): + text = text.replace(placeholder, value) + target.write_text(text, encoding="utf-8") + else: + shutil.copy2(src, target) + + +def create_host( + dest: Path, + name: str, + modules: Sequence[str], + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules) + _apply_template_files( + _resolve_template_root("host", template_root), + dest, + {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, + ) + logger.info( + "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" + ) + return dest + + +def create_module( + dest: Path, + name: str, + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + display_name = to_pascal_case(name) + slug = to_kebab_case(name) + package_name = to_snake_case(name) + _apply_template_files( + _resolve_template_root("module", template_root), + dest, + substitutions={ + "{{MODULE_NAME}}": display_name, + "{{MODULE_SLUG}}": slug, + "{{PACKAGE_NAME}}": package_name, + }, + path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, + ) + logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) + return dest +``` + +- [ ] **Step 4: Add `force-include` for templates in `framework/cli/pyproject.toml`** + +Append to the `[tool.hatch.build.targets.wheel]` block in `framework/cli/pyproject.toml`: + +```toml +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] + +[tool.hatch.build.targets.wheel.shared-data] +"simple_module/templates" = "simple_module/templates" +``` + +(Hatchling auto-includes anything inside a `packages = [...]` directory by default. The shared-data block is belt-and-braces for files like `Makefile.snippet` whose extensions aren't recognized as package code.) + +- [ ] **Step 5: Reduce hosting's `scaffolding.py` to a thin shim** + +Replace `framework/hosting/simple_module_hosting/scaffolding.py` with: + +```python +"""Re-exports of moved scaffolding APIs. + +The actual implementations live in :mod:`simple_module.scaffolding`. This +shim lets the in-tree hosting code keep importing from the historical +path during the migration; it is removed in the final cleanup task. +""" + +from __future__ import annotations + +from simple_module.scaffolding import create_host, create_module + +from simple_module_hosting.manifest import ( + collect_module_js_deps, + compute_module_pages, + read_module_package_json, + repo_root_from_client_app, + write_module_pages_manifest, +) + +# create_app_project lives in app_project.py; re-exported via __all__. +from simple_module_hosting.app_project import create_app_project as create_app_project + +__all__ = [ + "collect_module_js_deps", + "compute_module_pages", + "create_app_project", + "create_host", + "create_module", + "read_module_package_json", + "repo_root_from_client_app", + "write_module_pages_manifest", +] +``` + +- [ ] **Step 6: Update hosting's `app_project.py` to import `create_host` from the new location** + +Inside `create_app_project`, change the local import: + +```python +from simple_module.scaffolding import create_host +``` + +(Keep the `simple_module_hosting.cli.{catalog,recipes}` imports — those move in Task 4.) + +- [ ] **Step 7: Move test files** + +```bash +git mv framework/hosting/tests/test_scaffolding_host.py framework/cli/tests/test_scaffolding_host.py +git mv framework/hosting/tests/test_scaffolding_module.py framework/cli/tests/test_scaffolding_module.py +``` + +- [ ] **Step 8: Update imports in the moved tests** + +In both moved files, replace `from simple_module_hosting.scaffolding import ...` with `from simple_module.scaffolding import ...`. The `from simple_module_hosting.cli import main` in the `Click sm create-host/create-module` integration tests stays — those tests get rewritten in Task 5. + +Concretely, in `framework/cli/tests/test_scaffolding_host.py`: +- Line 14: `from simple_module_hosting.scaffolding import compute_module_pages` — leave for now (`compute_module_pages` is re-exported via the shim — works through Task 8). +- All other `from simple_module_hosting.scaffolding import create_host` / `compute_module_pages` lines: leave them; they work through the shim. + +In `framework/cli/tests/test_scaffolding_module.py`: +- All `from simple_module_hosting.scaffolding import create_module` lines: leave them; the shim re-exports `create_module`. + +This is intentional — Task 3 just relocates; the shim keeps tests green. Tests get retargeted in Task 8 when the shim is removed. + +- [ ] **Step 9: Add `framework/cli/conftest.py` if needed** + +If the moved tests reference framework-level fixtures (like `db_session`), they pull in heavy deps. Inspect: + +Run: `grep -n 'db_session\|app\|authenticated_client\|settings' framework/cli/tests/test_scaffolding_host.py framework/cli/tests/test_scaffolding_module.py` +Expected: no matches (these are pure scaffolding tests, no fixture deps). + +If matches appear, decide test-by-test: either leave the test in `framework/hosting/tests/` (if it really needs runtime fixtures) or write a slim `framework/cli/conftest.py` covering only the needed bits. For the planned moves, no conftest is needed. + +- [ ] **Step 10: Run pytest** + +Run: `uv run pytest framework/cli/ framework/hosting/ -q` +Expected: all tests pass (the relocated `test_scaffolding_*` files run from their new home; everything else unaffected). + +- [ ] **Step 11: Commit** + +```bash +git add -A +git commit -m "refactor(cli): move scaffolding + templates to simple_module package + +$(cat <<'EOF' +create_host, create_module, _apply_template_files, and the entire +templates/ tree relocate from simple_module_hosting to the new +simple_module package. Hosting's scaffolding.py becomes a re-export +shim so existing import sites keep working through Task 7. +EOF +)" +``` + +--- + +## Task 4: Move `app_project.py`, `catalog.py`, `wizard.py`, `recipes.py` + +Click is still in play — this task only relocates code without changing the framework. Typer port lands in Task 5. + +**Files:** +- Move: `framework/hosting/simple_module_hosting/app_project.py` → `framework/cli/simple_module/app_project.py` +- Move: `framework/hosting/simple_module_hosting/cli/{catalog,wizard,recipes}.py` → `framework/cli/simple_module/{catalog,wizard,recipes}.py` +- Move: tests `test_cli_{catalog,wizard,recipes}.py` → `framework/cli/tests/` +- Modify: `framework/hosting/simple_module_hosting/scaffolding.py` (shim — `create_app_project` re-import path). +- Modify: `framework/hosting/simple_module_hosting/cli/__init__.py` — drop the catalog/wizard/recipes imports from `new_project`. + +- [ ] **Step 1: Move the four source files** + +```bash +git mv framework/hosting/simple_module_hosting/app_project.py framework/cli/simple_module/app_project.py +git mv framework/hosting/simple_module_hosting/cli/catalog.py framework/cli/simple_module/catalog.py +git mv framework/hosting/simple_module_hosting/cli/wizard.py framework/cli/simple_module/wizard.py +git mv framework/hosting/simple_module_hosting/cli/recipes.py framework/cli/simple_module/recipes.py +``` + +- [ ] **Step 2: Fix imports inside the moved files** + +In `framework/cli/simple_module/app_project.py`, change: +- `from simple_module._env import set_env_key` — already correct. +- `from simple_module.case import to_kebab_case, to_pascal_case` — already correct. +- Inside `create_app_project`, replace the local imports: + +```python +def create_app_project( + target: Path, + *, + name: str, + db: str = "sqlite", + tenancy: bool = False, + selected: Sequence[str] | None = None, +) -> None: + from simple_module.catalog import CATALOG, PRESETS, expand_deps + from simple_module.recipes import RECIPES, ScaffoldCtx + from simple_module.scaffolding import create_host + ... +``` + +In `framework/cli/simple_module/wizard.py`: +- `from .catalog import CATALOG, PRESETS, expand_deps` → `from simple_module.catalog import CATALOG, PRESETS, expand_deps` +- (Or use relative imports; they all live in the same package now: `from .catalog import ...` still works.) + +In `framework/cli/simple_module/recipes.py`: +- `from simple_module._env import set_env_key` — already correct. +- The `_optional_template_root` helper currently does `importlib.resources.files("simple_module_hosting")` — change to `importlib.resources.files("simple_module")`: + +```python +def _optional_template_root(name: str) -> Path: + """Resolve ``templates/host/_optional//`` from package data.""" + base = importlib.resources.files("simple_module") + return Path(str(base / "templates" / "host" / "_optional" / name)) +``` + +In `framework/cli/simple_module/catalog.py`: +- No external imports — leave as is. + +- [ ] **Step 3: Move the three test files** + +```bash +git mv framework/hosting/tests/test_cli_catalog.py framework/cli/tests/test_cli_catalog.py +git mv framework/hosting/tests/test_cli_wizard.py framework/cli/tests/test_cli_wizard.py +git mv framework/hosting/tests/test_cli_recipes.py framework/cli/tests/test_cli_recipes.py +``` + +- [ ] **Step 4: Update imports in the three moved test files** + +`framework/cli/tests/test_cli_catalog.py`: +```python +from simple_module.catalog import ( + CATALOG, + PRESETS, + ModuleEntry, + expand_deps, +) +``` + +`framework/cli/tests/test_cli_wizard.py`: +```python +from simple_module.wizard import run_wizard +``` + +`framework/cli/tests/test_cli_recipes.py`: +```python +from simple_module.recipes import ( + RECIPES, + BackgroundTasksRecipe, + ScaffoldCtx, +) +from simple_module.scaffolding import create_host +``` + +- [ ] **Step 5: Patch the hosting scaffolding shim** + +The shim from Task 3 already re-exports `create_app_project` from `simple_module_hosting.app_project` — but that file just moved. Update the shim to re-export from the new home: + +In `framework/hosting/simple_module_hosting/scaffolding.py`, change: + +```python +from simple_module_hosting.app_project import create_app_project as create_app_project +``` + +to: + +```python +from simple_module.app_project import create_app_project as create_app_project +``` + +- [ ] **Step 6: Patch the hosting `cli/__init__.py` imports** + +The Click `new_project` command in `framework/hosting/simple_module_hosting/cli/__init__.py` is removed in Task 5; for now, just adjust its imports so tests keep passing: + +Edit `framework/hosting/simple_module_hosting/cli/__init__.py`: +- The bottom-of-file `from .new import new_project as _new_project` line: change to `from simple_module.new import new_project as _new_project` for now. (Both files still exist via Click; Task 5 deletes the `new.py` in hosting.) + +Wait — `cli/new.py` was already moved out by `git mv` in Task 4 Step 1? No — I only moved `catalog.py`, `wizard.py`, `recipes.py`. The `new.py` is still in `simple_module_hosting/cli/new.py`. Move it now too: + +```bash +git mv framework/hosting/simple_module_hosting/cli/new.py framework/cli/simple_module/new.py +``` + +In the moved `framework/cli/simple_module/new.py`, update imports: + +```python +from simple_module.app_project import create_app_project +from simple_module.catalog import PRESETS, expand_deps +from simple_module.wizard import run_wizard +``` + +And in `framework/hosting/simple_module_hosting/cli/__init__.py`: + +```python +from simple_module.new import new_project as _new_project # noqa: E402 +``` + +- [ ] **Step 7: Move `test_cli_new.py` and update imports** + +```bash +git mv framework/hosting/tests/test_cli_new.py framework/cli/tests/test_cli_new.py +``` + +In `framework/cli/tests/test_cli_new.py`, change: +- `from simple_module_hosting.cli import main` — leave for now; `simple_module_hosting.cli:main` still works (Click group + the relocated `new_project` registered into it). The Typer port in Task 5 will switch this to `from simple_module.cli import app` and `runner.invoke(app, ...)`. +- All `from simple_module_hosting.scaffolding import create_app_project` — leave; works through the Task 3 shim. + +- [ ] **Step 8: Run the test suite** + +Run: `uv run pytest framework/cli/ framework/hosting/ -q` +Expected: all tests pass — the existing Click `sm` is still functional through the hosting package; tests in `framework/cli/tests/` import from the moved homes and exercise the same code. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "refactor(cli): move app_project, catalog, wizard, recipes, new to simple_module + +$(cat <<'EOF' +All scaffolding logic now lives in the simple_module package. Hosting's +cli/__init__.py click group still works (it imports new_project from +the new location); fully replaced by the Typer port in the next commit. +EOF +)" +``` + +--- + +## Task 5: Convert CLI to Typer + build root `cli.py` + +Big change. Rewrites the four command files — `new.py`, `cli.py` (new), and the wizard's prompt usage — from Click to Typer. Updates the test runner. + +**Files:** +- Modify: `framework/cli/simple_module/new.py` (Click decorators → Typer). +- Modify: `framework/cli/simple_module/wizard.py` (Click prompts → Typer prompts). +- Create: `framework/cli/simple_module/cli.py` (root Typer app + `create-host` / `create-module` commands + `main`). +- Modify: all five `framework/cli/tests/test_cli_*.py` files: `from click.testing import CliRunner` → `from typer.testing import CliRunner`; `from simple_module_hosting.cli import main` → `from simple_module.cli import app`. +- Delete: `framework/hosting/simple_module_hosting/cli/__init__.py` (and the empty `cli/` dir). + +- [ ] **Step 1: Rewrite `framework/cli/simple_module/new.py` in Typer style** + +Replace the entire file with: + +```python +"""``sm new`` Typer command — flag-driven or interactive scaffolder.""" + +from __future__ import annotations + +import subprocess +import sys +from enum import Enum +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module.app_project import create_app_project +from simple_module.catalog import PRESETS, expand_deps +from simple_module.wizard import run_wizard + +__all__ = ["new_project"] + + +class Db(str, Enum): + sqlite = "sqlite" + postgres = "postgres" + + +class Preset(str, Enum): + minimal = "minimal" + standard = "standard" + full = "full" + + +def new_project( + name: Annotated[str, typer.Argument(help="App name (used for directory + package).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + db: Annotated[ + Db, + typer.Option("--db", help="Database backend to configure in .env.example."), + ] = Db.sqlite, + tenancy: Annotated[ + bool, + typer.Option("--tenancy/--no-tenancy", help="Enable the multi-tenant middleware."), + ] = False, + preset: Annotated[ + Preset | None, + typer.Option("--preset", help="Module preset. Combine with --with."), + ] = None, + extra: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated extra modules (e.g. background_tasks,file_storage).", + ), + ] = "", + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip interactive prompts; accept defaults."), + ] = False, + no_install: Annotated[ + bool, + typer.Option( + "--no-install", + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", + ), + ] = False, +) -> None: + """Scaffold a new SimpleModule app, optionally with background jobs.""" + target = dest or Path.cwd() / name + extra_list = [m.strip() for m in extra.split(",") if m.strip()] + flag_driven = preset is not None or bool(extra_list) + db_value: str = db.value + tenancy_value: bool = tenancy + + if yes or flag_driven: + chosen = list(PRESETS[(preset or Preset.standard).value]) + extra_list + try: + resolved, added = expand_deps(chosen) + except KeyError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + for added_name, required_by in added: + typer.echo(f"Added {added_name} (required by {required_by})") + else: + try: + db_value, tenancy_value, resolved = run_wizard( + default_db=db.value, default_tenancy=tenancy + ) + except typer.Abort: + typer.echo("Aborted.", err=True) + raise typer.Exit(code=1) + + try: + create_app_project( + target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved + ) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created app '{name}' at {target}") + typer.echo(f"Modules: {', '.join(resolved)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + if no_install: + typer.echo(" uv sync") + typer.echo(" npm install") + typer.echo(" alembic upgrade head") + typer.echo(" make dev") + if "background_tasks" in resolved: + typer.echo(" docker compose up -d redis worker beat # background jobs") + return + + typer.echo("Installing dependencies...") + for cmd in (["uv", "sync"], ["npm", "install"]): + result = subprocess.run(cmd, cwd=target, check=False) + if result.returncode != 0: + typer.echo( + f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " + "finish setup manually.", + err=True, + ) + return + + subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, 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") +``` + +- [ ] **Step 2: Rewrite `framework/cli/simple_module/wizard.py` to use Typer prompts** + +Replace the file with: + +```python +"""Interactive prompt sequence for ``sm new``.""" + +from __future__ import annotations + +import typer + +from simple_module.catalog import CATALOG, PRESETS, expand_deps + +__all__ = ["run_wizard"] + +_PRESET_CHOICES = ("minimal", "standard", "full", "custom") + + +def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: + db = typer.prompt( + "Database backend", default=default_db, type=str + ) + if db not in ("sqlite", "postgres"): + typer.echo(f"Invalid database: {db!r}; expected sqlite or postgres", err=True) + raise typer.Abort() + tenancy = typer.confirm("Enable multi-tenancy?", default=default_tenancy) + + typer.echo("\nPreset:") + typer.echo(" [1] minimal — users only") + typer.echo(" [2] standard — users, dashboard, permissions (default)") + typer.echo(" [3] full — every module") + typer.echo(" [4] custom — pick modules one by one") + choice = typer.prompt("Choose", default="2", type=str) + if choice not in {"1", "2", "3", "4"}: + typer.echo(f"Invalid choice: {choice!r}", err=True) + raise typer.Abort() + preset_name = _PRESET_CHOICES[int(choice) - 1] + + if preset_name == "custom": + picked = [ + name + for name in CATALOG + if typer.confirm(f"Include {CATALOG[name].display}?", default=False) + ] + else: + picked = list(PRESETS[preset_name]) + + resolved, added = expand_deps(picked) + for name, required_by in added: + typer.echo(f"Added {name} (required by {required_by})") + typer.echo(f"Selected modules: {', '.join(resolved)}") + + if not typer.confirm("Proceed?", default=True): + raise typer.Abort() + return db, tenancy, resolved +``` + +(Note: typer.prompt doesn't have a built-in `choice` validator the way `click.prompt` does, so we validate manually after the prompt and raise `typer.Abort` on invalid input. Behavior equivalent for the test scenarios.) + +- [ ] **Step 3: Create `framework/cli/simple_module/cli.py`** (root app) + +Replace the stub from Task 1 with: + +```python +"""Root `sm` Typer app — scaffolders + plugin mount. + +Built-in commands: + sm new + sm create-host + sm create-module + +Plugins discovered via the ``simple_module.cli_plugins`` entry-point +group are mounted as named subgroups (e.g. ``sm host gen-pages``). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module.case import to_kebab_case +from simple_module.new import new_project +from simple_module.plugins import discover_and_mount +from simple_module.scaffolding import create_host as _create_host +from simple_module.scaffolding import create_module as _create_module + +app = typer.Typer( + help="SimpleModule developer CLI.", + no_args_is_help=True, + add_completion=False, +) + +# Built-in commands +app.command("new")(new_project) + + +@app.command("create-host") +def create_host( + name: Annotated[str, typer.Argument(help="Host project name.")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + modules: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated module names to declare as deps (e.g. Auth,Products).", + ), + ] = "", +) -> None: + """Scaffold a new SimpleModule host project at ./.""" + target = dest or Path.cwd() / name + selected = [m.strip() for m in modules.split(",") if m.strip()] + try: + _create_host(target, name=name, modules=selected) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created host '{name}' at {target}") + if selected: + typer.echo(f"Declared modules: {', '.join(selected)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + 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(" python main.py") + + +@app.command("create-module") +def create_module( + name: Annotated[str, typer.Argument(help="Module name (any case).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination dir. Defaults to ./simple_module_."), + ] = None, +) -> None: + """Scaffold a publishable SimpleModule module package.""" + slug = to_kebab_case(name) + package = slug.replace("-", "_") + target = dest or Path.cwd() / f"simple_module_{package}" + try: + _create_module(target, name=name) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created module 'simple_module_{package}' at {target}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + typer.echo(" uv sync --extra dev") + typer.echo(" uv run pytest") + + +# Plugin discovery — mount any installed `simple_module.cli_plugins` +# entry-point apps as named subgroups (e.g. `sm host gen-pages`). +discover_and_mount(app) + + +def main() -> None: + """Entry point for the `sm` console script.""" + app() + + +if __name__ == "__main__": + main() +``` + +(Note: `discover_and_mount` is implemented in Task 6. For now this import will fail — that's OK; we land both tasks before re-running the suite.) + +- [ ] **Step 4: Stub `framework/cli/simple_module/plugins.py`** + +Create a minimal stub so Task 5 can run end-to-end before Task 6 fleshes it out: + +```python +"""Plugin discovery via ``simple_module.cli_plugins`` entry points. + +Real implementation lands in Task 6. For now this is a no-op so the +root Typer app imports cleanly. +""" + +from __future__ import annotations + +import typer + + +def discover_and_mount(app: typer.Typer) -> None: + """No-op stub. Implemented in Task 6.""" +``` + +- [ ] **Step 5: Update test files for Typer** + +Replace `from click.testing import CliRunner` with `from typer.testing import CliRunner` in: +- `framework/cli/tests/test_cli_new.py` +- (`test_cli_wizard.py` uses Click's CliRunner inside a wrapper — see below) + +In `framework/cli/tests/test_cli_new.py`: +- `from simple_module_hosting.cli import main` → `from simple_module.cli import app` +- All `runner.invoke(main, ["new", ...])` → `runner.invoke(app, ["new", ...])` +- All `from simple_module_hosting.scaffolding import create_app_project` → `from simple_module.app_project import create_app_project` + +In `framework/cli/tests/test_cli_wizard.py`: + +The wizard tests currently wrap `run_wizard` inside an ad-hoc `@click.command` for stdin driving. Rewrite the helper to use a Typer app: + +```python +from __future__ import annotations + +import typer +from typer.testing import CliRunner + +from simple_module.wizard import run_wizard + + +def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: + captured: dict = {} + wrapper_app = typer.Typer() + + @wrapper_app.command() + def wrapper() -> None: + db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) + captured["db"] = db + captured["tenancy"] = tenancy + captured["selected"] = selected + + runner = CliRunner() + result = runner.invoke(wrapper_app, [], input="\n".join(answers) + "\n") + assert result.exit_code == 0, result.output + return captured["db"], captured["tenancy"], captured["selected"], result.output + + +def test_wizard_aborts_on_confirm_no() -> None: + captured: dict = {} + wrapper_app = typer.Typer() + + @wrapper_app.command() + def wrapper() -> None: + try: + run_wizard(default_db="sqlite", default_tenancy=False) + except typer.Abort: + captured["aborted"] = True + raise + + runner = CliRunner() + result = runner.invoke(wrapper_app, [], input="\n".join(["", "", "", "n"]) + "\n") + assert result.exit_code != 0 + assert captured.get("aborted") is True +``` + +(All other wizard test bodies stay the same — they call `_drive(...)` and assert.) + +- [ ] **Step 6: Delete the hosting CLI package** + +The Click `sm` entry point in `simple_module_hosting` is being replaced by `simple_module.cli:main`. Delete: + +```bash +git rm -r framework/hosting/simple_module_hosting/cli/ +``` + +(This removes `__init__.py`, `__pycache__/`, and any leftover files.) + +In `framework/hosting/pyproject.toml`, drop the script entries (Task 7 adds the entry-point line): + +```toml +# REMOVE THESE TWO LINES: +sm = "simple_module_hosting.cli:main" +simple-module = "simple_module_hosting.cli:main" +``` + +Leave the `[project.scripts]` table empty for now (or remove it entirely if no scripts remain). + +- [ ] **Step 7: Update remaining hosting test files for the move** + +The two tests that import `from simple_module_hosting.cli import main` (`framework/cli/tests/test_scaffolding_host.py`, `framework/cli/tests/test_scaffolding_module.py`) need updating: + +```python +# Before +from simple_module_hosting.cli import main +runner.invoke(main, ["create-host", ...]) + +# After +from simple_module.cli import app +runner.invoke(app, ["create-host", ...]) +``` + +Also switch `from click.testing import CliRunner` → `from typer.testing import CliRunner` in those two files. + +- [ ] **Step 8: Update `framework/cli/tests/test_cli_recipes.py`** + +`from simple_module_hosting.scaffolding import create_host` → `from simple_module.scaffolding import create_host`. The shim still works, but the direct path is preferred now. + +- [ ] **Step 9: Re-install workspace and run tests** + +Run: `uv sync --all-packages && uv run pytest framework/cli/ framework/hosting/ -q` +Expected: all tests pass. The `sm` console script is now provided by `simple-module`; the old `simple_module_hosting.cli:main` no longer exists. + +Smoke check the binary: + +Run: `uv run sm --help` +Expected: lists `new`, `create-host`, `create-module` (no plugins yet — Task 6). + +Run: `uv run sm new demo --yes --preset full --no-install --dest /tmp/sm-typer-smoke` +Expected: works the same as before; produces the demo project. Clean up: `rm -rf /tmp/sm-typer-smoke`. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "feat(cli): port sm to Typer; new simple-module package owns the binary + +$(cat <<'EOF' +- All Click decorators rewritten in Typer's Annotated[] style. +- Wizard uses typer.prompt / typer.confirm. +- Tests use typer.testing.CliRunner (drop-in for click.testing). +- simple_module_hosting drops sm/simple-module console scripts. +- simple_module_hosting/cli/ package deleted. +- Plugin discovery hook added (no-op stub; real impl next commit). +EOF +)" +``` + +--- + +## Task 6: Plugin discovery + +**Files:** +- Modify: `framework/cli/simple_module/plugins.py` (real implementation). +- Create: `framework/cli/tests/test_plugin_discovery.py` + +- [ ] **Step 1: Write the failing test** + +Create `framework/cli/tests/test_plugin_discovery.py`: + +```python +"""Tests for entry-point-based plugin discovery.""" + +from __future__ import annotations + +from importlib.metadata import EntryPoint + +import pytest +import typer +from typer.testing import CliRunner + +from simple_module.plugins import discover_and_mount + + +def _make_entry(name: str, module_attr: str) -> EntryPoint: + return EntryPoint(name=name, value=module_attr, group="simple_module.cli_plugins") + + +@pytest.fixture +def fake_plugin_module(tmp_path, monkeypatch): + """Create a tiny package on sys.path that exports a Typer ``app``.""" + import sys + import textwrap + + pkg_dir = tmp_path / "fake_sm_plugin" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( + textwrap.dedent( + """ + import typer + app = typer.Typer(help="Fake plugin.") + + @app.command("ping") + def ping(): + typer.echo("pong-from-fake") + """ + ) + ) + monkeypatch.syspath_prepend(str(tmp_path)) + yield "fake_sm_plugin:app" + sys.modules.pop("fake_sm_plugin", None) + + +def test_discover_mounts_valid_plugin(monkeypatch, fake_plugin_module) -> None: + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", + lambda: [_make_entry("fake", fake_plugin_module)], + ) + root = typer.Typer() + discover_and_mount(root) + + runner = CliRunner() + result = runner.invoke(root, ["fake", "ping"]) + assert result.exit_code == 0, result.output + assert "pong-from-fake" in result.output + + +def test_discover_skips_broken_plugin(monkeypatch) -> None: + bad = _make_entry("broken", "nonexistent_module:app") + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", lambda: [bad] + ) + root = typer.Typer() + discover_and_mount(root) # should not raise + + runner = CliRunner() + result = runner.invoke(root, ["broken"]) + assert result.exit_code != 0 # subgroup absent → error + + +def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, capsys) -> None: + a = _make_entry("dup", fake_plugin_module) + b = _make_entry("dup", fake_plugin_module) + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", lambda: [a, b] + ) + root = typer.Typer() + discover_and_mount(root) + captured = capsys.readouterr() + assert "duplicate" in captured.err.lower() or "already" in captured.err.lower() + + +def test_discover_with_no_plugins_is_noop(monkeypatch) -> None: + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: []) + root = typer.Typer() + discover_and_mount(root) # no error, no commands added + runner = CliRunner() + result = runner.invoke(root, ["--help"]) + assert result.exit_code == 0 +``` + +- [ ] **Step 2: Run the failing tests** + +Run: `uv run pytest framework/cli/tests/test_plugin_discovery.py -v` +Expected: FAIL — `_iter_plugin_entries` does not exist yet, plus the stub doesn't actually mount. + +- [ ] **Step 3: Implement `framework/cli/simple_module/plugins.py`** + +Replace the stub with: + +```python +"""Plugin discovery for ``sm`` via the ``simple_module.cli_plugins`` group. + +Each entry-point's value (``module:attr``) must resolve to a +:class:`typer.Typer` instance. The entry-point name becomes the +subcommand namespace under ``sm`` (e.g. ``sm host gen-pages``). + +Failed loads (broken import, wrong type) print one line to stderr and +are skipped — ``sm`` keeps working with whatever else loads. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterator +from importlib.metadata import EntryPoint, entry_points + +import typer + +__all__ = ["discover_and_mount"] + +_GROUP = "simple_module.cli_plugins" + + +def _iter_plugin_entries() -> Iterator[EntryPoint]: + """Indirection point for tests to inject fake entry points.""" + yield from entry_points(group=_GROUP) + + +def discover_and_mount(root: typer.Typer) -> None: + """Mount every installed plugin under its entry-point name.""" + seen: set[str] = set() + for entry in _iter_plugin_entries(): + if entry.name in seen: + print( + f"[simple-module] duplicate plugin subgroup '{entry.name}' " + f"from {entry.value!r}; keeping first registration.", + file=sys.stderr, + ) + continue + try: + plugin_app = entry.load() + except Exception as exc: # noqa: BLE001 — plugin authors can fail in any way + print( + f"[simple-module] failed to load plugin '{entry.name}' " + f"({entry.value}): {exc}", + file=sys.stderr, + ) + continue + if not isinstance(plugin_app, typer.Typer): + print( + f"[simple-module] plugin '{entry.name}' did not export a " + f"typer.Typer instance (got {type(plugin_app).__name__}); skipping.", + file=sys.stderr, + ) + continue + root.add_typer(plugin_app, name=entry.name) + seen.add(entry.name) +``` + +- [ ] **Step 4: Run the tests** + +Run: `uv run pytest framework/cli/tests/test_plugin_discovery.py -v` +Expected: 4 tests pass. + +- [ ] **Step 5: Verify `sm --help` still works (no plugins installed yet)** + +Run: `uv run sm --help` +Expected: same output as before, no errors. No `host`, `users`, or `settings` subgroups appear yet (those are wired in Tasks 7 & 8). + +- [ ] **Step 6: Commit** + +```bash +git add framework/cli/simple_module/plugins.py framework/cli/tests/test_plugin_discovery.py +git commit -m "feat(cli): plugin discovery via simple_module.cli_plugins entry points + +$(cat <<'EOF' +discover_and_mount() walks the entry-point group, validates each load +target is a typer.Typer, and mounts it under its entry name. Broken or +duplicate plugins log one line to stderr and are skipped. +EOF +)" +``` + +--- + +## Task 7: Carve out `simple_module_hosting.host_cli` plugin + +**Files:** +- Create: `framework/hosting/simple_module_hosting/host_cli.py` (Typer app with `gen-pages` + `sync-js-deps`). +- Modify: `framework/hosting/pyproject.toml` (add `[project.entry-points."simple_module.cli_plugins"]`). +- Modify: `Makefile` (`sm gen-pages` → `sm host gen-pages`; `sm sync-js-deps` → `sm host sync-js-deps`). +- Create: `framework/hosting/tests/test_host_cli.py` (smoke). + +- [ ] **Step 1: Write the failing test** + +Create `framework/hosting/tests/test_host_cli.py`: + +```python +"""Smoke tests for the simple_module_hosting host_cli Typer plugin.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from typer.testing import CliRunner + +from simple_module_hosting.host_cli import app + + +def test_app_is_typer_instance() -> None: + assert isinstance(app, typer.Typer) + + +def test_help_lists_gen_pages_and_sync_js_deps() -> None: + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "gen-pages" in result.output + assert "sync-js-deps" in result.output + + +def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke( + app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")] + ) + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `uv run pytest framework/hosting/tests/test_host_cli.py -v` +Expected: FAIL — `simple_module_hosting.host_cli` does not exist. + +- [ ] **Step 3: Create `framework/hosting/simple_module_hosting/host_cli.py`** + +Translate the existing `gen-pages` and `sync-js-deps` Click commands to Typer: + +```python +"""``sm host`` plugin — project-time helpers exposed through the simple-module CLI. + +Commands here need module discovery (``simple_module_core.discover_modules``) +and the manifest helpers; they're not part of the standalone scaffolder. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Annotated + +import typer +from simple_module_core import discover_modules + +from simple_module_hosting.manifest import ( + collect_module_js_deps, + repo_root_from_client_app, + write_module_pages_manifest, +) + +app = typer.Typer( + help="Project-time helpers (frontend pages manifest, module JS dep sync).", + no_args_is_help=True, +) + + +@app.command("gen-pages") +def gen_pages( + host_dir: Annotated[ + Path, + typer.Option( + "--host-dir", + help="Path to the host's client_app directory. Defaults to ./client_app.", + ), + ] = Path("client_app"), +) -> None: + """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_dir.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True) + raise typer.Exit(code=1) + modules = discover_modules() + written = write_module_pages_manifest(modules, host_dir) + typer.echo( + f"Wrote {written['manifest'].name}, {written['generated'].name}, " + f"{written['css'].name} to {host_dir}" + ) + + +@app.command("sync-js-deps") +def sync_js_deps( + host_client_app: Annotated[ + Path, + typer.Option( + "--host-client-app", + help="Path to host/client_app. Defaults to ./client_app.", + ), + ] = Path("client_app"), + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Print the npm install command only.") + ] = False, +) -> None: + """Install JS deps declared by installed modules into host's node_modules.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_client_app.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_client_app}", err=True) + raise typer.Exit(code=1) + + modules = discover_modules() + by_module = collect_module_js_deps(modules) + if not by_module: + typer.echo("No module JS dependencies declared.") + return + + specs: list[str] = [] + for mod_name in sorted(by_module): + for dep, rng in sorted(by_module[mod_name].items()): + specs.append(f"{dep}@{rng}") + deduped: list[str] = [] + seen: set[str] = set() + for spec in specs: + if spec not in seen: + seen.add(spec) + deduped.append(spec) + + npm = shutil.which("npm") + if npm is None: + typer.echo("ERROR: npm not found on PATH.", err=True) + raise typer.Exit(code=1) + + repo_root = repo_root_from_client_app(host_client_app) + try: + workspace = str(host_client_app.resolve().relative_to(repo_root)) + except ValueError: + workspace = str(host_client_app.resolve()) + + cmd = [ + npm, "install", "--workspace", workspace, + "--save=false", "--no-audit", "--no-fund", *deduped, + ] + typer.echo("Installing module JS deps:") + for spec in deduped: + typer.echo(f" {spec}") + if dry_run: + typer.echo("(dry-run) " + " ".join(cmd)) + return + result = subprocess.run(cmd, cwd=repo_root, check=False) + raise typer.Exit(code=result.returncode) +``` + +- [ ] **Step 4: Register the plugin entry point in `framework/hosting/pyproject.toml`** + +After `[project.urls]`, add: + +```toml +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" +``` + +The `[project.scripts]` table should already be empty (or removed) from Task 5. + +- [ ] **Step 5: Run hosting tests** + +Run: `uv run pytest framework/hosting/tests/test_host_cli.py -v` +Expected: 3 tests pass. + +- [ ] **Step 6: Re-install workspace and verify the plugin mounts** + +Run: `uv sync --all-packages && uv run sm --help` +Expected: `host` appears in the subcommand list (from the entry point). + +Run: `uv run sm host --help` +Expected: lists `gen-pages` and `sync-js-deps`. + +- [ ] **Step 7: Update Makefile** + +In `Makefile`, replace: + +```makefile +gen-pages: + uv run --project host sm gen-pages --host-dir=host/client_app + +sync-module-deps: + uv run --project host sm sync-js-deps --host-client-app=host/client_app +``` + +with: + +```makefile +gen-pages: + uv run --project host sm host gen-pages --host-dir=host/client_app + +sync-module-deps: + uv run --project host sm host sync-js-deps --host-client-app=host/client_app +``` + +- [ ] **Step 8: Verify Make targets still work** + +Run: `make gen-pages 2>&1 | tail -3` +Expected: Wrote manifest.json, generated.ts, generated.css. (Or, if the host hasn't been bootstrapped, an unrelated error — what matters is the CLI invocation itself works. Confirm by inspecting stdout for the new "Wrote …" line.) + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(hosting): host_cli plugin (sm host gen-pages, sm host sync-js-deps) + +$(cat <<'EOF' +gen-pages and sync-js-deps move out of the deleted sm console script +and into a Typer plugin published under the simple_module.cli_plugins +entry-point group. Makefile updated for the new sm host * shape. +EOF +)" +``` + +--- + +## Task 8: Convert `users` and `settings` modules to plugins + +**Files:** +- Modify: `modules/users/pyproject.toml` (drop `sm-users`, add entry point). +- Modify: `modules/users/users/cli.py` (already a Typer app — verify; minimal changes). +- Modify: `modules/settings/settings/cli.py` (rewrite as Typer app named `app`). +- Modify: `modules/settings/pyproject.toml` (drop `sm-settings`, add entry point). +- Modify: `README.md` (`sm-users` / `sm-settings` snippets → `sm users` / `sm settings`). + +- [ ] **Step 1: Update `modules/users/pyproject.toml`** + +Find: + +```toml +[project.scripts] +sm-users = "users.cli:app" +``` + +Replace with: + +```toml +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" +``` + +`modules/users/users/cli.py` is already a Typer app named `app` — no code change needed. + +- [ ] **Step 2: Rewrite `modules/settings/settings/cli.py` as a Typer plugin** + +Replace the entire file with: + +```python +"""``sm settings`` plugin — currently only ``import-from-env``. + +One-shot migration: walks every registered module's BaseSettings and +writes a SYSTEM-scoped override for each ``SM__`` env +var that is set. +""" + +from __future__ import annotations + +import asyncio +import os + +import typer +from fastapi import FastAPI + +from settings.constants import MODULE_PACKAGE +from settings.env_vars import env_prefix_for +from settings.hydrate import value_type_for_field +from settings.store import SettingsStore + +app = typer.Typer(help="Settings module administration.", no_args_is_help=True) + + +async def import_from_env_impl(app_inst: FastAPI, store: SettingsStore) -> int: + """Write a SYSTEM override for every ``SM__`` env var set.""" + registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry + count = 0 + for package, cls in registry.items(): + prefix = env_prefix_for(package) + for field_name in cls.model_fields: + raw = os.environ.get(f"{prefix}{field_name.upper()}") + if raw is None: + continue + vtype = value_type_for_field(cls, field_name) + await store.set_override(package, field_name, raw, vtype) + count += 1 + return count + + +@app.command("import-from-env") +def import_from_env() -> None: + """Write SYSTEM overrides for every SM__ env var set.""" + from simple_module_hosting.app_builder import create_app + from simple_module_hosting.settings import Settings + + from settings.service import SettingService + + fastapi_app = create_app(Settings()) + + async def run() -> int: + async with ( + fastapi_app.router.lifespan_context(fastapi_app), + fastapi_app.state.sm.db.session_factory() as session, + ): + store = SettingsStore(SettingService(session)) + n = await import_from_env_impl(fastapi_app, store) + await session.commit() + typer.echo(f"Imported {n} override(s) from environment.") + return 0 + + raise typer.Exit(code=asyncio.run(run())) +``` + +- [ ] **Step 3: Update `modules/settings/pyproject.toml`** + +Find: + +```toml +[project.scripts] +sm-settings = "settings.cli:main" +``` + +Replace with: + +```toml +[project.entry-points."simple_module.cli_plugins"] +settings = "settings.cli:app" +``` + +- [ ] **Step 4: Update existing settings test if any** + +Run: `grep -n "sm-settings\|settings.cli.main\|from settings.cli import main" modules/settings/tests/` +If there are matches, fix them — `main` no longer exists. The new entry is `app`. Most likely there are no test changes needed (the existing settings tests target `import_from_env_impl` directly). + +- [ ] **Step 5: Re-install + smoke** + +Run: `uv sync --all-packages && uv run sm --help 2>&1 | head -20` +Expected: subgroups `host`, `users`, `settings` all appear. + +Run: `uv run sm users --help` and `uv run sm settings --help` +Expected: each lists their subcommand(s). + +- [ ] **Step 6: Update README** + +In `README.md`, replace: +- `uv run sm-users create-admin --email …` → `uv run sm users create-admin --email …` +- `uv run sm-settings import-from-env` → `uv run sm settings import-from-env` +- Any other occurrences of `sm-users` / `sm-settings`. + +- [ ] **Step 7: Run the full test suite** + +Run: `uv run pytest -q` +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(modules): convert users + settings to sm plugins + +$(cat <<'EOF' +Drops sm-users and sm-settings console scripts. Both modules now +register Typer apps under the simple_module.cli_plugins entry-point +group, mounted as `sm users` and `sm settings`. settings/cli.py +rewritten as a Typer app (was hand-rolled Click-style argv parsing). +README updated for the new command shape. +EOF +)" +``` + +--- + +## Task 9: Final cleanup, dep guard, lint, full verification + +**Files:** +- Delete: `framework/hosting/simple_module_hosting/scaffolding.py` (the shim). +- Modify: `framework/hosting/pyproject.toml` (drop `simple_module` workspace dep — hosting no longer needs it). +- Modify: any remaining test imports referencing `simple_module_hosting.scaffolding`. +- Create: `framework/cli/tests/test_no_framework_deps.py`. +- Final: lint + typecheck + file-size + full pytest. + +- [ ] **Step 1: Find all remaining import sites of the shim** + +Run: `grep -rn 'from simple_module_hosting.scaffolding\|from simple_module_hosting.app_project\|from simple_module_hosting._env\|simple_module_hosting.cli' --include="*.py" framework/ modules/ host/ scripts/ tests/ 2>/dev/null` +Expected: a small list of test files and possibly internal hosting modules. + +For each match: +- If it's in a test file under `framework/cli/tests/`, change to import from the new `simple_module` location directly. +- If it's in `framework/hosting/simple_module_hosting/manifest.py` or another hosting runtime file, update to `simple_module.scaffolding` / `simple_module.app_project` etc. +- If it's outside the framework (host code, modules), update similarly. + +Concretely the planned remaining sites are in `framework/cli/tests/test_scaffolding_*.py` (already updated in Task 5 Step 7), `framework/cli/tests/test_cli_*.py` (updated Task 5 Step 5/8). Sweep once more to be sure. + +- [ ] **Step 2: Delete the shim** + +```bash +git rm framework/hosting/simple_module_hosting/scaffolding.py +``` + +- [ ] **Step 3: Drop the `simple_module` workspace dep from hosting** + +In `framework/hosting/pyproject.toml`, remove: + +```toml +"simple_module==0.0.1", +``` + +from `dependencies` and remove `simple_module = { workspace = true }` from `[tool.uv.sources]`. Hosting's runtime (app_builder, middleware, manifest, host_cli) no longer needs to import from `simple_module` at runtime — `host_cli` imports stay because they depend on `discover_modules` from core, not anything in `simple_module`. Verify: + +Run: `grep -rn 'from simple_module\b\|import simple_module\b' --include="*.py" framework/hosting/simple_module_hosting/` +Expected: no matches. If anything appears, update it. + +- [ ] **Step 4: Re-sync workspace** + +Run: `uv sync --all-packages` +Expected: succeeds. If `simple_module` is now an unused dep anywhere, the resolver removes it cleanly. + +- [ ] **Step 5: Add the no-framework-deps guard test** + +Create `framework/cli/tests/test_no_framework_deps.py`: + +```python +"""Guard: `simple-module` distribution depends only on typer + tomlkit. + +If a future change accidentally pulls in simple_module_core, FastAPI, +SQLModel, or anything else, this test fires immediately. +""" + +from __future__ import annotations + +from importlib.metadata import distribution + + +def _normalize(req: str) -> str: + """'typer (>=0.12)' -> 'typer'. Strip version specs + extras + spaces.""" + return req.split(";")[0].split("(")[0].split(">=")[0].split(">")[0].split("<")[0].split("==")[0].split("[")[0].strip().lower().replace("_", "-") + + +def test_simple_module_runtime_deps_are_minimal() -> None: + requires = distribution("simple-module").requires or [] + names = {_normalize(r) for r in requires} + # Allowed: declared deps + their transitive obligations are NOT checked here; + # only the direct deps of `simple-module` itself. + assert names == {"typer", "tomlkit"}, ( + f"simple-module direct deps drifted; got {sorted(names)}, " + "expected {'typer', 'tomlkit'}" + ) +``` + +- [ ] **Step 6: Run the dep-guard test** + +Run: `uv run pytest framework/cli/tests/test_no_framework_deps.py -v` +Expected: PASS. + +- [ ] **Step 7: Run the project lint suite** + +Run: `make lint` +Expected: all checks pass — ruff, ty, file-size, biome, tsc, metadata, readmes. + +If file-size fires for any moved file, split it (the moved files all stayed close to their previous sizes; recipes ≈ 95 lines, app_project ≈ 130, etc., well under 300). + +- [ ] **Step 8: Run the full test suite** + +Run: `uv run pytest -q` +Expected: all tests green. The plan's planned test count is roughly: +- Original 1009 tests still passing. +- 47 CLI tests (now under `framework/cli/tests/`). +- 4 plugin discovery tests (new). +- 3 host_cli tests (new). +- 1 no-framework-deps test (new). +- Total expected: ~1064. + +- [ ] **Step 9: Final smoke test of the user-visible CLI** + +Run: +```bash +uv run sm --help +uv run sm host --help +uv run sm users --help +uv run sm settings --help +TMP=$(mktemp -d) && uv run sm new demo --yes --preset full --no-install --dest "$TMP/demo" +ls "$TMP/demo/scripts/run_worker.py" "$TMP/demo/docker-compose.yml" +``` +Expected: every command works; the new project lands all background-task scaffolding correctly. + +- [ ] **Step 10: Commit** + +```bash +git add -A +git commit -m "refactor(cli): finish the standalone simple-module carve-out + +$(cat <<'EOF' +- Delete the simple_module_hosting.scaffolding shim and the + workspace dep on simple_module. +- Add framework/cli/tests/test_no_framework_deps.py to guard + against future dep drift in the standalone scaffolder. +- README + Makefile reflect the final sm host/users/settings shape. +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Distribution layout (`framework/cli/`, dep tuple typer + tomlkit) → Task 1. +- File moves (`_env`, case helpers, scaffolding, app_project, catalog, wizard, recipes, new, templates) → Tasks 2–4. +- Click → Typer port → Task 5. +- Plugin discovery (`simple_module.cli_plugins`, `discover_and_mount`, error handling, dup detection) → Task 6. +- `host_cli` plugin (gen-pages, sync-js-deps), Makefile rename → Task 7. +- `users` + `settings` plugin migration, drop sm-users/sm-settings scripts → Task 8. +- No-framework-deps guard → Task 9. +- Cleanup, README updates, full verification → Tasks 8 & 9. + +**No placeholders:** every step contains the actual code or command needed. + +**Type consistency:** `app` is the Typer instance everywhere it's referenced; `discover_and_mount(app)` is the same name in `cli.py`, `plugins.py`, and the tests; `_iter_plugin_entries` is the test seam used in both `plugins.py` and `test_plugin_discovery.py`. Case helpers renamed without leading underscores (`to_snake_case` / `to_kebab_case` / `to_pascal_case`) consistently across all modules. + +**Out of scope:** version bumps, release-workflow matrix updates, doc-site changes — all deferred to the public-release plan. From 0b23b689624c0b86f0e3ef4f0a535806af9e09b8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:57:23 +0200 Subject: [PATCH 13/24] feat(cli): bootstrap simple-module distribution New workspace member framework/cli/ shipping the simple-module PyPI distribution. Contains stub cli.py for the sm entry point; real commands land in subsequent commits. --- framework/cli/LICENSE | 21 +++++++++++++ framework/cli/README.md | 23 ++++++++++++++ framework/cli/pyproject.toml | 41 +++++++++++++++++++++++++ framework/cli/simple_module/__init__.py | 0 framework/cli/simple_module/cli.py | 15 +++++++++ framework/cli/tests/__init__.py | 0 6 files changed, 100 insertions(+) create mode 100644 framework/cli/LICENSE create mode 100644 framework/cli/README.md create mode 100644 framework/cli/pyproject.toml create mode 100644 framework/cli/simple_module/__init__.py create mode 100644 framework/cli/simple_module/cli.py create mode 100644 framework/cli/tests/__init__.py diff --git a/framework/cli/LICENSE b/framework/cli/LICENSE new file mode 100644 index 00000000..a1e1d361 --- /dev/null +++ b/framework/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Anto Subash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/framework/cli/README.md b/framework/cli/README.md new file mode 100644 index 00000000..f1059b61 --- /dev/null +++ b/framework/cli/README.md @@ -0,0 +1,23 @@ +# simple-module + +Standalone scaffolder for the [SimpleModule framework](https://github.com/antosubash/simple_module_python). + +```bash +pip install simple-module # or: pipx install simple-module +sm new my-app # interactive wizard +sm new my-app --yes --preset full +``` + +Provides three built-in commands: `sm new`, `sm create-host`, `sm create-module`. + +When other framework packages are installed, they contribute additional subcommands via the `simple_module.cli_plugins` entry-point group: + +| Package | Commands | +|---|---| +| `simple_module_hosting` | `sm host gen-pages`, `sm host sync-js-deps` | +| `simple_module_users` | `sm users create-admin` | +| `simple_module_settings` | `sm settings import-from-env` | + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml new file mode 100644 index 00000000..6450c26f --- /dev/null +++ b/framework/cli/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "simple-module" +version = "0.0.1" +description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "scaffolding", "cli", "fastapi", "modular-monolith"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "typer>=0.12", + "tomlkit>=0.13", +] + +[project.scripts] +sm = "simple_module.cli:main" +simple-module = "simple_module.cli:main" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["simple_module"] diff --git a/framework/cli/simple_module/__init__.py b/framework/cli/simple_module/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/framework/cli/simple_module/cli.py b/framework/cli/simple_module/cli.py new file mode 100644 index 00000000..80160bd7 --- /dev/null +++ b/framework/cli/simple_module/cli.py @@ -0,0 +1,15 @@ +"""Root `sm` command — scaffolders + plugin mount. + +This file gets fleshed out in Task 5 (Typer port) and Task 6 (plugin +discovery). For now it exists only so the ``sm = simple_module.cli:main`` +console-script entry point resolves cleanly during workspace install. +""" + +from __future__ import annotations + + +def main() -> int: + raise SystemExit( + "simple-module CLI is being installed but its commands have not " + "been wired up yet. Re-install once Task 5 lands." + ) diff --git a/framework/cli/tests/__init__.py b/framework/cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b From 877d96f1e4076abf99294a9d76327bde763c9ea0 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 20:59:40 +0200 Subject: [PATCH 14/24] refactor(cli): move _env and case helpers to simple_module package Pure utilities; first pieces of the simple-module distribution. Hosting keeps temporary workspace dep on simple-module so existing modules can import from both during the migration. --- .../simple_module}/_env.py | 0 framework/cli/simple_module/case.py | 30 +++++++++++++++++++ framework/hosting/pyproject.toml | 2 ++ .../simple_module_hosting/app_project.py | 15 ++++------ .../simple_module_hosting/cli/__init__.py | 4 +-- .../simple_module_hosting/cli/recipes.py | 2 +- .../simple_module_hosting/scaffolding.py | 27 ++++------------- 7 files changed, 46 insertions(+), 34 deletions(-) rename framework/{hosting/simple_module_hosting => cli/simple_module}/_env.py (100%) create mode 100644 framework/cli/simple_module/case.py diff --git a/framework/hosting/simple_module_hosting/_env.py b/framework/cli/simple_module/_env.py similarity index 100% rename from framework/hosting/simple_module_hosting/_env.py rename to framework/cli/simple_module/_env.py diff --git a/framework/cli/simple_module/case.py b/framework/cli/simple_module/case.py new file mode 100644 index 00000000..527626bc --- /dev/null +++ b/framework/cli/simple_module/case.py @@ -0,0 +1,30 @@ +"""Identifier case-conversion helpers used by every scaffolder. + +Module/host names are accepted in any case style and normalized to the +three forms the templates need: snake_case (Python package + entry-point +key), kebab-case (PyPI slug), and PascalCase (display name in Meta). +""" + +from __future__ import annotations + +import re + +__all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"] + + +def to_snake_case(name: str) -> str: + """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'.""" + s = re.sub(r"(? str: + """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug).""" + return to_snake_case(name).replace("_", "-") + + +def to_pascal_case(name: str) -> str: + """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" + snake = to_snake_case(name) + return "".join(part.capitalize() for part in snake.split("_") if part) diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index 636659fe..29715028 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "fastapi-inertia>=1.0", "httpx>=0.27", "jinja2>=3.1", + "simple_module==0.0.1", "simple_module_core==0.0.1", "simple_module_db==0.0.1", "starlette>=0.44", @@ -51,5 +52,6 @@ build-backend = "hatchling.build" packages = ["simple_module_hosting"] [tool.uv.sources] +simple_module = { workspace = true } simple_module_core = { workspace = true } simple_module_db = { workspace = true } diff --git a/framework/hosting/simple_module_hosting/app_project.py b/framework/hosting/simple_module_hosting/app_project.py index 7c9e185c..7bc4336e 100644 --- a/framework/hosting/simple_module_hosting/app_project.py +++ b/framework/hosting/simple_module_hosting/app_project.py @@ -17,7 +17,8 @@ from collections.abc import Sequence from pathlib import Path -from simple_module_hosting._env import set_env_key +from simple_module._env import set_env_key +from simple_module.case import to_kebab_case, to_pascal_case __all__ = ["create_app_project"] @@ -61,11 +62,7 @@ def create_app_project( # cannot import from scaffolding at module-load time. from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx - from simple_module_hosting.scaffolding import ( - _to_kebab_case, - _to_pascal_case, - create_host, - ) + from simple_module_hosting.scaffolding import create_host if target.exists() and any(target.iterdir()): raise FileExistsError( @@ -76,7 +73,7 @@ def create_app_project( chosen = list(selected) if selected is not None else list(PRESETS["standard"]) resolved, _added = expand_deps(chosen) - display_names = [_to_pascal_case(CATALOG[m].display) for m in resolved] + display_names = [to_pascal_case(CATALOG[m].display) for m in resolved] create_host(target, name=name, modules=display_names) py_deps = [f"simple_module_hosting=={_FRAMEWORK_VERSION}"] + [ @@ -86,7 +83,7 @@ def create_app_project( 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))) env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false") env_path.write_text(env_text, encoding="utf-8") @@ -100,7 +97,7 @@ def create_app_project( 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 = {"name": to_kebab_case(name), "private": True, "type": "module"} data.setdefault("dependencies", {}).update(_APP_NPM_DEPS) data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS) pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8") diff --git a/framework/hosting/simple_module_hosting/cli/__init__.py b/framework/hosting/simple_module_hosting/cli/__init__.py index 5ab4ea1f..ea2dd92c 100644 --- a/framework/hosting/simple_module_hosting/cli/__init__.py +++ b/framework/hosting/simple_module_hosting/cli/__init__.py @@ -22,8 +22,8 @@ import click from simple_module_core import discover_modules +from simple_module.case import to_kebab_case from simple_module_hosting.scaffolding import ( - _to_kebab_case, collect_module_js_deps, create_module, repo_root_from_client_app, @@ -86,7 +86,7 @@ def create_host(name: str, dest: Path | None, modules: str) -> None: ) def create_module_cmd(name: str, dest: Path | None) -> None: """Scaffold a publishable SimpleModule module package.""" - slug = _to_kebab_case(name) + slug = to_kebab_case(name) package = slug.replace("-", "_") target = dest or Path.cwd() / f"simple_module_{package}" diff --git a/framework/hosting/simple_module_hosting/cli/recipes.py b/framework/hosting/simple_module_hosting/cli/recipes.py index 7b508b7d..10bf5c27 100644 --- a/framework/hosting/simple_module_hosting/cli/recipes.py +++ b/framework/hosting/simple_module_hosting/cli/recipes.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Protocol -from simple_module_hosting._env import set_env_key +from simple_module._env import set_env_key __all__ = [ "RECIPES", diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py index 783d552b..14b030b1 100644 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ b/framework/hosting/simple_module_hosting/scaffolding.py @@ -15,11 +15,12 @@ import importlib.resources import logging -import re import shutil from collections.abc import Mapping, Sequence from pathlib import Path +from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case + from simple_module_hosting.app_project import create_app_project as create_app_project from simple_module_hosting.manifest import ( collect_module_js_deps, @@ -146,24 +147,6 @@ def create_host( return dest -def _to_snake_case(name: str) -> str: - """'MyFeature' / 'my-feature' / 'My Feature' -> 'my_feature'.""" - s = re.sub(r"(? str: - """'MyFeature' / 'my_feature' -> 'my-feature' (used as the PyPI slug).""" - return _to_snake_case(name).replace("_", "-") - - -def _to_pascal_case(name: str) -> str: - """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" - snake = _to_snake_case(name) - return "".join(part.capitalize() for part in snake.split("_") if part) - - def create_module( dest: Path, name: str, @@ -182,9 +165,9 @@ def create_module( dest = Path(dest) _require_empty_dest(dest) - display_name = _to_pascal_case(name) - slug = _to_kebab_case(name) - package_name = _to_snake_case(name) + display_name = to_pascal_case(name) + slug = to_kebab_case(name) + package_name = to_snake_case(name) _apply_template_files( _resolve_template_root("module", template_root), From 279810c7e0eefaf9e34111280d492907056b9b20 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:01:21 +0200 Subject: [PATCH 15/24] refactor(cli): move scaffolding + templates to simple_module package create_host, create_module, _apply_template_files, and the entire templates/ tree relocate from simple_module_hosting to the new simple_module package. Hosting's scaffolding.py becomes a re-export shim so existing import sites keep working through Task 7. Recipes template path updated accordingly. --- framework/cli/pyproject.toml | 3 + framework/cli/simple_module/scaffolding.py | 124 +++++++++++++ .../templates/host/.env.example | 0 .../simple_module}/templates/host/.gitignore | 0 .../simple_module}/templates/host/Makefile | 0 .../templates/host/README.md.tpl | 0 .../background_tasks/Makefile.snippet | 0 .../background_tasks/docker-compose.yml | 0 .../_optional/background_tasks/run_worker.py | 0 .../background_tasks/worker.Dockerfile | 0 .../simple_module}/templates/host/alembic.ini | 0 .../templates/host/client_app/app.tsx | 0 .../templates/host/client_app/main.tsx | 0 .../host/client_app/package.json.tpl | 0 .../templates/host/client_app/pages.ts | 0 .../templates/host/client_app/pages/Error.tsx | 0 .../templates/host/client_app/styles.css | 0 .../templates/host/client_app/tsconfig.json | 0 .../templates/host/client_app/vite.config.ts | 0 .../simple_module}/templates/host/main.py | 0 .../templates/host/migrations/env.py | 0 .../templates/host/migrations/script.py.mako | 0 .../host/migrations/versions/.gitkeep | 0 .../templates/host/pyproject.toml.tpl | 0 .../templates/host/templates/index.html | 0 .../templates/module/.github/workflows/ci.yml | 0 .../module/.github/workflows/publish.yml.tpl | 0 .../templates/module/.gitignore | 0 .../templates/module/README.md.tpl | 0 .../templates/module/__PACKAGE__/__init__.py | 0 .../module/__PACKAGE__/endpoints/__init__.py | 0 .../module/__PACKAGE__/endpoints/api.py.tpl | 0 .../module/__PACKAGE__/module.py.tpl | 0 .../module/__PACKAGE__/pages/.gitkeep | 0 .../module/__PACKAGE__/services.py.tpl | 0 .../templates/module/package.json.tpl | 0 .../templates/module/pyproject.toml.tpl | 0 .../templates/module/tests/__init__.py | 0 .../templates/module/tests/test_module.py.tpl | 0 .../templates/module/tsconfig.json.tpl | 0 .../tests/test_scaffolding_host.py | 0 .../tests/test_scaffolding_module.py | 0 .../simple_module_hosting/app_project.py | 3 +- .../simple_module_hosting/cli/recipes.py | 2 +- .../simple_module_hosting/scaffolding.py | 164 +----------------- pyproject.toml | 2 +- 46 files changed, 136 insertions(+), 162 deletions(-) create mode 100644 framework/cli/simple_module/scaffolding.py rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/.env.example (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/.gitignore (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/Makefile (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/README.md.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/_optional/background_tasks/Makefile.snippet (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/_optional/background_tasks/docker-compose.yml (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/_optional/background_tasks/run_worker.py (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/_optional/background_tasks/worker.Dockerfile (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/alembic.ini (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/app.tsx (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/main.tsx (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/package.json.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/pages.ts (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/pages/Error.tsx (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/styles.css (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/tsconfig.json (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/client_app/vite.config.ts (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/main.py (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/migrations/env.py (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/migrations/script.py.mako (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/migrations/versions/.gitkeep (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/pyproject.toml.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/host/templates/index.html (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/.github/workflows/ci.yml (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/.github/workflows/publish.yml.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/.gitignore (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/README.md.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/__PACKAGE__/__init__.py (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/__PACKAGE__/endpoints/__init__.py (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/__PACKAGE__/endpoints/api.py.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/__PACKAGE__/module.py.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/__PACKAGE__/pages/.gitkeep (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/__PACKAGE__/services.py.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/package.json.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/pyproject.toml.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/tests/__init__.py (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/tests/test_module.py.tpl (100%) rename framework/{hosting/simple_module_hosting => cli/simple_module}/templates/module/tsconfig.json.tpl (100%) rename framework/{hosting => cli}/tests/test_scaffolding_host.py (100%) rename framework/{hosting => cli}/tests/test_scaffolding_module.py (100%) diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml index 6450c26f..9913f4a5 100644 --- a/framework/cli/pyproject.toml +++ b/framework/cli/pyproject.toml @@ -39,3 +39,6 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["simple_module"] + +[tool.hatch.build.targets.wheel.shared-data] +"simple_module/templates" = "simple_module/templates" diff --git a/framework/cli/simple_module/scaffolding.py b/framework/cli/simple_module/scaffolding.py new file mode 100644 index 00000000..31a62a62 --- /dev/null +++ b/framework/cli/simple_module/scaffolding.py @@ -0,0 +1,124 @@ +"""Host + module scaffolding via package-data templates. + +* :func:`create_host` materializes a new host project from the templates + under ``simple_module/templates/host/``. +* :func:`create_module` materializes a new module package from + ``simple_module/templates/module/``. + +The frontend pages manifest + per-module JS dep discovery live in +:mod:`simple_module_hosting.manifest` (those need module-discovery and +stay in hosting). +""" + +from __future__ import annotations + +import importlib.resources +import logging +import shutil +from collections.abc import Mapping, Sequence +from pathlib import Path + +from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case + +__all__ = ["create_host", "create_module"] + +logger = logging.getLogger(__name__) + +_TEMPLATES_PACKAGE = "simple_module.templates" +_PACKAGE_PATH_TOKEN = "__PACKAGE__" + + +def _module_to_pypi_name(name: str) -> str: + return f"simple_module_{name.lower()}" + + +def _iter_template_files(template_root: Path): + """Yield every file under ``template_root``. Skips ``_optional/`` paths.""" + for path in template_root.rglob("*"): + if not path.is_file(): + continue + if "_optional" in path.relative_to(template_root).parts: + continue + yield path + + +def _require_empty_dest(dest: Path) -> None: + if dest.exists() and any(dest.iterdir()): + raise FileExistsError( + f"Destination {dest} already exists and is non-empty. " + "Choose a new path or remove the contents first." + ) + dest.mkdir(parents=True, exist_ok=True) + + +def _resolve_template_root(subdir: str, override: Path | None) -> Path: + if override is not None: + return Path(override) + return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir)) + + +def _apply_template_files( + src_root: Path, + dest: Path, + substitutions: Mapping[str, str], + *, + path_rewrites: Mapping[str, str] | None = None, +) -> None: + for src in _iter_template_files(src_root): + rel_str = str(src.relative_to(src_root)) + for old, new in (path_rewrites or {}).items(): + rel_str = rel_str.replace(old, new) + rel_str = rel_str.removesuffix(".tpl") + target = dest / rel_str + target.parent.mkdir(parents=True, exist_ok=True) + if src.suffix == ".tpl": + text = src.read_text(encoding="utf-8") + for placeholder, value in substitutions.items(): + text = text.replace(placeholder, value) + target.write_text(text, encoding="utf-8") + else: + shutil.copy2(src, target) + + +def create_host( + dest: Path, + name: str, + modules: Sequence[str], + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules) + _apply_template_files( + _resolve_template_root("host", template_root), + dest, + {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, + ) + logger.info( + "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" + ) + return dest + + +def create_module( + dest: Path, + name: str, + template_root: Path | None = None, +) -> Path: + dest = Path(dest) + _require_empty_dest(dest) + display_name = to_pascal_case(name) + slug = to_kebab_case(name) + package_name = to_snake_case(name) + _apply_template_files( + _resolve_template_root("module", template_root), + dest, + substitutions={ + "{{MODULE_NAME}}": display_name, + "{{MODULE_SLUG}}": slug, + "{{PACKAGE_NAME}}": package_name, + }, + path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, + ) + logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) + return dest diff --git a/framework/hosting/simple_module_hosting/templates/host/.env.example b/framework/cli/simple_module/templates/host/.env.example similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/.env.example rename to framework/cli/simple_module/templates/host/.env.example diff --git a/framework/hosting/simple_module_hosting/templates/host/.gitignore b/framework/cli/simple_module/templates/host/.gitignore similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/.gitignore rename to framework/cli/simple_module/templates/host/.gitignore diff --git a/framework/hosting/simple_module_hosting/templates/host/Makefile b/framework/cli/simple_module/templates/host/Makefile similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/Makefile rename to framework/cli/simple_module/templates/host/Makefile diff --git a/framework/hosting/simple_module_hosting/templates/host/README.md.tpl b/framework/cli/simple_module/templates/host/README.md.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/README.md.tpl rename to framework/cli/simple_module/templates/host/README.md.tpl diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet b/framework/cli/simple_module/templates/host/_optional/background_tasks/Makefile.snippet similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/Makefile.snippet rename to framework/cli/simple_module/templates/host/_optional/background_tasks/Makefile.snippet diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml b/framework/cli/simple_module/templates/host/_optional/background_tasks/docker-compose.yml similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/docker-compose.yml rename to framework/cli/simple_module/templates/host/_optional/background_tasks/docker-compose.yml diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py b/framework/cli/simple_module/templates/host/_optional/background_tasks/run_worker.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/run_worker.py rename to framework/cli/simple_module/templates/host/_optional/background_tasks/run_worker.py diff --git a/framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile b/framework/cli/simple_module/templates/host/_optional/background_tasks/worker.Dockerfile similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/_optional/background_tasks/worker.Dockerfile rename to framework/cli/simple_module/templates/host/_optional/background_tasks/worker.Dockerfile diff --git a/framework/hosting/simple_module_hosting/templates/host/alembic.ini b/framework/cli/simple_module/templates/host/alembic.ini similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/alembic.ini rename to framework/cli/simple_module/templates/host/alembic.ini diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/app.tsx b/framework/cli/simple_module/templates/host/client_app/app.tsx similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/app.tsx rename to framework/cli/simple_module/templates/host/client_app/app.tsx diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/main.tsx b/framework/cli/simple_module/templates/host/client_app/main.tsx similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/main.tsx rename to framework/cli/simple_module/templates/host/client_app/main.tsx diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/package.json.tpl b/framework/cli/simple_module/templates/host/client_app/package.json.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/package.json.tpl rename to framework/cli/simple_module/templates/host/client_app/package.json.tpl diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/pages.ts b/framework/cli/simple_module/templates/host/client_app/pages.ts similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/pages.ts rename to framework/cli/simple_module/templates/host/client_app/pages.ts diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/pages/Error.tsx b/framework/cli/simple_module/templates/host/client_app/pages/Error.tsx similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/pages/Error.tsx rename to framework/cli/simple_module/templates/host/client_app/pages/Error.tsx diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/styles.css b/framework/cli/simple_module/templates/host/client_app/styles.css similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/styles.css rename to framework/cli/simple_module/templates/host/client_app/styles.css diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/tsconfig.json b/framework/cli/simple_module/templates/host/client_app/tsconfig.json similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/tsconfig.json rename to framework/cli/simple_module/templates/host/client_app/tsconfig.json diff --git a/framework/hosting/simple_module_hosting/templates/host/client_app/vite.config.ts b/framework/cli/simple_module/templates/host/client_app/vite.config.ts similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/client_app/vite.config.ts rename to framework/cli/simple_module/templates/host/client_app/vite.config.ts diff --git a/framework/hosting/simple_module_hosting/templates/host/main.py b/framework/cli/simple_module/templates/host/main.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/main.py rename to framework/cli/simple_module/templates/host/main.py diff --git a/framework/hosting/simple_module_hosting/templates/host/migrations/env.py b/framework/cli/simple_module/templates/host/migrations/env.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/migrations/env.py rename to framework/cli/simple_module/templates/host/migrations/env.py diff --git a/framework/hosting/simple_module_hosting/templates/host/migrations/script.py.mako b/framework/cli/simple_module/templates/host/migrations/script.py.mako similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/migrations/script.py.mako rename to framework/cli/simple_module/templates/host/migrations/script.py.mako diff --git a/framework/hosting/simple_module_hosting/templates/host/migrations/versions/.gitkeep b/framework/cli/simple_module/templates/host/migrations/versions/.gitkeep similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/migrations/versions/.gitkeep rename to framework/cli/simple_module/templates/host/migrations/versions/.gitkeep diff --git a/framework/hosting/simple_module_hosting/templates/host/pyproject.toml.tpl b/framework/cli/simple_module/templates/host/pyproject.toml.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/pyproject.toml.tpl rename to framework/cli/simple_module/templates/host/pyproject.toml.tpl diff --git a/framework/hosting/simple_module_hosting/templates/host/templates/index.html b/framework/cli/simple_module/templates/host/templates/index.html similarity index 100% rename from framework/hosting/simple_module_hosting/templates/host/templates/index.html rename to framework/cli/simple_module/templates/host/templates/index.html diff --git a/framework/hosting/simple_module_hosting/templates/module/.github/workflows/ci.yml b/framework/cli/simple_module/templates/module/.github/workflows/ci.yml similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/.github/workflows/ci.yml rename to framework/cli/simple_module/templates/module/.github/workflows/ci.yml diff --git a/framework/hosting/simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl b/framework/cli/simple_module/templates/module/.github/workflows/publish.yml.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/.github/workflows/publish.yml.tpl rename to framework/cli/simple_module/templates/module/.github/workflows/publish.yml.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/.gitignore b/framework/cli/simple_module/templates/module/.gitignore similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/.gitignore rename to framework/cli/simple_module/templates/module/.gitignore diff --git a/framework/hosting/simple_module_hosting/templates/module/README.md.tpl b/framework/cli/simple_module/templates/module/README.md.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/README.md.tpl rename to framework/cli/simple_module/templates/module/README.md.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/__init__.py b/framework/cli/simple_module/templates/module/__PACKAGE__/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/__init__.py rename to framework/cli/simple_module/templates/module/__PACKAGE__/__init__.py diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py b/framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/__init__.py rename to framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/__init__.py diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl b/framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/api.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/endpoints/api.py.tpl rename to framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/api.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl b/framework/cli/simple_module/templates/module/__PACKAGE__/module.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/module.py.tpl rename to framework/cli/simple_module/templates/module/__PACKAGE__/module.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep b/framework/cli/simple_module/templates/module/__PACKAGE__/pages/.gitkeep similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/pages/.gitkeep rename to framework/cli/simple_module/templates/module/__PACKAGE__/pages/.gitkeep diff --git a/framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl b/framework/cli/simple_module/templates/module/__PACKAGE__/services.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/__PACKAGE__/services.py.tpl rename to framework/cli/simple_module/templates/module/__PACKAGE__/services.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/package.json.tpl b/framework/cli/simple_module/templates/module/package.json.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/package.json.tpl rename to framework/cli/simple_module/templates/module/package.json.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/pyproject.toml.tpl b/framework/cli/simple_module/templates/module/pyproject.toml.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/pyproject.toml.tpl rename to framework/cli/simple_module/templates/module/pyproject.toml.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/tests/__init__.py b/framework/cli/simple_module/templates/module/tests/__init__.py similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/tests/__init__.py rename to framework/cli/simple_module/templates/module/tests/__init__.py diff --git a/framework/hosting/simple_module_hosting/templates/module/tests/test_module.py.tpl b/framework/cli/simple_module/templates/module/tests/test_module.py.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/tests/test_module.py.tpl rename to framework/cli/simple_module/templates/module/tests/test_module.py.tpl diff --git a/framework/hosting/simple_module_hosting/templates/module/tsconfig.json.tpl b/framework/cli/simple_module/templates/module/tsconfig.json.tpl similarity index 100% rename from framework/hosting/simple_module_hosting/templates/module/tsconfig.json.tpl rename to framework/cli/simple_module/templates/module/tsconfig.json.tpl diff --git a/framework/hosting/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py similarity index 100% rename from framework/hosting/tests/test_scaffolding_host.py rename to framework/cli/tests/test_scaffolding_host.py diff --git a/framework/hosting/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py similarity index 100% rename from framework/hosting/tests/test_scaffolding_module.py rename to framework/cli/tests/test_scaffolding_module.py diff --git a/framework/hosting/simple_module_hosting/app_project.py b/framework/hosting/simple_module_hosting/app_project.py index 7bc4336e..84aa4e04 100644 --- a/framework/hosting/simple_module_hosting/app_project.py +++ b/framework/hosting/simple_module_hosting/app_project.py @@ -60,9 +60,10 @@ def create_app_project( # Imports are local to break the import cycle: scaffolding.py # re-exports ``create_app_project`` from this module, so this module # cannot import from scaffolding at module-load time. + from simple_module.scaffolding import create_host + from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx - from simple_module_hosting.scaffolding import create_host if target.exists() and any(target.iterdir()): raise FileExistsError( diff --git a/framework/hosting/simple_module_hosting/cli/recipes.py b/framework/hosting/simple_module_hosting/cli/recipes.py index 10bf5c27..59b6af54 100644 --- a/framework/hosting/simple_module_hosting/cli/recipes.py +++ b/framework/hosting/simple_module_hosting/cli/recipes.py @@ -44,7 +44,7 @@ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... def _optional_template_root(name: str) -> Path: """Resolve ``templates/host/_optional//`` from package data.""" - base = importlib.resources.files("simple_module_hosting") + base = importlib.resources.files("simple_module") return Path(str(base / "templates" / "host" / "_optional" / name)) diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py index 14b030b1..18fec846 100644 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ b/framework/hosting/simple_module_hosting/scaffolding.py @@ -1,25 +1,13 @@ -"""Host + module scaffolding via package-data templates. +"""Re-exports of moved scaffolding APIs. -* :func:`create_host` materializes a new host project from the templates - under ``simple_module_hosting/templates/host/``. -* :func:`create_module` materializes a new module package from - ``simple_module_hosting/templates/module/``. - -The frontend pages manifest + per-module JS dep discovery used to live -here as well; both moved to :mod:`simple_module_hosting.manifest` to -keep this file under the project's per-file line cap. They're re-exported -below so existing import sites keep working. +The actual implementations live in :mod:`simple_module.scaffolding`. This +shim lets the in-tree hosting code keep importing from the historical +path during the migration; it is removed in the final cleanup task. """ from __future__ import annotations -import importlib.resources -import logging -import shutil -from collections.abc import Mapping, Sequence -from pathlib import Path - -from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case +from simple_module.scaffolding import create_host, create_module from simple_module_hosting.app_project import create_app_project as create_app_project from simple_module_hosting.manifest import ( @@ -40,145 +28,3 @@ "repo_root_from_client_app", "write_module_pages_manifest", ] - -logger = logging.getLogger(__name__) - -# Templates ship as package data under simple_module_hosting/templates/{host,module}/. -_TEMPLATES_PACKAGE = "simple_module_hosting.templates" - -# Path-segment substitution token used by create_module. -_PACKAGE_PATH_TOKEN = "__PACKAGE__" - - -def _module_to_pypi_name(name: str) -> str: - """'Products' -> 'simple_module_products'. Matches the publishing convention.""" - return f"simple_module_{name.lower()}" - - -def _iter_template_files(template_root: Path): - """Yield every file under ``template_root``, preserving relative paths. - - Skips any path under an ``_optional/`` segment — those are recipe-managed - templates consumed by :mod:`simple_module_hosting.cli.recipes`, not by - the default scaffolding pass. - """ - for path in template_root.rglob("*"): - if not path.is_file(): - continue - if "_optional" in path.relative_to(template_root).parts: - continue - yield path - - -def _require_empty_dest(dest: Path) -> None: - """Raise if ``dest`` is an existing non-empty directory — never clobber files.""" - if dest.exists() and any(dest.iterdir()): - raise FileExistsError( - f"Destination {dest} already exists and is non-empty. " - "Choose a new path or remove the contents first." - ) - dest.mkdir(parents=True, exist_ok=True) - - -def _resolve_template_root(subdir: str, override: Path | None) -> Path: - """Return the scaffold template root, either from package data or an override.""" - if override is not None: - return Path(override) - return Path(str(importlib.resources.files(_TEMPLATES_PACKAGE) / subdir)) - - -def _apply_template_files( - src_root: Path, - dest: Path, - substitutions: Mapping[str, str], - *, - path_rewrites: Mapping[str, str] | None = None, -) -> None: - """Copy every file under ``src_root`` to ``dest``, applying substitutions. - - Files ending in ``.tpl`` are read as text, placeholders replaced, and - written without the suffix. Every other file is copied verbatim. If - ``path_rewrites`` is given, each key is replaced by its value anywhere - in relative paths (used by :func:`create_module` to rename the - ``__PACKAGE__`` directory placeholder). - """ - for src in _iter_template_files(src_root): - rel_str = str(src.relative_to(src_root)) - for old, new in (path_rewrites or {}).items(): - rel_str = rel_str.replace(old, new) - rel_str = rel_str.removesuffix(".tpl") - target = dest / rel_str - target.parent.mkdir(parents=True, exist_ok=True) - - if src.suffix == ".tpl": - text = src.read_text(encoding="utf-8") - for placeholder, value in substitutions.items(): - text = text.replace(placeholder, value) - target.write_text(text, encoding="utf-8") - else: - shutil.copy2(src, target) - - -def create_host( - dest: Path, - name: str, - modules: Sequence[str], - template_root: Path | None = None, -) -> Path: - """Materialize a SimpleModule host scaffold at ``dest``. - - Modules listed in ``modules`` become PyPI dependencies in the scaffolded - ``pyproject.toml`` (e.g. ``"simple_module_products>=0.1,<1.0"``). Raises - :class:`FileExistsError` if ``dest`` is an existing non-empty directory. - """ - dest = Path(dest) - _require_empty_dest(dest) - - module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules) - _apply_template_files( - _resolve_template_root("host", template_root), - dest, - {"{{HOST_NAME}}": name, "{{MODULE_DEPS}}": module_dep_lines}, - ) - - logger.info( - "Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "" - ) - return dest - - -def create_module( - dest: Path, - name: str, - template_root: Path | None = None, -) -> Path: - """Materialize a publishable module package at ``dest``. - - ``name`` is accepted in any case style (``MyFeature``, ``my-feature``, - ``my_feature``) and normalized to three forms: - - * ``MODULE_NAME`` — ``PascalCase``, appears in ``Meta(name=...)`` - * ``MODULE_SLUG`` — ``kebab-case``, used in the PyPI distribution name - * ``PACKAGE_NAME`` — ``snake_case``, the importable Python package and - the entry_point key - """ - dest = Path(dest) - _require_empty_dest(dest) - - display_name = to_pascal_case(name) - slug = to_kebab_case(name) - package_name = to_snake_case(name) - - _apply_template_files( - _resolve_template_root("module", template_root), - dest, - substitutions={ - "{{MODULE_NAME}}": display_name, - "{{MODULE_SLUG}}": slug, - "{{PACKAGE_NAME}}": package_name, - }, - path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, - ) - - logger.info("Scaffolded module '%s' at %s (package: %s)", display_name, dest, package_name) - return dest diff --git a/pyproject.toml b/pyproject.toml index c6201676..e7f0c616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ line-length = 100 target-version = "py312" # Scaffold templates are copied verbatim (and some live under __PACKAGE__/ # placeholder dirs that aren't valid Python packages until substituted). -extend-exclude = ["framework/hosting/simple_module_hosting/templates"] +extend-exclude = ["framework/cli/simple_module/templates"] [tool.ruff.lint] select = [ From 99ee0488b570b4dd7ac38315d9b23ef64f41885a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:03:23 +0200 Subject: [PATCH 16/24] refactor(cli): move app_project, catalog, wizard, recipes, new to simple_module All scaffolding logic now lives in the simple_module package. Hosting's cli/__init__.py click group still works (it imports new_project from the new location); fully replaced by the Typer port in the next commit. --- .../simple_module}/app_project.py | 8 ++------ .../cli => cli/simple_module}/catalog.py | 0 .../cli => cli/simple_module}/new.py | 7 +++---- .../cli => cli/simple_module}/recipes.py | 0 .../cli => cli/simple_module}/wizard.py | 0 framework/{hosting => cli}/tests/test_cli_catalog.py | 2 +- framework/{hosting => cli}/tests/test_cli_new.py | 6 +++--- framework/{hosting => cli}/tests/test_cli_recipes.py | 4 ++-- framework/{hosting => cli}/tests/test_cli_wizard.py | 2 +- framework/hosting/simple_module_hosting/cli/__init__.py | 2 +- framework/hosting/simple_module_hosting/scaffolding.py | 2 +- 11 files changed, 14 insertions(+), 19 deletions(-) rename framework/{hosting/simple_module_hosting => cli/simple_module}/app_project.py (92%) rename framework/{hosting/simple_module_hosting/cli => cli/simple_module}/catalog.py (100%) rename framework/{hosting/simple_module_hosting/cli => cli/simple_module}/new.py (96%) rename framework/{hosting/simple_module_hosting/cli => cli/simple_module}/recipes.py (100%) rename framework/{hosting/simple_module_hosting/cli => cli/simple_module}/wizard.py (100%) rename framework/{hosting => cli}/tests/test_cli_catalog.py (98%) rename framework/{hosting => cli}/tests/test_cli_new.py (97%) rename framework/{hosting => cli}/tests/test_cli_recipes.py (96%) rename framework/{hosting => cli}/tests/test_cli_wizard.py (97%) diff --git a/framework/hosting/simple_module_hosting/app_project.py b/framework/cli/simple_module/app_project.py similarity index 92% rename from framework/hosting/simple_module_hosting/app_project.py rename to framework/cli/simple_module/app_project.py index 84aa4e04..753aa0e6 100644 --- a/framework/hosting/simple_module_hosting/app_project.py +++ b/framework/cli/simple_module/app_project.py @@ -57,14 +57,10 @@ def create_app_project( framework versions, and applies any matching post-scaffold recipes (e.g. the ``background_tasks`` recipe drops a Celery worker stack). """ - # Imports are local to break the import cycle: scaffolding.py - # re-exports ``create_app_project`` from this module, so this module - # cannot import from scaffolding at module-load time. + from simple_module.catalog import CATALOG, PRESETS, expand_deps + from simple_module.recipes import RECIPES, ScaffoldCtx from simple_module.scaffolding import create_host - from simple_module_hosting.cli.catalog import CATALOG, PRESETS, expand_deps - from simple_module_hosting.cli.recipes import RECIPES, ScaffoldCtx - if target.exists() and any(target.iterdir()): raise FileExistsError( f"Destination {target} already exists and is non-empty; " diff --git a/framework/hosting/simple_module_hosting/cli/catalog.py b/framework/cli/simple_module/catalog.py similarity index 100% rename from framework/hosting/simple_module_hosting/cli/catalog.py rename to framework/cli/simple_module/catalog.py diff --git a/framework/hosting/simple_module_hosting/cli/new.py b/framework/cli/simple_module/new.py similarity index 96% rename from framework/hosting/simple_module_hosting/cli/new.py rename to framework/cli/simple_module/new.py index 8736276e..ec6637b4 100644 --- a/framework/hosting/simple_module_hosting/cli/new.py +++ b/framework/cli/simple_module/new.py @@ -14,10 +14,9 @@ import click -from simple_module_hosting.scaffolding import create_app_project - -from .catalog import PRESETS, expand_deps -from .wizard import run_wizard +from simple_module.app_project import create_app_project +from simple_module.catalog import PRESETS, expand_deps +from simple_module.wizard import run_wizard __all__ = ["new_project"] diff --git a/framework/hosting/simple_module_hosting/cli/recipes.py b/framework/cli/simple_module/recipes.py similarity index 100% rename from framework/hosting/simple_module_hosting/cli/recipes.py rename to framework/cli/simple_module/recipes.py diff --git a/framework/hosting/simple_module_hosting/cli/wizard.py b/framework/cli/simple_module/wizard.py similarity index 100% rename from framework/hosting/simple_module_hosting/cli/wizard.py rename to framework/cli/simple_module/wizard.py diff --git a/framework/hosting/tests/test_cli_catalog.py b/framework/cli/tests/test_cli_catalog.py similarity index 98% rename from framework/hosting/tests/test_cli_catalog.py rename to framework/cli/tests/test_cli_catalog.py index 71027a8a..f7cdc49d 100644 --- a/framework/hosting/tests/test_cli_catalog.py +++ b/framework/cli/tests/test_cli_catalog.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from simple_module_hosting.cli.catalog import ( +from simple_module.catalog import ( CATALOG, PRESETS, ModuleEntry, diff --git a/framework/hosting/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py similarity index 97% rename from framework/hosting/tests/test_cli_new.py rename to framework/cli/tests/test_cli_new.py index da1adf06..73db9367 100644 --- a/framework/hosting/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -65,7 +65,7 @@ def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: - from simple_module_hosting.scaffolding import create_app_project + from simple_module.app_project import create_app_project target = tmp_path / "demo" create_app_project( @@ -83,7 +83,7 @@ def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> None: - from simple_module_hosting.scaffolding import create_app_project + from simple_module.app_project import create_app_project target = tmp_path / "demo" create_app_project( @@ -102,7 +102,7 @@ def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) -> None: - from simple_module_hosting.scaffolding import create_app_project + from simple_module.app_project import create_app_project target = tmp_path / "demo" create_app_project(target, name="demo", db="sqlite", tenancy=False) diff --git a/framework/hosting/tests/test_cli_recipes.py b/framework/cli/tests/test_cli_recipes.py similarity index 96% rename from framework/hosting/tests/test_cli_recipes.py rename to framework/cli/tests/test_cli_recipes.py index b9e950d5..3c3f4f34 100644 --- a/framework/hosting/tests/test_cli_recipes.py +++ b/framework/cli/tests/test_cli_recipes.py @@ -5,12 +5,12 @@ from pathlib import Path import pytest -from simple_module_hosting.cli.recipes import ( +from simple_module.recipes import ( RECIPES, BackgroundTasksRecipe, ScaffoldCtx, ) -from simple_module_hosting.scaffolding import create_host +from simple_module.scaffolding import create_host def _scaffold_minimal_host(target: Path) -> None: diff --git a/framework/hosting/tests/test_cli_wizard.py b/framework/cli/tests/test_cli_wizard.py similarity index 97% rename from framework/hosting/tests/test_cli_wizard.py rename to framework/cli/tests/test_cli_wizard.py index 006b778f..61043088 100644 --- a/framework/hosting/tests/test_cli_wizard.py +++ b/framework/cli/tests/test_cli_wizard.py @@ -4,7 +4,7 @@ import click from click.testing import CliRunner -from simple_module_hosting.cli.wizard import run_wizard +from simple_module.wizard import run_wizard def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: diff --git a/framework/hosting/simple_module_hosting/cli/__init__.py b/framework/hosting/simple_module_hosting/cli/__init__.py index ea2dd92c..43922ea2 100644 --- a/framework/hosting/simple_module_hosting/cli/__init__.py +++ b/framework/hosting/simple_module_hosting/cli/__init__.py @@ -206,7 +206,7 @@ def sync_js_deps(host_client_app: Path | None, dry_run: bool) -> None: # `sm new` lives in its own module (catalog + wizard + recipes wiring). -from .new import new_project as _new_project # noqa: E402 +from simple_module.new import new_project as _new_project # noqa: E402 main.add_command(_new_project) diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py index 18fec846..d781f461 100644 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ b/framework/hosting/simple_module_hosting/scaffolding.py @@ -7,9 +7,9 @@ from __future__ import annotations +from simple_module.app_project import create_app_project as create_app_project from simple_module.scaffolding import create_host, create_module -from simple_module_hosting.app_project import create_app_project as create_app_project from simple_module_hosting.manifest import ( collect_module_js_deps, compute_module_pages, From cb068d789d810948370e792db6af2db57ad02b2b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:06:48 +0200 Subject: [PATCH 17/24] feat(cli): port sm to Typer; new simple-module package owns the binary - All Click decorators rewritten in Typer's Annotated[] style. - Wizard uses typer.prompt / typer.confirm. - Tests use typer.testing.CliRunner (drop-in for click.testing). - simple_module_hosting drops sm/simple-module console scripts. - simple_module_hosting/cli/ package deleted. - Plugin discovery hook added (no-op stub; real impl next commit). --- framework/cli/simple_module/cli.py | 107 ++++++++- framework/cli/simple_module/new.py | 168 +++++++------- framework/cli/simple_module/plugins.py | 13 ++ framework/cli/simple_module/wizard.py | 55 ++--- framework/cli/tests/test_cli_new.py | 24 +- framework/cli/tests/test_cli_wizard.py | 21 +- framework/cli/tests/test_scaffolding_host.py | 6 +- .../cli/tests/test_scaffolding_module.py | 6 +- framework/hosting/pyproject.toml | 4 - .../simple_module_hosting/cli/__init__.py | 215 ------------------ 10 files changed, 242 insertions(+), 377 deletions(-) create mode 100644 framework/cli/simple_module/plugins.py delete mode 100644 framework/hosting/simple_module_hosting/cli/__init__.py diff --git a/framework/cli/simple_module/cli.py b/framework/cli/simple_module/cli.py index 80160bd7..5f1054c2 100644 --- a/framework/cli/simple_module/cli.py +++ b/framework/cli/simple_module/cli.py @@ -1,15 +1,104 @@ -"""Root `sm` command — scaffolders + plugin mount. +"""Root `sm` Typer app — scaffolders + plugin mount. -This file gets fleshed out in Task 5 (Typer port) and Task 6 (plugin -discovery). For now it exists only so the ``sm = simple_module.cli:main`` -console-script entry point resolves cleanly during workspace install. +Built-in commands: + sm new + sm create-host + sm create-module + +Plugins discovered via the ``simple_module.cli_plugins`` entry-point +group are mounted as named subgroups (e.g. ``sm host gen-pages``). """ from __future__ import annotations +from pathlib import Path +from typing import Annotated + +import typer + +from simple_module.case import to_kebab_case +from simple_module.new import new_project +from simple_module.plugins import discover_and_mount +from simple_module.scaffolding import create_host as _create_host +from simple_module.scaffolding import create_module as _create_module + +app = typer.Typer( + help="SimpleModule developer CLI.", + no_args_is_help=True, + add_completion=False, +) + +app.command("new")(new_project) + + +@app.command("create-host") +def create_host( + name: Annotated[str, typer.Argument(help="Host project name.")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + modules: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated module names to declare as deps (e.g. Auth,Products).", + ), + ] = "", +) -> None: + """Scaffold a new SimpleModule host project at ./.""" + target = dest or Path.cwd() / name + selected = [m.strip() for m in modules.split(",") if m.strip()] + try: + _create_host(target, name=name, modules=selected) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created host '{name}' at {target}") + if selected: + typer.echo(f"Declared modules: {', '.join(selected)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + 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(" python main.py") + + +@app.command("create-module") +def create_module( + name: Annotated[str, typer.Argument(help="Module name (any case).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination dir. Defaults to ./simple_module_."), + ] = None, +) -> None: + """Scaffold a publishable SimpleModule module package.""" + slug = to_kebab_case(name) + package = slug.replace("-", "_") + target = dest or Path.cwd() / f"simple_module_{package}" + try: + _create_module(target, name=name) + except FileExistsError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + + typer.echo(f"Created module 'simple_module_{package}' at {target}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") + typer.echo(" uv sync --extra dev") + typer.echo(" uv run pytest") + + +discover_and_mount(app) + + +def main() -> None: + """Entry point for the `sm` console script.""" + app() + -def main() -> int: - raise SystemExit( - "simple-module CLI is being installed but its commands have not " - "been wired up yet. Re-install once Task 5 lands." - ) +if __name__ == "__main__": + main() diff --git a/framework/cli/simple_module/new.py b/framework/cli/simple_module/new.py index ec6637b4..38a7ec93 100644 --- a/framework/cli/simple_module/new.py +++ b/framework/cli/simple_module/new.py @@ -1,18 +1,13 @@ -"""The upgraded ``sm new`` command. - -Combines flag-driven non-interactive use (``--preset`` / ``--with``) with -the interactive wizard. All paths converge on -:func:`simple_module_hosting.scaffolding.create_app_project` with a -resolved module list. -""" +"""``sm new`` Typer command — flag-driven or interactive scaffolder.""" from __future__ import annotations import subprocess -import sys +from enum import Enum from pathlib import Path +from typing import Annotated -import click +import typer from simple_module.app_project import create_app_project from simple_module.catalog import PRESETS, expand_deps @@ -21,108 +16,105 @@ __all__ = ["new_project"] -@click.command("new") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./.", -) -@click.option( - "--db", - type=click.Choice(["sqlite", "postgres"]), - default="sqlite", - show_default=True, - help="Database backend to configure in .env.example.", -) -@click.option( - "--tenancy/--no-tenancy", - default=False, - show_default=True, - help="Enable the multi-tenant middleware by default.", -) -@click.option( - "--preset", - type=click.Choice(["minimal", "standard", "full"]), - default=None, - help="Module preset. Combine with --with to add modules on top.", -) -@click.option( - "--with", - "extra", - default="", - help="Comma-separated extra module names (e.g. background_tasks,file_storage).", -) -@click.option( - "--yes", - "-y", - is_flag=True, - default=False, - help="Skip interactive prompts; accept defaults.", -) -@click.option( - "--no-install", - is_flag=True, - default=False, - help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", -) +class Db(str, Enum): + sqlite = "sqlite" + postgres = "postgres" + + +class Preset(str, Enum): + minimal = "minimal" + standard = "standard" + full = "full" + + def new_project( - name: str, - dest: Path | None, - db: str, - tenancy: bool, - preset: str | None, - extra: str, - yes: bool, - no_install: bool, + name: Annotated[str, typer.Argument(help="App name (used for directory + package).")], + dest: Annotated[ + Path | None, + typer.Option("--dest", help="Destination directory. Defaults to ./."), + ] = None, + db: Annotated[ + Db, + typer.Option("--db", help="Database backend to configure in .env.example."), + ] = Db.sqlite, + tenancy: Annotated[ + bool, + typer.Option("--tenancy/--no-tenancy", help="Enable the multi-tenant middleware."), + ] = False, + preset: Annotated[ + Preset | None, + typer.Option("--preset", help="Module preset. Combine with --with."), + ] = None, + extra: Annotated[ + str, + typer.Option( + "--with", + help="Comma-separated extra modules (e.g. background_tasks,file_storage).", + ), + ] = "", + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip interactive prompts; accept defaults."), + ] = False, + no_install: Annotated[ + bool, + typer.Option( + "--no-install", + help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.", + ), + ] = False, ) -> None: """Scaffold a new SimpleModule app, optionally with background jobs.""" target = dest or Path.cwd() / name - extra_list = [m.strip() for m in extra.split(",") if m.strip()] flag_driven = preset is not None or bool(extra_list) + db_value: str = db.value + tenancy_value: bool = tenancy if yes or flag_driven: - chosen = list(PRESETS[preset or "standard"]) + extra_list + chosen = list(PRESETS[(preset or Preset.standard).value]) + extra_list try: resolved, added = expand_deps(chosen) except KeyError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc for added_name, required_by in added: - click.echo(f"Added {added_name} (required by {required_by})") + typer.echo(f"Added {added_name} (required by {required_by})") else: try: - db, tenancy, resolved = run_wizard(default_db=db, default_tenancy=tenancy) - except click.Abort: - click.echo("Aborted.", err=True) - sys.exit(1) + db_value, tenancy_value, resolved = run_wizard( + default_db=db.value, default_tenancy=tenancy + ) + except typer.Abort: + typer.echo("Aborted.", err=True) + raise typer.Exit(code=1) from None try: - create_app_project(target, name=name, db=db, tenancy=tenancy, selected=resolved) + create_app_project( + target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved + ) except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc - click.echo(f"Created app '{name}' at {target}") - click.echo(f"Modules: {', '.join(resolved)}") - click.echo("\nNext steps:") - click.echo(f" cd {target}") + typer.echo(f"Created app '{name}' at {target}") + typer.echo(f"Modules: {', '.join(resolved)}") + typer.echo("\nNext steps:") + typer.echo(f" cd {target}") if no_install: - click.echo(" uv sync") - click.echo(" npm install") - click.echo(" alembic upgrade head") - click.echo(" make dev") + typer.echo(" uv sync") + typer.echo(" npm install") + typer.echo(" alembic upgrade head") + typer.echo(" make dev") if "background_tasks" in resolved: - click.echo(" docker compose up -d redis worker beat # background jobs") + typer.echo(" docker compose up -d redis worker beat # background jobs") return - click.echo("Installing dependencies...") + typer.echo("Installing dependencies...") for cmd in (["uv", "sync"], ["npm", "install"]): result = subprocess.run(cmd, cwd=target, check=False) if result.returncode != 0: - click.echo( + typer.echo( f"WARNING: {' '.join(cmd)} failed (exit {result.returncode}); " "finish setup manually.", err=True, @@ -130,6 +122,6 @@ def new_project( return subprocess.run(["uv", "run", "alembic", "upgrade", "head"], cwd=target, check=False) - click.echo("\nSetup complete. Run `make dev` in the new directory.") + typer.echo("\nSetup complete. Run `make dev` in the new directory.") if "background_tasks" in resolved: - click.echo("For background jobs, also run: docker compose up -d redis worker beat") + typer.echo("For background jobs, also run: docker compose up -d redis worker beat") diff --git a/framework/cli/simple_module/plugins.py b/framework/cli/simple_module/plugins.py new file mode 100644 index 00000000..a764d471 --- /dev/null +++ b/framework/cli/simple_module/plugins.py @@ -0,0 +1,13 @@ +"""Plugin discovery via ``simple_module.cli_plugins`` entry points. + +Real implementation lands in Task 6. For now this is a no-op so the +root Typer app imports cleanly. +""" + +from __future__ import annotations + +import typer + + +def discover_and_mount(app: typer.Typer) -> None: + """No-op stub. Implemented in Task 6.""" diff --git a/framework/cli/simple_module/wizard.py b/framework/cli/simple_module/wizard.py index 0a138313..4b53ed29 100644 --- a/framework/cli/simple_module/wizard.py +++ b/framework/cli/simple_module/wizard.py @@ -1,57 +1,48 @@ -"""Interactive prompt sequence for ``sm new``. - -Returns the user's choices as ``(db, tenancy, selected)`` where -``selected`` is the topologically resolved module list (already includes -transitive requires). All prompts use ``click`` — no extra TUI dep. -""" +"""Interactive prompt sequence for ``sm new``.""" from __future__ import annotations -import click +import typer -from .catalog import CATALOG, PRESETS, expand_deps +from simple_module.catalog import CATALOG, PRESETS, expand_deps __all__ = ["run_wizard"] - _PRESET_CHOICES = ("minimal", "standard", "full", "custom") def run_wizard(*, default_db: str, default_tenancy: bool) -> tuple[str, bool, list[str]]: - db = click.prompt( - "Database backend", - default=default_db, - type=click.Choice(["sqlite", "postgres"]), - ) - tenancy = click.confirm("Enable multi-tenancy?", default=default_tenancy) - - click.echo("\nPreset:") - click.echo(" [1] minimal — users only") - click.echo(" [2] standard — users, dashboard, permissions (default)") - click.echo(" [3] full — every module") - click.echo(" [4] custom — pick modules one by one") - choice = click.prompt( - "Choose", - default="2", - type=click.Choice(["1", "2", "3", "4"]), - show_choices=False, - ) + db = typer.prompt("Database backend", default=default_db, type=str) + if db not in ("sqlite", "postgres"): + typer.echo(f"Invalid database: {db!r}; expected sqlite or postgres", err=True) + raise typer.Abort() + tenancy = typer.confirm("Enable multi-tenancy?", default=default_tenancy) + + typer.echo("\nPreset:") + typer.echo(" [1] minimal — users only") + typer.echo(" [2] standard — users, dashboard, permissions (default)") + typer.echo(" [3] full — every module") + typer.echo(" [4] custom — pick modules one by one") + choice = typer.prompt("Choose", default="2", type=str) + if choice not in {"1", "2", "3", "4"}: + typer.echo(f"Invalid choice: {choice!r}", err=True) + raise typer.Abort() preset_name = _PRESET_CHOICES[int(choice) - 1] if preset_name == "custom": picked = [ name for name in CATALOG - if click.confirm(f"Include {CATALOG[name].display}?", default=False) + if typer.confirm(f"Include {CATALOG[name].display}?", default=False) ] else: picked = list(PRESETS[preset_name]) resolved, added = expand_deps(picked) for name, required_by in added: - click.echo(f"Added {name} (required by {required_by})") - click.echo(f"Selected modules: {', '.join(resolved)}") + typer.echo(f"Added {name} (required by {required_by})") + typer.echo(f"Selected modules: {', '.join(resolved)}") - if not click.confirm("Proceed?", default=True): - raise click.Abort() + if not typer.confirm("Proceed?", default=True): + raise typer.Abort() return db, tenancy, resolved diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 73db9367..eb8e8eb3 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -5,15 +5,15 @@ import json from pathlib import Path -from click.testing import CliRunner -from simple_module_hosting.cli import main +from typer.testing import CliRunner +from simple_module.cli import app def test_sm_new_creates_app_directory(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app" result = runner.invoke( - main, + app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) assert result.exit_code == 0, result.output @@ -24,7 +24,7 @@ def test_sm_new_generates_pyproject_with_expected_deps(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app" runner.invoke( - main, + app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) pyproject_text = (target / "pyproject.toml").read_text() @@ -41,7 +41,7 @@ def test_sm_new_generates_package_json_with_npm_deps(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app" runner.invoke( - main, + app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) data = json.loads((target / "package.json").read_text()) @@ -54,7 +54,7 @@ def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "my-app" runner.invoke( - main, + app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) env_text = (target / ".env.example").read_text() @@ -119,7 +119,7 @@ def test_sm_new_with_preset_full_includes_background_tasks(tmp_path: Path) -> No runner = CliRunner() target = tmp_path / "demo" result = runner.invoke( - main, + app, ["new", "demo", "--yes", "--preset", "full", "--no-install", "--dest", str(target)], ) assert result.exit_code == 0, result.output @@ -133,7 +133,7 @@ def test_sm_new_with_explicit_with_flag(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "demo" result = runner.invoke( - main, + app, [ "new", "demo", @@ -158,7 +158,7 @@ def test_sm_new_unknown_with_module_errors(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "demo" result = runner.invoke( - main, + app, ["new", "demo", "--yes", "--with", "does_not_exist", "--no-install", "--dest", str(target)], ) assert result.exit_code != 0 @@ -170,7 +170,7 @@ def test_sm_new_yes_with_no_flags_uses_standard_preset(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "demo" result = runner.invoke( - main, + app, ["new", "demo", "--yes", "--no-install", "--dest", str(target)], ) assert result.exit_code == 0, result.output @@ -188,7 +188,7 @@ def test_sm_new_interactive_full_preset(tmp_path: Path) -> None: runner = CliRunner() target = tmp_path / "demo" result = runner.invoke( - main, + app, ["new", "demo", "--no-install", "--dest", str(target)], input="\n".join(["", "", "3", ""]) + "\n", ) @@ -203,7 +203,7 @@ def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None: runner = CliRunner() result = runner.invoke( - main, + app, ["new", "my-app", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], ) assert result.exit_code != 0 diff --git a/framework/cli/tests/test_cli_wizard.py b/framework/cli/tests/test_cli_wizard.py index 61043088..23838f58 100644 --- a/framework/cli/tests/test_cli_wizard.py +++ b/framework/cli/tests/test_cli_wizard.py @@ -2,17 +2,18 @@ from __future__ import annotations -import click -from click.testing import CliRunner +import typer +from typer.testing import CliRunner + from simple_module.wizard import run_wizard def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: """Run the wizard with stdin pre-fed; return (db, tenancy, selected, output).""" - captured: dict = {} + wrapper_app = typer.Typer() - @click.command() + @wrapper_app.command() def wrapper() -> None: db, tenancy, selected = run_wizard(default_db="sqlite", default_tenancy=False) captured["db"] = db @@ -20,7 +21,7 @@ def wrapper() -> None: captured["selected"] = selected runner = CliRunner() - result = runner.invoke(wrapper, input="\n".join(answers) + "\n") + result = runner.invoke(wrapper_app, [], input="\n".join(answers) + "\n") assert result.exit_code == 0, result.output return captured["db"], captured["tenancy"], captured["selected"], result.output @@ -53,9 +54,6 @@ def test_wizard_full_preset_includes_background_tasks() -> None: def test_wizard_custom_picks_only_yes_answers() -> None: - # CATALOG order is: auth, users, permissions, products, dashboard, settings, - # feature_flags, file_storage, background_tasks, datasets — answer y to - # background_tasks only. answers = ["", "", "4"] + ["n"] * 8 + ["y", "n", ""] _, _, selected, out = _drive(answers) assert set(selected) == {"background_tasks", "users", "auth"} @@ -65,16 +63,17 @@ def test_wizard_custom_picks_only_yes_answers() -> None: def test_wizard_aborts_on_confirm_no() -> None: captured: dict = {} + wrapper_app = typer.Typer() - @click.command() + @wrapper_app.command() def wrapper() -> None: try: run_wizard(default_db="sqlite", default_tenancy=False) - except click.Abort: + except typer.Abort: captured["aborted"] = True raise runner = CliRunner() - result = runner.invoke(wrapper, input="\n".join(["", "", "", "n"]) + "\n") + result = runner.invoke(wrapper_app, [], input="\n".join(["", "", "", "n"]) + "\n") assert result.exit_code != 0 assert captured.get("aborted") is True diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index 0e18d920..9ef2ee7d 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -155,12 +155,12 @@ async def test_env_py_uses_shared_helper(self, tmp_path): async def test_cli_create_host_runs_end_to_end(self, tmp_path): """The Click `sm create-host` command produces a working scaffold.""" - from click.testing import CliRunner - from simple_module_hosting.cli import main + from typer.testing import CliRunner + from simple_module.cli import app runner = CliRunner() result = runner.invoke( - main, + app, ["create-host", "smoke-host", "--dest", str(tmp_path / "out"), "--with", "Products"], ) assert result.exit_code == 0, result.output diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index 852809d9..d73e43e6 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -71,13 +71,13 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_cli_create_module_runs_end_to_end(self, tmp_path): """The Click `sm create-module` command produces a working scaffold.""" - from click.testing import CliRunner - from simple_module_hosting.cli import main + from typer.testing import CliRunner + from simple_module.cli import app runner = CliRunner() dest = tmp_path / "simple-module-smoke" result = runner.invoke( - main, + app, ["create-module", "Smoke", "--dest", str(dest)], ) assert result.exit_code == 0, result.output diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index 29715028..20f12811 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -34,10 +34,6 @@ dependencies = [ "uvicorn[standard]>=0.34", ] -[project.scripts] -sm = "simple_module_hosting.cli:main" -simple-module = "simple_module_hosting.cli:main" - [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" Repository = "https://github.com/antosubash/simple_module_python" diff --git a/framework/hosting/simple_module_hosting/cli/__init__.py b/framework/hosting/simple_module_hosting/cli/__init__.py deleted file mode 100644 index 43922ea2..00000000 --- a/framework/hosting/simple_module_hosting/cli/__init__.py +++ /dev/null @@ -1,215 +0,0 @@ -"""SimpleModule CLI — `sm` console script. - -Exposes: - -* ``sm new `` — scaffold a runnable app with selected modules - (and a Celery stack when background_tasks is opted in). Defined in - :mod:`.new`. -* ``sm create-host `` — scaffold a bare host directory. -* ``sm create-module `` — scaffold a publishable module package. -* ``sm gen-pages`` — regenerate the frontend pages manifest + Tailwind CSS. -* ``sm sync-js-deps`` — install JS deps declared by installed modules. -""" - -from __future__ import annotations - -import logging -import shutil -import subprocess -import sys -from pathlib import Path - -import click -from simple_module_core import discover_modules - -from simple_module.case import to_kebab_case -from simple_module_hosting.scaffolding import ( - collect_module_js_deps, - create_module, - repo_root_from_client_app, - write_module_pages_manifest, -) -from simple_module_hosting.scaffolding import ( - create_host as _create_host, -) - - -@click.group() -def main() -> None: - """SimpleModule developer CLI.""" - - -@main.command("create-host") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./.", -) -@click.option( - "--with", - "modules", - default="", - help="Comma-separated module names to declare as deps (e.g. --with=Auth,Products).", -) -def create_host(name: str, dest: Path | None, modules: str) -> None: - """Scaffold a new SimpleModule host project at ./.""" - target = dest or Path.cwd() / name - selected = [m.strip() for m in modules.split(",") if m.strip()] - - try: - _create_host(target, name=name, modules=selected) - except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) - - click.echo(f"Created host '{name}' at {target}") - if selected: - click.echo(f"Declared modules: {', '.join(selected)}") - click.echo("\nNext steps:") - click.echo(f" cd {target}") - click.echo(" uv sync") - click.echo(" cp .env.example .env") - click.echo(' alembic revision --autogenerate -m "initial schema"') - click.echo(" alembic upgrade head") - click.echo(" python main.py") - - -@main.command("create-module") -@click.argument("name") -@click.option( - "--dest", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help="Destination directory. Defaults to ./simple_module_.", -) -def create_module_cmd(name: str, dest: Path | None) -> None: - """Scaffold a publishable SimpleModule module package.""" - slug = to_kebab_case(name) - package = slug.replace("-", "_") - target = dest or Path.cwd() / f"simple_module_{package}" - - try: - create_module(target, name=name) - except FileExistsError as exc: - click.echo(f"ERROR: {exc}", err=True) - sys.exit(1) - - click.echo(f"Created module 'simple_module_{package}' at {target}") - click.echo("\nNext steps:") - click.echo(f" cd {target}") - click.echo(" uv sync --extra dev") - click.echo(" uv run pytest") - - -@main.command("gen-pages") -@click.option( - "--host-dir", - type=click.Path(file_okay=False, exists=True, path_type=Path), - default=None, - help="Path to the host's client_app directory. Defaults to ./client_app.", -) -def gen_pages(host_dir: Path | None) -> None: - """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - output = host_dir or Path.cwd() / "client_app" - if not output.is_dir(): - click.echo(f"ERROR: client_app directory not found at {output}", err=True) - sys.exit(1) - - modules = discover_modules() - written = write_module_pages_manifest(modules, output) - click.echo( - f"Wrote {written['manifest'].name}, {written['generated'].name}, " - f"{written['css'].name} to {output}" - ) - - -@main.command("sync-js-deps") -@click.option( - "--host-client-app", - type=click.Path(file_okay=False, exists=True, path_type=Path), - default=None, - help="Path to host/client_app. Defaults to ./client_app.", -) -@click.option( - "--dry-run", - is_flag=True, - default=False, - help="Print the npm install command without running it.", -) -def sync_js_deps(host_client_app: Path | None, dry_run: bool) -> None: - """Install JS deps declared by installed modules into host's node_modules. - - Walks every discovered module, reads its package.json, and runs a single - ``npm install --workspace host/client_app --save=false ``. Use - this after ``pip install``-ing a module wheel that declares JS deps; - in-repo modules already flow through npm workspaces and need nothing. - """ - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") - - output = host_client_app or Path.cwd() / "client_app" - if not output.is_dir(): - click.echo(f"ERROR: client_app directory not found at {output}", err=True) - sys.exit(1) - - modules = discover_modules() - by_module = collect_module_js_deps(modules) - if not by_module: - click.echo("No module JS dependencies declared.") - return - - # Flatten into a single spec list. npm's own resolver handles conflicts. - specs: list[str] = [] - for mod_name in sorted(by_module): - for dep, rng in sorted(by_module[mod_name].items()): - specs.append(f"{dep}@{rng}") - # Dedupe while preserving first-seen order. - deduped: list[str] = [] - seen: set[str] = set() - for spec in specs: - if spec not in seen: - seen.add(spec) - deduped.append(spec) - - npm = shutil.which("npm") - if npm is None: - click.echo("ERROR: npm not found on PATH.", err=True) - sys.exit(1) - - # Workspace path is relative to the repo root — derive it from output. - repo_root = repo_root_from_client_app(output) - try: - workspace = str(output.resolve().relative_to(repo_root)) - except ValueError: - workspace = str(output.resolve()) - - cmd = [ - npm, - "install", - "--workspace", - workspace, - "--save=false", - "--no-audit", - "--no-fund", - *deduped, - ] - click.echo("Installing module JS deps:") - for spec in deduped: - click.echo(f" {spec}") - if dry_run: - click.echo("(dry-run) " + " ".join(cmd)) - return - result = subprocess.run(cmd, cwd=repo_root, check=False) - sys.exit(result.returncode) - - -# `sm new` lives in its own module (catalog + wizard + recipes wiring). -from simple_module.new import new_project as _new_project # noqa: E402 - -main.add_command(_new_project) - - -if __name__ == "__main__": - main() From a6a0e8171fe665d64828933be25b0c9c16974ef8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:07:57 +0200 Subject: [PATCH 18/24] feat(cli): plugin discovery via simple_module.cli_plugins entry points discover_and_mount() walks the entry-point group, validates each load target is a typer.Typer, and mounts it under its entry name. Broken or duplicate plugins log one line to stderr and are skipped. --- framework/cli/simple_module/plugins.py | 54 ++++++++++- framework/cli/tests/test_plugin_discovery.py | 98 ++++++++++++++++++++ 2 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 framework/cli/tests/test_plugin_discovery.py diff --git a/framework/cli/simple_module/plugins.py b/framework/cli/simple_module/plugins.py index a764d471..5f6a2342 100644 --- a/framework/cli/simple_module/plugins.py +++ b/framework/cli/simple_module/plugins.py @@ -1,13 +1,57 @@ -"""Plugin discovery via ``simple_module.cli_plugins`` entry points. +"""Plugin discovery for ``sm`` via the ``simple_module.cli_plugins`` group. -Real implementation lands in Task 6. For now this is a no-op so the -root Typer app imports cleanly. +Each entry-point's value (``module:attr``) must resolve to a +:class:`typer.Typer` instance. The entry-point name becomes the +subcommand namespace under ``sm`` (e.g. ``sm host gen-pages``). + +Failed loads (broken import, wrong type) print one line to stderr and +are skipped — ``sm`` keeps working with whatever else loads. """ from __future__ import annotations +import sys +from collections.abc import Iterator +from importlib.metadata import EntryPoint, entry_points + import typer +__all__ = ["discover_and_mount"] + +_GROUP = "simple_module.cli_plugins" + + +def _iter_plugin_entries() -> Iterator[EntryPoint]: + """Indirection point for tests to inject fake entry points.""" + yield from entry_points(group=_GROUP) + -def discover_and_mount(app: typer.Typer) -> None: - """No-op stub. Implemented in Task 6.""" +def discover_and_mount(root: typer.Typer) -> None: + """Mount every installed plugin under its entry-point name.""" + seen: set[str] = set() + for entry in _iter_plugin_entries(): + if entry.name in seen: + print( + f"[simple-module] duplicate plugin subgroup '{entry.name}' " + f"from {entry.value!r}; keeping first registration.", + file=sys.stderr, + ) + continue + try: + plugin_app = entry.load() + except Exception as exc: # noqa: BLE001 — plugin authors can fail in any way + print( + f"[simple-module] failed to load plugin '{entry.name}' " + f"({entry.value}): {exc}", + file=sys.stderr, + ) + continue + if not isinstance(plugin_app, typer.Typer): + print( + f"[simple-module] plugin '{entry.name}' did not export a " + f"typer.Typer instance (got {type(plugin_app).__name__}); skipping.", + file=sys.stderr, + ) + continue + root.add_typer(plugin_app, name=entry.name) + seen.add(entry.name) diff --git a/framework/cli/tests/test_plugin_discovery.py b/framework/cli/tests/test_plugin_discovery.py new file mode 100644 index 00000000..a20d44dc --- /dev/null +++ b/framework/cli/tests/test_plugin_discovery.py @@ -0,0 +1,98 @@ +"""Tests for entry-point-based plugin discovery.""" + +from __future__ import annotations + +import textwrap +from importlib.metadata import EntryPoint + +import pytest +import typer +from typer.testing import CliRunner + +from simple_module.plugins import discover_and_mount + + +def _make_entry(name: str, module_attr: str) -> EntryPoint: + return EntryPoint(name=name, value=module_attr, group="simple_module.cli_plugins") + + +@pytest.fixture +def fake_plugin_module(tmp_path, monkeypatch): + """Create a tiny package on sys.path that exports a Typer ``app``.""" + import sys + + pkg_dir = tmp_path / "fake_sm_plugin" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text( + textwrap.dedent( + """ + import typer + app = typer.Typer(help="Fake plugin.") + + @app.command("ping") + def ping(): + typer.echo("pong-from-fake") + """ + ) + ) + monkeypatch.syspath_prepend(str(tmp_path)) + yield "fake_sm_plugin:app" + sys.modules.pop("fake_sm_plugin", None) + + +def test_discover_mounts_valid_plugin(monkeypatch, fake_plugin_module) -> None: + monkeypatch.setattr( + "simple_module.plugins._iter_plugin_entries", + lambda: [_make_entry("fake", fake_plugin_module)], + ) + root = typer.Typer() + discover_and_mount(root) + + runner = CliRunner() + result = runner.invoke(root, ["fake", "ping"]) + assert result.exit_code == 0, result.output + assert "pong-from-fake" in result.output + + +def _root_with_builtin() -> typer.Typer: + """Typer requires at least one command before --help works.""" + root = typer.Typer() + + @root.command("noop") + def _noop() -> None: + pass + + return root + + +def test_discover_skips_broken_plugin(monkeypatch, capsys) -> None: + bad = _make_entry("broken", "nonexistent_module:app") + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: [bad]) + root = _root_with_builtin() + discover_and_mount(root) # should not raise + + captured = capsys.readouterr() + assert "failed to load plugin 'broken'" in captured.err + + runner = CliRunner() + result = runner.invoke(root, ["broken"]) + assert result.exit_code != 0 + + +def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, capsys) -> None: + a = _make_entry("dup", fake_plugin_module) + b = _make_entry("dup", fake_plugin_module) + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: [a, b]) + root = typer.Typer() + discover_and_mount(root) + captured = capsys.readouterr() + assert "duplicate" in captured.err.lower() or "already" in captured.err.lower() + + +def test_discover_with_no_plugins_is_noop(monkeypatch) -> None: + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: []) + root = _root_with_builtin() + discover_and_mount(root) + runner = CliRunner() + result = runner.invoke(root, ["--help"]) + assert result.exit_code == 0 From a241ba1b851955b3a0ae53552707a54026d59c4b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:09:15 +0200 Subject: [PATCH 19/24] feat(hosting): host_cli plugin (sm host gen-pages, sm host sync-js-deps) gen-pages and sync-js-deps move out of the deleted sm console script and into a Typer plugin published under the simple_module.cli_plugins entry-point group. Makefile updated for the new sm host * shape. --- Makefile | 4 +- framework/hosting/pyproject.toml | 3 + .../hosting/simple_module_hosting/host_cli.py | 117 ++++++++++++++++++ framework/hosting/tests/test_host_cli.py | 31 +++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 framework/hosting/simple_module_hosting/host_cli.py create mode 100644 framework/hosting/tests/test_host_cli.py diff --git a/Makefile b/Makefile index 3ee7da3e..1ab28bc4 100644 --- a/Makefile +++ b/Makefile @@ -25,12 +25,12 @@ dev-ui: # Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} from installed modules. gen-pages: - uv run --project host sm gen-pages --host-dir=host/client_app + uv run --project host sm host gen-pages --host-dir=host/client_app # Install JS deps declared by installed modules into host/client_app/node_modules. # Wheel-installed modules need this; in-repo workspace modules do not. sync-module-deps: - uv run --project host sm sync-js-deps --host-client-app=host/client_app + uv run --project host sm host sync-js-deps --host-client-app=host/client_app # Build build: diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index 20f12811..7cbadd3a 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -34,6 +34,9 @@ dependencies = [ "uvicorn[standard]>=0.34", ] +[project.entry-points."simple_module.cli_plugins"] +host = "simple_module_hosting.host_cli:app" + [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" Repository = "https://github.com/antosubash/simple_module_python" diff --git a/framework/hosting/simple_module_hosting/host_cli.py b/framework/hosting/simple_module_hosting/host_cli.py new file mode 100644 index 00000000..a386cb56 --- /dev/null +++ b/framework/hosting/simple_module_hosting/host_cli.py @@ -0,0 +1,117 @@ +"""``sm host`` plugin — project-time helpers exposed through the simple-module CLI. + +Commands here need module discovery (``simple_module_core.discover_modules``) +and the manifest helpers; they're not part of the standalone scaffolder. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from pathlib import Path +from typing import Annotated + +import typer +from simple_module_core import discover_modules + +from simple_module_hosting.manifest import ( + collect_module_js_deps, + repo_root_from_client_app, + write_module_pages_manifest, +) + +app = typer.Typer( + help="Project-time helpers (frontend pages manifest, module JS dep sync).", + no_args_is_help=True, +) + + +@app.command("gen-pages") +def gen_pages( + host_dir: Annotated[ + Path, + typer.Option( + "--host-dir", + help="Path to the host's client_app directory. Defaults to ./client_app.", + ), + ] = Path("client_app"), +) -> None: + """Regenerate client_app/modules.{manifest.json,generated.ts,generated.css}.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_dir.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_dir}", err=True) + raise typer.Exit(code=1) + modules = discover_modules() + written = write_module_pages_manifest(modules, host_dir) + typer.echo( + f"Wrote {written['manifest'].name}, {written['generated'].name}, " + f"{written['css'].name} to {host_dir}" + ) + + +@app.command("sync-js-deps") +def sync_js_deps( + host_client_app: Annotated[ + Path, + typer.Option( + "--host-client-app", + help="Path to host/client_app. Defaults to ./client_app.", + ), + ] = Path("client_app"), + dry_run: Annotated[ + bool, typer.Option("--dry-run", help="Print the npm install command only.") + ] = False, +) -> None: + """Install JS deps declared by installed modules into host's node_modules.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if not host_client_app.is_dir(): + typer.echo(f"ERROR: client_app directory not found at {host_client_app}", err=True) + raise typer.Exit(code=1) + + modules = discover_modules() + by_module = collect_module_js_deps(modules) + if not by_module: + typer.echo("No module JS dependencies declared.") + return + + specs: list[str] = [] + for mod_name in sorted(by_module): + for dep, rng in sorted(by_module[mod_name].items()): + specs.append(f"{dep}@{rng}") + deduped: list[str] = [] + seen: set[str] = set() + for spec in specs: + if spec not in seen: + seen.add(spec) + deduped.append(spec) + + npm = shutil.which("npm") + if npm is None: + typer.echo("ERROR: npm not found on PATH.", err=True) + raise typer.Exit(code=1) + + repo_root = repo_root_from_client_app(host_client_app) + try: + workspace = str(host_client_app.resolve().relative_to(repo_root)) + except ValueError: + workspace = str(host_client_app.resolve()) + + cmd = [ + npm, + "install", + "--workspace", + workspace, + "--save=false", + "--no-audit", + "--no-fund", + *deduped, + ] + typer.echo("Installing module JS deps:") + for spec in deduped: + typer.echo(f" {spec}") + if dry_run: + typer.echo("(dry-run) " + " ".join(cmd)) + return + result = subprocess.run(cmd, cwd=repo_root, check=False) + raise typer.Exit(code=result.returncode) diff --git a/framework/hosting/tests/test_host_cli.py b/framework/hosting/tests/test_host_cli.py new file mode 100644 index 00000000..0ee307a5 --- /dev/null +++ b/framework/hosting/tests/test_host_cli.py @@ -0,0 +1,31 @@ +"""Smoke tests for the simple_module_hosting host_cli Typer plugin.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from typer.testing import CliRunner + +from simple_module_hosting.host_cli import app + + +def test_app_is_typer_instance() -> None: + assert isinstance(app, typer.Typer) + + +def test_help_lists_gen_pages_and_sync_js_deps() -> None: + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "gen-pages" in result.output + assert "sync-js-deps" in result.output + + +def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke( + app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")] + ) + assert result.exit_code != 0 + assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() From f00871abd63b313af62bd4828ef4569f6327bfbe Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:14:41 +0200 Subject: [PATCH 20/24] refactor(modules): convert users + settings to sm plugins Drops sm-users and sm-settings console scripts. Both modules now register Typer apps under the simple_module.cli_plugins entry-point group, mounted as \`sm users\` and \`sm settings\`. settings/cli.py rewritten as a Typer app (was hand-rolled Click-style argv parsing). README updated for the new command shape; pytest testpaths gain framework/cli/tests. --- README.md | 4 +-- framework/cli/tests/__init__.py | 0 modules/settings/pyproject.toml | 5 +-- modules/settings/settings/cli.py | 53 +++++++++++--------------------- modules/users/pyproject.toml | 4 +-- pyproject.toml | 2 +- 6 files changed, 26 insertions(+), 42 deletions(-) delete mode 100644 framework/cli/tests/__init__.py diff --git a/README.md b/README.md index 6ab80041..00246c5d 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Power users can still override the following bootstrap knobs via env if needed: All module-level settings — users, SMTP, Celery broker, file storage backend, etc. — live in the admin UI. After upgrading an existing deployment, run once: ```bash -uv run sm-settings import-from-env +uv run sm settings import-from-env ``` to seed DB overrides from the current `SM_*` environment. @@ -135,7 +135,7 @@ See `framework-conventions.md` for the settings-per-module convention. Either use the CLI: ```bash -uv run sm-users create-admin --email admin@example.com --password changeme +uv run sm users create-admin --email admin@example.com --password changeme ``` Or let the app bootstrap it automatically on first boot by setting env vars **before** running `make migrate && make dev`: diff --git a/framework/cli/tests/__init__.py b/framework/cli/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/settings/pyproject.toml b/modules/settings/pyproject.toml index c9ad9c80..60393b14 100644 --- a/modules/settings/pyproject.toml +++ b/modules/settings/pyproject.toml @@ -24,13 +24,14 @@ dependencies = [ "simple_module_core==0.0.1", "simple_module_db==0.0.1", "simple_module_hosting==0.0.1", + "typer>=0.12", ] [project.entry-points.simple_module] settings = "settings.module:SettingsModule" -[project.scripts] -sm-settings = "settings.cli:main" +[project.entry-points."simple_module.cli_plugins"] +settings = "settings.cli:app" [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" diff --git a/modules/settings/settings/cli.py b/modules/settings/settings/cli.py index eff67039..217d11d0 100644 --- a/modules/settings/settings/cli.py +++ b/modules/settings/settings/cli.py @@ -1,16 +1,16 @@ -"""``sm-settings`` CLI — currently only ``import-from-env``. +"""``sm settings`` plugin — currently only ``import-from-env``. -One-shot migration: walks every registered module's ``BaseSettings`` and, -for each field whose legacy ``SM__`` env var is set, writes -a SYSTEM-scoped override into the Settings store. +One-shot migration: walks every registered module's BaseSettings and +writes a SYSTEM-scoped override for each ``SM__`` env +var that is set. """ from __future__ import annotations import asyncio import os -import sys +import typer from fastapi import FastAPI from settings.constants import MODULE_PACKAGE @@ -18,14 +18,12 @@ from settings.hydrate import value_type_for_field from settings.store import SettingsStore +app = typer.Typer(help="Settings module administration.", no_args_is_help=True) -async def import_from_env_impl(app: FastAPI, store: SettingsStore) -> int: - """Write a SYSTEM override for every ``SM__`` env var set. - Returns the count of overrides written. Env vars that don't match a - registered field are ignored. - """ - registry = getattr(app.state, MODULE_PACKAGE).module_registry +async def import_from_env_impl(app_inst: FastAPI, store: SettingsStore) -> int: + """Write a SYSTEM override for every ``SM__`` env var set.""" + registry = getattr(app_inst.state, MODULE_PACKAGE).module_registry count = 0 for package, cls in registry.items(): prefix = env_prefix_for(package) @@ -39,40 +37,25 @@ async def import_from_env_impl(app: FastAPI, store: SettingsStore) -> int: return count -def main() -> int: - """Console-script entry point for ``sm-settings``. - - Supports a single subcommand: ``import-from-env``. - """ - argv = sys.argv[1:] - if not argv or argv[0] in ("-h", "--help"): - print("Usage: sm-settings import-from-env") - return 0 if argv else 1 - if argv[0] != "import-from-env": - print(f"Unknown command: {argv[0]}", file=sys.stderr) - print("Usage: sm-settings import-from-env", file=sys.stderr) - return 2 - +@app.command("import-from-env") +def import_from_env() -> None: + """Write SYSTEM overrides for every SM__ env var set.""" from simple_module_hosting.app_builder import create_app from simple_module_hosting.settings import Settings from settings.service import SettingService - app = create_app(Settings()) + fastapi_app = create_app(Settings()) async def run() -> int: async with ( - app.router.lifespan_context(app), - app.state.sm.db.session_factory() as session, + fastapi_app.router.lifespan_context(fastapi_app), + fastapi_app.state.sm.db.session_factory() as session, ): store = SettingsStore(SettingService(session)) - n = await import_from_env_impl(app, store) + n = await import_from_env_impl(fastapi_app, store) await session.commit() - print(f"Imported {n} override(s) from environment.") + typer.echo(f"Imported {n} override(s) from environment.") return 0 - return asyncio.run(run()) - - -if __name__ == "__main__": - sys.exit(main()) + raise typer.Exit(code=asyncio.run(run())) diff --git a/modules/users/pyproject.toml b/modules/users/pyproject.toml index 5d13b986..87c81117 100644 --- a/modules/users/pyproject.toml +++ b/modules/users/pyproject.toml @@ -37,8 +37,8 @@ dependencies = [ [project.entry-points.simple_module] users = "users.module:UsersModule" -[project.scripts] -sm-users = "users.cli:app" +[project.entry-points."simple_module.cli_plugins"] +users = "users.cli:app" [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" diff --git a/pyproject.toml b/pyproject.toml index e7f0c616..8627521c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ invalid-argument-type = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/datasets/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "modules/datasets/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", From 2fdc821f6600024a964e4f8ee29ddbf9717c47aa Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:17:37 +0200 Subject: [PATCH 21/24] refactor(cli): finish the standalone simple-module carve-out - Delete simple_module_hosting.scaffolding shim and the workspace dep on simple_module. - Add framework/cli/tests/test_no_framework_deps.py to guard against future dep drift in the standalone scaffolder. - Use enum.StrEnum (Python 3.12+) for Db / Preset. - README + Makefile reflect the final sm host/users/settings shape. --- framework/cli/simple_module/new.py | 10 +++---- framework/cli/simple_module/plugins.py | 5 ++-- framework/cli/tests/test_cli_new.py | 2 +- framework/cli/tests/test_cli_wizard.py | 3 +- framework/cli/tests/test_no_framework_deps.py | 25 ++++++++++++++++ framework/cli/tests/test_plugin_discovery.py | 5 ++-- framework/cli/tests/test_scaffolding_host.py | 20 ++++++------- .../cli/tests/test_scaffolding_module.py | 28 ++++++++--------- framework/hosting/pyproject.toml | 2 -- .../simple_module_hosting/app_builder.py | 2 +- .../simple_module_hosting/scaffolding.py | 30 ------------------- framework/hosting/tests/test_host_cli.py | 7 ++--- 12 files changed, 62 insertions(+), 77 deletions(-) create mode 100644 framework/cli/tests/test_no_framework_deps.py delete mode 100644 framework/hosting/simple_module_hosting/scaffolding.py diff --git a/framework/cli/simple_module/new.py b/framework/cli/simple_module/new.py index 38a7ec93..a8bd79ee 100644 --- a/framework/cli/simple_module/new.py +++ b/framework/cli/simple_module/new.py @@ -3,7 +3,7 @@ from __future__ import annotations import subprocess -from enum import Enum +from enum import StrEnum from pathlib import Path from typing import Annotated @@ -16,12 +16,12 @@ __all__ = ["new_project"] -class Db(str, Enum): +class Db(StrEnum): sqlite = "sqlite" postgres = "postgres" -class Preset(str, Enum): +class Preset(StrEnum): minimal = "minimal" standard = "standard" full = "full" @@ -90,9 +90,7 @@ def new_project( raise typer.Exit(code=1) from None try: - create_app_project( - target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved - ) + create_app_project(target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved) except FileExistsError as exc: typer.echo(f"ERROR: {exc}", err=True) raise typer.Exit(code=1) from exc diff --git a/framework/cli/simple_module/plugins.py b/framework/cli/simple_module/plugins.py index 5f6a2342..0dfbf6d8 100644 --- a/framework/cli/simple_module/plugins.py +++ b/framework/cli/simple_module/plugins.py @@ -39,10 +39,9 @@ def discover_and_mount(root: typer.Typer) -> None: continue try: plugin_app = entry.load() - except Exception as exc: # noqa: BLE001 — plugin authors can fail in any way + except Exception as exc: print( - f"[simple-module] failed to load plugin '{entry.name}' " - f"({entry.value}): {exc}", + f"[simple-module] failed to load plugin '{entry.name}' ({entry.value}): {exc}", file=sys.stderr, ) continue diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index eb8e8eb3..5a1e43e4 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -5,8 +5,8 @@ import json from pathlib import Path -from typer.testing import CliRunner from simple_module.cli import app +from typer.testing import CliRunner def test_sm_new_creates_app_directory(tmp_path: Path) -> None: diff --git a/framework/cli/tests/test_cli_wizard.py b/framework/cli/tests/test_cli_wizard.py index 23838f58..b5f32593 100644 --- a/framework/cli/tests/test_cli_wizard.py +++ b/framework/cli/tests/test_cli_wizard.py @@ -3,9 +3,8 @@ from __future__ import annotations import typer -from typer.testing import CliRunner - from simple_module.wizard import run_wizard +from typer.testing import CliRunner def _drive(answers: list[str]) -> tuple[str, bool, list[str], str]: diff --git a/framework/cli/tests/test_no_framework_deps.py b/framework/cli/tests/test_no_framework_deps.py new file mode 100644 index 00000000..7315d809 --- /dev/null +++ b/framework/cli/tests/test_no_framework_deps.py @@ -0,0 +1,25 @@ +"""Guard: `simple-module` distribution depends only on typer + tomlkit. + +If a future change accidentally pulls in simple_module_core, FastAPI, +SQLModel, or anything else, this test fires immediately. +""" + +from __future__ import annotations + +from importlib.metadata import distribution + + +def _normalize(req: str) -> str: + """'typer (>=0.12)' -> 'typer'. Strip version specs + extras + spaces.""" + head = req.split(";", 1)[0] + for sep in ("(", ">=", ">", "<", "==", "[", " "): + head = head.split(sep, 1)[0] + return head.strip().lower().replace("_", "-") + + +def test_simple_module_runtime_deps_are_minimal() -> None: + requires = distribution("simple-module").requires or [] + names = {_normalize(r) for r in requires} + assert names == {"typer", "tomlkit"}, ( + f"simple-module direct deps drifted; got {sorted(names)}, expected {{'typer', 'tomlkit'}}" + ) diff --git a/framework/cli/tests/test_plugin_discovery.py b/framework/cli/tests/test_plugin_discovery.py index a20d44dc..4a021a40 100644 --- a/framework/cli/tests/test_plugin_discovery.py +++ b/framework/cli/tests/test_plugin_discovery.py @@ -7,9 +7,8 @@ import pytest import typer -from typer.testing import CliRunner - from simple_module.plugins import discover_and_mount +from typer.testing import CliRunner def _make_entry(name: str, module_attr: str) -> EntryPoint: @@ -90,7 +89,7 @@ def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, c def test_discover_with_no_plugins_is_noop(monkeypatch) -> None: - monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: []) + monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", list) root = _root_with_builtin() discover_and_mount(root) runner = CliRunner() diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index 9ef2ee7d..c4870a13 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -11,7 +11,7 @@ class TestModulePagesManifest: async def test_compute_returns_existing_page_dirs(self): """Returns {ModuleName: Path} for installed modules that ship a pages/ dir.""" from simple_module_core import discover_modules - from simple_module_hosting.scaffolding import compute_module_pages + from simple_module_hosting.manifest import compute_module_pages modules = discover_modules() result = compute_module_pages(modules) @@ -26,7 +26,7 @@ async def test_compute_returns_existing_page_dirs(self): async def test_compute_skips_modules_without_pages_dir(self, tmp_path, monkeypatch): """A module whose package has no pages/ dir is omitted (not an error).""" from simple_module_core import ModuleBase, ModuleMeta - from simple_module_hosting.scaffolding import compute_module_pages + from simple_module_hosting.manifest import compute_module_pages class HeadlessMod(ModuleBase): meta = ModuleMeta(name="Headless") @@ -39,7 +39,7 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): import json from simple_module_core import discover_modules - from simple_module_hosting.scaffolding import write_module_pages_manifest + from simple_module_hosting.manifest import write_module_pages_manifest modules = discover_modules() written = write_module_pages_manifest(modules, tmp_path) @@ -75,7 +75,7 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): class TestCreateHost: async def test_creates_expected_backend_files(self, tmp_path): """create_host writes the full backend + frontend scaffold.""" - from simple_module_hosting.scaffolding import create_host + from simple_module.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo-host", modules=["Products", "Auth"]) @@ -105,7 +105,7 @@ async def test_creates_expected_backend_files(self, tmp_path): async def test_package_json_carries_host_name(self, tmp_path): """client_app/package.json has its `name` prefixed with the host name.""" - from simple_module_hosting.scaffolding import create_host + from simple_module.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="my-host", modules=[]) @@ -114,7 +114,7 @@ async def test_package_json_carries_host_name(self, tmp_path): async def test_substitutes_host_name_into_pyproject(self, tmp_path): """The host name lands in pyproject.toml's [project].name field.""" - from simple_module_hosting.scaffolding import create_host + from simple_module.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="my-acme-app", modules=[]) @@ -123,7 +123,7 @@ async def test_substitutes_host_name_into_pyproject(self, tmp_path): async def test_declares_selected_module_deps(self, tmp_path): """Each module from --with appears as a PyPI dep in pyproject.toml.""" - from simple_module_hosting.scaffolding import create_host + from simple_module.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo", modules=["Products", "Auth"]) @@ -133,7 +133,7 @@ async def test_declares_selected_module_deps(self, tmp_path): async def test_refuses_existing_non_empty_dir(self, tmp_path): """create_host aborts if the destination exists and is non-empty — no clobbering.""" - from simple_module_hosting.scaffolding import create_host + from simple_module.scaffolding import create_host dest = tmp_path / "existing" dest.mkdir() @@ -144,7 +144,7 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_env_py_uses_shared_helper(self, tmp_path): """Scaffolded migrations/env.py delegates to the shared helper, not inline logic.""" - from simple_module_hosting.scaffolding import create_host + from simple_module.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo", modules=[]) @@ -155,8 +155,8 @@ async def test_env_py_uses_shared_helper(self, tmp_path): async def test_cli_create_host_runs_end_to_end(self, tmp_path): """The Click `sm create-host` command produces a working scaffold.""" - from typer.testing import CliRunner from simple_module.cli import app + from typer.testing import CliRunner runner = CliRunner() result = runner.invoke( diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index d73e43e6..966d2e6f 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -8,7 +8,7 @@ class TestCreateModule: async def test_creates_expected_module_files(self, tmp_path): """create_module writes a PyPI-ready module package.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -28,7 +28,7 @@ async def test_creates_expected_module_files(self, tmp_path): async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): """pyproject.toml sets the entry_point and pins the framework API range.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -41,7 +41,7 @@ async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): async def test_module_py_subclasses_module_base(self, tmp_path): """The generated module.py has a ModuleBase subclass with the right Meta.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -53,7 +53,7 @@ async def test_module_py_subclasses_module_base(self, tmp_path): async def test_snake_case_derivation(self, tmp_path): """Module names with dashes, spaces, or camel case convert to snake_case packages.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-order-tracker" create_module(dest, name="OrderTracker") @@ -61,7 +61,7 @@ async def test_snake_case_derivation(self, tmp_path): async def test_refuses_existing_non_empty_dir(self, tmp_path): """create_module aborts rather than clobber an existing directory.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "existing" dest.mkdir() @@ -71,8 +71,8 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_cli_create_module_runs_end_to_end(self, tmp_path): """The Click `sm create-module` command produces a working scaffold.""" - from typer.testing import CliRunner from simple_module.cli import app + from typer.testing import CliRunner runner = CliRunner() dest = tmp_path / "simple-module-smoke" @@ -88,7 +88,7 @@ async def test_cli_create_module_runs_end_to_end(self, tmp_path): async def test_scaffold_ships_github_workflows(self, tmp_path): """Gap 8: scaffolded modules include publish.yml + ci.yml.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -100,7 +100,7 @@ async def test_scaffold_ships_github_workflows(self, tmp_path): async def test_publish_workflow_uses_trusted_publishing(self, tmp_path): """publish.yml must request OIDC token and use pypa/gh-action-pypi-publish.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -114,7 +114,7 @@ async def test_publish_workflow_uses_trusted_publishing(self, tmp_path): async def test_publish_workflow_triggers_on_version_tag(self, tmp_path): """publish.yml fires only on tag push, not every commit to main.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -124,7 +124,7 @@ async def test_publish_workflow_triggers_on_version_tag(self, tmp_path): async def test_workflows_parse_as_valid_yaml(self, tmp_path): """Both workflow files must be parseable YAML — catches template substitution bugs.""" import yaml - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -137,7 +137,7 @@ async def test_workflows_parse_as_valid_yaml(self, tmp_path): async def test_scaffold_has_pages_dir(self, tmp_path): """Gap 2b: modules intended to ship TSX pages get a pages/ dir from day one.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -147,7 +147,7 @@ async def test_scaffold_has_pages_dir(self, tmp_path): async def test_pyproject_force_includes_static_dist(self, tmp_path): """Gap 2b: pyproject.toml must ship /static/dist/ inside the wheel.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -160,7 +160,7 @@ async def test_pyproject_force_includes_static_dist(self, tmp_path): async def test_module_py_mounts_static_dist_conditionally(self, tmp_path): """Generated module.py exposes static_mounts() that tolerates a missing dist/.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -171,7 +171,7 @@ async def test_module_py_mounts_static_dist_conditionally(self, tmp_path): async def test_gitignore_excludes_built_assets(self, tmp_path): """Built JS lives in source control's blind spot; only wheels carry it.""" - from simple_module_hosting.scaffolding import create_module + from simple_module.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index 7cbadd3a..34cdffae 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "fastapi-inertia>=1.0", "httpx>=0.27", "jinja2>=3.1", - "simple_module==0.0.1", "simple_module_core==0.0.1", "simple_module_db==0.0.1", "starlette>=0.44", @@ -51,6 +50,5 @@ build-backend = "hatchling.build" packages = ["simple_module_hosting"] [tool.uv.sources] -simple_module = { workspace = true } simple_module_core = { workspace = true } simple_module_db = { workspace = true } diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py index 080ab136..7a006fed 100644 --- a/framework/hosting/simple_module_hosting/app_builder.py +++ b/framework/hosting/simple_module_hosting/app_builder.py @@ -131,7 +131,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: # Emit frontend module-pages manifest so Vite can find pages shipped # inside pip-installed module wheels. See scaffolding.py. try: - from simple_module_hosting.scaffolding import write_module_pages_manifest + from simple_module_hosting.manifest import write_module_pages_manifest client_app = _PROJECT_ROOT / "host" / "client_app" if client_app.is_dir(): diff --git a/framework/hosting/simple_module_hosting/scaffolding.py b/framework/hosting/simple_module_hosting/scaffolding.py deleted file mode 100644 index d781f461..00000000 --- a/framework/hosting/simple_module_hosting/scaffolding.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Re-exports of moved scaffolding APIs. - -The actual implementations live in :mod:`simple_module.scaffolding`. This -shim lets the in-tree hosting code keep importing from the historical -path during the migration; it is removed in the final cleanup task. -""" - -from __future__ import annotations - -from simple_module.app_project import create_app_project as create_app_project -from simple_module.scaffolding import create_host, create_module - -from simple_module_hosting.manifest import ( - collect_module_js_deps, - compute_module_pages, - read_module_package_json, - repo_root_from_client_app, - write_module_pages_manifest, -) - -__all__ = [ - "collect_module_js_deps", - "compute_module_pages", - "create_app_project", - "create_host", - "create_module", - "read_module_package_json", - "repo_root_from_client_app", - "write_module_pages_manifest", -] diff --git a/framework/hosting/tests/test_host_cli.py b/framework/hosting/tests/test_host_cli.py index 0ee307a5..f5a8b7ac 100644 --- a/framework/hosting/tests/test_host_cli.py +++ b/framework/hosting/tests/test_host_cli.py @@ -5,9 +5,8 @@ from pathlib import Path import typer -from typer.testing import CliRunner - from simple_module_hosting.host_cli import app +from typer.testing import CliRunner def test_app_is_typer_instance() -> None: @@ -24,8 +23,6 @@ def test_help_lists_gen_pages_and_sync_js_deps() -> None: def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None: runner = CliRunner() - result = runner.invoke( - app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")] - ) + result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")]) assert result.exit_code != 0 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower() From 10581761327ed70b5f373c6a26d101ba0b30d3e2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:23:13 +0200 Subject: [PATCH 22/24] refactor(cli): apply simplify findings - scripts/new_module.py uses simple_module.case.to_pascal_case instead of its private to_class_name helper. - app_project.py hoists previously-deferred catalog/recipes/scaffolding imports to module scope; the import cycle they were breaking no longer exists after the carve-out. - new_project drops the db_value/tenancy_value shadow vars; the two branches now assign the final tuple directly. --- framework/cli/simple_module/app_project.py | 7 +++---- framework/cli/simple_module/new.py | 7 +++---- scripts/new_module.py | 11 ++++------- scripts/tests/test_helpers.py | 10 +++++----- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/framework/cli/simple_module/app_project.py b/framework/cli/simple_module/app_project.py index 753aa0e6..428af733 100644 --- a/framework/cli/simple_module/app_project.py +++ b/framework/cli/simple_module/app_project.py @@ -19,6 +19,9 @@ from simple_module._env import set_env_key from simple_module.case import to_kebab_case, to_pascal_case +from simple_module.catalog import CATALOG, PRESETS, expand_deps +from simple_module.recipes import RECIPES, ScaffoldCtx +from simple_module.scaffolding import create_host __all__ = ["create_app_project"] @@ -57,10 +60,6 @@ def create_app_project( framework versions, and applies any matching post-scaffold recipes (e.g. the ``background_tasks`` recipe drops a Celery worker stack). """ - from simple_module.catalog import CATALOG, PRESETS, expand_deps - from simple_module.recipes import RECIPES, ScaffoldCtx - from simple_module.scaffolding import create_host - if target.exists() and any(target.iterdir()): raise FileExistsError( f"Destination {target} already exists and is non-empty; " diff --git a/framework/cli/simple_module/new.py b/framework/cli/simple_module/new.py index a8bd79ee..142a3f4a 100644 --- a/framework/cli/simple_module/new.py +++ b/framework/cli/simple_module/new.py @@ -68,8 +68,6 @@ def new_project( target = dest or Path.cwd() / name extra_list = [m.strip() for m in extra.split(",") if m.strip()] flag_driven = preset is not None or bool(extra_list) - db_value: str = db.value - tenancy_value: bool = tenancy if yes or flag_driven: chosen = list(PRESETS[(preset or Preset.standard).value]) + extra_list @@ -80,9 +78,10 @@ def new_project( raise typer.Exit(code=1) from exc for added_name, required_by in added: typer.echo(f"Added {added_name} (required by {required_by})") + db_final, tenancy_final = db.value, tenancy else: try: - db_value, tenancy_value, resolved = run_wizard( + db_final, tenancy_final, resolved = run_wizard( default_db=db.value, default_tenancy=tenancy ) except typer.Abort: @@ -90,7 +89,7 @@ def new_project( raise typer.Exit(code=1) from None try: - create_app_project(target, name=name, db=db_value, tenancy=tenancy_value, selected=resolved) + create_app_project(target, name=name, db=db_final, tenancy=tenancy_final, selected=resolved) except FileExistsError as exc: typer.echo(f"ERROR: {exc}", err=True) raise typer.Exit(code=1) from exc diff --git a/scripts/new_module.py b/scripts/new_module.py index a133b544..ba337aef 100644 --- a/scripts/new_module.py +++ b/scripts/new_module.py @@ -19,6 +19,8 @@ import textwrap from pathlib import Path +from simple_module.case import to_pascal_case + # Make sibling template modules importable when this file is run as a script. sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -54,11 +56,6 @@ def validate_name(name: str) -> str: return name -def to_class_name(name: str) -> str: - """Convert snake_case module name to PascalCase class name.""" - return "".join(word.capitalize() for word in name.split("_")) - - def to_singular(name: str) -> str: """Naive singularization: strip trailing 's' if present.""" if name.endswith("s") and not name.endswith("ss"): @@ -77,9 +74,9 @@ def _build_context(name: str) -> ScaffoldContext: singular = to_singular(name) return ScaffoldContext( name=name, - class_name=to_class_name(name), + class_name=to_pascal_case(name), singular=singular, - singular_class=to_class_name(singular), + singular_class=to_pascal_case(singular), pkg=name, ) diff --git a/scripts/tests/test_helpers.py b/scripts/tests/test_helpers.py index 14894dde..ff9dd9fa 100644 --- a/scripts/tests/test_helpers.py +++ b/scripts/tests/test_helpers.py @@ -8,10 +8,10 @@ from new_module import ( _insert_after_last_match, create_file, - to_class_name, to_singular, validate_name, ) +from simple_module.case import to_pascal_case class TestValidateName: @@ -41,15 +41,15 @@ def test_rejects_empty(self): validate_name("") -class TestToClassName: +class TestToPascalCase: def test_single_word(self): - assert to_class_name("orders") == "Orders" + assert to_pascal_case("orders") == "Orders" def test_two_words(self): - assert to_class_name("blog_posts") == "BlogPosts" + assert to_pascal_case("blog_posts") == "BlogPosts" def test_three_words(self): - assert to_class_name("user_role_assignments") == "UserRoleAssignments" + assert to_pascal_case("user_role_assignments") == "UserRoleAssignments" class TestToSingular: From 7d3c9d88113e5c595411a99f4151008997cba834 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:34:43 +0200 Subject: [PATCH 23/24] =?UTF-8?q?rename(cli):=20simple-module=20=E2=86=92?= =?UTF-8?q?=20simple-module-cli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PyPI distribution name 'simple-module' is already taken. Rename to 'simple-module-cli' (and importable package 'simple_module_cli'). - framework/cli/simple_module/ → framework/cli/simple_module_cli/ - All Python imports: from simple_module.X → from simple_module_cli.X - Entry-point group simple_module.cli_plugins → simple_module_cli.cli_plugins (plugin registrations in hosting / users / settings updated) - Console scripts sm / simple-module now point at simple_module_cli.cli:main - Templates package: simple_module.templates → simple_module_cli.templates - Release workflow: simple_module_cli added to publish-pypi matrix - Dep-guard test pinned to distribution('simple-module-cli') - README + CHANGELOG + hosting README refer to the new name --- .github/workflows/release.yml | 1 + CHANGELOG.md | 2 +- README.md | 2 +- framework/cli/README.md | 8 +++--- framework/cli/pyproject.toml | 10 +++---- .../__init__.py | 0 .../_env.py | 0 .../app_project.py | 10 +++---- .../case.py | 0 .../catalog.py | 0 .../cli.py | 12 ++++---- .../new.py | 6 ++-- .../plugins.py | 4 +-- .../recipes.py | 4 +-- .../scaffolding.py | 4 +-- .../templates/host/.env.example | 0 .../templates/host/.gitignore | 0 .../templates/host/Makefile | 0 .../templates/host/README.md.tpl | 0 .../background_tasks/Makefile.snippet | 0 .../background_tasks/docker-compose.yml | 0 .../_optional/background_tasks/run_worker.py | 0 .../background_tasks/worker.Dockerfile | 0 .../templates/host/alembic.ini | 0 .../templates/host/client_app/app.tsx | 0 .../templates/host/client_app/main.tsx | 0 .../host/client_app/package.json.tpl | 0 .../templates/host/client_app/pages.ts | 0 .../templates/host/client_app/pages/Error.tsx | 0 .../templates/host/client_app/styles.css | 0 .../templates/host/client_app/tsconfig.json | 0 .../templates/host/client_app/vite.config.ts | 0 .../templates/host/main.py | 0 .../templates/host/migrations/env.py | 0 .../templates/host/migrations/script.py.mako | 0 .../host/migrations/versions/.gitkeep | 0 .../templates/host/pyproject.toml.tpl | 0 .../templates/host/templates/index.html | 0 .../templates/module/.github/workflows/ci.yml | 0 .../module/.github/workflows/publish.yml.tpl | 0 .../templates/module/.gitignore | 0 .../templates/module/README.md.tpl | 0 .../templates/module/__PACKAGE__/__init__.py | 0 .../module/__PACKAGE__/endpoints/__init__.py | 0 .../module/__PACKAGE__/endpoints/api.py.tpl | 0 .../module/__PACKAGE__/module.py.tpl | 0 .../module/__PACKAGE__/pages/.gitkeep | 0 .../module/__PACKAGE__/services.py.tpl | 0 .../templates/module/package.json.tpl | 0 .../templates/module/pyproject.toml.tpl | 0 .../templates/module/tests/__init__.py | 0 .../templates/module/tests/test_module.py.tpl | 0 .../templates/module/tsconfig.json.tpl | 0 .../wizard.py | 2 +- framework/cli/tests/test_cli_catalog.py | 2 +- framework/cli/tests/test_cli_new.py | 8 +++--- framework/cli/tests/test_cli_recipes.py | 4 +-- framework/cli/tests/test_cli_wizard.py | 2 +- framework/cli/tests/test_no_framework_deps.py | 11 ++++---- framework/cli/tests/test_plugin_discovery.py | 12 ++++---- framework/cli/tests/test_scaffolding_host.py | 14 +++++----- .../cli/tests/test_scaffolding_module.py | 28 +++++++++---------- framework/hosting/README.md | 18 +++++------- framework/hosting/pyproject.toml | 2 +- modules/settings/pyproject.toml | 2 +- modules/users/pyproject.toml | 2 +- pyproject.toml | 2 +- scripts/new_module.py | 2 +- scripts/tests/test_helpers.py | 2 +- 69 files changed, 87 insertions(+), 89 deletions(-) rename framework/cli/{simple_module => simple_module_cli}/__init__.py (100%) rename framework/cli/{simple_module => simple_module_cli}/_env.py (100%) rename framework/cli/{simple_module => simple_module_cli}/app_project.py (93%) rename framework/cli/{simple_module => simple_module_cli}/case.py (100%) rename framework/cli/{simple_module => simple_module_cli}/catalog.py (100%) rename framework/cli/{simple_module => simple_module_cli}/cli.py (87%) rename framework/cli/{simple_module => simple_module_cli}/new.py (95%) rename framework/cli/{simple_module => simple_module_cli}/plugins.py (93%) rename framework/cli/{simple_module => simple_module_cli}/recipes.py (96%) rename framework/cli/{simple_module => simple_module_cli}/scaffolding.py (96%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/.env.example (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/.gitignore (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/Makefile (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/README.md.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/_optional/background_tasks/Makefile.snippet (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/_optional/background_tasks/docker-compose.yml (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/_optional/background_tasks/run_worker.py (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/_optional/background_tasks/worker.Dockerfile (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/alembic.ini (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/app.tsx (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/main.tsx (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/package.json.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/pages.ts (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/pages/Error.tsx (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/styles.css (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/tsconfig.json (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/client_app/vite.config.ts (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/main.py (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/migrations/env.py (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/migrations/script.py.mako (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/migrations/versions/.gitkeep (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/pyproject.toml.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/host/templates/index.html (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/.github/workflows/ci.yml (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/.github/workflows/publish.yml.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/.gitignore (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/README.md.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/__PACKAGE__/__init__.py (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/__PACKAGE__/endpoints/__init__.py (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/__PACKAGE__/endpoints/api.py.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/__PACKAGE__/module.py.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/__PACKAGE__/pages/.gitkeep (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/__PACKAGE__/services.py.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/package.json.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/pyproject.toml.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/tests/__init__.py (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/tests/test_module.py.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/templates/module/tsconfig.json.tpl (100%) rename framework/cli/{simple_module => simple_module_cli}/wizard.py (96%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ad8f892..e01c914d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,7 @@ jobs: fail-fast: false matrix: package: + - simple_module_cli - simple_module_core - simple_module_db - simple_module_hosting diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e620e1a..3f55ec18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Initial public release. All 14 Python packages publish to PyPI and all 3 JS pack ### Added -- `simple-module new ` / `sm new ` CLI generator scaffolding a working app with `users + dashboard + permissions` pre-wired. +- `sm new ` CLI generator (shipped via the `simple-module-cli` PyPI distribution) scaffolding a working app with `users + dashboard + permissions` pre-wired. - PyPI Trusted Publishing workflow (`.github/workflows/release.yml`) for zero-secret releases. - npm Trusted Publishing for all three JS packages. diff --git a/README.md b/README.md index 00246c5d..96110449 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont If you want to **build an app on simple_module**, not hack on the framework itself: ```bash -uvx --from simple_module_hosting simple-module new my-app +uvx --from simple-module-cli sm new my-app cd my-app make dev ``` diff --git a/framework/cli/README.md b/framework/cli/README.md index f1059b61..9402a2c8 100644 --- a/framework/cli/README.md +++ b/framework/cli/README.md @@ -1,16 +1,16 @@ -# simple-module +# simple-module-cli Standalone scaffolder for the [SimpleModule framework](https://github.com/antosubash/simple_module_python). ```bash -pip install simple-module # or: pipx install simple-module -sm new my-app # interactive wizard +pip install simple-module-cli # or: pipx install simple-module-cli +sm new my-app # interactive wizard sm new my-app --yes --preset full ``` Provides three built-in commands: `sm new`, `sm create-host`, `sm create-module`. -When other framework packages are installed, they contribute additional subcommands via the `simple_module.cli_plugins` entry-point group: +When other framework packages are installed, they contribute additional subcommands via the `simple_module_cli.cli_plugins` entry-point group: | Package | Commands | |---|---| diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml index 9913f4a5..684b391b 100644 --- a/framework/cli/pyproject.toml +++ b/framework/cli/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "simple-module" +name = "simple-module-cli" version = "0.0.1" description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." readme = "README.md" @@ -25,8 +25,8 @@ dependencies = [ ] [project.scripts] -sm = "simple_module.cli:main" -simple-module = "simple_module.cli:main" +sm = "simple_module_cli.cli:main" +simple-module = "simple_module_cli.cli:main" [project.urls] Homepage = "https://github.com/antosubash/simple_module_python" @@ -38,7 +38,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["simple_module"] +packages = ["simple_module_cli"] [tool.hatch.build.targets.wheel.shared-data] -"simple_module/templates" = "simple_module/templates" +"simple_module_cli/templates" = "simple_module_cli/templates" diff --git a/framework/cli/simple_module/__init__.py b/framework/cli/simple_module_cli/__init__.py similarity index 100% rename from framework/cli/simple_module/__init__.py rename to framework/cli/simple_module_cli/__init__.py diff --git a/framework/cli/simple_module/_env.py b/framework/cli/simple_module_cli/_env.py similarity index 100% rename from framework/cli/simple_module/_env.py rename to framework/cli/simple_module_cli/_env.py diff --git a/framework/cli/simple_module/app_project.py b/framework/cli/simple_module_cli/app_project.py similarity index 93% rename from framework/cli/simple_module/app_project.py rename to framework/cli/simple_module_cli/app_project.py index 428af733..3662ba50 100644 --- a/framework/cli/simple_module/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -17,11 +17,11 @@ from collections.abc import Sequence from pathlib import Path -from simple_module._env import set_env_key -from simple_module.case import to_kebab_case, to_pascal_case -from simple_module.catalog import CATALOG, PRESETS, expand_deps -from simple_module.recipes import RECIPES, ScaffoldCtx -from simple_module.scaffolding import create_host +from simple_module_cli._env import set_env_key +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 create_host __all__ = ["create_app_project"] diff --git a/framework/cli/simple_module/case.py b/framework/cli/simple_module_cli/case.py similarity index 100% rename from framework/cli/simple_module/case.py rename to framework/cli/simple_module_cli/case.py diff --git a/framework/cli/simple_module/catalog.py b/framework/cli/simple_module_cli/catalog.py similarity index 100% rename from framework/cli/simple_module/catalog.py rename to framework/cli/simple_module_cli/catalog.py diff --git a/framework/cli/simple_module/cli.py b/framework/cli/simple_module_cli/cli.py similarity index 87% rename from framework/cli/simple_module/cli.py rename to framework/cli/simple_module_cli/cli.py index 5f1054c2..4792dad7 100644 --- a/framework/cli/simple_module/cli.py +++ b/framework/cli/simple_module_cli/cli.py @@ -5,7 +5,7 @@ sm create-host sm create-module -Plugins discovered via the ``simple_module.cli_plugins`` entry-point +Plugins discovered via the ``simple_module_cli.cli_plugins`` entry-point group are mounted as named subgroups (e.g. ``sm host gen-pages``). """ @@ -16,11 +16,11 @@ import typer -from simple_module.case import to_kebab_case -from simple_module.new import new_project -from simple_module.plugins import discover_and_mount -from simple_module.scaffolding import create_host as _create_host -from simple_module.scaffolding import create_module as _create_module +from simple_module_cli.case import to_kebab_case +from simple_module_cli.new import new_project +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 app = typer.Typer( help="SimpleModule developer CLI.", diff --git a/framework/cli/simple_module/new.py b/framework/cli/simple_module_cli/new.py similarity index 95% rename from framework/cli/simple_module/new.py rename to framework/cli/simple_module_cli/new.py index 142a3f4a..c9295e92 100644 --- a/framework/cli/simple_module/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -9,9 +9,9 @@ import typer -from simple_module.app_project import create_app_project -from simple_module.catalog import PRESETS, expand_deps -from simple_module.wizard import run_wizard +from simple_module_cli.app_project import create_app_project +from simple_module_cli.catalog import PRESETS, expand_deps +from simple_module_cli.wizard import run_wizard __all__ = ["new_project"] diff --git a/framework/cli/simple_module/plugins.py b/framework/cli/simple_module_cli/plugins.py similarity index 93% rename from framework/cli/simple_module/plugins.py rename to framework/cli/simple_module_cli/plugins.py index 0dfbf6d8..be340876 100644 --- a/framework/cli/simple_module/plugins.py +++ b/framework/cli/simple_module_cli/plugins.py @@ -1,4 +1,4 @@ -"""Plugin discovery for ``sm`` via the ``simple_module.cli_plugins`` group. +"""Plugin discovery for ``sm`` via the ``simple_module_cli.cli_plugins`` group. Each entry-point's value (``module:attr``) must resolve to a :class:`typer.Typer` instance. The entry-point name becomes the @@ -18,7 +18,7 @@ __all__ = ["discover_and_mount"] -_GROUP = "simple_module.cli_plugins" +_GROUP = "simple_module_cli.cli_plugins" def _iter_plugin_entries() -> Iterator[EntryPoint]: diff --git a/framework/cli/simple_module/recipes.py b/framework/cli/simple_module_cli/recipes.py similarity index 96% rename from framework/cli/simple_module/recipes.py rename to framework/cli/simple_module_cli/recipes.py index 59b6af54..31aed925 100644 --- a/framework/cli/simple_module/recipes.py +++ b/framework/cli/simple_module_cli/recipes.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Protocol -from simple_module._env import set_env_key +from simple_module_cli._env import set_env_key __all__ = [ "RECIPES", @@ -44,7 +44,7 @@ def apply(self, target: Path, ctx: ScaffoldCtx) -> None: ... def _optional_template_root(name: str) -> Path: """Resolve ``templates/host/_optional//`` from package data.""" - base = importlib.resources.files("simple_module") + base = importlib.resources.files("simple_module_cli") return Path(str(base / "templates" / "host" / "_optional" / name)) diff --git a/framework/cli/simple_module/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py similarity index 96% rename from framework/cli/simple_module/scaffolding.py rename to framework/cli/simple_module_cli/scaffolding.py index 31a62a62..ae877e90 100644 --- a/framework/cli/simple_module/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -18,13 +18,13 @@ from collections.abc import Mapping, Sequence from pathlib import Path -from simple_module.case import to_kebab_case, to_pascal_case, to_snake_case +from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case __all__ = ["create_host", "create_module"] logger = logging.getLogger(__name__) -_TEMPLATES_PACKAGE = "simple_module.templates" +_TEMPLATES_PACKAGE = "simple_module_cli.templates" _PACKAGE_PATH_TOKEN = "__PACKAGE__" diff --git a/framework/cli/simple_module/templates/host/.env.example b/framework/cli/simple_module_cli/templates/host/.env.example similarity index 100% rename from framework/cli/simple_module/templates/host/.env.example rename to framework/cli/simple_module_cli/templates/host/.env.example diff --git a/framework/cli/simple_module/templates/host/.gitignore b/framework/cli/simple_module_cli/templates/host/.gitignore similarity index 100% rename from framework/cli/simple_module/templates/host/.gitignore rename to framework/cli/simple_module_cli/templates/host/.gitignore diff --git a/framework/cli/simple_module/templates/host/Makefile b/framework/cli/simple_module_cli/templates/host/Makefile similarity index 100% rename from framework/cli/simple_module/templates/host/Makefile rename to framework/cli/simple_module_cli/templates/host/Makefile diff --git a/framework/cli/simple_module/templates/host/README.md.tpl b/framework/cli/simple_module_cli/templates/host/README.md.tpl similarity index 100% rename from framework/cli/simple_module/templates/host/README.md.tpl rename to framework/cli/simple_module_cli/templates/host/README.md.tpl diff --git a/framework/cli/simple_module/templates/host/_optional/background_tasks/Makefile.snippet b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet similarity index 100% rename from framework/cli/simple_module/templates/host/_optional/background_tasks/Makefile.snippet rename to framework/cli/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet diff --git a/framework/cli/simple_module/templates/host/_optional/background_tasks/docker-compose.yml b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml similarity index 100% rename from framework/cli/simple_module/templates/host/_optional/background_tasks/docker-compose.yml rename to framework/cli/simple_module_cli/templates/host/_optional/background_tasks/docker-compose.yml diff --git a/framework/cli/simple_module/templates/host/_optional/background_tasks/run_worker.py b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py similarity index 100% rename from framework/cli/simple_module/templates/host/_optional/background_tasks/run_worker.py rename to framework/cli/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py diff --git a/framework/cli/simple_module/templates/host/_optional/background_tasks/worker.Dockerfile b/framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile similarity index 100% rename from framework/cli/simple_module/templates/host/_optional/background_tasks/worker.Dockerfile rename to framework/cli/simple_module_cli/templates/host/_optional/background_tasks/worker.Dockerfile diff --git a/framework/cli/simple_module/templates/host/alembic.ini b/framework/cli/simple_module_cli/templates/host/alembic.ini similarity index 100% rename from framework/cli/simple_module/templates/host/alembic.ini rename to framework/cli/simple_module_cli/templates/host/alembic.ini diff --git a/framework/cli/simple_module/templates/host/client_app/app.tsx b/framework/cli/simple_module_cli/templates/host/client_app/app.tsx similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/app.tsx rename to framework/cli/simple_module_cli/templates/host/client_app/app.tsx diff --git a/framework/cli/simple_module/templates/host/client_app/main.tsx b/framework/cli/simple_module_cli/templates/host/client_app/main.tsx similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/main.tsx rename to framework/cli/simple_module_cli/templates/host/client_app/main.tsx diff --git a/framework/cli/simple_module/templates/host/client_app/package.json.tpl b/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/package.json.tpl rename to framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl diff --git a/framework/cli/simple_module/templates/host/client_app/pages.ts b/framework/cli/simple_module_cli/templates/host/client_app/pages.ts similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/pages.ts rename to framework/cli/simple_module_cli/templates/host/client_app/pages.ts diff --git a/framework/cli/simple_module/templates/host/client_app/pages/Error.tsx b/framework/cli/simple_module_cli/templates/host/client_app/pages/Error.tsx similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/pages/Error.tsx rename to framework/cli/simple_module_cli/templates/host/client_app/pages/Error.tsx diff --git a/framework/cli/simple_module/templates/host/client_app/styles.css b/framework/cli/simple_module_cli/templates/host/client_app/styles.css similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/styles.css rename to framework/cli/simple_module_cli/templates/host/client_app/styles.css diff --git a/framework/cli/simple_module/templates/host/client_app/tsconfig.json b/framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/tsconfig.json rename to framework/cli/simple_module_cli/templates/host/client_app/tsconfig.json diff --git a/framework/cli/simple_module/templates/host/client_app/vite.config.ts b/framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts similarity index 100% rename from framework/cli/simple_module/templates/host/client_app/vite.config.ts rename to framework/cli/simple_module_cli/templates/host/client_app/vite.config.ts diff --git a/framework/cli/simple_module/templates/host/main.py b/framework/cli/simple_module_cli/templates/host/main.py similarity index 100% rename from framework/cli/simple_module/templates/host/main.py rename to framework/cli/simple_module_cli/templates/host/main.py diff --git a/framework/cli/simple_module/templates/host/migrations/env.py b/framework/cli/simple_module_cli/templates/host/migrations/env.py similarity index 100% rename from framework/cli/simple_module/templates/host/migrations/env.py rename to framework/cli/simple_module_cli/templates/host/migrations/env.py diff --git a/framework/cli/simple_module/templates/host/migrations/script.py.mako b/framework/cli/simple_module_cli/templates/host/migrations/script.py.mako similarity index 100% rename from framework/cli/simple_module/templates/host/migrations/script.py.mako rename to framework/cli/simple_module_cli/templates/host/migrations/script.py.mako diff --git a/framework/cli/simple_module/templates/host/migrations/versions/.gitkeep b/framework/cli/simple_module_cli/templates/host/migrations/versions/.gitkeep similarity index 100% rename from framework/cli/simple_module/templates/host/migrations/versions/.gitkeep rename to framework/cli/simple_module_cli/templates/host/migrations/versions/.gitkeep diff --git a/framework/cli/simple_module/templates/host/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl similarity index 100% rename from framework/cli/simple_module/templates/host/pyproject.toml.tpl rename to framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl diff --git a/framework/cli/simple_module/templates/host/templates/index.html b/framework/cli/simple_module_cli/templates/host/templates/index.html similarity index 100% rename from framework/cli/simple_module/templates/host/templates/index.html rename to framework/cli/simple_module_cli/templates/host/templates/index.html diff --git a/framework/cli/simple_module/templates/module/.github/workflows/ci.yml b/framework/cli/simple_module_cli/templates/module/.github/workflows/ci.yml similarity index 100% rename from framework/cli/simple_module/templates/module/.github/workflows/ci.yml rename to framework/cli/simple_module_cli/templates/module/.github/workflows/ci.yml diff --git a/framework/cli/simple_module/templates/module/.github/workflows/publish.yml.tpl b/framework/cli/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/.github/workflows/publish.yml.tpl rename to framework/cli/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl diff --git a/framework/cli/simple_module/templates/module/.gitignore b/framework/cli/simple_module_cli/templates/module/.gitignore similarity index 100% rename from framework/cli/simple_module/templates/module/.gitignore rename to framework/cli/simple_module_cli/templates/module/.gitignore diff --git a/framework/cli/simple_module/templates/module/README.md.tpl b/framework/cli/simple_module_cli/templates/module/README.md.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/README.md.tpl rename to framework/cli/simple_module_cli/templates/module/README.md.tpl diff --git a/framework/cli/simple_module/templates/module/__PACKAGE__/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/__init__.py similarity index 100% rename from framework/cli/simple_module/templates/module/__PACKAGE__/__init__.py rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/__init__.py diff --git a/framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/__init__.py b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py similarity index 100% rename from framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/__init__.py rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py diff --git a/framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/api.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/__PACKAGE__/endpoints/api.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl diff --git a/framework/cli/simple_module/templates/module/__PACKAGE__/module.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/__PACKAGE__/module.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl diff --git a/framework/cli/simple_module/templates/module/__PACKAGE__/pages/.gitkeep b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep similarity index 100% rename from framework/cli/simple_module/templates/module/__PACKAGE__/pages/.gitkeep rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/pages/.gitkeep diff --git a/framework/cli/simple_module/templates/module/__PACKAGE__/services.py.tpl b/framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/__PACKAGE__/services.py.tpl rename to framework/cli/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl diff --git a/framework/cli/simple_module/templates/module/package.json.tpl b/framework/cli/simple_module_cli/templates/module/package.json.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/package.json.tpl rename to framework/cli/simple_module_cli/templates/module/package.json.tpl diff --git a/framework/cli/simple_module/templates/module/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/pyproject.toml.tpl rename to framework/cli/simple_module_cli/templates/module/pyproject.toml.tpl diff --git a/framework/cli/simple_module/templates/module/tests/__init__.py b/framework/cli/simple_module_cli/templates/module/tests/__init__.py similarity index 100% rename from framework/cli/simple_module/templates/module/tests/__init__.py rename to framework/cli/simple_module_cli/templates/module/tests/__init__.py diff --git a/framework/cli/simple_module/templates/module/tests/test_module.py.tpl b/framework/cli/simple_module_cli/templates/module/tests/test_module.py.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/tests/test_module.py.tpl rename to framework/cli/simple_module_cli/templates/module/tests/test_module.py.tpl diff --git a/framework/cli/simple_module/templates/module/tsconfig.json.tpl b/framework/cli/simple_module_cli/templates/module/tsconfig.json.tpl similarity index 100% rename from framework/cli/simple_module/templates/module/tsconfig.json.tpl rename to framework/cli/simple_module_cli/templates/module/tsconfig.json.tpl diff --git a/framework/cli/simple_module/wizard.py b/framework/cli/simple_module_cli/wizard.py similarity index 96% rename from framework/cli/simple_module/wizard.py rename to framework/cli/simple_module_cli/wizard.py index 4b53ed29..a83edc12 100644 --- a/framework/cli/simple_module/wizard.py +++ b/framework/cli/simple_module_cli/wizard.py @@ -4,7 +4,7 @@ import typer -from simple_module.catalog import CATALOG, PRESETS, expand_deps +from simple_module_cli.catalog import CATALOG, PRESETS, expand_deps __all__ = ["run_wizard"] diff --git a/framework/cli/tests/test_cli_catalog.py b/framework/cli/tests/test_cli_catalog.py index f7cdc49d..dd8c0188 100644 --- a/framework/cli/tests/test_cli_catalog.py +++ b/framework/cli/tests/test_cli_catalog.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from simple_module.catalog import ( +from simple_module_cli.catalog import ( CATALOG, PRESETS, ModuleEntry, diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 5a1e43e4..e82a966f 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -5,7 +5,7 @@ import json from pathlib import Path -from simple_module.cli import app +from simple_module_cli.cli import app from typer.testing import CliRunner @@ -65,7 +65,7 @@ def test_sm_new_writes_generated_secret_key(tmp_path: Path) -> None: def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: - from simple_module.app_project import create_app_project + from simple_module_cli.app_project import create_app_project target = tmp_path / "demo" create_app_project( @@ -83,7 +83,7 @@ def test_create_app_project_with_selected_kwarg(tmp_path: Path) -> None: def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> None: - from simple_module.app_project import create_app_project + from simple_module_cli.app_project import create_app_project target = tmp_path / "demo" create_app_project( @@ -102,7 +102,7 @@ def test_create_app_project_runs_recipe_for_background_tasks(tmp_path: Path) -> def test_create_app_project_default_selected_keeps_back_compat(tmp_path: Path) -> None: - from simple_module.app_project import create_app_project + from simple_module_cli.app_project import create_app_project target = tmp_path / "demo" create_app_project(target, name="demo", db="sqlite", tenancy=False) diff --git a/framework/cli/tests/test_cli_recipes.py b/framework/cli/tests/test_cli_recipes.py index 3c3f4f34..bd6ab95d 100644 --- a/framework/cli/tests/test_cli_recipes.py +++ b/framework/cli/tests/test_cli_recipes.py @@ -5,12 +5,12 @@ from pathlib import Path import pytest -from simple_module.recipes import ( +from simple_module_cli.recipes import ( RECIPES, BackgroundTasksRecipe, ScaffoldCtx, ) -from simple_module.scaffolding import create_host +from simple_module_cli.scaffolding import create_host def _scaffold_minimal_host(target: Path) -> None: diff --git a/framework/cli/tests/test_cli_wizard.py b/framework/cli/tests/test_cli_wizard.py index b5f32593..53cc6bcd 100644 --- a/framework/cli/tests/test_cli_wizard.py +++ b/framework/cli/tests/test_cli_wizard.py @@ -3,7 +3,7 @@ from __future__ import annotations import typer -from simple_module.wizard import run_wizard +from simple_module_cli.wizard import run_wizard from typer.testing import CliRunner diff --git a/framework/cli/tests/test_no_framework_deps.py b/framework/cli/tests/test_no_framework_deps.py index 7315d809..c30de080 100644 --- a/framework/cli/tests/test_no_framework_deps.py +++ b/framework/cli/tests/test_no_framework_deps.py @@ -1,4 +1,4 @@ -"""Guard: `simple-module` distribution depends only on typer + tomlkit. +"""Guard: `simple-module-cli` distribution depends only on typer + tomlkit. If a future change accidentally pulls in simple_module_core, FastAPI, SQLModel, or anything else, this test fires immediately. @@ -17,9 +17,10 @@ def _normalize(req: str) -> str: return head.strip().lower().replace("_", "-") -def test_simple_module_runtime_deps_are_minimal() -> None: - requires = distribution("simple-module").requires or [] +def test_simple_module_cli_runtime_deps_are_minimal() -> None: + requires = distribution("simple-module-cli").requires or [] names = {_normalize(r) for r in requires} - assert names == {"typer", "tomlkit"}, ( - f"simple-module direct deps drifted; got {sorted(names)}, expected {{'typer', 'tomlkit'}}" + expected = {"typer", "tomlkit"} + assert names == expected, ( + f"simple-module-cli direct deps drifted; got {sorted(names)}, expected {sorted(expected)}" ) diff --git a/framework/cli/tests/test_plugin_discovery.py b/framework/cli/tests/test_plugin_discovery.py index 4a021a40..05aaa69c 100644 --- a/framework/cli/tests/test_plugin_discovery.py +++ b/framework/cli/tests/test_plugin_discovery.py @@ -7,12 +7,12 @@ import pytest import typer -from simple_module.plugins import discover_and_mount +from simple_module_cli.plugins import discover_and_mount from typer.testing import CliRunner def _make_entry(name: str, module_attr: str) -> EntryPoint: - return EntryPoint(name=name, value=module_attr, group="simple_module.cli_plugins") + return EntryPoint(name=name, value=module_attr, group="simple_module_cli.cli_plugins") @pytest.fixture @@ -41,7 +41,7 @@ def ping(): def test_discover_mounts_valid_plugin(monkeypatch, fake_plugin_module) -> None: monkeypatch.setattr( - "simple_module.plugins._iter_plugin_entries", + "simple_module_cli.plugins._iter_plugin_entries", lambda: [_make_entry("fake", fake_plugin_module)], ) root = typer.Typer() @@ -66,7 +66,7 @@ def _noop() -> None: def test_discover_skips_broken_plugin(monkeypatch, capsys) -> None: bad = _make_entry("broken", "nonexistent_module:app") - monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: [bad]) + monkeypatch.setattr("simple_module_cli.plugins._iter_plugin_entries", lambda: [bad]) root = _root_with_builtin() discover_and_mount(root) # should not raise @@ -81,7 +81,7 @@ def test_discover_skips_broken_plugin(monkeypatch, capsys) -> None: def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, capsys) -> None: a = _make_entry("dup", fake_plugin_module) b = _make_entry("dup", fake_plugin_module) - monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", lambda: [a, b]) + monkeypatch.setattr("simple_module_cli.plugins._iter_plugin_entries", lambda: [a, b]) root = typer.Typer() discover_and_mount(root) captured = capsys.readouterr() @@ -89,7 +89,7 @@ def test_discover_warns_on_duplicate_subgroup(monkeypatch, fake_plugin_module, c def test_discover_with_no_plugins_is_noop(monkeypatch) -> None: - monkeypatch.setattr("simple_module.plugins._iter_plugin_entries", list) + monkeypatch.setattr("simple_module_cli.plugins._iter_plugin_entries", list) root = _root_with_builtin() discover_and_mount(root) runner = CliRunner() diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index c4870a13..c256f70e 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -75,7 +75,7 @@ async def test_write_manifest_emits_json_and_ts(self, tmp_path): class TestCreateHost: async def test_creates_expected_backend_files(self, tmp_path): """create_host writes the full backend + frontend scaffold.""" - from simple_module.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo-host", modules=["Products", "Auth"]) @@ -105,7 +105,7 @@ async def test_creates_expected_backend_files(self, tmp_path): async def test_package_json_carries_host_name(self, tmp_path): """client_app/package.json has its `name` prefixed with the host name.""" - from simple_module.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="my-host", modules=[]) @@ -114,7 +114,7 @@ async def test_package_json_carries_host_name(self, tmp_path): async def test_substitutes_host_name_into_pyproject(self, tmp_path): """The host name lands in pyproject.toml's [project].name field.""" - from simple_module.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="my-acme-app", modules=[]) @@ -123,7 +123,7 @@ async def test_substitutes_host_name_into_pyproject(self, tmp_path): async def test_declares_selected_module_deps(self, tmp_path): """Each module from --with appears as a PyPI dep in pyproject.toml.""" - from simple_module.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo", modules=["Products", "Auth"]) @@ -133,7 +133,7 @@ async def test_declares_selected_module_deps(self, tmp_path): async def test_refuses_existing_non_empty_dir(self, tmp_path): """create_host aborts if the destination exists and is non-empty — no clobbering.""" - from simple_module.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "existing" dest.mkdir() @@ -144,7 +144,7 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_env_py_uses_shared_helper(self, tmp_path): """Scaffolded migrations/env.py delegates to the shared helper, not inline logic.""" - from simple_module.scaffolding import create_host + from simple_module_cli.scaffolding import create_host dest = tmp_path / "demo" create_host(dest, name="demo", modules=[]) @@ -155,7 +155,7 @@ async def test_env_py_uses_shared_helper(self, tmp_path): async def test_cli_create_host_runs_end_to_end(self, tmp_path): """The Click `sm create-host` command produces a working scaffold.""" - from simple_module.cli import app + from simple_module_cli.cli import app from typer.testing import CliRunner runner = CliRunner() diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index 966d2e6f..ece250ff 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -8,7 +8,7 @@ class TestCreateModule: async def test_creates_expected_module_files(self, tmp_path): """create_module writes a PyPI-ready module package.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -28,7 +28,7 @@ async def test_creates_expected_module_files(self, tmp_path): async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): """pyproject.toml sets the entry_point and pins the framework API range.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -41,7 +41,7 @@ async def test_pyproject_declares_entry_point_and_deps(self, tmp_path): async def test_module_py_subclasses_module_base(self, tmp_path): """The generated module.py has a ModuleBase subclass with the right Meta.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-my-feature" create_module(dest, name="MyFeature") @@ -53,7 +53,7 @@ async def test_module_py_subclasses_module_base(self, tmp_path): async def test_snake_case_derivation(self, tmp_path): """Module names with dashes, spaces, or camel case convert to snake_case packages.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-order-tracker" create_module(dest, name="OrderTracker") @@ -61,7 +61,7 @@ async def test_snake_case_derivation(self, tmp_path): async def test_refuses_existing_non_empty_dir(self, tmp_path): """create_module aborts rather than clobber an existing directory.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "existing" dest.mkdir() @@ -71,7 +71,7 @@ async def test_refuses_existing_non_empty_dir(self, tmp_path): async def test_cli_create_module_runs_end_to_end(self, tmp_path): """The Click `sm create-module` command produces a working scaffold.""" - from simple_module.cli import app + from simple_module_cli.cli import app from typer.testing import CliRunner runner = CliRunner() @@ -88,7 +88,7 @@ async def test_cli_create_module_runs_end_to_end(self, tmp_path): async def test_scaffold_ships_github_workflows(self, tmp_path): """Gap 8: scaffolded modules include publish.yml + ci.yml.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -100,7 +100,7 @@ async def test_scaffold_ships_github_workflows(self, tmp_path): async def test_publish_workflow_uses_trusted_publishing(self, tmp_path): """publish.yml must request OIDC token and use pypa/gh-action-pypi-publish.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -114,7 +114,7 @@ async def test_publish_workflow_uses_trusted_publishing(self, tmp_path): async def test_publish_workflow_triggers_on_version_tag(self, tmp_path): """publish.yml fires only on tag push, not every commit to main.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -124,7 +124,7 @@ async def test_publish_workflow_triggers_on_version_tag(self, tmp_path): async def test_workflows_parse_as_valid_yaml(self, tmp_path): """Both workflow files must be parseable YAML — catches template substitution bugs.""" import yaml - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -137,7 +137,7 @@ async def test_workflows_parse_as_valid_yaml(self, tmp_path): async def test_scaffold_has_pages_dir(self, tmp_path): """Gap 2b: modules intended to ship TSX pages get a pages/ dir from day one.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -147,7 +147,7 @@ async def test_scaffold_has_pages_dir(self, tmp_path): async def test_pyproject_force_includes_static_dist(self, tmp_path): """Gap 2b: pyproject.toml must ship /static/dist/ inside the wheel.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -160,7 +160,7 @@ async def test_pyproject_force_includes_static_dist(self, tmp_path): async def test_module_py_mounts_static_dist_conditionally(self, tmp_path): """Generated module.py exposes static_mounts() that tolerates a missing dist/.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") @@ -171,7 +171,7 @@ async def test_module_py_mounts_static_dist_conditionally(self, tmp_path): async def test_gitignore_excludes_built_assets(self, tmp_path): """Built JS lives in source control's blind spot; only wheels carry it.""" - from simple_module.scaffolding import create_module + from simple_module_cli.scaffolding import create_module dest = tmp_path / "simple-module-widget" create_module(dest, name="Widget") diff --git a/framework/hosting/README.md b/framework/hosting/README.md index 0d5fea0f..e4855dcf 100644 --- a/framework/hosting/README.md +++ b/framework/hosting/README.md @@ -1,6 +1,6 @@ # simple_module_hosting -FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/antosubash/simple_module_python) framework — builds the app, wires the middleware pipeline, exposes the `sm` / `simple-module` CLI, and ships the project scaffolder. +FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/antosubash/simple_module_python) framework — builds the app, wires the middleware pipeline, and contributes the `sm host` plugin to the standalone `sm` CLI. ## Install @@ -8,10 +8,10 @@ FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/ant pip install simple_module_hosting ``` -For a new project, most users run the generator instead: +For a new project, most users run the generator instead (shipped by the standalone `simple-module-cli` distribution): ```bash -uvx simple-module new my-app +uvx --from simple-module-cli sm new my-app ``` ## What it provides @@ -19,8 +19,7 @@ uvx simple-module new my-app - `create_app(settings)` — returns a fully-wired `FastAPI` instance with all discovered modules registered. - Middleware pipeline (execution order): CorrelationId → RequestLogging → SecurityHeaders → Session → `` → Tenant (opt-in) → Locale → InertiaLayoutData → app. - Inertia wiring — shared props (`auth`, `menus`, `i18n`), `InertiaDep`, page-route lookup. -- CLI entry points: both `sm` and `simple-module` are installed and alias the same Click tree. -- Scaffolders — `sm create-host`, `sm create-module`, `sm new` (greenfield app with users + dashboard + permissions pre-wired), `sm gen-pages`. +- `sm host` plugin — `sm host gen-pages` regenerates the frontend pages manifest; `sm host sync-js-deps` installs JS deps declared by installed modules. The `sm` binary itself comes from `simple-module-cli`. ## Usage @@ -38,16 +37,13 @@ if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) ``` -CLI: +CLI (after also installing `simple-module-cli`): ```bash -simple-module new my-app # scaffold a new project -simple-module doctor # diagnostic codes (SM001-SM017) -simple-module gen-pages # regenerate client_app/modules.generated.ts +sm host gen-pages # regenerate client_app/modules.generated.ts +sm host sync-js-deps # sync module JS deps into client_app/node_modules ``` -`sm` works identically to `simple-module`. - ## Depends on - `simple_module_core`, `simple_module_db` diff --git a/framework/hosting/pyproject.toml b/framework/hosting/pyproject.toml index 34cdffae..b8513000 100644 --- a/framework/hosting/pyproject.toml +++ b/framework/hosting/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "uvicorn[standard]>=0.34", ] -[project.entry-points."simple_module.cli_plugins"] +[project.entry-points."simple_module_cli.cli_plugins"] host = "simple_module_hosting.host_cli:app" [project.urls] diff --git a/modules/settings/pyproject.toml b/modules/settings/pyproject.toml index 60393b14..b3248444 100644 --- a/modules/settings/pyproject.toml +++ b/modules/settings/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ [project.entry-points.simple_module] settings = "settings.module:SettingsModule" -[project.entry-points."simple_module.cli_plugins"] +[project.entry-points."simple_module_cli.cli_plugins"] settings = "settings.cli:app" [project.urls] diff --git a/modules/users/pyproject.toml b/modules/users/pyproject.toml index 87c81117..a6f50f0d 100644 --- a/modules/users/pyproject.toml +++ b/modules/users/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ [project.entry-points.simple_module] users = "users.module:UsersModule" -[project.entry-points."simple_module.cli_plugins"] +[project.entry-points."simple_module_cli.cli_plugins"] users = "users.cli:app" [project.urls] diff --git a/pyproject.toml b/pyproject.toml index 8627521c..47958736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ line-length = 100 target-version = "py312" # Scaffold templates are copied verbatim (and some live under __PACKAGE__/ # placeholder dirs that aren't valid Python packages until substituted). -extend-exclude = ["framework/cli/simple_module/templates"] +extend-exclude = ["framework/cli/simple_module_cli/templates"] [tool.ruff.lint] select = [ diff --git a/scripts/new_module.py b/scripts/new_module.py index ba337aef..a41f647b 100644 --- a/scripts/new_module.py +++ b/scripts/new_module.py @@ -19,7 +19,7 @@ import textwrap from pathlib import Path -from simple_module.case import to_pascal_case +from simple_module_cli.case import to_pascal_case # Make sibling template modules importable when this file is run as a script. sys.path.insert(0, str(Path(__file__).resolve().parent)) diff --git a/scripts/tests/test_helpers.py b/scripts/tests/test_helpers.py index ff9dd9fa..44754088 100644 --- a/scripts/tests/test_helpers.py +++ b/scripts/tests/test_helpers.py @@ -11,7 +11,7 @@ to_singular, validate_name, ) -from simple_module.case import to_pascal_case +from simple_module_cli.case import to_pascal_case class TestValidateName: From f74b720e9e8f46b0e9a6db8dcb48f5c78b79ea43 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 26 Apr 2026 21:44:37 +0200 Subject: [PATCH 24/24] fix(cli): use simple_module_cli (underscore form) per repo convention scripts/check_metadata.py enforces 'simple_module_*' names across the framework. Switch the new distribution from kebab 'simple-module-cli' to underscore 'simple_module_cli' to match every other framework package (simple_module_core, simple_module_hosting, etc.). - pyproject.toml: name = 'simple_module_cli' - README + CHANGELOG snippets use the underscore form for install instructions (PyPI normalizes either way; we keep the convention consistent with the rest of the repo) - test_no_framework_deps pins distribution('simple_module_cli') - framework/cli/README.md gains explicit Install / Usage sections (required by scripts/check_readmes.py) --- CHANGELOG.md | 2 +- README.md | 2 +- framework/cli/README.md | 25 +++++++++++++++---- framework/cli/pyproject.toml | 2 +- framework/cli/tests/test_no_framework_deps.py | 6 ++--- framework/hosting/README.md | 8 +++--- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f55ec18..123291a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Initial public release. All 14 Python packages publish to PyPI and all 3 JS pack ### Added -- `sm new ` CLI generator (shipped via the `simple-module-cli` PyPI distribution) scaffolding a working app with `users + dashboard + permissions` pre-wired. +- `sm new ` CLI generator (shipped via the `simple_module_cli` PyPI distribution) scaffolding a working app with `users + dashboard + permissions` pre-wired. - PyPI Trusted Publishing workflow (`.github/workflows/release.yml`) for zero-secret releases. - npm Trusted Publishing for all three JS packages. diff --git a/README.md b/README.md index 96110449..1214d1f0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont If you want to **build an app on simple_module**, not hack on the framework itself: ```bash -uvx --from simple-module-cli sm new my-app +uvx --from simple_module_cli sm new my-app cd my-app make dev ``` diff --git a/framework/cli/README.md b/framework/cli/README.md index 9402a2c8..fdac1d5e 100644 --- a/framework/cli/README.md +++ b/framework/cli/README.md @@ -1,14 +1,29 @@ -# simple-module-cli +# simple_module_cli Standalone scaffolder for the [SimpleModule framework](https://github.com/antosubash/simple_module_python). +## Install + +```bash +pip install simple_module_cli +# or, to keep the CLI in its own venv: +pipx install simple_module_cli +# or, to run it without installing: +uvx --from simple_module_cli sm new my-app +``` + +The package depends only on `typer` and `tomlkit` — installing it does **not** pull in FastAPI, SQLModel, or any other framework runtime. + +## Usage + ```bash -pip install simple-module-cli # or: pipx install simple-module-cli -sm new my-app # interactive wizard -sm new my-app --yes --preset full +sm new my-app # interactive wizard +sm new my-app --yes --preset full # all built-in modules + background jobs +sm create-module my_feature # scaffold a publishable module package +sm create-host bare-host # scaffold a bare host (no opinionated wiring) ``` -Provides three built-in commands: `sm new`, `sm create-host`, `sm create-module`. +Built-in commands: `sm new`, `sm create-host`, `sm create-module`. When other framework packages are installed, they contribute additional subcommands via the `simple_module_cli.cli_plugins` entry-point group: diff --git a/framework/cli/pyproject.toml b/framework/cli/pyproject.toml index 684b391b..681d0420 100644 --- a/framework/cli/pyproject.toml +++ b/framework/cli/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "simple-module-cli" +name = "simple_module_cli" version = "0.0.1" description = "Standalone scaffolder for the SimpleModule framework — `sm new`, `sm create-module`, plugin host." readme = "README.md" diff --git a/framework/cli/tests/test_no_framework_deps.py b/framework/cli/tests/test_no_framework_deps.py index c30de080..65ed57ef 100644 --- a/framework/cli/tests/test_no_framework_deps.py +++ b/framework/cli/tests/test_no_framework_deps.py @@ -1,4 +1,4 @@ -"""Guard: `simple-module-cli` distribution depends only on typer + tomlkit. +"""Guard: `simple_module_cli` distribution depends only on typer + tomlkit. If a future change accidentally pulls in simple_module_core, FastAPI, SQLModel, or anything else, this test fires immediately. @@ -18,9 +18,9 @@ def _normalize(req: str) -> str: def test_simple_module_cli_runtime_deps_are_minimal() -> None: - requires = distribution("simple-module-cli").requires or [] + requires = distribution("simple_module_cli").requires or [] names = {_normalize(r) for r in requires} expected = {"typer", "tomlkit"} assert names == expected, ( - f"simple-module-cli direct deps drifted; got {sorted(names)}, expected {sorted(expected)}" + f"simple_module_cli direct deps drifted; got {sorted(names)}, expected {sorted(expected)}" ) diff --git a/framework/hosting/README.md b/framework/hosting/README.md index e4855dcf..283ae541 100644 --- a/framework/hosting/README.md +++ b/framework/hosting/README.md @@ -8,10 +8,10 @@ FastAPI + Inertia.js host runtime for the [simple_module](https://github.com/ant pip install simple_module_hosting ``` -For a new project, most users run the generator instead (shipped by the standalone `simple-module-cli` distribution): +For a new project, most users run the generator instead (shipped by the standalone `simple_module_cli` distribution): ```bash -uvx --from simple-module-cli sm new my-app +uvx --from simple_module_cli sm new my-app ``` ## What it provides @@ -19,7 +19,7 @@ uvx --from simple-module-cli sm new my-app - `create_app(settings)` — returns a fully-wired `FastAPI` instance with all discovered modules registered. - Middleware pipeline (execution order): CorrelationId → RequestLogging → SecurityHeaders → Session → `` → Tenant (opt-in) → Locale → InertiaLayoutData → app. - Inertia wiring — shared props (`auth`, `menus`, `i18n`), `InertiaDep`, page-route lookup. -- `sm host` plugin — `sm host gen-pages` regenerates the frontend pages manifest; `sm host sync-js-deps` installs JS deps declared by installed modules. The `sm` binary itself comes from `simple-module-cli`. +- `sm host` plugin — `sm host gen-pages` regenerates the frontend pages manifest; `sm host sync-js-deps` installs JS deps declared by installed modules. The `sm` binary itself comes from `simple_module_cli`. ## Usage @@ -37,7 +37,7 @@ if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) ``` -CLI (after also installing `simple-module-cli`): +CLI (after also installing `simple_module_cli`): ```bash sm host gen-pages # regenerate client_app/modules.generated.ts