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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions framework/cli/simple_module_cli/app_project.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,15 +18,19 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from pathlib import Path
from typing import Any

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
from simple_module_cli.scaffolding import _module_to_pypi_name, create_host, create_module

__all__ = ["create_app_project"]

_SAMPLE_MODULE_NAME = "hello"
_SAMPLE_MODULE_PKG = _module_to_pypi_name(_SAMPLE_MODULE_NAME)


def _resolve_framework_version() -> str:
"""Resolve the framework version to pin scaffolded apps against.
Expand DownExpand Up@@ -69,6 +73,7 @@ def create_app_project(
db: str = "sqlite",
tenancy: bool = False,
selected: Sequence[str] | None = None,
flat: bool = False,
) -> None:
"""Greenfield ``simple-module new`` scaffold.

Expand DownExpand Up@@ -101,19 +106,26 @@ def create_app_project(
env_text = set_env_key(env_text, "SM_MULTI_TENANT", "true" if tenancy else "false")
env_path.write_text(env_text, encoding="utf-8")

if not flat:
_scaffold_sample_module(target)
py_deps.append(_SAMPLE_MODULE_PKG)

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)
text = _rewrite_pyproject(text, py_deps, _APP_PY_DEV_DEPS, flat=flat)
pyproject.write_text(text, encoding="utf-8")

pkg_path = target / "package.json"
data: dict[str, Any]
if pkg_path.exists():
data = _json.loads(pkg_path.read_text(encoding="utf-8"))
else:
data = {"name": to_kebab_case(name), "private": True, "type": "module"}
data.setdefault("dependencies", {}).update(_APP_NPM_DEPS)
data.setdefault("devDependencies", {}).update(_APP_NPM_DEV_DEPS)
if not flat:
data["workspaces"] = ["client_app", "modules/*"]
pkg_path.write_text(_json.dumps(data, indent=2) + "\n", encoding="utf-8")

ctx = ScaffoldCtx(name=name, db=db, tenancy=tenancy, selected=tuple(resolved))
Expand All@@ -123,19 +135,45 @@ def create_app_project(
RECIPES[recipe_key].apply(target, ctx)


def _scaffold_sample_module(target: Path) -> None:
"""Give the user a place to copy when they want to add a feature module.

The alternative is reverse-engineering one of the wheel-installed
framework modules from ``.venv/site-packages/``.
"""
sample_dest = target / "modules" / _SAMPLE_MODULE_NAME
if sample_dest.exists():
return
create_module(sample_dest, name=_SAMPLE_MODULE_NAME)


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."""
def _rewrite_pyproject(text: str, deps: list[str], dev_deps: list[str], *, flat: bool) -> str:
"""Replace deps + wire uv workspace based on ``flat`` mode.

Workspace mode (``flat=False``) adds a ``[tool.uv.sources]`` entry so uv
resolves the bundled sample module from the workspace, not PyPI. Flat
mode strips the static ``[tool.uv.workspace]`` block inherited from the
template — there is no ``modules/`` tree for it to point at.
"""
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)
tool = doc.setdefault("tool", tomlkit.table())
uv_table = tool.setdefault("uv", tomlkit.table())
if flat:
if "workspace" in uv_table:
del uv_table["workspace"]
else:
sources = uv_table.setdefault("sources", tomlkit.table())
sources[_SAMPLE_MODULE_PKG] = {"workspace": True}
return tomlkit.dumps(doc)
19 changes: 18 additions & 1 deletion framework/cli/simple_module_cli/new.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -64,6 +64,16 @@ def new_project(
help="Skip 'uv sync' / 'npm install' / 'alembic upgrade head' after scaffolding.",
),
] = False,
flat: Annotated[
bool,
typer.Option(
"--flat",
help=(
"Skip the modules/ directory and sample module. Use when the host "
"will only consume published modules and never author its own."
),
),
] = False,
) -> None:
"""Scaffold a new SimpleModule app, optionally with background jobs."""
target = dest or Path.cwd() / name
Expand All@@ -90,7 +100,14 @@ def new_project(
raise typer.Exit(code=1) from None

try:
create_app_project(target, name=name, db=db_final, tenancy=tenancy_final, selected=resolved)
create_app_project(
target,
name=name,
db=db_final,
tenancy=tenancy_final,
selected=resolved,
flat=flat,
)
except FileExistsError as exc:
typer.echo(f"ERROR: {exc}", err=True)
raise typer.Exit(code=1) from exc
Expand Down
1 change: 1 addition & 0 deletions framework/cli/simple_module_cli/scaffolding.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -117,6 +117,7 @@ def create_module(
"{{MODULE_NAME}}": display_name,
"{{MODULE_SLUG}}": slug,
"{{PACKAGE_NAME}}": package_name,
"{{PACKAGE_NAME_UPPER}}": package_name.upper(),
},
path_rewrites={_PACKAGE_PATH_TOKEN: package_name},
)
Expand Down
6 changes: 5 additions & 1 deletion framework/cli/simple_module_cli/templates/host/Makefile
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
.PHONY: install dev dev-api dev-ui build migrate gen-pages
.PHONY: install dev dev-api dev-ui build migrate gen-pages sync-js-deps

install:
uv sync
cd client_app && npm install
$(MAKE) sync-js-deps

dev: gen-pages
@echo "Starting API and UI dev servers..."
Expand All@@ -22,3 +23,6 @@ migrate:

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

sync-js-deps:
uv run python -m simple_module_hosting sync-js-deps --host-client-app=client_app
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,3 +16,9 @@ dependencies = [
# Host is an application, not a distributable package.
[tool.uv]
package = false

# Add a new module with `sm create-module <name>`, then add
# `simple_module_<name>` to the dependency list above and to
# [tool.uv.sources] as `{ workspace = true }`.
[tool.uv.workspace]
members = ["modules/*"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
"""{{MODULE_NAME}} module settings.

Per-module env-var prefix is ``SM_{{PACKAGE_NAME_UPPER}}_*``. Add fields here
as the module grows; the framework wires them onto
``app.state.{{PACKAGE_NAME}}`` via ``register_settings``.
"""

from __future__ import annotations

from pydantic_settings import BaseSettings, SettingsConfigDict


class {{MODULE_NAME}}Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SM_{{PACKAGE_NAME_UPPER}}_", extra="ignore")
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ dependencies = [
"simple_module_core>=1.0,<2.0",
"simple_module_db>=1.0,<2.0",
"simple_module_hosting>=1.0,<2.0",
"pydantic-settings>=2.0",
"sqlalchemy>=2.0",
]

Expand Down
60 changes: 60 additions & 0 deletions framework/cli/tests/test_cli_new.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -196,6 +196,66 @@ def test_sm_new_interactive_full_preset(tmp_path: Path) -> None:
assert (target / "docker-compose.yml").is_file()


def test_sm_new_default_scaffolds_sample_hello_module(tmp_path: Path) -> None:
"""Default (workspace) mode lays down modules/hello/ as an authoring template."""
runner = CliRunner()
target = tmp_path / "demo"
result = runner.invoke(
app,
["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)],
)
assert result.exit_code == 0, result.output
assert (target / "modules" / "hello" / "pyproject.toml").is_file()
assert (target / "modules" / "hello" / "hello" / "module.py").is_file()


def test_sm_new_default_wires_workspace_in_pyproject(tmp_path: Path) -> None:
"""Default mode adds [tool.uv.workspace] members + a workspace source for the sample."""
runner = CliRunner()
target = tmp_path / "demo"
runner.invoke(
app,
["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)],
)
pyproject_text = (target / "pyproject.toml").read_text()
assert "[tool.uv.workspace]" in pyproject_text
assert 'members = ["modules/*"]' in pyproject_text
assert "simple_module_hello" in pyproject_text
# Sample module is a workspace source, not pulled from PyPI.
assert "[tool.uv.sources" in pyproject_text
assert "workspace = true" in pyproject_text


def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None:
"""Default mode declares ``workspaces`` so vite picks up modules/<name>/."""
runner = CliRunner()
target = tmp_path / "demo"
runner.invoke(
app,
["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)],
)
data = json.loads((target / "package.json").read_text())
assert data.get("workspaces") == ["client_app", "modules/*"]


def test_sm_new_flat_skips_modules_dir(tmp_path: Path) -> None:
"""``--flat`` keeps the legacy single-host layout: no modules/ tree, no sample."""
runner = CliRunner()
target = tmp_path / "demo"
result = runner.invoke(
app,
["new", "demo", "--yes", "--flat", "--no-install", "--dest", str(target)],
)
assert result.exit_code == 0, result.output
assert not (target / "modules").exists()
pyproject_text = (target / "pyproject.toml").read_text()
assert "simple_module_hello" not in pyproject_text
# No workspace plumbing pointing at a non-existent modules/ tree.
assert "[tool.uv.workspace]" not in pyproject_text
data = json.loads((target / "package.json").read_text())
assert "workspaces" not in data


def test_sm_new_refuses_to_overwrite(tmp_path: Path) -> None:
target = tmp_path / "my-app"
target.mkdir()
Expand Down
5 changes: 4 additions & 1 deletion modules/background_tasks/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,8 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"lucide-react": "^1.8.0",
"sonner": "^2.0.0"
}
}
4 changes: 3 additions & 1 deletion modules/dashboard/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,7 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"lucide-react": "^1.8.0"
}
}
5 changes: 4 additions & 1 deletion modules/feature_flags/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,8 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"lucide-react": "^1.8.0",
"sonner": "^2.0.0"
}
}
5 changes: 4 additions & 1 deletion modules/file_storage/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,8 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"lucide-react": "^1.8.0",
"sonner": "^2.0.0"
}
}
5 changes: 4 additions & 1 deletion modules/permissions/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,8 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"lucide-react": "^1.8.0",
"sonner": "^2.0.0"
}
}
5 changes: 4 additions & 1 deletion modules/users/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,5 +12,8 @@
"devDependencies": {
"@simple-module-py/tsconfig": "*"
},
"dependencies": {}
"dependencies": {
"lucide-react": "^1.8.0",
"sonner": "^2.0.0"
}
}
47 changes: 33 additions & 14 deletions packages/ui/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,13 +26,31 @@
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./components/ui/*": "./src/components/ui/*.tsx",
"./components/*": "./src/components/*.tsx",
"./layouts/*": "./src/layouts/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
"./lib/*": "./src/lib/*.ts",
"./styles/*": "./src/styles/*",
"./*": "./src/*"
"./components/ui/*": {
"types": "./src/components/ui/*.tsx",
"default": "./src/components/ui/*.tsx"
},
"./components/*": {
"types": "./src/components/*.tsx",
"default": "./src/components/*.tsx"
},
"./layouts/*": {
"types": "./src/layouts/*.tsx",
"default": "./src/layouts/*.tsx"
},
"./hooks/*": {
"types": "./src/hooks/*.ts",
"default": "./src/hooks/*.ts"
},
"./lib/*": {
"types": "./src/lib/*.ts",
"default": "./src/lib/*.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./styles/*": "./src/styles/*"
},
"files": [
"src",
Expand All@@ -42,27 +60,28 @@
"access": "public"
},
"peerDependencies": {
"@base-ui/react": "^1.0.0",
"@inertiajs/react": "^2.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@simple-module-py/i18n": "0.0.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^1.0.0",
"embla-carousel-react": "^8.0.0",
"input-otp": "^1.0.0",
"lucide-react": "*",
"lucide-react": "^1.8.0",
"next-themes": "^0.4.0",
"radix-ui": "*",
"react": "^19.0.0",
"radix-ui": "^1.4.0",
"react-day-picker": "^9.0.0",
"react-resizable-panels": "^4.0.0",
"recharts": "^3.0.0",
"sonner": "^2.0.0",
"tailwind-merge": "^3.0.0",
"vaul": "^1.0.0"
},
"dependencies": {
"@simple-module-py/i18n": "0.0.7"
},
"devDependencies": {
"@simple-module-py/tsconfig": "0.0.7"
}
Expand Down
Loading