diff --git a/framework/cli/simple_module_cli/app_project.py b/framework/cli/simple_module_cli/app_project.py index 52192082..6c5b85c4 100644 --- a/framework/cli/simple_module_cli/app_project.py +++ b/framework/cli/simple_module_cli/app_project.py @@ -17,8 +17,6 @@ import secrets as _secrets import shutil as _shutil from collections.abc import Sequence -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as _pkg_version from pathlib import Path from typing import Any @@ -32,6 +30,7 @@ create_host, create_module, create_workspace, + resolve_framework_version, ) __all__ = ["create_app_project"] @@ -40,30 +39,21 @@ _SAMPLE_MODULE_PKG = _module_to_pypi_name(_SAMPLE_MODULE_NAME) -def _resolve_framework_version() -> str: - """Resolve the framework version to pin scaffolded apps against. - - The CLI ships in lockstep with the rest of the framework (one - ``bump_version.py`` rewrites every ``pyproject.toml`` in the repo), so - its own installed version is the source of truth. Falling back to a - placeholder lets editable installs without dist-info still scaffold — - but that path should never be reached in a release wheel. - """ - try: - return _pkg_version("simple_module_cli") - except PackageNotFoundError: - return "0.0.0" - - -_FRAMEWORK_VERSION = _resolve_framework_version() +_FRAMEWORK_VERSION = resolve_framework_version() # Pin ``simple_module_cli`` as a dev dep so ``uv run smpy`` resolves to the # project venv. The global ``uv tool`` install runs in its own isolated venv -# that can't see the project's plugin entry points (issue #134). +# that can't see the project's plugin entry points (issue #134). The lint / +# test tooling (ruff, ty, pytest-*) backs the generated `make lint`/`make +# test` targets so a fresh app can run its own quality gates. _APP_PY_DEV_DEPS = [ f"simple_module_test=={_FRAMEWORK_VERSION}", f"simple_module_cli=={_FRAMEWORK_VERSION}", "pytest>=8.0", + "pytest-asyncio>=0.24", + "pytest-playwright>=0.7.2", + "ruff>=0.8", + "ty>=0.0.29", ] _APP_NPM_DEPS = { @@ -120,7 +110,12 @@ def create_app_project( preserved: list[Path] = [] if not flat: preserved.extend( - create_workspace(target, name=name, preserve_existing=SAFE_PRESERVED_NAMES) + create_workspace( + target, + name=name, + framework_version=_FRAMEWORK_VERSION, + preserve_existing=SAFE_PRESERVED_NAMES, + ) ) preserved.extend( create_host( @@ -195,11 +190,13 @@ def _scaffold_sample_module(target: Path) -> None: sample_dest = target / "modules" / _SAMPLE_MODULE_NAME if sample_dest.exists(): return - create_module(sample_dest, name=_SAMPLE_MODULE_NAME) + # Pin the sample's framework deps to the exact framework version so the + # workspace resolves (the template's >=1.0,<2.0 ranges don't exist on PyPI + # pre-1.0). See GH #195. + create_module(sample_dest, name=_SAMPLE_MODULE_NAME, framework_version=_FRAMEWORK_VERSION) # GitHub only reads workflows from the repo root, so the template's # .github/ is dead inside a workspace. _shutil.rmtree(sample_dest / ".github") - _pin_sample_module_deps(sample_dest) _seed_static_dist_placeholder(sample_dest / _SAMPLE_MODULE_NAME / "static" / "dist") @@ -210,35 +207,6 @@ def _seed_static_dist_placeholder(static_dist: Path) -> None: (static_dist / ".gitkeep").touch() -def _pin_sample_module_deps(sample_dest: Path) -> None: - """Replace the module template's future-API range pins with exact pins. - - The shared ``smpy create-module`` template ships ``>=1.0,<2.0`` against the - framework's eventual stable line, but the workspace-bundled sample has to - resolve against whatever the framework version actually is today (``==X`` - in pre-1.0). Without rewriting, ``uv sync`` can't satisfy the workspace. - """ - import tomlkit - - pyproject = sample_dest / "pyproject.toml" - doc = tomlkit.parse(pyproject.read_text(encoding="utf-8")) - project = doc.setdefault("project", tomlkit.table()) - project["dependencies"] = [_pin_or_keep(dep) for dep in project.get("dependencies", [])] - optional = project.get("optional-dependencies") - if optional is not None: - for extra, deps in list(optional.items()): - optional[extra] = [_pin_or_keep(dep) for dep in deps] - pyproject.write_text(tomlkit.dumps(doc), encoding="utf-8") - - -def _pin_or_keep(dep: str) -> str: - """Pin a ``simple_module_*`` requirement to the framework version; pass through otherwise.""" - pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip() - if pkg.startswith(("simple_module_", "simple-module-")): - return f"{pkg}=={_FRAMEWORK_VERSION}" - return dep - - def _db_url(db: str, slug: str, *, flat: bool) -> str: if db == "postgres": return f"postgresql+asyncpg://postgres:postgres@localhost:5432/{slug}" diff --git a/framework/cli/simple_module_cli/cli.py b/framework/cli/simple_module_cli/cli.py index 31e01800..8b667406 100644 --- a/framework/cli/simple_module_cli/cli.py +++ b/framework/cli/simple_module_cli/cli.py @@ -23,6 +23,7 @@ from simple_module_cli.plugins import discover_and_mount from simple_module_cli.scaffolding import create_host as _create_host from simple_module_cli.scaffolding import create_module as _create_module +from simple_module_cli.scaffolding import resolve_framework_version from simple_module_cli.skills_cmd import app as skills_app app = typer.Typer( @@ -68,7 +69,7 @@ def create_host( typer.echo(" uv sync") typer.echo(" cp .env.example .env") typer.echo(' alembic revision --autogenerate -m "initial schema"') - typer.echo(" alembic upgrade head") + typer.echo(" alembic upgrade heads") typer.echo(" python main.py") @@ -85,7 +86,10 @@ def create_module( package = slug.replace("-", "_") target = dest or Path.cwd() / f"simple_module_{package}" try: - _create_module(target, name=name) + # Pin framework deps to the installed framework version so the module + # resolves against the app that created it (the template's >=1.0,<2.0 + # ranges don't exist on PyPI pre-1.0). See GH #195. + _create_module(target, name=name, framework_version=resolve_framework_version()) except FileExistsError as exc: typer.echo(f"ERROR: {exc}", err=True) raise typer.Exit(code=1) from exc diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index d230d0ac..b331818c 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -164,7 +164,9 @@ def new_project( return _bootstrap_initial_migration(host_dir) - subprocess.run([*_ALEMBIC, "upgrade", "head"], cwd=host_dir, check=False) + # `heads` (plural) applies every per-module branch head; `head` (singular) + # errors once a second module ships its own migration branch label. + subprocess.run([*_ALEMBIC, "upgrade", "heads"], cwd=host_dir, check=False) typer.echo("\nSetup complete. Run `make dev` in the new directory.") if "background_tasks" in resolved: typer.echo("For background jobs, also run: docker compose up -d redis worker beat") diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index 1be7adb7..22652829 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -33,6 +33,8 @@ "create_host", "create_module", "create_workspace", + "pin_framework_deps", + "resolve_framework_version", ] logger = logging.getLogger(__name__) @@ -55,6 +57,59 @@ def _module_to_pypi_name(name: str) -> str: return f"simple_module_{name.lower()}" +def resolve_framework_version() -> str: + """Resolve the framework version that scaffolded apps should pin against. + + The CLI ships in lockstep with the rest of the framework (one + ``bump_version.py`` rewrites every ``pyproject.toml``), so its own + installed distribution version is the source of truth. Falls back to a + placeholder for editable installs lacking dist-info — never reached from a + release wheel. + """ + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as pkg_version + + try: + return pkg_version("simple_module_cli") + except PackageNotFoundError: + return "0.0.0" + + +def _pin_one(dep: str, version: str) -> str: + """Pin a single ``simple_module_*`` requirement to ``==version``; else pass through.""" + pkg = dep.split(">=", 1)[0].split("==", 1)[0].split("<", 1)[0].strip() + if pkg.startswith(("simple_module_", "simple-module-")): + return f"{pkg}=={version}" + return dep + + +def pin_framework_deps(pyproject_path: Path, version: str) -> None: + """Pin every ``simple_module_*`` requirement in a pyproject to ``==version``. + + The module template ships forward-looking ranges (``>=1.0,<2.0``) against + the framework's eventual stable line, but the published distributions are + pre-1.0 (``0.0.x``), so those ranges resolve to nothing on PyPI. Rewriting + to an exact pin lets a freshly created module resolve against the framework + version that created it — e.g. ``uv add ./modules/`` into the same + workspace. Both ``dependencies`` and every ``optional-dependencies`` extra + (the ``dev`` extra pins ``simple_module_test``) are rewritten. See GH #195. + """ + import tomlkit + + doc = tomlkit.parse(pyproject_path.read_text(encoding="utf-8")) + project = doc.get("project") + if project is None: + return + deps = project.get("dependencies") + if deps is not None: + project["dependencies"] = [_pin_one(dep, version) for dep in deps] + optional = project.get("optional-dependencies") + if optional is not None: + for extra, items in list(optional.items()): + optional[extra] = [_pin_one(dep, version) for dep in items] + pyproject_path.write_text(tomlkit.dumps(doc), encoding="utf-8") + + def _iter_template_files(template_root: Path): """Yield every file under ``template_root``. Skips ``_optional/`` paths.""" for path in template_root.rglob("*"): @@ -124,15 +179,20 @@ def create_workspace( dest: Path, name: str, template_root: Path | None = None, + framework_version: str = "*", *, preserve_existing: frozenset[str] = frozenset(), ) -> list[Path]: """Materialize the workspace-root shell at ``dest``; return preserved paths. - Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json`` - (npm workspace), ``Makefile`` (delegates to host), ``.env.example``, - ``.gitignore``, and ``README.md``. Does NOT create the host or any - modules — those go under ``dest/host`` and ``dest/modules/`` afterwards. + Lays down the top-level ``pyproject.toml`` (uv workspace + dev tooling for + ``make test``/``lint``), ``package.json`` (npm workspace), ``Makefile`` + (delegates to host), ``.env.example``, ``.gitignore``, and ``README.md``. + Does NOT create the host or any modules — those go under ``dest/host`` and + ``dest/modules/`` afterwards. + + ``framework_version`` pins ``simple_module_test`` in the root dev group; + defaults to ``"*"`` for callers that don't need an exact pin. ``preserve_existing`` lists top-level entry names that may already exist in ``dest``; the scaffold's copy is skipped and the preserved path is @@ -147,6 +207,7 @@ def create_workspace( { "{{HOST_NAME}}": validate_scaffold_name(name), "{{HOST_PYPI_NAME}}": to_kebab_case(name), + "{{FRAMEWORK_VERSION}}": framework_version, }, preserve_existing=preserve_existing, ) @@ -191,7 +252,17 @@ def create_module( dest: Path, name: str, template_root: Path | None = None, + *, + framework_version: str | None = None, ) -> Path: + """Scaffold a module package at ``dest``. + + When ``framework_version`` is given, the template's forward-looking + ``simple_module_*`` ranges are rewritten to an exact pin so the module + resolves against that framework version (e.g. ``uv add`` into the workspace + that created it). Left as ``None``, the template's ranges are kept verbatim. + See GH #195. + """ dest = Path(dest) existed_before = dest.exists() _require_empty_dest(dest) @@ -210,6 +281,8 @@ def create_module( }, path_rewrites={_PACKAGE_PATH_TOKEN: package_name}, ) + if framework_version is not None: + pin_framework_deps(dest / "pyproject.toml", framework_version) except Exception: # Rollback so a half-scaffolded directory doesn't leave the user # with an unparseable Python package and the impression that a diff --git a/framework/cli/simple_module_cli/templates/host/Makefile b/framework/cli/simple_module_cli/templates/host/Makefile index 162089d4..dce73537 100644 --- a/framework/cli/simple_module_cli/templates/host/Makefile +++ b/framework/cli/simple_module_cli/templates/host/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-js-deps +.PHONY: install dev dev-api dev-ui build test test-py test-js lint doctor migrate migration gen-pages sync-js-deps install: uv sync @@ -18,15 +18,38 @@ dev-ui: build: cd client_app && npm run build -migrate: - uv run alembic upgrade head +# Testing +test: test-py test-js -migration: - @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1) - uv run alembic revision --autogenerate -m "$(msg)" +test-py: + uv run pytest + +# --if-present is a no-op until you add a "test" script (e.g. vitest) to package.json. +test-js: + cd client_app && npm run test --if-present + +# Lint + typecheck (Python). Mirrors the framework's own quality gate. +lint: + uv run ruff format --check . + uv run ruff check . + uv run ty check + +# Module diagnostics — the same checks that run at prod boot (orphan pages, +# coupling violations, migration drift, locale issues). +doctor: + uv run python -m simple_module_core gen-pages: uv run python -m simple_module_hosting gen-pages --host-dir=client_app sync-js-deps: uv run python -m simple_module_hosting sync-js-deps --host-client-app=client_app + +# `upgrade heads` (plural) applies every per-module branch head; `upgrade head` +# (singular) errors once a second module adds its own migration branch label. +migrate: + uv run alembic upgrade heads + +migration: + @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1) + uv run alembic revision --autogenerate -m "$(msg)" diff --git a/framework/cli/simple_module_cli/templates/host/main.py b/framework/cli/simple_module_cli/templates/host/main.py index 18fd1b1f..758f5586 100644 --- a/framework/cli/simple_module_cli/templates/host/main.py +++ b/framework/cli/simple_module_cli/templates/host/main.py @@ -5,25 +5,38 @@ """ import os +import sys from pathlib import Path from simple_module_core.dotenv import load_dotenv_into_environ +# Pin this host directory on ``sys.path`` as an ABSOLUTE path *before* the +# chdir below, so ``from routes import ...`` resolves no matter what the cwd +# is. The scaffolded Makefile launches the app as ``cd host && uvicorn +# main:app``; uvicorn puts the launch cwd on ``sys.path`` as the empty string +# ``''`` (resolved lazily against the *current* cwd). Once we chdir to the +# repo root, that ``''`` entry points at the wrong directory and the sibling +# ``routes`` module is no longer importable — and the ``--reload`` subprocess +# re-imports via the same path, so it breaks there too. See GH #194. +_HOST_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _HOST_DIR.parent +if str(_HOST_DIR) not in sys.path: + sys.path.insert(0, str(_HOST_DIR)) + # Resolve the workspace root from this file's location so the web process -# behaves the same regardless of where uvicorn was launched (the scaffolded -# Makefile uses ``cd host && uvicorn main:app``, but ``uv run --project host`` -# or a wheel deployment may run from elsewhere). chdir up front so cwd-relative -# paths in ``.env`` (e.g. ``sqlite+aiosqlite:///./host/app.db``) resolve -# consistently; load ``.env`` into ``os.environ`` so framework code reading -# ``os.environ.get("SM_…")`` directly sees the same values pydantic does. -_REPO_ROOT = Path(__file__).resolve().parent.parent +# behaves the same regardless of where uvicorn was launched (``uv run +# --project host`` or a wheel deployment may run from elsewhere). chdir up +# front so cwd-relative paths in ``.env`` (e.g. +# ``sqlite+aiosqlite:///./host/app.db``) resolve consistently; load ``.env`` +# into ``os.environ`` so framework code reading ``os.environ.get("SM_…")`` +# directly sees the same values pydantic does. os.chdir(_REPO_ROOT) load_dotenv_into_environ(_REPO_ROOT / ".env") -from simple_module_hosting import Settings, create_app -from simple_module_hosting.logging import setup_logging +from simple_module_hosting import Settings, create_app # noqa: E402 +from simple_module_hosting.logging import setup_logging # noqa: E402 -from routes import router as host_router +from routes import router as host_router # noqa: E402 settings = Settings() diff --git a/framework/cli/simple_module_cli/templates/host/migrations/env.py b/framework/cli/simple_module_cli/templates/host/migrations/env.py index 8d53c183..58ca765c 100644 --- a/framework/cli/simple_module_cli/templates/host/migrations/env.py +++ b/framework/cli/simple_module_cli/templates/host/migrations/env.py @@ -37,10 +37,7 @@ def _get_url() -> str: """Read database URL from settings, convert async to sync driver.""" settings = Settings() - url = settings.database_url - url = url.replace("+aiosqlite", "") - url = url.replace("+asyncpg", "+psycopg2") - return url + return settings.database_url.replace("+aiosqlite", "").replace("+asyncpg", "+psycopg2") def run_migrations_offline() -> None: diff --git a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl index 5eefebdb..ada8aca3 100644 --- a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl @@ -16,3 +16,36 @@ dependencies = [ # Host is an application, not a distributable package. [tool.uv] package = false + +# Quality-gate config for `make test` / `make lint`. In a workspace scaffold +# the same config also lives at the workspace root (where those targets run); +# in a flat scaffold this host dir *is* the project root. +[tool.pytest.ini_options] +asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests requiring a live browser", +] +addopts = "-m 'not e2e'" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "C4", "RET", "PTH", "PIE", "RUF"] +ignore = [ + "B008", # Depends() in default args is idiomatic FastAPI + "B027", # Empty methods in ABC without @abstractmethod — used for optional hooks +] + +# SQLModel declares fields with plain Python types even though at runtime they +# become SQLAlchemy InstrumentedAttributes (.in_(), .ilike(), ==, ...). ty can't +# see through this, so ORM query expressions trip these rules with false +# positives — real bugs still surface in tests. +[tool.ty.rules] +unresolved-attribute = "ignore" +unsupported-operator = "ignore" +unknown-argument = "ignore" +no-matching-overload = "ignore" +invalid-argument-type = "ignore" +invalid-assignment = "ignore" diff --git a/framework/cli/simple_module_cli/templates/workspace/Makefile b/framework/cli/simple_module_cli/templates/workspace/Makefile index 84dfafa3..856b6dda 100644 --- a/framework/cli/simple_module_cli/templates/workspace/Makefile +++ b/framework/cli/simple_module_cli/templates/workspace/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev dev-api dev-ui build migrate migration gen-pages sync-module-deps kill +.PHONY: install dev dev-api dev-ui build test test-py test-js lint doctor migrate migration gen-pages sync-module-deps kill install: uv sync --all-packages @@ -9,8 +9,11 @@ dev: gen-pages @echo "Starting API and UI dev servers..." $(MAKE) -j2 dev-api dev-ui +# --reload-dir keeps the reloader watching in-repo module packages under +# modules/* as well as the host, so edits to a module's routes/endpoints/ +# locales hot-reload too (uvicorn otherwise watches only the launch cwd). dev-api: - cd host && uv run uvicorn main:app --reload --port 8000 + cd host && uv run uvicorn main:app --reload --reload-dir . --reload-dir ../modules --port 8000 dev-ui: npm run dev @@ -18,6 +21,27 @@ dev-ui: build: npm run build +# Testing +test: test-py test-js + +test-py: + uv run pytest + +# --if-present is a no-op until you add a "test" script (e.g. vitest) to package.json. +test-js: + npm run test --if-present + +# Lint + typecheck (Python). Mirrors the framework's own quality gate. +lint: + uv run ruff format --check . + uv run ruff check . + uv run ty check + +# Module diagnostics — the same checks that run at prod boot (orphan pages, +# coupling violations, migration drift, locale issues). +doctor: + uv run python -m simple_module_core + # Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} # from installed modules (workspace + wheel-installed). gen-pages: @@ -28,8 +52,10 @@ gen-pages: sync-module-deps: cd host && uv run python -m simple_module_hosting sync-js-deps --host-client-app=client_app +# `upgrade heads` (plural) applies every per-module branch head; `upgrade head` +# (singular) errors once a second module adds its own migration branch label. migrate: - cd host && uv run alembic upgrade head + cd host && uv run alembic upgrade heads migration: @test -n "$(msg)" || (echo 'Usage: make migration msg="describe the change"' && exit 1) diff --git a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl index 2010d858..30174eb7 100644 --- a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl @@ -15,3 +15,47 @@ package = false # module: `simple_module_ = { workspace = true }`. [tool.uv.workspace] members = ["host", "modules/*"] + +# Dev tooling for `make test` / `make lint`. These live in the workspace +# root's dependency-group (synced by default by `uv sync --all-packages`) +# so the shared venv that `make test`/`lint`/`doctor` run against has them. +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "pytest-playwright>=0.7.2", + "ruff>=0.8", + "ty>=0.0.29", + # Provides the build_test_app / fake_event_bus fixtures (pytest11 plugin). + "simple_module_test=={{FRAMEWORK_VERSION}}", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests requiring a live browser", +] +addopts = "-m 'not e2e'" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "N", "UP", "B", "SIM", "C4", "RET", "PTH", "PIE", "RUF"] +ignore = [ + "B008", # Depends() in default args is idiomatic FastAPI + "B027", # Empty methods in ABC without @abstractmethod — used for optional hooks +] + +# SQLModel declares fields with plain Python types even though at runtime they +# become SQLAlchemy InstrumentedAttributes (.in_(), .ilike(), ==, ...). ty can't +# see through this, so ORM query expressions trip these rules with false +# positives — real bugs still surface in tests. +[tool.ty.rules] +unresolved-attribute = "ignore" +unsupported-operator = "ignore" +unknown-argument = "ignore" +no-matching-overload = "ignore" +invalid-argument-type = "ignore" +invalid-assignment = "ignore" diff --git a/framework/cli/tests/test_cli_new.py b/framework/cli/tests/test_cli_new.py index 15615f37..a1162711 100644 --- a/framework/cli/tests/test_cli_new.py +++ b/framework/cli/tests/test_cli_new.py @@ -204,88 +204,6 @@ 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_lays_down_workspace_layout(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "demo" - runner.invoke( - app, - ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - for relpath in ("pyproject.toml", "package.json", "Makefile", ".env.example"): - assert (target / relpath).is_file(), f"missing workspace root file: {relpath}" - for relpath in ( - "main.py", - "alembic.ini", - "pyproject.toml", - "client_app/package.json", - "client_app/vite.config.ts", - ): - assert (target / "host" / relpath).is_file(), f"missing host file: {relpath}" - for relpath in (".env.example", "README.md", ".gitignore", "Makefile"): - assert not (target / "host" / relpath).exists() - assert not (target / "modules" / "hello" / ".github").exists() - - -def test_sm_new_default_wires_workspace_in_pyproject(tmp_path: Path) -> None: - runner = CliRunner() - target = tmp_path / "demo" - runner.invoke( - app, - ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], - ) - workspace_pyproject = (target / "pyproject.toml").read_text() - assert "[tool.uv.workspace]" in workspace_pyproject - assert 'members = ["host", "modules/*"]' in workspace_pyproject - - host_pyproject = (target / "host" / "pyproject.toml").read_text() - assert "simple_module_hello" in host_pyproject - # Sample module is resolved from the workspace, not PyPI. - assert "[tool.uv.sources" in host_pyproject - assert "workspace = true" in host_pyproject - - -def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None: - 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") == ["host/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() diff --git a/framework/cli/tests/test_cli_new_scaffold_layout.py b/framework/cli/tests/test_cli_new_scaffold_layout.py new file mode 100644 index 00000000..2af9f596 --- /dev/null +++ b/framework/cli/tests/test_cli_new_scaffold_layout.py @@ -0,0 +1,171 @@ +"""Tests for the `smpy new` scaffold layout and generated-app quality gates. + +Split out of test_cli_new.py to keep both files under the 300-line cap. Covers +the generated app's layout (workspace vs flat), the dev-api reload watching +modules/, and the Makefile / root-pyproject quality-gate plumbing +(regressions #201, #202). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest +from simple_module_cli.cli import app +from typer.testing import CliRunner + + +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_lays_down_workspace_layout(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + for relpath in ("pyproject.toml", "package.json", "Makefile", ".env.example"): + assert (target / relpath).is_file(), f"missing workspace root file: {relpath}" + for relpath in ( + "main.py", + "alembic.ini", + "pyproject.toml", + "client_app/package.json", + "client_app/vite.config.ts", + ): + assert (target / "host" / relpath).is_file(), f"missing host file: {relpath}" + for relpath in (".env.example", "README.md", ".gitignore", "Makefile"): + assert not (target / "host" / relpath).exists() + assert not (target / "modules" / "hello" / ".github").exists() + + +def test_sm_new_default_wires_workspace_in_pyproject(tmp_path: Path) -> None: + runner = CliRunner() + target = tmp_path / "demo" + runner.invoke( + app, + ["new", "demo", "--yes", "--db", "sqlite", "--no-install", "--dest", str(target)], + ) + workspace_pyproject = (target / "pyproject.toml").read_text() + assert "[tool.uv.workspace]" in workspace_pyproject + assert 'members = ["host", "modules/*"]' in workspace_pyproject + + host_pyproject = (target / "host" / "pyproject.toml").read_text() + assert "simple_module_hello" in host_pyproject + # Sample module is resolved from the workspace, not PyPI. + assert "[tool.uv.sources" in host_pyproject + assert "workspace = true" in host_pyproject + + +def test_sm_new_default_adds_npm_workspaces_field(tmp_path: Path) -> None: + 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") == ["host/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_dev_api_watches_modules_dir(tmp_path: Path) -> None: + """Regression #202: the dev-api reloader must watch modules/* so edits to + in-repo module packages (routes/endpoints/locales) hot-reload — uvicorn + otherwise only watches the launch cwd (host/).""" + from simple_module_cli.app_project import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=[]) + makefile = (target / "Makefile").read_text() + assert "--reload-dir" in makefile + assert "../modules" in makefile + + +def test_sm_new_makefile_has_quality_gate_targets(tmp_path: Path) -> None: + """Regression #201: the scaffold Makefile must emit test/lint/doctor targets + and use `alembic upgrade heads` (plural) for per-module branch heads.""" + from simple_module_cli.app_project import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=[]) + makefile = (target / "Makefile").read_text() + for tgt in ("test:", "test-py:", "test-js:", "lint:", "doctor:"): + assert tgt in makefile, f"missing Makefile target {tgt}" + assert "upgrade heads" in makefile + assert "upgrade head\n" not in makefile # the buggy singular form is gone + + +def test_sm_new_root_pyproject_has_dev_tooling_and_config(tmp_path: Path) -> None: + """Regression #201: the workspace root must ship the dev tooling + pytest / + ruff / ty config so `make test` and `make lint` work out of the box.""" + from simple_module_cli.app_project import create_app_project + + target = tmp_path / "demo" + create_app_project(target, name="demo", db="sqlite", tenancy=False, selected=[]) + data = tomllib.loads((target / "pyproject.toml").read_text()) + + dev = data["dependency-groups"]["dev"] + joined = " ".join(dev) + assert "ruff" in joined and "ty" in joined and "pytest" in joined + # simple_module_test is pinned to the framework version, not the broken range. + assert any(d.startswith("simple_module_test==") for d in dev) + assert data["tool"]["pytest"]["ini_options"]["asyncio_mode"] == "auto" + assert data["tool"]["ruff"]["line-length"] == 100 + assert "unresolved-attribute" in data["tool"]["ty"]["rules"] + + +def test_sm_new_generated_app_passes_its_own_ruff(tmp_path: Path) -> None: + """Regression #201: a freshly scaffolded app must pass its own `make lint` + ruff gate out of the box. Templates are excluded from the framework's own + ruff, so violations in generated code only surface against the scaffold's + shipped ruff config — this test runs it end to end.""" + ruff = shutil.which("ruff") + if ruff is None: + pytest.skip("ruff not installed") + + 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 + + fmt = subprocess.run( + [ruff, "format", "--check", "."], cwd=target, capture_output=True, text=True + ) + assert fmt.returncode == 0, f"`ruff format --check` failed:\n{fmt.stdout}\n{fmt.stderr}" + check = subprocess.run([ruff, "check", "."], cwd=target, capture_output=True, text=True) + assert check.returncode == 0, f"`ruff check` failed:\n{check.stdout}\n{check.stderr}" diff --git a/framework/cli/tests/test_scaffolding_host.py b/framework/cli/tests/test_scaffolding_host.py index 6864f8f3..b2e4c3da 100644 --- a/framework/cli/tests/test_scaffolding_host.py +++ b/framework/cli/tests/test_scaffolding_host.py @@ -177,6 +177,29 @@ async def test_main_py_loads_dotenv_before_settings(self, tmp_path): settings_import_idx = main_py.index("from simple_module_hosting import") assert dotenv_idx < settings_import_idx + async def test_main_py_pins_host_dir_on_syspath_before_chdir(self, tmp_path): + """Regression for #194: scaffolded main.py must pin the host dir on + ``sys.path`` (absolute) *before* ``os.chdir``, so ``from routes import`` + still resolves after the chdir. uvicorn launches the app as ``main:app`` + with ``sys.path[0] == ''`` (the cwd, resolved lazily); once main.py + chdirs to the repo root that entry points at the wrong dir and the + sibling ``routes`` module is no longer importable. + """ + from simple_module_cli.scaffolding import create_host + + dest = tmp_path / "demo" + create_host(dest, name="demo", modules=[]) + main_py = (dest / "main.py").read_text(encoding="utf-8") + + assert "sys.path.insert(0, str(_HOST_DIR))" in main_py + syspath_idx = main_py.index("sys.path.insert(0, str(_HOST_DIR))") + chdir_idx = main_py.index("os.chdir(") + # The real import statement (the comment uses "from routes import ..."). + routes_idx = main_py.index("from routes import router") + assert syspath_idx < chdir_idx < routes_idx, ( + "sys.path pin must precede os.chdir, which must precede the routes import" + ) + async def test_cli_create_host_runs_end_to_end(self, tmp_path): """The Click `smpy create-host` command produces a working scaffold.""" from simple_module_cli.cli import app diff --git a/framework/cli/tests/test_scaffolding_module.py b/framework/cli/tests/test_scaffolding_module.py index 302aaae4..c1765138 100644 --- a/framework/cli/tests/test_scaffolding_module.py +++ b/framework/cli/tests/test_scaffolding_module.py @@ -90,6 +90,51 @@ async def test_cli_create_module_runs_end_to_end(self, tmp_path): encoding="utf-8" ) + async def test_create_module_pins_framework_deps_when_version_given(self, tmp_path): + """Regression #195: create_module(..., framework_version=X) rewrites the + template's >=1.0,<2.0 ranges to ==X so `uv add ./modules/` resolves + against the framework version that created the module (pre-1.0 dists + don't satisfy >=1.0,<2.0).""" + from simple_module_cli.scaffolding import create_module + + dest = tmp_path / "simple-module-orders" + create_module(dest, name="Orders", framework_version="0.0.17") + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + + for pkg in ("simple_module_core", "simple_module_db", "simple_module_hosting"): + assert f"{pkg}==0.0.17" in pyproject + assert f"{pkg}>=1.0,<2.0" not in pyproject + # The dev extra's simple_module_test is pinned too (was >=0.1,<1.0). + assert "simple_module_test==0.0.17" in pyproject + # Non-framework deps are left untouched. + assert "pydantic-settings>=2.0" in pyproject + + async def test_create_module_keeps_ranges_without_version(self, tmp_path): + """Without framework_version the publishable template ranges are kept.""" + from simple_module_cli.scaffolding import create_module + + dest = tmp_path / "simple-module-orders" + create_module(dest, name="Orders") + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + assert "simple_module_core>=1.0,<2.0" in pyproject + + async def test_cli_create_module_pins_to_framework_version(self, tmp_path): + """The `smpy create-module` command pins framework deps so `uv add` + resolves into the app that created it (#195).""" + from simple_module_cli.cli import app + from simple_module_cli.scaffolding import resolve_framework_version + from typer.testing import CliRunner + + runner = CliRunner() + dest = tmp_path / "simple-module-orders" + result = runner.invoke(app, ["create-module", "Orders", "--dest", str(dest)]) + assert result.exit_code == 0, result.output + + pyproject = (dest / "pyproject.toml").read_text(encoding="utf-8") + version = resolve_framework_version() + assert f"simple_module_core=={version}" in pyproject + assert "simple_module_core>=1.0,<2.0" not in pyproject + async def test_scaffold_ships_github_workflows(self, tmp_path): """Gap 8: scaffolded modules include publish.yml + ci.yml.""" from simple_module_cli.scaffolding import create_module diff --git a/scripts/check_file_size.py b/scripts/check_file_size.py index 5ca16358..c5db9391 100644 --- a/scripts/check_file_size.py +++ b/scripts/check_file_size.py @@ -4,6 +4,11 @@ does not change the total. Exits 1 if any covered file exceeds ``--max`` (default 300). Exempt paths matching ``--exempt`` globs are skipped. +By default scans the git working tree — tracked files plus untracked files +that aren't gitignored — so a brand-new oversized file fails the check +before it's committed (#204). ``--no-git`` walks the filesystem instead +(does not honour ``.gitignore``). + Usage: uv run python scripts/check_file_size.py uv run python scripts/check_file_size.py --max 200 @@ -91,9 +96,17 @@ def _relative_for_match(path: Path, root: Path | None) -> Path: return path -def _list_git_tracked_files(root: Path) -> list[Path]: +def _list_git_files(root: Path) -> list[Path]: + """List tracked + untracked-but-not-ignored files via git. + + ``--cached`` covers tracked files and ``--others --exclude-standard`` + adds untracked files that aren't gitignored, so an oversized file is + caught the moment it's written — not only after it's committed (#204). + Gitignored paths (``.venv``, ``node_modules``, build output) stay out + of scope, which a bare filesystem walk would not respect. + """ result = subprocess.run( - ["git", "ls-files"], + ["git", "ls-files", "--cached", "--others", "--exclude-standard"], cwd=root, capture_output=True, text=True, @@ -107,7 +120,7 @@ def _walk_filesystem(root: Path) -> list[Path]: def _collect_candidates(root: Path, use_git: bool) -> list[Path]: - return _list_git_tracked_files(root) if use_git else _walk_filesystem(root) + return _list_git_files(root) if use_git else _walk_filesystem(root) def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: diff --git a/scripts/tests/test_check_file_size.py b/scripts/tests/test_check_file_size.py index a76a923f..bddabb09 100644 --- a/scripts/tests/test_check_file_size.py +++ b/scripts/tests/test_check_file_size.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path @@ -202,3 +203,63 @@ def test_default_exempt_globs_include_shadcn(self) -> None: def test_module_runs_as_script(self) -> None: assert hasattr(check_file_size, "main") assert callable(check_file_size.main) + + +def _git(args: list[str], cwd: Path) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +def _init_repo(root: Path) -> None: + _git(["init"], root) + _git(["config", "user.email", "test@example.com"], root) + _git(["config", "user.name", "Test"], root) + _git(["config", "commit.gpgsign", "false"], root) + + +class TestGitCandidateCollection: + """Default (git) mode must scan the working tree, not just the index. + + Regression for GH #204: an oversized file that is untracked but not + gitignored used to pass ``make lint`` (git ls-files lists only tracked + paths) and then fail only after being committed. + """ + + def test_default_scan_catches_untracked_not_ignored( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + _init_repo(tmp_path) + tracked = tmp_path / "tracked.py" + tracked.write_text("a\n" * 320, encoding="utf-8") + _git(["add", "tracked.py"], tmp_path) + _git(["commit", "-m", "init"], tmp_path) + + untracked = tmp_path / "untracked.py" + untracked.write_text("a\n" * 330, encoding="utf-8") + + (tmp_path / ".gitignore").write_text("ignored.py\n", encoding="utf-8") + ignored = tmp_path / "ignored.py" + ignored.write_text("a\n" * 340, encoding="utf-8") + + exit_code = main(["--root", str(tmp_path)]) # default = git mode + out = capsys.readouterr().out + + assert exit_code == 1 + assert "tracked.py" in out + assert "untracked.py" in out, "untracked-but-not-ignored file must be scanned (#204)" + assert "ignored.py" not in out, "gitignored files stay out of scope" + + def test_default_scan_passes_when_only_violation_is_ignored( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + _init_repo(tmp_path) + (tmp_path / "ok.py").write_text("a\n" * 10, encoding="utf-8") + _git(["add", "ok.py"], tmp_path) + _git(["commit", "-m", "init"], tmp_path) + + (tmp_path / ".gitignore").write_text("build/\n", encoding="utf-8") + build = tmp_path / "build" + build.mkdir() + (build / "huge.py").write_text("a\n" * 500, encoding="utf-8") + + assert main(["--root", str(tmp_path)]) == 0 + assert "huge.py" not in capsys.readouterr().out