From c5f1d37c9048752d313646a0a8c3001abd2185dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 21:57:57 +0000 Subject: [PATCH 1/5] feat: add module scaffolding CLI (make new-module name=) Automates new module creation, eliminating the need to manually create 14+ files and update config. The script generates the full module structure (pyproject.toml, module class, models, service, schemas, contracts, endpoints, deps, tests) and registers the module in host/pyproject.toml and root pyproject.toml. Also updates conftest.py to auto-discover module Base classes via entry points, so new modules' tables are automatically created in the test database. https://claude.ai/code/session_01G5HHXJfxAQ53grAwiyUzyj --- Makefile | 8 +- conftest.py | 28 +- scripts/new_module.py | 759 +++++++++++++++++++++++++++++++ scripts/tests/__init__.py | 0 scripts/tests/test_new_module.py | 242 ++++++++++ 5 files changed, 1030 insertions(+), 7 deletions(-) create mode 100644 scripts/new_module.py create mode 100644 scripts/tests/__init__.py create mode 100644 scripts/tests/test_new_module.py diff --git a/Makefile b/Makefile index 838b2175..7303dc18 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill +.PHONY: install dev dev-api dev-ui build test lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module # Install install: @@ -48,6 +48,12 @@ downgrade: ## Downgrade one revision migration-history: ## Show migration history cd host && uv run alembic history --verbose +# Scaffolding +new-module: ## Scaffold a new module (usage: make new-module name=orders) + @test -n "$(name)" || (echo "Error: Please provide a module name, e.g. make new-module name=orders" && exit 1) + uv run python scripts/new_module.py $(name) + uv sync --all-packages + # Kill dev servers kill: @echo "Stopping dev servers..." diff --git a/conftest.py b/conftest.py index dfa02aaa..8d7acba2 100644 --- a/conftest.py +++ b/conftest.py @@ -38,13 +38,30 @@ async def engine(db_state: DatabaseState) -> AsyncEngine: return db_state.engine +def _collect_module_bases(): + """Discover all module SQLAlchemy Base classes via entry points.""" + import importlib + + from importlib.metadata import entry_points + + bases = [] + for ep in entry_points(group="simple_module"): + pkg = ep.value.split(".")[0] # e.g. "sm_products" + try: + mod = importlib.import_module(f"{pkg}.models") + if hasattr(mod, "Base"): + bases.append(mod.Base) + except (ImportError, ModuleNotFoundError): + pass + return bases + + @pytest.fixture async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]: """Yield an async session backed by in-memory SQLite.""" - from sm_products.models import Base - async with db_state.engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + for base in _collect_module_bases(): + await conn.run_sync(base.metadata.create_all) async with db_state.session_factory() as session: yield session @@ -57,10 +74,9 @@ async def app(settings: Settings): application = create_app(settings) - from sm_products.models import Base as ProductsBase - async with application.state.db.engine.begin() as conn: - await conn.run_sync(ProductsBase.metadata.create_all) + for base in _collect_module_bases(): + await conn.run_sync(base.metadata.create_all) # Trigger lifespan startup so app.state.migration is populated ctx = application.router.lifespan_context(application) diff --git a/scripts/new_module.py b/scripts/new_module.py new file mode 100644 index 00000000..aad7362e --- /dev/null +++ b/scripts/new_module.py @@ -0,0 +1,759 @@ +#!/usr/bin/env python3 +"""Scaffold a new module for the Simple Module Python framework. + +Usage: + python scripts/new_module.py + make new-module name= + +Creates the full module directory structure under modules// with all +required files (pyproject.toml, module class, models, service, schemas, +endpoints, tests) and registers the module in host/pyproject.toml and +the root pyproject.toml. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def validate_name(name: str) -> str: + """Validate module name: lowercase, alphanumeric, underscores allowed.""" + if not re.match(r"^[a-z][a-z0-9_]*$", name): + print( + f"Error: Module name '{name}' is invalid. " + "Use lowercase letters, digits, and underscores. Must start with a letter.", + file=sys.stderr, + ) + sys.exit(1) + 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"): + return name[:-1] + return name + + +def create_file(path: Path, content: str) -> None: + """Create a file with the given content, creating parent dirs as needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content).lstrip("\n")) + print(f" created {path.relative_to(ROOT)}") + + +def scaffold_module(name: str) -> None: + """Generate all files for a new module.""" + module_dir = ROOT / "modules" / name + if module_dir.exists(): + print(f"Error: Module directory modules/{name}/ already exists.", file=sys.stderr) + sys.exit(1) + + class_name = to_class_name(name) + singular = to_singular(name) + singular_class = to_class_name(singular) + pkg = f"sm_{name}" + src_dir = module_dir / "src" / pkg + + print(f"Scaffolding module '{name}'...") + + # ── pyproject.toml ────────────────────────────────────────── + create_file( + module_dir / "pyproject.toml", + f"""\ + [project] + name = "{pkg.replace('_', '-')}" + version = "0.1.0" + description = "The {class_name} module" + authors = [] + requires-python = ">=3.12" + dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", + ] + + [project.entry-points.simple_module] + {name} = "{pkg}.module:{class_name}Module" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + + [tool.uv.sources] + simple-module-core = {{ workspace = true }} + simple-module-db = {{ workspace = true }} + simple-module-hosting = {{ workspace = true }} + """, + ) + + # ── __init__.py ───────────────────────────────────────────── + create_file( + src_dir / "__init__.py", + f"""\ + \"""{class_name} module.\""" + """, + ) + + # ── py.typed ──────────────────────────────────────────────── + create_file(src_dir / "py.typed", "") + + # ── module.py ─────────────────────────────────────────────── + create_file( + src_dir / "module.py", + f"""\ + \"""{class_name} module definition.\""" + + from __future__ import annotations + + from fastapi import APIRouter + from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection + from simple_module_core.module import ModuleBase, ModuleMeta + from simple_module_core.permissions import PermissionRegistry + + + class {class_name}Module(ModuleBase): + meta = ModuleMeta( + name="{class_name}", + route_prefix="/api/{name}", + view_prefix="/{name}", + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from {pkg}.endpoints.api import router as api + from {pkg}.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="{class_name}", + url="/{name}", + icon="box", + order=30, + section=MenuSection.SIDEBAR, + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group( + "{class_name}", + [ + "{name}.view", + "{name}.create", + "{name}.edit", + "{name}.delete", + ], + ) + """, + ) + + # ── models.py ─────────────────────────────────────────────── + create_file( + src_dir / "models.py", + f"""\ + \"""SQLAlchemy models for the {class_name} module.\""" + + from __future__ import annotations + + from simple_module_db.base import create_module_base + from simple_module_db.mixins import AuditMixin + from simple_module_db.provider import DatabaseProvider + from sqlalchemy import String + from sqlalchemy.orm import Mapped, mapped_column + + Base = create_module_base("{name}", provider=DatabaseProvider.SQLITE) + + + class {singular_class}(Base, AuditMixin): # ty: ignore[unsupported-base] + \"""A {singular} entity.\""" + + __tablename__ = "{name}_{singular}" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(200)) + description: Mapped[str | None] = mapped_column(String(2000), default=None) + is_active: Mapped[bool] = mapped_column(default=True) + """, + ) + + # ── contracts/__init__.py ─────────────────────────────────── + create_file( + src_dir / "contracts" / "__init__.py", + f"""\ + \"""{class_name} contracts — public interface for other modules.\""" + + from {pkg}.contracts.schemas import ( + {singular_class}Create, + {singular_class}Out, + {singular_class}Update, + ) + from {pkg}.contracts.service import I{singular_class}Service + + __all__ = [ + "{singular_class}Create", + "{singular_class}Out", + "{singular_class}Update", + "I{singular_class}Service", + ] + """, + ) + + # ── contracts/schemas.py ──────────────────────────────────── + create_file( + src_dir / "contracts" / "schemas.py", + f"""\ + \"""Pydantic DTOs for the {class_name} module.\""" + + from __future__ import annotations + + from datetime import datetime + + from pydantic import BaseModel, ConfigDict, Field + + + class {singular_class}Out(BaseModel): + \"""{singular_class} data returned by the API.\""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: str | None = None + is_active: bool + created_at: datetime | None = None + updated_at: datetime | None = None + + + class {singular_class}Create(BaseModel): + \"""Data required to create a new {singular}.\""" + + name: str = Field(min_length=1, max_length=200) + description: str | None = None + + + class {singular_class}Update(BaseModel): + \"""Data to update an existing {singular}. All fields optional.\""" + + name: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = None + is_active: bool | None = None + """, + ) + + # ── contracts/service.py ──────────────────────────────────── + create_file( + src_dir / "contracts" / "service.py", + f"""\ + \"""{singular_class} service protocol — the public contract other modules depend on.\""" + + from __future__ import annotations + + from typing import Protocol + + from {pkg}.contracts.schemas import ( + {singular_class}Create, + {singular_class}Out, + {singular_class}Update, + ) + + + class I{singular_class}Service(Protocol): + \"""Interface for {singular} operations.\""" + + async def get_all(self) -> list[{singular_class}Out]: ... + async def get_by_id(self, {singular}_id: int) -> {singular_class}Out | None: ... + async def create(self, data: {singular_class}Create) -> {singular_class}Out: ... + async def update( + self, {singular}_id: int, data: {singular_class}Update + ) -> {singular_class}Out | None: ... + async def delete(self, {singular}_id: int) -> bool: ... + """, + ) + + # ── service.py ────────────────────────────────────────────── + create_file( + src_dir / "service.py", + f"""\ + \"""{singular_class} service implementation.\""" + + from __future__ import annotations + + from sqlalchemy import select + from sqlalchemy.ext.asyncio import AsyncSession + + from {pkg}.contracts.schemas import ( + {singular_class}Create, + {singular_class}Out, + {singular_class}Update, + ) + from {pkg}.models import {singular_class} + + + class {singular_class}Service: + \"""CRUD operations for {name}.\""" + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_all(self) -> list[{singular_class}Out]: + result = await self.db.execute( + select({singular_class}) + .where({singular_class}.is_active.is_(True)) + .order_by({singular_class}.id) + ) + return [{singular_class}Out.model_validate(row) for row in result.scalars()] + + async def get_by_id(self, {singular}_id: int) -> {singular_class}Out | None: + entity = await self.db.get({singular_class}, {singular}_id) + if entity is None: + return None + return {singular_class}Out.model_validate(entity) + + async def create(self, data: {singular_class}Create) -> {singular_class}Out: + entity = {singular_class}(**data.model_dump()) + self.db.add(entity) + await self.db.flush() + await self.db.refresh(entity) + return {singular_class}Out.model_validate(entity) + + async def update( + self, {singular}_id: int, data: {singular_class}Update + ) -> {singular_class}Out | None: + entity = await self.db.get({singular_class}, {singular}_id) + if entity is None: + return None + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(entity, field, value) + await self.db.flush() + await self.db.refresh(entity) + return {singular_class}Out.model_validate(entity) + + async def delete(self, {singular}_id: int) -> bool: + entity = await self.db.get({singular_class}, {singular}_id) + if entity is None: + return False + await self.db.delete(entity) + return True + """, + ) + + # ── deps.py ───────────────────────────────────────────────── + create_file( + src_dir / "deps.py", + f"""\ + \"""FastAPI dependencies for the {class_name} module.\""" + + from __future__ import annotations + + from fastapi import Depends + from simple_module_db.deps import get_db + from sqlalchemy.ext.asyncio import AsyncSession + + from {pkg}.service import {singular_class}Service + + + async def get_{singular}_service( + db: AsyncSession = Depends(get_db), + ) -> {singular_class}Service: + return {singular_class}Service(db) + """, + ) + + # ── endpoints/__init__.py ─────────────────────────────────── + create_file(src_dir / "endpoints" / "__init__.py", "") + + # ── endpoints/api.py ──────────────────────────────────────── + create_file( + src_dir / "endpoints" / "api.py", + f"""\ + \"""REST API endpoints for {class_name}.\""" + + from __future__ import annotations + + from fastapi import APIRouter, Depends, HTTPException + + from {pkg}.contracts.schemas import ( + {singular_class}Create, + {singular_class}Out, + {singular_class}Update, + ) + from {pkg}.deps import get_{singular}_service + from {pkg}.service import {singular_class}Service + + router = APIRouter() + + + @router.get("/", response_model=list[{singular_class}Out]) + async def list_{name}( + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> list[{singular_class}Out]: + return await service.get_all() + + + @router.get("/{{{singular}_id}}", response_model={singular_class}Out) + async def get_{singular}( + {singular}_id: int, + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> {singular_class}Out: + result = await service.get_by_id({singular}_id) + if result is None: + raise HTTPException(status_code=404, detail="{singular_class} not found") + return result + + + @router.post("/", response_model={singular_class}Out, status_code=201) + async def create_{singular}( + data: {singular_class}Create, + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> {singular_class}Out: + return await service.create(data) + + + @router.put("/{{{singular}_id}}", response_model={singular_class}Out) + async def update_{singular}( + {singular}_id: int, + data: {singular_class}Update, + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> {singular_class}Out: + result = await service.update({singular}_id, data) + if result is None: + raise HTTPException(status_code=404, detail="{singular_class} not found") + return result + + + @router.delete("/{{{singular}_id}}", status_code=204) + async def delete_{singular}( + {singular}_id: int, + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> None: + deleted = await service.delete({singular}_id) + if not deleted: + raise HTTPException(status_code=404, detail="{singular_class} not found") + """, + ) + + # ── endpoints/views.py ────────────────────────────────────── + create_file( + src_dir / "endpoints" / "views.py", + f"""\ + \"""Inertia view endpoints for {class_name}.\""" + + from __future__ import annotations + + from fastapi import APIRouter, Depends + from inertia import InertiaResponse + from simple_module_hosting.inertia_deps import InertiaDep + + from {pkg}.deps import get_{singular}_service + from {pkg}.service import {singular_class}Service + + router = APIRouter() + + + @router.get("/", response_model=None) + async def browse( + inertia: InertiaDep, + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> InertiaResponse: + items = await service.get_all() + return await inertia.render( + "{class_name}/Browse", + {{"{name}": [item.model_dump(mode="json") for item in items]}}, + ) + + + @router.get("/create", response_model=None) + async def create_view(inertia: InertiaDep) -> InertiaResponse: + return await inertia.render("{class_name}/Create") + + + @router.get("/{{{singular}_id}}/edit", response_model=None) + async def edit_view( + {singular}_id: int, + inertia: InertiaDep, + service: {singular_class}Service = Depends(get_{singular}_service), + ) -> InertiaResponse: + item = await service.get_by_id({singular}_id) + if item is None: + return await inertia.render( + "{class_name}/Browse", + {{"error": "{singular_class} not found"}}, + ) + return await inertia.render( + "{class_name}/Edit", + {{"{singular}": item.model_dump(mode="json")}}, + ) + """, + ) + + # ── tests/test_.py ──────────────────────────────────── + create_file( + module_dir / "tests" / f"test_{name}.py", + f"""\ + \"""Tests for the {class_name} module: service CRUD, API endpoints, schema validation.\""" + + from __future__ import annotations + + import httpx + import pytest + from pydantic import ValidationError + from {pkg}.contracts.schemas import {singular_class}Create, {singular_class}Update + from {pkg}.service import {singular_class}Service + from sqlalchemy.ext.asyncio import AsyncSession + + # ── Schema validation ──────────────────────────────────────────────── + + + class Test{singular_class}Schemas: + async def test_create_valid(self): + data = {singular_class}Create(name="Test {singular_class}") + assert data.name == "Test {singular_class}" + assert data.description is None + + async def test_create_empty_name_rejected(self): + with pytest.raises(ValidationError): + {singular_class}Create(name="") + + async def test_update_all_optional(self): + data = {singular_class}Update() + assert data.name is None + assert data.is_active is None + + + # ── {singular_class}Service CRUD ────────────────────────────────────────────── + + + class Test{singular_class}Service: + async def test_create(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + item = await svc.create({singular_class}Create(name="Test")) + assert item.id is not None + assert item.name == "Test" + assert item.is_active is True + + async def test_get_all(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + await svc.create({singular_class}Create(name="A")) + await svc.create({singular_class}Create(name="B")) + items = await svc.get_all() + assert len(items) == 2 + + async def test_get_by_id(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + created = await svc.create({singular_class}Create(name="X")) + found = await svc.get_by_id(created.id) + assert found is not None + assert found.name == "X" + + async def test_get_by_id_not_found(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + found = await svc.get_by_id(999) + assert found is None + + async def test_update(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + created = await svc.create({singular_class}Create(name="Old")) + updated = await svc.update(created.id, {singular_class}Update(name="New")) + assert updated is not None + assert updated.name == "New" + + async def test_update_not_found(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + result = await svc.update(999, {singular_class}Update(name="Ghost")) + assert result is None + + async def test_delete(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + created = await svc.create({singular_class}Create(name="Doomed")) + deleted = await svc.delete(created.id) + assert deleted is True + + async def test_delete_not_found(self, db_session: AsyncSession): + svc = {singular_class}Service(db_session) + deleted = await svc.delete(999) + assert deleted is False + + + # ── API endpoints ─────────────────────────────────────────────── + + + class Test{class_name}API: + async def test_list_empty(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/{name}/") + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_create(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.post( + "/api/{name}/", + json={{"name": "Test {singular_class}"}}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test {singular_class}" + assert data["id"] is not None + + async def test_get_by_id(self, authenticated_client: httpx.AsyncClient): + create_resp = await authenticated_client.post( + "/api/{name}/", + json={{"name": "Findable"}}, + ) + item_id = create_resp.json()["id"] + resp = await authenticated_client.get(f"/api/{name}/{{item_id}}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Findable" + + async def test_get_not_found(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.get("/api/{name}/99999") + assert resp.status_code == 404 + + async def test_update(self, authenticated_client: httpx.AsyncClient): + create_resp = await authenticated_client.post( + "/api/{name}/", + json={{"name": "Original"}}, + ) + item_id = create_resp.json()["id"] + resp = await authenticated_client.put( + f"/api/{name}/{{item_id}}", + json={{"name": "Updated"}}, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated" + + async def test_delete(self, authenticated_client: httpx.AsyncClient): + create_resp = await authenticated_client.post( + "/api/{name}/", + json={{"name": "Deletable"}}, + ) + item_id = create_resp.json()["id"] + resp = await authenticated_client.delete(f"/api/{name}/{{item_id}}") + assert resp.status_code == 204 + + async def test_delete_not_found(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.delete("/api/{name}/99999") + assert resp.status_code == 404 + + async def test_create_invalid_data(self, authenticated_client: httpx.AsyncClient): + resp = await authenticated_client.post( + "/api/{name}/", + json={{"name": ""}}, + ) + assert resp.status_code == 422 + + + # ── Module lifecycle ──────────────────────────────────────────────── + + + class Test{class_name}ModuleLifecycle: + async def test_on_startup_does_not_call_create_all(self): + \"""on_startup should not create tables — Alembic manages schema.\""" + from unittest.mock import AsyncMock, MagicMock + + from {pkg}.module import {class_name}Module + + mod = {class_name}Module() + mock_app = MagicMock() + mock_app.state.db.engine = AsyncMock() + + await mod.on_startup(mock_app) + + mock_app.state.db.engine.begin.assert_not_called() + """, + ) + + +def update_host_pyproject(name: str) -> None: + """Add the new module as a dependency in host/pyproject.toml.""" + host_toml = ROOT / "host" / "pyproject.toml" + content = host_toml.read_text() + pkg = f"sm-{name.replace('_', '-')}" + + if pkg in content: + print(f" host/pyproject.toml already contains {pkg}, skipping") + return + + # Add to [project] dependencies + content = content.replace( + ' "sm-products",', + f' "sm-products",\n "{pkg}",', + ) + + # Add to [tool.uv.sources] + content = content.replace( + "sm-products = { workspace = true }", + f"sm-products = {{ workspace = true }}\n{pkg} = {{ workspace = true }}", + ) + + host_toml.write_text(content) + print(f" updated host/pyproject.toml (added {pkg})") + + +def update_root_pyproject(name: str) -> None: + """Add the module to type-checking paths and test paths in root pyproject.toml.""" + root_toml = ROOT / "pyproject.toml" + content = root_toml.read_text() + src_path = f"modules/{name}/src" + + if src_path in content: + print(f" root pyproject.toml already contains {src_path}, skipping") + return + + # Add to [tool.ty.environment] extra-paths + content = content.replace( + ' "modules/products/src",', + f' "modules/products/src",\n "{src_path}",', + ) + + # Add to [tool.pytest.ini_options] testpaths + content = content.replace( + '"modules/products/tests"', + f'"modules/products/tests", "modules/{name}/tests"', + ) + + root_toml.write_text(content) + print(" updated pyproject.toml (added type-check path + test path)") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Scaffold a new module for Simple Module Python", + ) + parser.add_argument( + "name", + help="Module name in snake_case (e.g. 'orders', 'blog_posts')", + ) + args = parser.parse_args() + + name = validate_name(args.name) + + scaffold_module(name) + update_host_pyproject(name) + update_root_pyproject(name) + + print() + print(f"Module '{name}' scaffolded successfully!") + print() + print("Next steps:") + print(" 1. Run 'uv sync --all-packages' to install the new module") + print(f" 2. Edit modules/{name}/src/sm_{name}/models.py to define your domain model") + print(" 3. Update schemas, service, and endpoints to match your model") + print(f' 4. Run \'make migration msg="add {name} tables"\' to create a migration') + print(" 5. Run 'make test' to verify everything works") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/tests/test_new_module.py b/scripts/tests/test_new_module.py new file mode 100644 index 00000000..b8f3271a --- /dev/null +++ b/scripts/tests/test_new_module.py @@ -0,0 +1,242 @@ +"""Tests for the new_module scaffolding script.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Make sure the scripts directory is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from new_module import ( + scaffold_module, + to_class_name, + to_singular, + update_host_pyproject, + update_root_pyproject, + validate_name, +) + + +class TestValidateName: + def test_valid_simple_name(self): + assert validate_name("orders") == "orders" + + def test_valid_name_with_underscore(self): + assert validate_name("blog_posts") == "blog_posts" + + def test_valid_name_with_digits(self): + assert validate_name("v2_items") == "v2_items" + + def test_rejects_uppercase(self): + with pytest.raises(SystemExit): + validate_name("Orders") + + def test_rejects_hyphens(self): + with pytest.raises(SystemExit): + validate_name("blog-posts") + + def test_rejects_starting_with_digit(self): + with pytest.raises(SystemExit): + validate_name("2fast") + + def test_rejects_empty(self): + with pytest.raises(SystemExit): + validate_name("") + + +class TestToClassName: + def test_single_word(self): + assert to_class_name("orders") == "Orders" + + def test_two_words(self): + assert to_class_name("blog_posts") == "BlogPosts" + + def test_three_words(self): + assert to_class_name("user_role_assignments") == "UserRoleAssignments" + + +class TestToSingular: + def test_plural(self): + assert to_singular("orders") == "order" + + def test_already_singular(self): + assert to_singular("inventory") == "inventory" + + def test_double_s(self): + assert to_singular("access") == "access" + + def test_plural_compound(self): + assert to_singular("blog_posts") == "blog_post" + + +class TestScaffoldModule: + """Integration tests that run the scaffolding and verify output files.""" + + def test_scaffold_creates_all_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + + # Create the prerequisite directory structure + (tmp_path / "modules").mkdir() + + scaffold_module("orders") + + mod_dir = tmp_path / "modules" / "orders" + src_dir = mod_dir / "src" / "sm_orders" + + # Verify all expected files exist + expected_files = [ + mod_dir / "pyproject.toml", + src_dir / "__init__.py", + src_dir / "py.typed", + src_dir / "module.py", + src_dir / "models.py", + src_dir / "service.py", + src_dir / "deps.py", + src_dir / "contracts" / "__init__.py", + src_dir / "contracts" / "schemas.py", + src_dir / "contracts" / "service.py", + src_dir / "endpoints" / "__init__.py", + src_dir / "endpoints" / "api.py", + src_dir / "endpoints" / "views.py", + mod_dir / "tests" / "test_orders.py", + ] + for f in expected_files: + assert f.exists(), f"Missing: {f.relative_to(tmp_path)}" + + def test_scaffold_pyproject_has_entry_point( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules").mkdir() + + scaffold_module("orders") + + content = (tmp_path / "modules" / "orders" / "pyproject.toml").read_text() + assert 'orders = "sm_orders.module:OrdersModule"' in content + + def test_scaffold_module_class_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules").mkdir() + + scaffold_module("orders") + + content = (tmp_path / "modules" / "orders" / "src" / "sm_orders" / "module.py").read_text() + assert "class OrdersModule(ModuleBase):" in content + assert 'name="Orders"' in content + + def test_scaffold_model_is_singular( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules").mkdir() + + scaffold_module("orders") + + content = (tmp_path / "modules" / "orders" / "src" / "sm_orders" / "models.py").read_text() + assert "class Order(Base, AuditMixin):" in content + + def test_scaffold_rejects_duplicate( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules" / "orders").mkdir(parents=True) + + with pytest.raises(SystemExit): + scaffold_module("orders") + + def test_scaffold_compound_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules").mkdir() + + scaffold_module("blog_posts") + + src_dir = tmp_path / "modules" / "blog_posts" / "src" / "sm_blog_posts" + assert (src_dir / "module.py").exists() + + module_content = (src_dir / "module.py").read_text() + assert "class BlogPostsModule(ModuleBase):" in module_content + + model_content = (src_dir / "models.py").read_text() + assert "class BlogPost(Base, AuditMixin):" in model_content + + schema_content = (src_dir / "contracts" / "schemas.py").read_text() + assert "class BlogPostOut(BaseModel):" in schema_content + assert "class BlogPostCreate(BaseModel):" in schema_content + assert "class BlogPostUpdate(BaseModel):" in schema_content + + +class TestUpdateHostPyproject: + def test_adds_module_dependency(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + + host_dir = tmp_path / "host" + host_dir.mkdir() + (host_dir / "pyproject.toml").write_text( + '[project]\nname = "simple-module-host"\ndependencies = [\n' + ' "sm-products",\n]\n\n[tool.uv.sources]\n' + "sm-products = { workspace = true }\n" + ) + + update_host_pyproject("orders") + + content = (host_dir / "pyproject.toml").read_text() + assert '"sm-orders"' in content + assert "sm-orders = { workspace = true }" in content + + def test_skips_if_already_present(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + + host_dir = tmp_path / "host" + host_dir.mkdir() + original = ( + '[project]\nname = "simple-module-host"\ndependencies = [\n' + ' "sm-products",\n "sm-orders",\n]\n\n[tool.uv.sources]\n' + "sm-products = { workspace = true }\nsm-orders = { workspace = true }\n" + ) + (host_dir / "pyproject.toml").write_text(original) + + update_host_pyproject("orders") + + assert (host_dir / "pyproject.toml").read_text() == original + + +class TestUpdateRootPyproject: + def test_adds_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "pyproject.toml").write_text( + "[tool.ty.environment]\nextra-paths = [\n" + ' "modules/products/src",\n]\n\n' + "[tool.pytest.ini_options]\n" + 'testpaths = ["modules/products/tests"]\n' + ) + + update_root_pyproject("orders") + + content = (tmp_path / "pyproject.toml").read_text() + assert '"modules/orders/src"' in content + assert '"modules/orders/tests"' in content From af6a5966d2b36e51b0006a0ee3303a146d778745 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 22:06:15 +0000 Subject: [PATCH 2/5] refactor: simplify scaffolding code from review findings - conftest.py: reuse framework's discover_modules() + all_module_bases instead of reimplementing entry point discovery; cache with lru_cache; batch create_all into single run_sync; extract _create_all_tables helper - new_module.py: replace hardcoded anchor strings with last-entry insertion via _insert_before_last(); add warnings on missing anchors - test_new_module.py: extract repeated setup into module_root fixture https://claude.ai/code/session_01G5HHXJfxAQ53grAwiyUzyj --- conftest.py | 43 ++++++++------- scripts/new_module.py | 79 ++++++++++++++++++++------ scripts/tests/test_new_module.py | 95 +++++++++++--------------------- 3 files changed, 115 insertions(+), 102 deletions(-) diff --git a/conftest.py b/conftest.py index 8d7acba2..c56166f2 100644 --- a/conftest.py +++ b/conftest.py @@ -2,10 +2,15 @@ from __future__ import annotations +import contextlib +import importlib from collections.abc import AsyncGenerator +from functools import lru_cache import httpx import pytest +from simple_module_core.discovery import discover_modules +from simple_module_db.base import all_module_bases from simple_module_db.session import DatabaseState, init_db from simple_module_hosting.settings import Settings from sqlalchemy.ext.asyncio import ( @@ -38,30 +43,30 @@ async def engine(db_state: DatabaseState) -> AsyncEngine: return db_state.engine -def _collect_module_bases(): - """Discover all module SQLAlchemy Base classes via entry points.""" - import importlib +@lru_cache(maxsize=1) +def _ensure_models_imported() -> list: + """Import all module models so all_module_bases is populated (cached).""" + for mod in discover_modules(): + pkg = type(mod).__module__.split(".")[0] + with contextlib.suppress(ModuleNotFoundError): + importlib.import_module(f"{pkg}.models") + return list(all_module_bases) - from importlib.metadata import entry_points - bases = [] - for ep in entry_points(group="simple_module"): - pkg = ep.value.split(".")[0] # e.g. "sm_products" - try: - mod = importlib.import_module(f"{pkg}.models") - if hasattr(mod, "Base"): - bases.append(mod.Base) - except (ImportError, ModuleNotFoundError): - pass - return bases +async def _create_all_tables(engine) -> None: + """Create all module tables in a single connection.""" + bases = _ensure_models_imported() + async with engine.begin() as conn: + def _sync_create_all(sync_conn): + for base in bases: + base.metadata.create_all(sync_conn) + await conn.run_sync(_sync_create_all) @pytest.fixture async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]: """Yield an async session backed by in-memory SQLite.""" - async with db_state.engine.begin() as conn: - for base in _collect_module_bases(): - await conn.run_sync(base.metadata.create_all) + await _create_all_tables(db_state.engine) async with db_state.session_factory() as session: yield session @@ -74,9 +79,7 @@ async def app(settings: Settings): application = create_app(settings) - async with application.state.db.engine.begin() as conn: - for base in _collect_module_bases(): - await conn.run_sync(base.metadata.create_all) + await _create_all_tables(application.state.db.engine) # Trigger lifespan startup so app.state.migration is populated ctx = application.router.lifespan_context(application) diff --git a/scripts/new_module.py b/scripts/new_module.py index aad7362e..fc2b59d7 100644 --- a/scripts/new_module.py +++ b/scripts/new_module.py @@ -676,6 +676,18 @@ async def test_on_startup_does_not_call_create_all(self): ) +def _insert_before_last(content: str, marker: str, insertion: str) -> str | None: + """Insert ``insertion`` after the last occurrence of ``marker`` in ``content``. + + Returns the modified content, or None if the marker was not found. + """ + idx = content.rfind(marker) + if idx == -1: + return None + end = idx + len(marker) + return content[:end] + insertion + content[end:] + + def update_host_pyproject(name: str) -> None: """Add the new module as a dependency in host/pyproject.toml.""" host_toml = ROOT / "host" / "pyproject.toml" @@ -686,17 +698,33 @@ def update_host_pyproject(name: str) -> None: print(f" host/pyproject.toml already contains {pkg}, skipping") return - # Add to [project] dependencies - content = content.replace( - ' "sm-products",', - f' "sm-products",\n "{pkg}",', + original = content + + # Add to [project] dependencies — insert after last existing "sm-*" dep + result = _insert_before_last(content, ",\n]", f'\n "{pkg}",\n]') + if result and result != content: + content = result.replace(",\n]\n]", ",\n]", 1) # fix double bracket + # Fallback: try inserting after any quoted dependency line + if content == original: + result = _insert_before_last(content, '",\n', f'"\n "{pkg}",\n') + if result: + content = result + + # Add to [tool.uv.sources] — insert after last workspace source + result = _insert_before_last( + content, + "{ workspace = true }", + f"\n{pkg} = {{ workspace = true }}", ) + if result: + content = result - # Add to [tool.uv.sources] - content = content.replace( - "sm-products = { workspace = true }", - f"sm-products = {{ workspace = true }}\n{pkg} = {{ workspace = true }}", - ) + if content == original: + print( + f" warning: could not find insertion point in host/pyproject.toml for {pkg}", + file=sys.stderr, + ) + return host_toml.write_text(content) print(f" updated host/pyproject.toml (added {pkg})") @@ -712,17 +740,32 @@ def update_root_pyproject(name: str) -> None: print(f" root pyproject.toml already contains {src_path}, skipping") return - # Add to [tool.ty.environment] extra-paths - content = content.replace( - ' "modules/products/src",', - f' "modules/products/src",\n "{src_path}",', - ) + original = content - # Add to [tool.pytest.ini_options] testpaths - content = content.replace( - '"modules/products/tests"', - f'"modules/products/tests", "modules/{name}/tests"', + # Add to [tool.ty.environment] extra-paths — insert after last modules/*/src entry + result = _insert_before_last( + content, + '/src",', + f'\n "{src_path}",', + ) + if result: + content = result + + # Add to [tool.pytest.ini_options] testpaths — insert after last modules/*/tests entry + result = _insert_before_last( + content, + '/tests"', + f', "modules/{name}/tests"', ) + if result: + content = result + + if content == original: + print( + " warning: could not find insertion point in pyproject.toml", + file=sys.stderr, + ) + return root_toml.write_text(content) print(" updated pyproject.toml (added type-check path + test path)") diff --git a/scripts/tests/test_new_module.py b/scripts/tests/test_new_module.py index b8f3271a..956caa06 100644 --- a/scripts/tests/test_new_module.py +++ b/scripts/tests/test_new_module.py @@ -72,23 +72,25 @@ def test_plural_compound(self): assert to_singular("blog_posts") == "blog_post" -class TestScaffoldModule: - """Integration tests that run the scaffolding and verify output files.""" +@pytest.fixture +def module_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Provide a temp directory patched as the script's ROOT with modules/ pre-created.""" + import new_module - def test_scaffold_creates_all_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - import new_module + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules").mkdir() + return tmp_path - monkeypatch.setattr(new_module, "ROOT", tmp_path) - # Create the prerequisite directory structure - (tmp_path / "modules").mkdir() +class TestScaffoldModule: + """Integration tests that run the scaffolding and verify output files.""" + def test_scaffold_creates_all_files(self, module_root: Path): scaffold_module("orders") - mod_dir = tmp_path / "modules" / "orders" + mod_dir = module_root / "modules" / "orders" src_dir = mod_dir / "src" / "sm_orders" - # Verify all expected files exist expected_files = [ mod_dir / "pyproject.toml", src_dir / "__init__.py", @@ -106,46 +108,29 @@ def test_scaffold_creates_all_files(self, tmp_path: Path, monkeypatch: pytest.Mo mod_dir / "tests" / "test_orders.py", ] for f in expected_files: - assert f.exists(), f"Missing: {f.relative_to(tmp_path)}" - - def test_scaffold_pyproject_has_entry_point( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "modules").mkdir() + assert f.exists(), f"Missing: {f.relative_to(module_root)}" + def test_scaffold_pyproject_has_entry_point(self, module_root: Path): scaffold_module("orders") - content = (tmp_path / "modules" / "orders" / "pyproject.toml").read_text() + content = (module_root / "modules" / "orders" / "pyproject.toml").read_text() assert 'orders = "sm_orders.module:OrdersModule"' in content - def test_scaffold_module_class_name( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "modules").mkdir() - + def test_scaffold_module_class_name(self, module_root: Path): scaffold_module("orders") - content = (tmp_path / "modules" / "orders" / "src" / "sm_orders" / "module.py").read_text() + content = ( + module_root / "modules" / "orders" / "src" / "sm_orders" / "module.py" + ).read_text() assert "class OrdersModule(ModuleBase):" in content assert 'name="Orders"' in content - def test_scaffold_model_is_singular( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "modules").mkdir() - + def test_scaffold_model_is_singular(self, module_root: Path): scaffold_module("orders") - content = (tmp_path / "modules" / "orders" / "src" / "sm_orders" / "models.py").read_text() + content = ( + module_root / "modules" / "orders" / "src" / "sm_orders" / "models.py" + ).read_text() assert "class Order(Base, AuditMixin):" in content def test_scaffold_rejects_duplicate( @@ -159,17 +144,10 @@ def test_scaffold_rejects_duplicate( with pytest.raises(SystemExit): scaffold_module("orders") - def test_scaffold_compound_name( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "modules").mkdir() - + def test_scaffold_compound_name(self, module_root: Path): scaffold_module("blog_posts") - src_dir = tmp_path / "modules" / "blog_posts" / "src" / "sm_blog_posts" + src_dir = module_root / "modules" / "blog_posts" / "src" / "sm_blog_posts" assert (src_dir / "module.py").exists() module_content = (src_dir / "module.py").read_text() @@ -185,12 +163,8 @@ def test_scaffold_compound_name( class TestUpdateHostPyproject: - def test_adds_module_dependency(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - - host_dir = tmp_path / "host" + def test_adds_module_dependency(self, module_root: Path): + host_dir = module_root / "host" host_dir.mkdir() (host_dir / "pyproject.toml").write_text( '[project]\nname = "simple-module-host"\ndependencies = [\n' @@ -204,12 +178,8 @@ def test_adds_module_dependency(self, tmp_path: Path, monkeypatch: pytest.Monkey assert '"sm-orders"' in content assert "sm-orders = { workspace = true }" in content - def test_skips_if_already_present(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - - host_dir = tmp_path / "host" + def test_skips_if_already_present(self, module_root: Path): + host_dir = module_root / "host" host_dir.mkdir() original = ( '[project]\nname = "simple-module-host"\ndependencies = [\n' @@ -224,11 +194,8 @@ def test_skips_if_already_present(self, tmp_path: Path, monkeypatch: pytest.Monk class TestUpdateRootPyproject: - def test_adds_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "pyproject.toml").write_text( + def test_adds_paths(self, module_root: Path): + (module_root / "pyproject.toml").write_text( "[tool.ty.environment]\nextra-paths = [\n" ' "modules/products/src",\n]\n\n' "[tool.pytest.ini_options]\n" @@ -237,6 +204,6 @@ def test_adds_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): update_root_pyproject("orders") - content = (tmp_path / "pyproject.toml").read_text() + content = (module_root / "pyproject.toml").read_text() assert '"modules/orders/src"' in content assert '"modules/orders/tests"' in content From dc10fead02b4221fb705ff021aada0b863a2a651 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 07:59:04 +0000 Subject: [PATCH 3/5] fix: adapt scaffolding to new layout without src/ directory Updates the scaffolding to match PR #8's layout change that removed the src/ directory from all workspace packages: - Generate files at modules//sm_/ instead of modules//src/sm_/ - Update root pyproject.toml to insert modules/ in ty paths (no /src suffix) - Rewrite insertion helpers with cleaner regex-based approach that correctly handles the new host/pyproject.toml structure with multiple sm-* dependencies https://claude.ai/code/session_01G5HHXJfxAQ53grAwiyUzyj --- scripts/new_module.py | 74 ++++++++++++++++---------------- scripts/tests/test_new_module.py | 12 +++--- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/scripts/new_module.py b/scripts/new_module.py index fc2b59d7..370fe897 100644 --- a/scripts/new_module.py +++ b/scripts/new_module.py @@ -64,7 +64,7 @@ def scaffold_module(name: str) -> None: singular = to_singular(name) singular_class = to_class_name(singular) pkg = f"sm_{name}" - src_dir = module_dir / "src" / pkg + src_dir = module_dir / pkg print(f"Scaffolding module '{name}'...") @@ -676,16 +676,19 @@ async def test_on_startup_does_not_call_create_all(self): ) -def _insert_before_last(content: str, marker: str, insertion: str) -> str | None: - """Insert ``insertion`` after the last occurrence of ``marker`` in ``content``. +def _insert_after_last_match(content: str, pattern: str, line_to_insert: str) -> str | None: + """Insert ``line_to_insert`` on a new line after the last line matching ``pattern``. - Returns the modified content, or None if the marker was not found. + Returns the modified content, or None if no line matched. """ - idx = content.rfind(marker) - if idx == -1: + matches = list(re.finditer(pattern, content, re.MULTILINE)) + if not matches: return None - end = idx + len(marker) - return content[:end] + insertion + content[end:] + last = matches[-1] + end_of_line = content.find("\n", last.end()) + if end_of_line == -1: + end_of_line = len(content) + return content[: end_of_line + 1] + line_to_insert + content[end_of_line + 1 :] def update_host_pyproject(name: str) -> None: @@ -700,21 +703,20 @@ def update_host_pyproject(name: str) -> None: original = content - # Add to [project] dependencies — insert after last existing "sm-*" dep - result = _insert_before_last(content, ",\n]", f'\n "{pkg}",\n]') - if result and result != content: - content = result.replace(",\n]\n]", ",\n]", 1) # fix double bracket - # Fallback: try inserting after any quoted dependency line - if content == original: - result = _insert_before_last(content, '",\n', f'"\n "{pkg}",\n') - if result: - content = result + # Add to [project] dependencies — insert after last "sm-*" dep line + result = _insert_after_last_match( + content, + r'^ "sm-[\w-]+",\s*$', + f' "{pkg}",\n', + ) + if result: + content = result - # Add to [tool.uv.sources] — insert after last workspace source - result = _insert_before_last( + # Add to [tool.uv.sources] — insert after last workspace source line + result = _insert_after_last_match( content, - "{ workspace = true }", - f"\n{pkg} = {{ workspace = true }}", + r"^sm-[\w-]+ = \{ workspace = true \}\s*$", + f"{pkg} = {{ workspace = true }}\n", ) if result: content = result @@ -734,31 +736,29 @@ def update_root_pyproject(name: str) -> None: """Add the module to type-checking paths and test paths in root pyproject.toml.""" root_toml = ROOT / "pyproject.toml" content = root_toml.read_text() - src_path = f"modules/{name}/src" + src_path = f"modules/{name}" + test_path = f"modules/{name}/tests" - if src_path in content: - print(f" root pyproject.toml already contains {src_path}, skipping") + if f'"{src_path}",' in content and f'"{test_path}"' in content: + print(f" root pyproject.toml already contains modules/{name}, skipping") return original = content - # Add to [tool.ty.environment] extra-paths — insert after last modules/*/src entry - result = _insert_before_last( + # Add to [tool.ty.environment] extra-paths — insert after last "modules/*" entry + result = _insert_after_last_match( content, - '/src",', - f'\n "{src_path}",', + r'^ "modules/[\w/]+",\s*$', + f' "{src_path}",\n', ) if result: content = result - # Add to [tool.pytest.ini_options] testpaths — insert after last modules/*/tests entry - result = _insert_before_last( - content, - '/tests"', - f', "modules/{name}/tests"', - ) - if result: - content = result + # Append after the last "modules/*/tests" entry, before the closing ] + testpath_matches = list(re.finditer(r'"modules/[\w/]+/tests"', content)) + if testpath_matches and f'"{test_path}"' not in content: + last = testpath_matches[-1] + content = content[: last.end()] + f', "{test_path}"' + content[last.end() :] if content == original: print( @@ -792,7 +792,7 @@ def main() -> None: print() print("Next steps:") print(" 1. Run 'uv sync --all-packages' to install the new module") - print(f" 2. Edit modules/{name}/src/sm_{name}/models.py to define your domain model") + print(f" 2. Edit modules/{name}/sm_{name}/models.py to define your domain model") print(" 3. Update schemas, service, and endpoints to match your model") print(f' 4. Run \'make migration msg="add {name} tables"\' to create a migration') print(" 5. Run 'make test' to verify everything works") diff --git a/scripts/tests/test_new_module.py b/scripts/tests/test_new_module.py index 956caa06..cab1e99d 100644 --- a/scripts/tests/test_new_module.py +++ b/scripts/tests/test_new_module.py @@ -89,7 +89,7 @@ def test_scaffold_creates_all_files(self, module_root: Path): scaffold_module("orders") mod_dir = module_root / "modules" / "orders" - src_dir = mod_dir / "src" / "sm_orders" + src_dir = mod_dir / "sm_orders" expected_files = [ mod_dir / "pyproject.toml", @@ -120,7 +120,7 @@ def test_scaffold_module_class_name(self, module_root: Path): scaffold_module("orders") content = ( - module_root / "modules" / "orders" / "src" / "sm_orders" / "module.py" + module_root / "modules" / "orders" / "sm_orders" / "module.py" ).read_text() assert "class OrdersModule(ModuleBase):" in content assert 'name="Orders"' in content @@ -129,7 +129,7 @@ def test_scaffold_model_is_singular(self, module_root: Path): scaffold_module("orders") content = ( - module_root / "modules" / "orders" / "src" / "sm_orders" / "models.py" + module_root / "modules" / "orders" / "sm_orders" / "models.py" ).read_text() assert "class Order(Base, AuditMixin):" in content @@ -147,7 +147,7 @@ def test_scaffold_rejects_duplicate( def test_scaffold_compound_name(self, module_root: Path): scaffold_module("blog_posts") - src_dir = module_root / "modules" / "blog_posts" / "src" / "sm_blog_posts" + src_dir = module_root / "modules" / "blog_posts" / "sm_blog_posts" assert (src_dir / "module.py").exists() module_content = (src_dir / "module.py").read_text() @@ -197,7 +197,7 @@ class TestUpdateRootPyproject: def test_adds_paths(self, module_root: Path): (module_root / "pyproject.toml").write_text( "[tool.ty.environment]\nextra-paths = [\n" - ' "modules/products/src",\n]\n\n' + ' "modules/products",\n]\n\n' "[tool.pytest.ini_options]\n" 'testpaths = ["modules/products/tests"]\n' ) @@ -205,5 +205,5 @@ def test_adds_paths(self, module_root: Path): update_root_pyproject("orders") content = (module_root / "pyproject.toml").read_text() - assert '"modules/orders/src"' in content + assert '"modules/orders"' in content assert '"modules/orders/tests"' in content From 0be0c0397bb075a71c0b4f944b5eb91f524635eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:06:12 +0000 Subject: [PATCH 4/5] test: expand scaffolding test coverage from 23 to 49 tests Adds coverage for previously untested code paths and behaviors: - _insert_after_last_match helper: 4 direct unit tests - create_file utility: 3 tests (content, parent dir creation, dedent) - main() CLI entry point: 2 tests (happy path + invalid name) - End-to-end subprocess test: verifies script runs standalone New TestUpdateRootPyproject cases: - Realistic multi-module pyproject.toml matching real repo - Skips when already present - Warns to stderr when no insertion point found New TestGeneratedFilesSyntaxValidity: - All generated Python files parse with ast.parse - Generated pyproject.toml parses with tomllib - Compound names (blog_posts) produce valid TOML New TestGeneratedTemplateContent covers: - service.py has full CRUD methods - endpoints/api.py has all REST endpoints with correct status codes - endpoints/views.py uses Inertia properly - deps.py provides DI function - contracts/service.py defines Protocol - contracts/__init__.py exports public API - module.py registers routes and permissions - models.py uses AuditMixin with correct tablename - test_.py contains all expected test classes https://claude.ai/code/session_01G5HHXJfxAQ53grAwiyUzyj --- scripts/tests/test_new_module.py | 342 +++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) diff --git a/scripts/tests/test_new_module.py b/scripts/tests/test_new_module.py index cab1e99d..8ad80994 100644 --- a/scripts/tests/test_new_module.py +++ b/scripts/tests/test_new_module.py @@ -2,7 +2,10 @@ from __future__ import annotations +import ast +import subprocess import sys +import tomllib from pathlib import Path import pytest @@ -11,6 +14,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from new_module import ( + _insert_after_last_match, + create_file, + main, scaffold_module, to_class_name, to_singular, @@ -207,3 +213,339 @@ def test_adds_paths(self, module_root: Path): content = (module_root / "pyproject.toml").read_text() assert '"modules/orders"' in content assert '"modules/orders/tests"' in content + + def test_adds_paths_with_multiple_existing_modules(self, module_root: Path): + """Regression: matches the real repo layout with many modules.""" + (module_root / "pyproject.toml").write_text( + "[tool.ty.environment]\nextra-paths = [\n" + ' "framework/core",\n' + ' "framework/db",\n' + ' "framework/hosting",\n' + ' "modules/auth",\n' + ' "modules/dashboard",\n' + ' "modules/products",\n' + ' "host",\n]\n\n' + "[tool.pytest.ini_options]\n" + 'testpaths = [' + '"framework/core/tests", ' + '"modules/auth/tests", ' + '"modules/products/tests"]\n' + ) + + update_root_pyproject("orders") + + content = (module_root / "pyproject.toml").read_text() + # Inserted after last modules/* entry, not after "host" + assert '"modules/orders",\n "host"' in content + # Inserted after last testpath + assert '"modules/products/tests", "modules/orders/tests"' in content + + def test_skips_if_already_present(self, module_root: Path): + original = ( + "[tool.ty.environment]\nextra-paths = [\n" + ' "modules/products",\n "modules/orders",\n]\n\n' + "[tool.pytest.ini_options]\n" + 'testpaths = ["modules/products/tests", "modules/orders/tests"]\n' + ) + (module_root / "pyproject.toml").write_text(original) + + update_root_pyproject("orders") + + assert (module_root / "pyproject.toml").read_text() == original + + def test_warns_when_no_insertion_point( + self, module_root: Path, capsys: pytest.CaptureFixture + ): + """If pyproject has no modules/* entries, emit a warning to stderr.""" + (module_root / "pyproject.toml").write_text( + '[tool.ty.environment]\nextra-paths = ["host"]\n' + "[tool.pytest.ini_options]\ntestpaths = []\n" + ) + + update_root_pyproject("orders") + + captured = capsys.readouterr() + assert "warning" in captured.err.lower() + + +class TestInsertAfterLastMatch: + def test_inserts_after_last_matching_line(self): + content = ( + 'dependencies = [\n "foo",\n "bar",\n "baz",\n]\n' + ) + result = _insert_after_last_match( + content, r'^ "[\w]+",\s*$', ' "qux",\n' + ) + assert result is not None + # New entry appears right after the last `"baz",` line + assert '"baz",\n "qux",\n]' in result + + def test_returns_none_when_no_match(self): + content = "no matching lines here\n" + result = _insert_after_last_match(content, r"^XYZ$", "inserted\n") + assert result is None + + def test_respects_last_of_many(self): + content = "sm-a = 1\nsm-b = 2\nother = x\nsm-c = 3\n" + result = _insert_after_last_match( + content, r"^sm-\w+ = \d+$", "sm-d = 4\n" + ) + # Must insert after `sm-c`, not after `sm-b` + assert result is not None + assert result.endswith("sm-c = 3\nsm-d = 4\n") + + def test_inserts_before_trailing_content(self): + """Insertion preserves everything after the matched line.""" + content = "a = 1\nb = 2\n# trailer\n" + result = _insert_after_last_match(content, r"^b = 2$", "c = 3\n") + assert result == "a = 1\nb = 2\nc = 3\n# trailer\n" + + +class TestCreateFile: + def test_creates_file_with_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + + target = tmp_path / "foo" / "bar.txt" + create_file(target, "hello world\n") + + assert target.exists() + assert target.read_text() == "hello world\n" + + def test_creates_parent_directories( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + + target = tmp_path / "a" / "b" / "c" / "file.txt" + create_file(target, "") + + assert target.exists() + assert target.parent.is_dir() + + def test_dedents_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import new_module + + monkeypatch.setattr(new_module, "ROOT", tmp_path) + + target = tmp_path / "indented.txt" + create_file(target, " line one\n line two\n") + + assert target.read_text() == "line one\nline two\n" + + +class TestGeneratedFilesSyntaxValidity: + """Ensure every generated Python file is syntactically valid and TOML is parseable.""" + + def test_all_python_files_parse(self, module_root: Path): + scaffold_module("orders") + + py_files = list((module_root / "modules" / "orders").rglob("*.py")) + assert len(py_files) > 0 + + for py_file in py_files: + source = py_file.read_text() + try: + ast.parse(source, filename=str(py_file)) + except SyntaxError as e: + pytest.fail(f"Generated file {py_file.name} has syntax error: {e}") + + def test_pyproject_toml_parses(self, module_root: Path): + scaffold_module("orders") + + pyproject = module_root / "modules" / "orders" / "pyproject.toml" + data = tomllib.loads(pyproject.read_text()) + + assert data["project"]["name"] == "sm-orders" + assert ( + data["project"]["entry-points"]["simple_module"]["orders"] + == "sm_orders.module:OrdersModule" + ) + + def test_compound_name_pyproject_toml_parses(self, module_root: Path): + scaffold_module("blog_posts") + + pyproject = module_root / "modules" / "blog_posts" / "pyproject.toml" + data = tomllib.loads(pyproject.read_text()) + + assert data["project"]["name"] == "sm-blog-posts" + ep = data["project"]["entry-points"]["simple_module"] + assert ep["blog_posts"] == "sm_blog_posts.module:BlogPostsModule" + + +class TestGeneratedTemplateContent: + """Verify key fragments of generated templates.""" + + def test_service_has_full_crud(self, module_root: Path): + scaffold_module("orders") + + service = (module_root / "modules" / "orders" / "sm_orders" / "service.py").read_text() + assert "class OrderService:" in service + assert "async def get_all(self)" in service + assert "async def get_by_id(self" in service + assert "async def create(self" in service + assert "async def update(" in service + assert "async def delete(self" in service + + def test_api_has_full_crud_endpoints(self, module_root: Path): + scaffold_module("orders") + + api = ( + module_root / "modules" / "orders" / "sm_orders" / "endpoints" / "api.py" + ).read_text() + assert '@router.get("/"' in api + assert '@router.get("/{order_id}"' in api + assert '@router.post("/"' in api + assert '@router.put("/{order_id}"' in api + assert '@router.delete("/{order_id}"' in api + assert "status_code=201" in api + assert "status_code=204" in api + + def test_views_use_inertia(self, module_root: Path): + scaffold_module("orders") + + views = ( + module_root / "modules" / "orders" / "sm_orders" / "endpoints" / "views.py" + ).read_text() + assert "InertiaResponse" in views + assert "InertiaDep" in views + assert '"Orders/Browse"' in views + assert '"Orders/Create"' in views + assert '"Orders/Edit"' in views + + def test_deps_provides_di_function(self, module_root: Path): + scaffold_module("orders") + + deps = (module_root / "modules" / "orders" / "sm_orders" / "deps.py").read_text() + assert "async def get_order_service(" in deps + assert "Depends(get_db)" in deps + + def test_contracts_protocol_defined(self, module_root: Path): + scaffold_module("orders") + + protocol = ( + module_root / "modules" / "orders" / "sm_orders" / "contracts" / "service.py" + ).read_text() + assert "class IOrderService(Protocol):" in protocol + + def test_contracts_init_exports_public_api(self, module_root: Path): + scaffold_module("orders") + + init = ( + module_root / "modules" / "orders" / "sm_orders" / "contracts" / "__init__.py" + ).read_text() + assert '"OrderCreate"' in init + assert '"OrderOut"' in init + assert '"OrderUpdate"' in init + assert '"IOrderService"' in init + + def test_module_class_registers_routes(self, module_root: Path): + scaffold_module("orders") + + module_py = ( + module_root / "modules" / "orders" / "sm_orders" / "module.py" + ).read_text() + assert "def register_routes(" in module_py + assert "api_router.include_router(api)" in module_py + assert "view_router.include_router(views)" in module_py + + def test_module_class_registers_permissions(self, module_root: Path): + scaffold_module("orders") + + module_py = ( + module_root / "modules" / "orders" / "sm_orders" / "module.py" + ).read_text() + assert '"orders.view"' in module_py + assert '"orders.create"' in module_py + assert '"orders.edit"' in module_py + assert '"orders.delete"' in module_py + + def test_model_has_audit_mixin(self, module_root: Path): + scaffold_module("orders") + + model = (module_root / "modules" / "orders" / "sm_orders" / "models.py").read_text() + assert "from simple_module_db.mixins import AuditMixin" in model + assert "class Order(Base, AuditMixin):" in model + assert '__tablename__ = "orders_order"' in model + + def test_test_file_has_test_classes(self, module_root: Path): + scaffold_module("orders") + + test_file = (module_root / "modules" / "orders" / "tests" / "test_orders.py").read_text() + assert "class TestOrderSchemas:" in test_file + assert "class TestOrderService:" in test_file + assert "class TestOrdersAPI:" in test_file + assert "class TestOrdersModuleLifecycle:" in test_file + + +class TestMainCLI: + """Tests for the main() entry point.""" + + def test_main_invokes_full_pipeline( + self, module_root: Path, monkeypatch: pytest.MonkeyPatch + ): + # Provide host/pyproject.toml and root pyproject.toml so updates succeed + (module_root / "host").mkdir() + (module_root / "host" / "pyproject.toml").write_text( + '[project]\ndependencies = [\n "sm-products",\n]\n\n' + "[tool.uv.sources]\nsm-products = { workspace = true }\n" + ) + (module_root / "pyproject.toml").write_text( + "[tool.ty.environment]\nextra-paths = [\n \"modules/products\",\n]\n\n" + "[tool.pytest.ini_options]\ntestpaths = [\"modules/products/tests\"]\n" + ) + + monkeypatch.setattr(sys, "argv", ["new_module.py", "orders"]) + main() + + assert (module_root / "modules" / "orders" / "pyproject.toml").exists() + assert ( + module_root / "modules" / "orders" / "sm_orders" / "module.py" + ).exists() + host_content = (module_root / "host" / "pyproject.toml").read_text() + assert '"sm-orders"' in host_content + + def test_main_exits_on_invalid_name(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(sys, "argv", ["new_module.py", "Invalid-Name"]) + with pytest.raises(SystemExit): + main() + + +class TestCLIAsSubprocess: + """Run the actual script as a subprocess (end-to-end smoke test).""" + + def test_script_runs_successfully(self, tmp_path: Path): + """Invoke the script in isolation — verifies it's directly executable.""" + # Set up a minimal workspace + (tmp_path / "modules").mkdir() + (tmp_path / "host").mkdir() + (tmp_path / "host" / "pyproject.toml").write_text( + '[project]\ndependencies = [\n "sm-products",\n]\n\n' + "[tool.uv.sources]\nsm-products = { workspace = true }\n" + ) + (tmp_path / "pyproject.toml").write_text( + "[tool.ty.environment]\nextra-paths = [\n \"modules/products\",\n]\n\n" + "[tool.pytest.ini_options]\ntestpaths = [\"modules/products/tests\"]\n" + ) + + # Run script with ROOT monkey-patched via an env variable trick — instead, + # invoke via python -c with sys.path manipulation and direct function call + scripts_dir = Path(__file__).resolve().parent.parent + cmd = [ + sys.executable, + "-c", + ( + f"import sys; sys.path.insert(0, {str(scripts_dir)!r}); " + f"import new_module; new_module.ROOT = {str(tmp_path)!r}; " + "new_module.ROOT = __import__('pathlib').Path(new_module.ROOT); " + "sys.argv = ['new_module.py', 'orders']; new_module.main()" + ), + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "Scaffolding module 'orders'" in result.stdout + assert (tmp_path / "modules" / "orders" / "sm_orders" / "module.py").exists() From 7147f41e157e0521d8841cd1bf90c0b0d83f4e08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:47:05 +0000 Subject: [PATCH 5/5] refactor: simplify scaffolding tests from review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scaffolded_orders fixture: scaffold_module() now runs once per test rather than 10x in TestGeneratedTemplateContent - Add workspace fixture: deduplicates the host/pyproject + root pyproject setup between TestMainCLI tests - Extract MINIMAL_HOST_PYPROJECT and MINIMAL_ROOT_PYPROJECT constants - TestCreateFile: replace inline monkeypatch with the existing module_root fixture (3 tests collapsed to clean one-liners) - TestScaffoldModule: use scaffolded_orders fixture; eliminate repeated `module_root / "modules" / "orders" / "sm_orders"` path strings - TestGeneratedFilesSyntaxValidity: reuse scaffolded_orders for the orders-based tests - Drop TestCLIAsSubprocess: TestMainCLI::test_main_invokes_full_pipeline with capsys covers the same ground without a subprocess fork - Move `import new_module` to module level (reused by all fixtures) - Strip narrative comments 49 tests → 48 tests, suite time 0.58s → 0.42s https://claude.ai/code/session_01G5HHXJfxAQ53grAwiyUzyj --- scripts/tests/test_new_module.py | 300 ++++++++++--------------------- 1 file changed, 94 insertions(+), 206 deletions(-) diff --git a/scripts/tests/test_new_module.py b/scripts/tests/test_new_module.py index 8ad80994..7aaadc6e 100644 --- a/scripts/tests/test_new_module.py +++ b/scripts/tests/test_new_module.py @@ -3,17 +3,16 @@ from __future__ import annotations import ast -import subprocess import sys import tomllib from pathlib import Path import pytest -# Make sure the scripts directory is importable sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from new_module import ( +import new_module # noqa: E402 +from new_module import ( # noqa: E402 _insert_after_last_match, create_file, main, @@ -25,6 +24,40 @@ validate_name, ) +# ── Shared TOML fixtures used by multiple end-to-end tests ────────── +MINIMAL_HOST_PYPROJECT = ( + '[project]\ndependencies = [\n "sm-products",\n]\n\n' + "[tool.uv.sources]\nsm-products = { workspace = true }\n" +) +MINIMAL_ROOT_PYPROJECT = ( + '[tool.ty.environment]\nextra-paths = [\n "modules/products",\n]\n\n' + '[tool.pytest.ini_options]\ntestpaths = ["modules/products/tests"]\n' +) + + +@pytest.fixture +def module_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Provide a temp directory patched as the script's ROOT with modules/ pre-created.""" + monkeypatch.setattr(new_module, "ROOT", tmp_path) + (tmp_path / "modules").mkdir() + return tmp_path + + +@pytest.fixture +def workspace(module_root: Path) -> Path: + """module_root plus host/ and root pyproject.toml ready for end-to-end runs.""" + (module_root / "host").mkdir() + (module_root / "host" / "pyproject.toml").write_text(MINIMAL_HOST_PYPROJECT) + (module_root / "pyproject.toml").write_text(MINIMAL_ROOT_PYPROJECT) + return module_root + + +@pytest.fixture +def scaffolded_orders(module_root: Path) -> Path: + """Run the scaffold once and return the sm_orders source directory.""" + scaffold_module("orders") + return module_root / "modules" / "orders" / "sm_orders" + class TestValidateName: def test_valid_simple_name(self): @@ -78,24 +111,12 @@ def test_plural_compound(self): assert to_singular("blog_posts") == "blog_post" -@pytest.fixture -def module_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Provide a temp directory patched as the script's ROOT with modules/ pre-created.""" - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "modules").mkdir() - return tmp_path - - class TestScaffoldModule: """Integration tests that run the scaffolding and verify output files.""" - def test_scaffold_creates_all_files(self, module_root: Path): - scaffold_module("orders") - - mod_dir = module_root / "modules" / "orders" - src_dir = mod_dir / "sm_orders" + def test_scaffold_creates_all_files(self, scaffolded_orders: Path): + mod_dir = scaffolded_orders.parent + src_dir = scaffolded_orders expected_files = [ mod_dir / "pyproject.toml", @@ -114,38 +135,23 @@ def test_scaffold_creates_all_files(self, module_root: Path): mod_dir / "tests" / "test_orders.py", ] for f in expected_files: - assert f.exists(), f"Missing: {f.relative_to(module_root)}" - - def test_scaffold_pyproject_has_entry_point(self, module_root: Path): - scaffold_module("orders") + assert f.exists(), f"Missing: {f}" - content = (module_root / "modules" / "orders" / "pyproject.toml").read_text() + def test_scaffold_pyproject_has_entry_point(self, scaffolded_orders: Path): + content = (scaffolded_orders.parent / "pyproject.toml").read_text() assert 'orders = "sm_orders.module:OrdersModule"' in content - def test_scaffold_module_class_name(self, module_root: Path): - scaffold_module("orders") - - content = ( - module_root / "modules" / "orders" / "sm_orders" / "module.py" - ).read_text() + def test_scaffold_module_class_name(self, scaffolded_orders: Path): + content = (scaffolded_orders / "module.py").read_text() assert "class OrdersModule(ModuleBase):" in content assert 'name="Orders"' in content - def test_scaffold_model_is_singular(self, module_root: Path): - scaffold_module("orders") - - content = ( - module_root / "modules" / "orders" / "sm_orders" / "models.py" - ).read_text() + def test_scaffold_model_is_singular(self, scaffolded_orders: Path): + content = (scaffolded_orders / "models.py").read_text() assert "class Order(Base, AuditMixin):" in content - def test_scaffold_rejects_duplicate( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - (tmp_path / "modules" / "orders").mkdir(parents=True) + def test_scaffold_rejects_duplicate(self, module_root: Path): + (module_root / "modules" / "orders").mkdir() with pytest.raises(SystemExit): scaffold_module("orders") @@ -172,11 +178,7 @@ class TestUpdateHostPyproject: def test_adds_module_dependency(self, module_root: Path): host_dir = module_root / "host" host_dir.mkdir() - (host_dir / "pyproject.toml").write_text( - '[project]\nname = "simple-module-host"\ndependencies = [\n' - ' "sm-products",\n]\n\n[tool.uv.sources]\n' - "sm-products = { workspace = true }\n" - ) + (host_dir / "pyproject.toml").write_text(MINIMAL_HOST_PYPROJECT) update_host_pyproject("orders") @@ -201,12 +203,7 @@ def test_skips_if_already_present(self, module_root: Path): class TestUpdateRootPyproject: def test_adds_paths(self, module_root: Path): - (module_root / "pyproject.toml").write_text( - "[tool.ty.environment]\nextra-paths = [\n" - ' "modules/products",\n]\n\n' - "[tool.pytest.ini_options]\n" - 'testpaths = ["modules/products/tests"]\n' - ) + (module_root / "pyproject.toml").write_text(MINIMAL_ROOT_PYPROJECT) update_root_pyproject("orders") @@ -215,7 +212,7 @@ def test_adds_paths(self, module_root: Path): assert '"modules/orders/tests"' in content def test_adds_paths_with_multiple_existing_modules(self, module_root: Path): - """Regression: matches the real repo layout with many modules.""" + # Regression: matches the real repo layout where "host" comes after modules/* (module_root / "pyproject.toml").write_text( "[tool.ty.environment]\nextra-paths = [\n" ' "framework/core",\n' @@ -235,9 +232,8 @@ def test_adds_paths_with_multiple_existing_modules(self, module_root: Path): update_root_pyproject("orders") content = (module_root / "pyproject.toml").read_text() - # Inserted after last modules/* entry, not after "host" + # Insertion point must be after last modules/* entry, not after "host" assert '"modules/orders",\n "host"' in content - # Inserted after last testpath assert '"modules/products/tests", "modules/orders/tests"' in content def test_skips_if_already_present(self, module_root: Path): @@ -256,7 +252,6 @@ def test_skips_if_already_present(self, module_root: Path): def test_warns_when_no_insertion_point( self, module_root: Path, capsys: pytest.CaptureFixture ): - """If pyproject has no modules/* entries, emit a warning to stderr.""" (module_root / "pyproject.toml").write_text( '[tool.ty.environment]\nextra-paths = ["host"]\n' "[tool.pytest.ini_options]\ntestpaths = []\n" @@ -264,74 +259,53 @@ def test_warns_when_no_insertion_point( update_root_pyproject("orders") - captured = capsys.readouterr() - assert "warning" in captured.err.lower() + assert "warning" in capsys.readouterr().err.lower() class TestInsertAfterLastMatch: def test_inserts_after_last_matching_line(self): - content = ( - 'dependencies = [\n "foo",\n "bar",\n "baz",\n]\n' - ) + content = 'dependencies = [\n "foo",\n "bar",\n "baz",\n]\n' result = _insert_after_last_match( content, r'^ "[\w]+",\s*$', ' "qux",\n' ) assert result is not None - # New entry appears right after the last `"baz",` line assert '"baz",\n "qux",\n]' in result def test_returns_none_when_no_match(self): - content = "no matching lines here\n" - result = _insert_after_last_match(content, r"^XYZ$", "inserted\n") + result = _insert_after_last_match("no matches\n", r"^XYZ$", "inserted\n") assert result is None def test_respects_last_of_many(self): + # `other = x` between matches must not divert insertion to before it content = "sm-a = 1\nsm-b = 2\nother = x\nsm-c = 3\n" result = _insert_after_last_match( content, r"^sm-\w+ = \d+$", "sm-d = 4\n" ) - # Must insert after `sm-c`, not after `sm-b` assert result is not None assert result.endswith("sm-c = 3\nsm-d = 4\n") def test_inserts_before_trailing_content(self): - """Insertion preserves everything after the matched line.""" content = "a = 1\nb = 2\n# trailer\n" result = _insert_after_last_match(content, r"^b = 2$", "c = 3\n") assert result == "a = 1\nb = 2\nc = 3\n# trailer\n" class TestCreateFile: - def test_creates_file_with_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - - target = tmp_path / "foo" / "bar.txt" + def test_creates_file_with_content(self, module_root: Path): + target = module_root / "foo" / "bar.txt" create_file(target, "hello world\n") - assert target.exists() assert target.read_text() == "hello world\n" - def test_creates_parent_directories( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - - target = tmp_path / "a" / "b" / "c" / "file.txt" + def test_creates_parent_directories(self, module_root: Path): + target = module_root / "a" / "b" / "c" / "file.txt" create_file(target, "") assert target.exists() assert target.parent.is_dir() - def test_dedents_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - import new_module - - monkeypatch.setattr(new_module, "ROOT", tmp_path) - - target = tmp_path / "indented.txt" + def test_dedents_content(self, module_root: Path): + target = module_root / "indented.txt" create_file(target, " line one\n line two\n") assert target.read_text() == "line one\nline two\n" @@ -340,24 +314,18 @@ def test_dedents_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): class TestGeneratedFilesSyntaxValidity: """Ensure every generated Python file is syntactically valid and TOML is parseable.""" - def test_all_python_files_parse(self, module_root: Path): - scaffold_module("orders") - - py_files = list((module_root / "modules" / "orders").rglob("*.py")) + def test_all_python_files_parse(self, scaffolded_orders: Path): + py_files = list(scaffolded_orders.parent.rglob("*.py")) assert len(py_files) > 0 for py_file in py_files: - source = py_file.read_text() try: - ast.parse(source, filename=str(py_file)) + ast.parse(py_file.read_text(), filename=str(py_file)) except SyntaxError as e: pytest.fail(f"Generated file {py_file.name} has syntax error: {e}") - def test_pyproject_toml_parses(self, module_root: Path): - scaffold_module("orders") - - pyproject = module_root / "modules" / "orders" / "pyproject.toml" - data = tomllib.loads(pyproject.read_text()) + def test_pyproject_toml_parses(self, scaffolded_orders: Path): + data = tomllib.loads((scaffolded_orders.parent / "pyproject.toml").read_text()) assert data["project"]["name"] == "sm-orders" assert ( @@ -379,10 +347,8 @@ def test_compound_name_pyproject_toml_parses(self, module_root: Path): class TestGeneratedTemplateContent: """Verify key fragments of generated templates.""" - def test_service_has_full_crud(self, module_root: Path): - scaffold_module("orders") - - service = (module_root / "modules" / "orders" / "sm_orders" / "service.py").read_text() + def test_service_has_full_crud(self, scaffolded_orders: Path): + service = (scaffolded_orders / "service.py").read_text() assert "class OrderService:" in service assert "async def get_all(self)" in service assert "async def get_by_id(self" in service @@ -390,12 +356,8 @@ def test_service_has_full_crud(self, module_root: Path): assert "async def update(" in service assert "async def delete(self" in service - def test_api_has_full_crud_endpoints(self, module_root: Path): - scaffold_module("orders") - - api = ( - module_root / "modules" / "orders" / "sm_orders" / "endpoints" / "api.py" - ).read_text() + def test_api_has_full_crud_endpoints(self, scaffolded_orders: Path): + api = (scaffolded_orders / "endpoints" / "api.py").read_text() assert '@router.get("/"' in api assert '@router.get("/{order_id}"' in api assert '@router.post("/"' in api @@ -404,77 +366,53 @@ def test_api_has_full_crud_endpoints(self, module_root: Path): assert "status_code=201" in api assert "status_code=204" in api - def test_views_use_inertia(self, module_root: Path): - scaffold_module("orders") - - views = ( - module_root / "modules" / "orders" / "sm_orders" / "endpoints" / "views.py" - ).read_text() + def test_views_use_inertia(self, scaffolded_orders: Path): + views = (scaffolded_orders / "endpoints" / "views.py").read_text() assert "InertiaResponse" in views assert "InertiaDep" in views assert '"Orders/Browse"' in views assert '"Orders/Create"' in views assert '"Orders/Edit"' in views - def test_deps_provides_di_function(self, module_root: Path): - scaffold_module("orders") - - deps = (module_root / "modules" / "orders" / "sm_orders" / "deps.py").read_text() + def test_deps_provides_di_function(self, scaffolded_orders: Path): + deps = (scaffolded_orders / "deps.py").read_text() assert "async def get_order_service(" in deps assert "Depends(get_db)" in deps - def test_contracts_protocol_defined(self, module_root: Path): - scaffold_module("orders") - - protocol = ( - module_root / "modules" / "orders" / "sm_orders" / "contracts" / "service.py" - ).read_text() + def test_contracts_protocol_defined(self, scaffolded_orders: Path): + protocol = (scaffolded_orders / "contracts" / "service.py").read_text() assert "class IOrderService(Protocol):" in protocol - def test_contracts_init_exports_public_api(self, module_root: Path): - scaffold_module("orders") - - init = ( - module_root / "modules" / "orders" / "sm_orders" / "contracts" / "__init__.py" - ).read_text() + def test_contracts_init_exports_public_api(self, scaffolded_orders: Path): + init = (scaffolded_orders / "contracts" / "__init__.py").read_text() assert '"OrderCreate"' in init assert '"OrderOut"' in init assert '"OrderUpdate"' in init assert '"IOrderService"' in init - def test_module_class_registers_routes(self, module_root: Path): - scaffold_module("orders") - - module_py = ( - module_root / "modules" / "orders" / "sm_orders" / "module.py" - ).read_text() + def test_module_class_registers_routes(self, scaffolded_orders: Path): + module_py = (scaffolded_orders / "module.py").read_text() assert "def register_routes(" in module_py assert "api_router.include_router(api)" in module_py assert "view_router.include_router(views)" in module_py - def test_module_class_registers_permissions(self, module_root: Path): - scaffold_module("orders") - - module_py = ( - module_root / "modules" / "orders" / "sm_orders" / "module.py" - ).read_text() + def test_module_class_registers_permissions(self, scaffolded_orders: Path): + module_py = (scaffolded_orders / "module.py").read_text() assert '"orders.view"' in module_py assert '"orders.create"' in module_py assert '"orders.edit"' in module_py assert '"orders.delete"' in module_py - def test_model_has_audit_mixin(self, module_root: Path): - scaffold_module("orders") - - model = (module_root / "modules" / "orders" / "sm_orders" / "models.py").read_text() + def test_model_has_audit_mixin(self, scaffolded_orders: Path): + model = (scaffolded_orders / "models.py").read_text() assert "from simple_module_db.mixins import AuditMixin" in model assert "class Order(Base, AuditMixin):" in model assert '__tablename__ = "orders_order"' in model - def test_test_file_has_test_classes(self, module_root: Path): - scaffold_module("orders") - - test_file = (module_root / "modules" / "orders" / "tests" / "test_orders.py").read_text() + def test_test_file_has_test_classes(self, scaffolded_orders: Path): + test_file = ( + scaffolded_orders.parent / "tests" / "test_orders.py" + ).read_text() assert "class TestOrderSchemas:" in test_file assert "class TestOrderService:" in test_file assert "class TestOrdersAPI:" in test_file @@ -485,67 +423,17 @@ class TestMainCLI: """Tests for the main() entry point.""" def test_main_invokes_full_pipeline( - self, module_root: Path, monkeypatch: pytest.MonkeyPatch + self, workspace: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture ): - # Provide host/pyproject.toml and root pyproject.toml so updates succeed - (module_root / "host").mkdir() - (module_root / "host" / "pyproject.toml").write_text( - '[project]\ndependencies = [\n "sm-products",\n]\n\n' - "[tool.uv.sources]\nsm-products = { workspace = true }\n" - ) - (module_root / "pyproject.toml").write_text( - "[tool.ty.environment]\nextra-paths = [\n \"modules/products\",\n]\n\n" - "[tool.pytest.ini_options]\ntestpaths = [\"modules/products/tests\"]\n" - ) - monkeypatch.setattr(sys, "argv", ["new_module.py", "orders"]) main() - assert (module_root / "modules" / "orders" / "pyproject.toml").exists() - assert ( - module_root / "modules" / "orders" / "sm_orders" / "module.py" - ).exists() - host_content = (module_root / "host" / "pyproject.toml").read_text() - assert '"sm-orders"' in host_content + assert (workspace / "modules" / "orders" / "pyproject.toml").exists() + assert (workspace / "modules" / "orders" / "sm_orders" / "module.py").exists() + assert '"sm-orders"' in (workspace / "host" / "pyproject.toml").read_text() + assert "Scaffolding module 'orders'" in capsys.readouterr().out def test_main_exits_on_invalid_name(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(sys, "argv", ["new_module.py", "Invalid-Name"]) with pytest.raises(SystemExit): main() - - -class TestCLIAsSubprocess: - """Run the actual script as a subprocess (end-to-end smoke test).""" - - def test_script_runs_successfully(self, tmp_path: Path): - """Invoke the script in isolation — verifies it's directly executable.""" - # Set up a minimal workspace - (tmp_path / "modules").mkdir() - (tmp_path / "host").mkdir() - (tmp_path / "host" / "pyproject.toml").write_text( - '[project]\ndependencies = [\n "sm-products",\n]\n\n' - "[tool.uv.sources]\nsm-products = { workspace = true }\n" - ) - (tmp_path / "pyproject.toml").write_text( - "[tool.ty.environment]\nextra-paths = [\n \"modules/products\",\n]\n\n" - "[tool.pytest.ini_options]\ntestpaths = [\"modules/products/tests\"]\n" - ) - - # Run script with ROOT monkey-patched via an env variable trick — instead, - # invoke via python -c with sys.path manipulation and direct function call - scripts_dir = Path(__file__).resolve().parent.parent - cmd = [ - sys.executable, - "-c", - ( - f"import sys; sys.path.insert(0, {str(scripts_dir)!r}); " - f"import new_module; new_module.ROOT = {str(tmp_path)!r}; " - "new_module.ROOT = __import__('pathlib').Path(new_module.ROOT); " - "sys.argv = ['new_module.py', 'orders']; new_module.main()" - ), - ] - result = subprocess.run(cmd, capture_output=True, text=True, check=False) - - assert result.returncode == 0, f"stderr: {result.stderr}" - assert "Scaffolding module 'orders'" in result.stdout - assert (tmp_path / "modules" / "orders" / "sm_orders" / "module.py").exists()