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 37dfd987..e1a65d69 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 ( @@ -41,13 +46,30 @@ async def engine(db_state: DatabaseState) -> AsyncEngine: return db_state.engine +@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) + + +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.""" - from sm_products.models import Base - - async with db_state.engine.begin() as conn: - 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 @@ -60,10 +82,7 @@ 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) + 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 new file mode 100644 index 00000000..370fe897 --- /dev/null +++ b/scripts/new_module.py @@ -0,0 +1,802 @@ +#!/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 / 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 _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 no line matched. + """ + matches = list(re.finditer(pattern, content, re.MULTILINE)) + if not matches: + return None + 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: + """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 + + original = content + + # 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 line + result = _insert_after_last_match( + content, + r"^sm-[\w-]+ = \{ workspace = true \}\s*$", + f"{pkg} = {{ workspace = true }}\n", + ) + if result: + content = result + + 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})") + + +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}" + test_path = f"modules/{name}/tests" + + 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/*" entry + result = _insert_after_last_match( + content, + r'^ "modules/[\w/]+",\s*$', + f' "{src_path}",\n', + ) + 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( + " 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)") + + +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}/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..7aaadc6e --- /dev/null +++ b/scripts/tests/test_new_module.py @@ -0,0 +1,439 @@ +"""Tests for the new_module scaffolding script.""" + +from __future__ import annotations + +import ast +import sys +import tomllib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import new_module # noqa: E402 +from new_module import ( # noqa: E402 + _insert_after_last_match, + create_file, + main, + scaffold_module, + to_class_name, + to_singular, + update_host_pyproject, + update_root_pyproject, + 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): + 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, scaffolded_orders: Path): + mod_dir = scaffolded_orders.parent + src_dir = scaffolded_orders + + 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}" + + 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, 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, scaffolded_orders: Path): + content = (scaffolded_orders / "models.py").read_text() + assert "class Order(Base, AuditMixin):" in content + + def test_scaffold_rejects_duplicate(self, module_root: Path): + (module_root / "modules" / "orders").mkdir() + + with pytest.raises(SystemExit): + scaffold_module("orders") + + def test_scaffold_compound_name(self, module_root: Path): + scaffold_module("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() + 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, module_root: Path): + host_dir = module_root / "host" + host_dir.mkdir() + (host_dir / "pyproject.toml").write_text(MINIMAL_HOST_PYPROJECT) + + 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, module_root: Path): + host_dir = module_root / "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, module_root: Path): + (module_root / "pyproject.toml").write_text(MINIMAL_ROOT_PYPROJECT) + + update_root_pyproject("orders") + + 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 where "host" comes after 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() + # Insertion point must be after last modules/* entry, not after "host" + assert '"modules/orders",\n "host"' in content + 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 + ): + (module_root / "pyproject.toml").write_text( + '[tool.ty.environment]\nextra-paths = ["host"]\n' + "[tool.pytest.ini_options]\ntestpaths = []\n" + ) + + update_root_pyproject("orders") + + 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' + result = _insert_after_last_match( + content, r'^ "[\w]+",\s*$', ' "qux",\n' + ) + assert result is not None + assert '"baz",\n "qux",\n]' in result + + def test_returns_none_when_no_match(self): + 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" + ) + assert result is not None + assert result.endswith("sm-c = 3\nsm-d = 4\n") + + def test_inserts_before_trailing_content(self): + 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, module_root: Path): + target = module_root / "foo" / "bar.txt" + create_file(target, "hello world\n") + + assert target.read_text() == "hello world\n" + + 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, 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" + + +class TestGeneratedFilesSyntaxValidity: + """Ensure every generated Python file is syntactically valid and TOML is parseable.""" + + 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: + try: + 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, scaffolded_orders: Path): + data = tomllib.loads((scaffolded_orders.parent / "pyproject.toml").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, 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 + 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, 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 + 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, 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, 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, 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, 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, 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, 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, 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, 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 + assert "class TestOrdersModuleLifecycle:" in test_file + + +class TestMainCLI: + """Tests for the main() entry point.""" + + def test_main_invokes_full_pipeline( + self, workspace: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ): + monkeypatch.setattr(sys, "argv", ["new_module.py", "orders"]) + main() + + 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()