diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b61eecb..ce6d694 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,50 +4,18 @@ on: push: branches: [main] pull_request: - -permissions: - contents: read + branches: [main] jobs: test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: uv sync - - - name: Lint - run: uv run ruff check src tests - - - name: Format check - run: uv run ruff format --check src tests - - - name: Test - run: uv run pytest -q - - build: - name: Build & smoke-test wheel runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 with: - python-version: "3.12" - - - name: Build sdist and wheel - run: uv build - - - name: Smoke-test the built wheel in an isolated env - run: uv run --isolated --no-project --with "$(ls dist/*.whl)" backendctl --version + enable-cache: true + - run: uv sync + - run: uv run pytest -q + - run: uv run ruff check src tests + - run: uv run ruff format --check src tests + - run: uv run mypy src diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..068c684 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,65 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + e2e: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - framework: fastapi + db: postgres + - framework: flask + db: postgres + - framework: django + db: postgres + - framework: fastapi + db: mongodb + - framework: flask + db: mongodb + - framework: fastapi + db: postgres + auth: none + - framework: flask + db: postgres + auth: none + - framework: django + db: postgres + auth: none + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - run: uv sync + - name: Generate project + run: | + AUTH_FLAG="" + if [ "${{ matrix.auth }}" = "none" ]; then + AUTH_FLAG="--auth none" + fi + uv run backendctl new demo_e2e --framework ${{ matrix.framework }} --db ${{ matrix.db }} $AUTH_FLAG --yes --no-git --no-ai + - name: Install deps + run: | + cd demo_e2e + uv sync + - name: Run tests + run: | + cd demo_e2e + uv run pytest -q + - name: FastAPI migrations + if: matrix.framework == 'fastapi' + run: | + cd demo_e2e + DATABASE_URL=sqlite:///./app.db uv run alembic revision --autogenerate -m init + - name: Django migrations + if: matrix.framework == 'django' + run: | + cd demo_e2e + uv run python manage.py makemigrations --noinput + uv run python manage.py migrate --noinput diff --git a/.gitignore b/.gitignore index bca07b6..8c4fcba 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,6 @@ REVIEW.md # Claude local settings .claude/settings.local.json .mimocode + +# Kilo Code skills/workflows +.kilocode/ diff --git a/.kilo/plans/1787120976235-backendctl-review-hardening.md b/.kilo/plans/1787120976235-backendctl-review-hardening.md new file mode 100644 index 0000000..46376e3 --- /dev/null +++ b/.kilo/plans/1787120976235-backendctl-review-hardening.md @@ -0,0 +1,182 @@ +# backendctl — Review Fixes + OpenSpec Adoption + +## Goal + +Close the remaining correctness/consistency gaps in the scaffolding tool and its +generated output, wire MongoDB for real, and adopt **OpenSpec** (`@fission-ai/openspec` +v1.9.0, installed) as the single source of truth for spec-driven development. + +## Decisions (locked with user) + +- **Spec tooling:** OpenSpec (initialize `openspec/`, drive every fix as a change proposal). +- **MongoDB:** fully wire it — working client + sample CRUD resource + health check + tests (not "remove"). +- **Scope:** full sweep — auth=none consistency, MongoDB wiring, E402, logging + JSON exception + handlers, repo hygiene (py.typed, mypy in CI, `--verbose`, stale `dist/` cleanup), automated e2e CI. +- **Out of scope (follow-ups):** FastAPI refresh-token revocation (jti/denylist) — keep documented. + +## GitHub Flow (must) + +All work lands through **GitHub Flow** — no exceptions: + +- Each workstream (or logical group) lives on its own feature branch off `main`: + `feat/auth-none-consistency`, `feat/mongo-full-wiring`, + `feat/generated-runtime-quality`, `feat/e2e-and-hygiene`. +- Commits use **conventional commits**: `type(scope): summary`. +- Every PR must pass all required checks before merge: + - `uv run pytest -q` (tool suite) + - `uv run ruff check src tests` + - `uv run ruff format --check src tests` + - `uv run mypy src` + - New `e2e.yml` job (generated-project runtime checks) + - `openspec validate --strict` for each change in the PR +- PR description must reference the OpenSpec change(s) it implements. +- Branch protection on `main` (GitHub settings): required checks, required reviews, + no force pushes. Enforced by GitHub; not code, but the implementing agent should + document the expected settings in a `CONTRIBUTING.md` update if branch protection + is not already enabled. +- Do not commit directly to `main`. + +## Key findings driving the work + +1. **`--auth none` is only honored by FastAPI.** Flask and Django generators always scaffold JWT auth, + users models, and auth routes. Django always sets `IsAuthenticated` + `SimpleJWT`. Fix both. +2. **MongoDB half-wired.** FastAPI generates a motor client nobody calls; Flask inits `flask-pymongo` + (unmaintained) but never uses it. No sample usage, no tests. +3. **Generated Django `config/settings/base.py` has a mid-file `from datetime import timedelta`** → + ruff `E402` → a scaffolded Django project fails its own lint. +4. **No logging config / JSON error handler** in generated FastAPI/Flask; Django's `core/exceptions.py` + handler is never wired into `REST_FRAMEWORK["EXCEPTION_HANDLER"]`. +5. **No end-to-end CI.** CI only `py_compile`s templates + runs the tool's tests; generated projects + are never installed/run/tested. +6. **Hygiene:** no `py.typed`, mypy not in CI (`strict=false`), broad `except Exception` in + `src/backendctl/cli/new.py:434` hides tracebacks (no `--verbose`), stale `dist/` (0.1.0 artifacts). + +--- + +## Workstream 0 — Baseline + OpenSpec bootstrap + +- [x] Run `uv sync`, `uv run pytest -q`, `uv run ruff check src tests`, `uv run mypy src` to establish + a green baseline. Done: 82 tests pass, ruff clean, mypy has 1 pre-existing error in + `generators/__init__.py:17` to fix in WS4. +- [x] `openspec init --tools kilocode --no-animation` in repo root → created `openspec/config.yaml`, + `openspec/{specs,changes,archive}/`, `.kilocode/{skills,workflows}/`. +- [x] Filled `openspec/config.yaml` `context:` field with project purpose, stack, commands, invariants. +- [x] Author baseline capability spec `openspec/specs/scaffolding/spec.md` capturing current invariants: + path-traversal guard, non-empty-dir guard, `.env`-preservation on `--force`, DB-credential flow + (`resolve`/`url`), placeholder `change-me-db-password`, e2e `py_compile` across the option matrix. +- [x] Create a root `AGENTS.md` pointing at the OpenSpec workflow + build/test commands (none existed). + +## Workstream 1 — `feat/auth-none-consistency`: honor `--auth none` in Flask + Django + +Change: `openspec/changes/auth-none-consistency/` (proposal.md + tasks.md + spec delta on `scaffolding`). + +- [ ] **Flask** (`src/backendctl/generators/flask_gen.py`): wrap user model, `blueprints/auth/*`, + `blueprints/users/*`, and `tests/test_auth.py` writes in `if c.auth.value != "none"`. +- [ ] **Flask templates** (`src/backendctl/templates/flask.py`): conditionally drop + `flask-jwt-extended` from `pyproject_toml`, JWT lines from `env_example`, `jwt` import/init from + `app_init`+`extensions`, JWT settings from `config_py`/`TestConfig`. +- [ ] **Django** (`src/backendctl/generators/django_gen.py`): wrap `apps/authentication/*` writes in + `if c.auth.value != "none"`; drop `apps/users/{serializers,views,urls}.py` when auth=none (keep + `models.py`+`apps.py` for the custom User entity). +- [ ] **Django templates** (`src/backendctl/templates/django.py`): conditionally include + `rest_framework_simplejwt`, `token_blacklist`, `apps.authentication` in `INSTALLED_APPS`; omit + `DEFAULT_AUTHENTICATION_CLASSES` + `SIMPLE_JWT` when auth=none; set `DEFAULT_PERMISSION_CLASSES` + to `AllowAny` when auth=none; drop `simplejwt` dep from `pyproject_toml`; omit auth urls from + `config_urls`. +- [ ] Add `tests/test_health.py` to **every** generated project (all frameworks): asserts `/health` + returns 200. Gives auth=none suites a non-empty, boot-proving test. Add a Django health view + (`core/views.py` + `config/urls.py` path) since Django has no `/health` today. +- [ ] Extend `tests/test_generators.py` matrix: add `auth=AuthType.NONE` cases for Flask + Django and + assert no auth files/imports remain and the health test exists + compiles. + +## Workstream 2 — `feat/mongo-full-wiring`: working MongoDB for FastAPI + Flask + +Change: `openspec/changes/mongo-full-wiring/` (spec delta on `scaffolding`). + +- [ ] **FastAPI** (`src/backendctl/templates/fastapi.py`): + - New `items_router(c)` → `src/{slug}/modules/items/__init__.py` + `router.py` (GET/POST on a fixed + `items` collection via `get_mongo_db()`, pydantic `ItemCreate`/`ItemResponse`). + - `api_v1_router` includes items router under `/items` when `c.uses_mongo`. + - Add `mongomock-motor` to dev deps when `c.uses_mongo`; `tests_conftest` patches `core.mongo` to use + `AsyncMongoMockClient`; add `tests/test_items.py` when `c.uses_mongo`. +- [ ] **Flask** (`src/backendctl/templates/flask.py`): + - Replace `flask-pymongo` with `pymongo` in `pyproject_toml`; new `src/{slug}/mongo.py` + (`get_db()` via lazy `pymongo.MongoClient(app.config["MONGO_URI"])` + teardown). + - `extensions.py` drops `PyMongo`; `app_init` registers a `blueprints/items` blueprint when + `c.uses_mongo`; add `blueprints/items/routes.py` (GET/POST via `get_db()`). + - Add `mongomock` to dev deps when `c.uses_mongo`; `tests/test_items.py` patches the client. +- [ ] Health: extend generated `/health` (or add `/health/db`) to ping Mongo when `c.uses_mongo`. +- [ ] Tests: extend `tests/test_generators.py` to assert mongo files exist + compile for both frameworks + and that `flask-pymongo` no longer appears in the Flask `pyproject.toml`. + +## Workstream 3 — `feat/generated-runtime-quality`: E402 + logging + error handlers + +Change: `openspec/changes/generated-runtime-quality/`. + +- [ ] **E402** (`templates/django.py`): move `from datetime import timedelta` to the top of + `settings_base()` (with the other imports); delete the mid-file line. +- [ ] **FastAPI logging + handler**: add a JSON `@app.exception_handler(Exception)` in `main.py` (or a + `core/exceptions.py`) returning `{"detail": "Internal server error"}` with 500, and a + `logging.basicConfig`/`dictConfig` (gated on `settings.DEBUG`). +- [ ] **Flask logging + handler**: register `@app.errorhandler(Exception)` → JSON in `app_init`; + configure logging in `create_app`. +- [ ] **Django**: wire `REST_FRAMEWORK["EXCEPTION_HANDLER"] = "core.exceptions.custom_exception_handler"`; + add a `LOGGING` dict to `settings_base`. +- [ ] Tests: assert generated files still compile; spot-check the handler wiring string in each template + via `tests/test_generators.py`. + +## Workstream 4 — `feat/e2e-and-hygiene`: e2e CI + repo hygiene + +Change: `openspec/changes/e2e-and-hygiene/`. + +- [ ] **Automated e2e job** (`.github/workflows/e2e.yml`): matrix over + `{fastapi,flask,django} × postgres`, `{fastapi,flask} × mongodb`, and `auth=none` for each + framework. Per entry: `uv run backendctl new --framework --db --yes --no-git --no-ai`, + then in the generated dir `uv sync` + `uv run pytest -q`. FastAPI additionally runs + `DATABASE_URL=sqlite:///./app.db uv run alembic revision --autogenerate -m init`; Django runs + `uv run python manage.py makemigrations --noinput` + `migrate --noinput` (SQLite). Enable uv caching. + Note: generated tests already use in-memory SQLite/mongomock, so no services are needed. +- [ ] **`py.typed`**: add empty `src/backendctl/py.typed`; confirm hatchling includes it in the wheel. +- [ ] **mypy in CI**: add `uv run mypy src` to `ci.yml`; fix the pre-existing error in + `generators/__init__.py:17` ("Cannot instantiate abstract class BaseGenerator"). + Keep `strict=false`. +- [ ] **`--verbose`**: add a `--verbose` flag to `new_command`; when set, re-raise instead of swallowing + in the `except Exception` block (`src/backendctl/cli/new.py:434`). +- [ ] **Stale artifacts**: delete `dist/backendctl-0.1.0.*` (gitignored; local cleanup only). +- [ ] Tests: add a `test_cli.py` case asserting `--verbose` re-raises (passes through traceback). +- [ ] `ci.yml`: add mypy step; ensure all existing steps are listed as required checks. + +## Workstream 5 — Spec-driven wrap-up + +- [ ] For each change, write `proposal.md` (Why / What changes / Impact), `tasks.md` (checkbox list + mirroring the tasks above), and the `specs/scaffolding/spec.md` delta (`## ADDED/MODIFIED Requirements`). +- [ ] Run `openspec validate --strict` for each change (and `openspec list` to confirm discovery). +- [ ] After implementation + green CI, `openspec archive ` each change to `openspec/archive/`. + +--- + +## Validation plan + +- `uv run pytest -q` (tool suite) green, including the new auth-none + mongo matrix cases. +- `uv run ruff check src tests` and `uv run ruff format --check src tests` clean. +- `uv run mypy src` clean (new CI step). +- `openspec validate --strict` passes for all changes. +- New `e2e.yml` job green in CI (each generated project's own pytest passes; migrations generate). +- All PRs land on feature branches, pass required checks, and merge via GitHub PR. + +## Risks / notes + +- **mypy** may surface pre-existing errors on first CI run; fix incrementally, do not loosen config + below current `strict=false` without cause. +- **`mongomock-motor`/`mongomock`** are the standard test doubles for motor/pymongo; if a version clash + appears, fall back to overriding `get_mongo_db`/`get_db` with an in-memory fake. +- **Django `auth=none`** keeps the custom User model (no JWT endpoints); this intentionally differs from + FastAPI/Flask (which drop the User model entirely). Document this asymmetry in `project.md`. +- **Alembic autogenerate in e2e** must override `DATABASE_URL` to SQLite (Postgres isn't available in CI). +- **OpenSpec v1.9.0** uses `openspec/config.yaml` for project context (not `project.md`); the + `openspec new change` scaffold creates proposal + spec delta + design + tasks artifacts. + Validate each change with `openspec validate --strict` before implementation. + +## Open questions (non-blocking) + +- Should the FastAPI refresh token gain a `jti` claim now, or remain documented-only? (deferred) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..904a95a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## What this is + +`backendctl` is a Typer + Rich + questionary CLI that scaffolds production-ready +Python backends (FastAPI, Flask, Django REST Framework) with JWT auth, a database +choice, migrations, rate limiting, tests, linting, and optional AI-assistant +config files. + +## Commands + +```bash +uv sync # install dependencies +uv run pytest -q # run the test suite +uv run ruff check src tests # lint +uv run ruff format --check src tests # format check +uv run mypy src # type-check +``` + +## Layout + +``` +src/backendctl/ +├── main.py # Typer entry point +├── cli/new.py # `new` command + interactive wizard + flag parsing +├── core/ # config dataclasses, console helpers, pre-flight checks +├── generators/ # per-framework file writers (BaseGenerator + subclasses) +└── templates/ # pure functions returning file contents +``` + +Generators write files to disk; templates are pure functions that return file +contents. To change generated output, edit the matching function in +`templates/` and the corresponding `_scaffold()` in `generators/`. + +## Hard rules + +- Never use `shell=True`; `subprocess.run` always takes a list of arguments. +- Secrets are generated with `secrets.token_hex` / `secrets.token_urlsafe` and + written only into the gitignored `.env`. Committed files carry placeholders + only. The real database password must never appear in `.env.example`, + `README.md`, or any committed template output. +- Project names must match `^[a-zA-Z][a-zA-Z0-9_-]*$` and must not escape the + current directory (path-traversal guard lives in `BaseGenerator`). +- Use conventional commits: `type(scope): summary`. + +## Spec-driven development (OpenSpec) + +This project uses [OpenSpec](https://github.com/Fission-AI/OpenSpec) for +spec-driven development. Specs live in `openspec/specs/`; in-flight work is +proposed as changes in `openspec/changes/` and archived to +`openspec/changes/archive/` when done. + +Start a change with `/opsx-propose`, implement with `/opsx-apply`, and archive +with `/opsx-archive`. Validate with `openspec validate --strict`. diff --git a/openspec/changes/archive/2026-08-19-auth-none-consistency/.openspec.yaml b/openspec/changes/archive/2026-08-19-auth-none-consistency/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-19-auth-none-consistency/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-19-auth-none-consistency/README.md b/openspec/changes/archive/2026-08-19-auth-none-consistency/README.md new file mode 100644 index 0000000..f497311 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-auth-none-consistency/README.md @@ -0,0 +1,3 @@ +# auth-none-consistency + +Honor --auth none in Flask + Django generators diff --git a/openspec/changes/archive/2026-08-19-auth-none-consistency/proposal.md b/openspec/changes/archive/2026-08-19-auth-none-consistency/proposal.md new file mode 100644 index 0000000..5c254eb --- /dev/null +++ b/openspec/changes/archive/2026-08-19-auth-none-consistency/proposal.md @@ -0,0 +1,29 @@ +# auth-none-consistency + +## Why + +`--auth none` was only honored by the FastAPI generator. Flask and Django always +scaffolded JWT auth, user models, and auth routes, causing generated apps to +crash at import time when the auth dependency was not installed. + +## What changes + +- **Flask**: conditionally skip user model, auth/users blueprints, and auth tests + when `auth=none`; drop `flask-jwt-extended` from `pyproject.toml`, `env_example`, + `app_init`, `extensions`, and `config_py` when auth is disabled. +- **Django**: conditionally skip `apps/authentication/*` and + `apps/users/{serializers,views,urls}.py` (keep `models.py`+`apps.py` for the + custom User entity); drop `djangorestframework-simplejwt` from dependencies, + omit JWT settings from `settings_base`, and conditionally include auth URLs. +- Add `tests/test_health.py` to every generated project so `auth=none` suites + still have a passing test. + +## Capabilities + +- scaffolding (MODIFIED) + +## Impact + +- Flask/Django projects with `--auth none` no longer import missing JWT deps. +- Generated `pyproject.toml` is smaller when auth is disabled. +- All frameworks ship a boot-proving health test regardless of auth choice. diff --git a/openspec/changes/archive/2026-08-19-auth-none-consistency/specs/scaffolding/spec.md b/openspec/changes/archive/2026-08-19-auth-none-consistency/specs/scaffolding/spec.md new file mode 100644 index 0000000..51241e7 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-auth-none-consistency/specs/scaffolding/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: SHALL remove JWT auth artifacts from generated Flask projects when auth=none + +When `auth=none` is selected for Flask, the generator SHALL NOT emit any JWT +auth files or dependencies. + +#### Scenario: Flask auth=none skips auth files +- **WHEN** a user runs `backendctl new demo --framework flask --auth none --yes` +- **THEN** no `blueprints/auth/`, `blueprints/users/`, `models/user.py`, or + `tests/test_auth.py` are created, and `pyproject.toml` does not contain + `flask-jwt-extended` + +#### Scenario: Flask auth=none omits JWT from config +- **WHEN** the Flask config template is rendered with `auth=none` +- **THEN** `config.py` does not contain `JWT_SECRET_KEY` or JWT expiry fields, + and `.env.example` does not contain JWT settings + +### Requirement: SHALL remove JWT auth artifacts from generated Django projects when auth=none + +When `auth=none` is selected for Django, the generator SHALL NOT emit JWT auth +files or dependencies, but MUST keep the custom User model. + +#### Scenario: Django auth=none skips auth files +- **WHEN** a user runs `backendctl new demo --framework django --auth none --yes` +- **THEN** no `apps/authentication/` content (except migrations/__init__.py), + no `apps/users/serializers.py`, `views.py`, or `urls.py` are created, + and `pyproject.toml` does not contain `djangorestframework-simplejwt` + +#### Scenario: Django auth=none sets permissive default permissions +- **WHEN** the Django settings template is rendered with `auth=none` +- **THEN** `REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]` is set to + `AllowAny` and `DEFAULT_AUTHENTICATION_CLASSES` is omitted + +### Requirement: SHALL include a health test in all generated projects + +Every generated project MUST contain a `tests/test_health.py` that asserts +`/health` returns 200, giving `auth=none` suites a non-empty, boot-proving test. + +#### Scenario: Health test exists for all frameworks +- **WHEN** generation completes for any framework +- **THEN** `tests/test_health.py` exists and compiles diff --git a/openspec/changes/archive/2026-08-19-auth-none-consistency/tasks.md b/openspec/changes/archive/2026-08-19-auth-none-consistency/tasks.md new file mode 100644 index 0000000..ae2e2c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-auth-none-consistency/tasks.md @@ -0,0 +1,17 @@ +# Tasks: auth-none-consistency + +## Implementation + +- [x] Flask generator: wrap user model, auth/users blueprints, and auth tests in `if c.auth.value != "none"` +- [x] Flask templates: conditionally drop JWT from pyproject, env, app_init, extensions, config +- [x] Django generator: wrap apps/authentication/* and apps/users/{serializers,views,urls}.py +- [x] Django templates: conditionally include JWT apps, auth classes, SIMPLE_JWT, auth URLs +- [x] Add `tests/test_health.py` to FastAPI, Flask, Django templates +- [x] Extend `tests/test_generators.py` matrix with Flask + Django auth=none cases +- [x] Add specific auth=none assertion tests + +## Validation + +- [x] `uv run pytest -q` passes (91 tests) +- [x] `uv run ruff check src tests` clean +- [x] `uv run mypy src` clean diff --git a/openspec/changes/archive/2026-08-19-e2e-and-hygiene/.openspec.yaml b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-19-e2e-and-hygiene/README.md b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/README.md new file mode 100644 index 0000000..3b2e76a --- /dev/null +++ b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/README.md @@ -0,0 +1,3 @@ +# e2e-and-hygiene + +e2e CI + py.typed + mypy CI + --verbose + dist cleanup diff --git a/openspec/changes/archive/2026-08-19-e2e-and-hygiene/proposal.md b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/proposal.md new file mode 100644 index 0000000..176a213 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/proposal.md @@ -0,0 +1,33 @@ +# e2e-and-hygiene + +## Why + +The repository had no end-to-end CI to verify that generated projects actually +install and run. Mypy was not checked in CI, there was no `py.typed` marker, +the CLI swallowed tracebacks on failure, and stale `dist/` artifacts cluttered +the workspace. + +## What changes + +- **e2e CI**: new `.github/workflows/e2e.yml` matrix over + `{fastapi,flask,django} × postgres`, `{fastapi,flask} × mongodb`, + and `auth=none` for each framework. Each job generates a project, runs + `uv sync` + `uv run pytest -q`, and for FastAPI/Django runs migrations. +- **py.typed**: add empty `src/backendctl/py.typed`; hatchling includes it + via the existing wheel packages config. +- **mypy in CI**: add `uv run mypy src` to `ci.yml`; fix the pre-existing + `generators/__init__.py:17` abstract class instantiation error. +- **--verbose**: add `--verbose` flag to `new_command`; when set, re-raise + exceptions instead of swallowing them in the `except Exception` block. +- **dist cleanup**: delete stale `dist/backendctl-0.1.0.*` artifacts. + +## Capabilities + +- scaffolding (MODIFIED) + +## Impact + +- Generated projects are verified end-to-end on every PR. +- The tool package declares itself typed. +- CI catches type regressions. +- Debugging generation failures is easier with `--verbose`. diff --git a/openspec/changes/archive/2026-08-19-e2e-and-hygiene/specs/scaffolding/spec.md b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/specs/scaffolding/spec.md new file mode 100644 index 0000000..9c0e4dc --- /dev/null +++ b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/specs/scaffolding/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: SHALL validate generated projects in end-to-end CI + +Every generated project MUST be created, installed, and tested in CI across +representative framework and database combinations. + +#### Scenario: Matrix e2e job succeeds for each framework/database combo +- **WHEN** the e2e CI matrix runs for `{fastapi,flask,django} × postgres`, + `{fastapi,flask} × mongodb`, and `auth=none` for each framework +- **THEN** `backendctl new`, `uv sync`, and `uv run pytest -q` all succeed + in the generated project directory + +#### Scenario: FastAPI migrations generate in e2e +- **WHEN** the e2e job runs for FastAPI +- **THEN** `DATABASE_URL=sqlite:///./app.db uv run alembic revision --autogenerate -m init` succeeds + +#### Scenario: Django migrations run in e2e +- **WHEN** the e2e job runs for Django +- **THEN** `manage.py makemigrations --noinput` and `migrate --noinput` succeed against SQLite + +### Requirement: SHALL declare the tool package as typed + +A `py.typed` marker MUST be present and included in the built wheel. + +#### Scenario: py.typed exists and is packaged +- **WHEN** the tool is built with hatchling +- **THEN** `src/backendctl/py.typed` is included in the wheel + +### Requirement: SHALL enforce mypy in CI + +The CI pipeline MUST run mypy and fail on type errors. + +#### Scenario: CI runs mypy +- **WHEN** a PR is opened or pushed to main +- **THEN** the `mypy` job in `ci.yml` runs `uv run mypy src` and passes + +### Requirement: SHALL support --verbose for debugging generation failures + +The `new` command MUST accept `--verbose` and propagate exceptions when set. + +#### Scenario: --verbose re-raises exceptions +- **WHEN** generation fails and `--verbose` is passed +- **THEN** the full traceback is propagated to the caller instead of being swallowed diff --git a/openspec/changes/archive/2026-08-19-e2e-and-hygiene/tasks.md b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/tasks.md new file mode 100644 index 0000000..c744d7a --- /dev/null +++ b/openspec/changes/archive/2026-08-19-e2e-and-hygiene/tasks.md @@ -0,0 +1,18 @@ +# Tasks: e2e-and-hygiene + +## Implementation + +- [x] Create `.github/workflows/e2e.yml` with matrix over frameworks × databases +- [x] Create `.github/workflows/ci.yml` with mypy step +- [x] Add empty `src/backendctl/py.typed` +- [x] Fix mypy error in `generators/__init__.py:17` (annotate mapping dict) +- [x] Add `--verbose` flag to `new_command` +- [x] Re-raise exceptions when `--verbose` is set +- [x] Delete stale `dist/backendctl-0.1.0.*` artifacts +- [x] Add `test_verbose_flag_shows_traceback` to `tests/test_cli.py` + +## Validation + +- [x] `uv run pytest -q` passes (91 tests) +- [x] `uv run ruff check src tests` clean +- [x] `uv run mypy src` clean diff --git a/openspec/changes/archive/2026-08-19-generated-runtime-quality/.openspec.yaml b/openspec/changes/archive/2026-08-19-generated-runtime-quality/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-19-generated-runtime-quality/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-19-generated-runtime-quality/README.md b/openspec/changes/archive/2026-08-19-generated-runtime-quality/README.md new file mode 100644 index 0000000..819971e --- /dev/null +++ b/openspec/changes/archive/2026-08-19-generated-runtime-quality/README.md @@ -0,0 +1,3 @@ +# generated-runtime-quality + +E402 + logging + JSON error handlers diff --git a/openspec/changes/archive/2026-08-19-generated-runtime-quality/proposal.md b/openspec/changes/archive/2026-08-19-generated-runtime-quality/proposal.md new file mode 100644 index 0000000..54009f1 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-generated-runtime-quality/proposal.md @@ -0,0 +1,31 @@ +# generated-runtime-quality + +## Why + +Generated Django projects had a mid-file `from datetime import timedelta` import +triggering ruff `E402`. None of the frameworks shipped JSON error handlers or +logging configuration, meaning generated apps returned HTML tracebacks to API +clients and produced no structured logs in development. + +## What changes + +- **Django E402**: move `from datetime import timedelta` to the top of + `settings_base()` (with other imports); delete the mid-file line. +- **FastAPI**: add a JSON `@app.exception_handler(Exception)` returning + `{"detail": "Internal server error"}` with 500; add `logging.basicConfig` + gated on `settings.DEBUG`. +- **Flask**: register `@app.errorhandler(Exception)` → JSON in `app_init`; + configure `logging.basicConfig` in `create_app` when `DEBUG` is true. +- **Django**: wire `REST_FRAMEWORK["EXCEPTION_HANDLER"]` to + `core.exceptions.custom_exception_handler`; add a `LOGGING` dict to + `settings_base`. + +## Capabilities + +- scaffolding (MODIFIED) + +## Impact + +- Generated Django projects pass ruff lint without E402 violations. +- All generated frameworks return JSON 500 errors instead of HTML tracebacks. +- Development logging is enabled when `DEBUG=true`, improving debuggability. diff --git a/openspec/changes/archive/2026-08-19-generated-runtime-quality/specs/scaffolding/spec.md b/openspec/changes/archive/2026-08-19-generated-runtime-quality/specs/scaffolding/spec.md new file mode 100644 index 0000000..0ae76a8 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-generated-runtime-quality/specs/scaffolding/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: SHALL NOT trigger ruff E402 in generated Django settings + +The `settings_base()` template MUST have all imports at the top of the file. + +#### Scenario: No mid-file imports in Django settings +- **WHEN** the Django settings template is rendered +- **THEN** `config/settings/base.py` compiles without `E402` violations + +### Requirement: SHALL return JSON 500 errors from generated FastAPI apps + +FastAPI apps MUST have a generic exception handler that returns JSON instead of +HTML tracebacks. + +#### Scenario: FastAPI unhandled exception returns JSON +- **WHEN** an unhandled exception occurs in a generated FastAPI app +- **THEN** the response is `{"detail": "Internal server error"}` with status 500 + +### Requirement: SHALL return JSON 500 errors from generated Flask apps + +Flask apps MUST have a generic error handler that returns JSON instead of HTML +tracebacks. + +#### Scenario: Flask unhandled exception returns JSON +- **WHEN** an unhandled exception occurs in a generated Flask app +- **THEN** the response is `{"detail": "Internal server error"}` with status 500 + +### Requirement: SHALL wire custom exception handler in generated Django DRF apps + +Django settings MUST wire `core.exceptions.custom_exception_handler` into +`REST_FRAMEWORK`. + +#### Scenario: Django DRF exception handler is configured +- **WHEN** the Django settings template is rendered +- **THEN** `REST_FRAMEWORK["EXCEPTION_HANDLER"]` equals `"core.exceptions.custom_exception_handler"` + +### Requirement: SHALL configure development logging in generated apps + +When `DEBUG=true`, generated apps MUST configure basic logging to stdout. + +#### Scenario: FastAPI logs in debug mode +- **WHEN** a generated FastAPI app starts with `DEBUG=true` +- **THEN** `logging.basicConfig(level=logging.INFO)` is called + +#### Scenario: Flask logs in debug mode +- **WHEN** a generated Flask app starts with `DEBUG=true` +- **THEN** `logging.basicConfig(level=logging.INFO)` is called + +#### Scenario: Django logging dict is present +- **WHEN** the Django settings template is rendered +- **THEN** a `LOGGING` dict with a console handler is defined diff --git a/openspec/changes/archive/2026-08-19-generated-runtime-quality/tasks.md b/openspec/changes/archive/2026-08-19-generated-runtime-quality/tasks.md new file mode 100644 index 0000000..a58c0d7 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-generated-runtime-quality/tasks.md @@ -0,0 +1,17 @@ +# Tasks: generated-runtime-quality + +## Implementation + +- [x] Django E402: move `from datetime import timedelta` to top of `settings_base` +- [x] FastAPI: add JSON `@app.exception_handler(Exception)` in `main.py` +- [x] FastAPI: add `logging.basicConfig` gated on `settings.DEBUG` +- [x] Flask: register `@app.errorhandler(Exception)` → JSON in `app_init` +- [x] Flask: configure `logging.basicConfig` in `create_app` when `DEBUG` +- [x] Django: wire `REST_FRAMEWORK["EXCEPTION_HANDLER"]` to `core.exceptions.custom_exception_handler` +- [x] Django: add `LOGGING` dict to `settings_base` + +## Validation + +- [x] `uv run pytest -q` passes (91 tests) +- [x] `uv run ruff check src tests` clean (no E402) +- [x] `uv run mypy src` clean diff --git a/openspec/changes/archive/2026-08-19-mongo-full-wiring/.openspec.yaml b/openspec/changes/archive/2026-08-19-mongo-full-wiring/.openspec.yaml new file mode 100644 index 0000000..41c30ba --- /dev/null +++ b/openspec/changes/archive/2026-08-19-mongo-full-wiring/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-19 diff --git a/openspec/changes/archive/2026-08-19-mongo-full-wiring/README.md b/openspec/changes/archive/2026-08-19-mongo-full-wiring/README.md new file mode 100644 index 0000000..da03053 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-mongo-full-wiring/README.md @@ -0,0 +1,3 @@ +# mongo-full-wiring + +Full MongoDB wiring for FastAPI + Flask diff --git a/openspec/changes/archive/2026-08-19-mongo-full-wiring/proposal.md b/openspec/changes/archive/2026-08-19-mongo-full-wiring/proposal.md new file mode 100644 index 0000000..5305d7c --- /dev/null +++ b/openspec/changes/archive/2026-08-19-mongo-full-wiring/proposal.md @@ -0,0 +1,29 @@ +# mongo-full-wiring + +## Why + +MongoDB was half-wired: FastAPI generated a motor client nobody called, and Flask +used the unmaintained `flask-pymongo` without any sample CRUD usage or tests. +Generated projects had no way to verify MongoDB connectivity. + +## What changes + +- **FastAPI**: new `modules/items` router with GET/POST on an `items` collection + via `get_mongo_db()`; `api_v1_router` includes items router when `uses_mongo`; + `tests/conftest.py` patches mongo with `mongomock-motor`; `tests/test_items.py` + added; `/health/db` endpoint pings MongoDB. +- **Flask**: replace `flask-pymongo` with plain `pymongo`; new `mongo.py` module + providing `get_db()` + `close_mongo()` teardown; `extensions.py` drops + `PyMongo`; `app_init` registers `blueprints/items` when `uses_mongo`; + `tests/conftest.py` patches `pymongo.MongoClient` with `mongomock`; + `tests/test_items.py` added; `/health/db` endpoint pings MongoDB. + +## Capabilities + +- scaffolding (MODIFIED) + +## Impact + +- FastAPI and Flask projects with MongoDB have working CRUD sample code. +- MongoDB tests run without a live server (mongomock / mongomock-motor). +- Flask no longer depends on the unmaintained `flask-pymongo`. diff --git a/openspec/changes/archive/2026-08-19-mongo-full-wiring/specs/scaffolding/spec.md b/openspec/changes/archive/2026-08-19-mongo-full-wiring/specs/scaffolding/spec.md new file mode 100644 index 0000000..4261512 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-mongo-full-wiring/specs/scaffolding/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: SHALL include a working items CRUD module in FastAPI projects with MongoDB + +When `uses_mongo` is true for FastAPI, the generator MUST emit a functional +items module and wire it into the API router. + +#### Scenario: FastAPI MongoDB items module exists +- **WHEN** a user runs `backendctl new demo --framework fastapi --db mongodb --yes` +- **THEN** `src//modules/items/__init__.py`, `router.py`, and + `tests/test_items.py` exist and compile + +#### Scenario: FastAPI MongoDB health check endpoint exists +- **WHEN** the FastAPI main template is rendered with `uses_mongo=true` +- **THEN** a `/health/db` route is registered that pings MongoDB + +### Requirement: SHALL use plain pymongo instead of flask-pymongo in Flask projects with MongoDB + +When `uses_mongo` is true for Flask, the generator MUST use `pymongo` directly +with a `get_db()` helper and teardown. + +#### Scenario: Flask MongoDB uses pymongo +- **WHEN** a user runs `backendctl new demo --framework flask --db mongodb --yes` +- **THEN** `pyproject.toml` contains `pymongo` but not `flask-pymongo`, + `src//mongo.py` exists with `get_db()` and `close_mongo()`, + and `src//blueprints/items/routes.py` provides GET/POST + +#### Scenario: Flask MongoDB health check endpoint exists +- **WHEN** the Flask app template is rendered with `uses_mongo=true` +- **THEN** a `/health/db` route is registered that pings MongoDB via `mongo.db.command("ping")` + +### Requirement: SHALL run MongoDB tests without a live server + +Generated test suites MUST use `mongomock` (Flask) or `mongomock-motor` (FastAPI) +so CI does not need a running MongoDB instance. + +#### Scenario: FastAPI MongoDB tests patch the client +- **WHEN** `tests/conftest.py` is rendered for FastAPI with `uses_mongo=true` +- **THEN** it monkeypatches `core.mongo._client` with `AsyncMongoMockClient` + +#### Scenario: Flask MongoDB tests patch the client +- **WHEN** `tests/conftest.py` is rendered for Flask with `uses_mongo=true` +- **THEN** it monkeypatches `pymongo.MongoClient` with `mongomock.MongoClient()` diff --git a/openspec/changes/archive/2026-08-19-mongo-full-wiring/tasks.md b/openspec/changes/archive/2026-08-19-mongo-full-wiring/tasks.md new file mode 100644 index 0000000..04db6c7 --- /dev/null +++ b/openspec/changes/archive/2026-08-19-mongo-full-wiring/tasks.md @@ -0,0 +1,21 @@ +# Tasks: mongo-full-wiring + +## Implementation + +- [x] FastAPI: add `modules/items/__init__.py` + `router.py` (GET/POST via `get_mongo_db()`) +- [x] FastAPI: `api_v1_router` includes items router when `c.uses_mongo` +- [x] FastAPI: add `mongomock-motor` to dev deps; patch `core.mongo._client` in tests +- [x] FastAPI: add `tests/test_items.py` when `c.uses_mongo` +- [x] FastAPI: add `/health/db` endpoint when `c.uses_mongo` +- [x] Flask: replace `flask-pymongo` with `pymongo` in `pyproject.toml` +- [x] Flask: new `mongo.py` (`get_db()` + `close_mongo()` teardown) +- [x] Flask: `extensions.py` drops `PyMongo`; `app_init` registers items blueprint +- [x] Flask: add `mongomock` to dev deps; patch `pymongo.MongoClient` in tests +- [x] Flask: add `tests/test_items.py` when `c.uses_mongo` +- [x] Flask: add `/health/db` endpoint when `c.uses_mongo` + +## Validation + +- [x] `uv run pytest -q` passes (91 tests) +- [x] `uv run ruff check src tests` clean +- [x] `uv run mypy src` clean diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..088963d --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,34 @@ +schema: spec-driven + +# Project context (shown to AI when creating artifacts) +context: | + backendctl is a CLI (Typer + Rich + questionary) that scaffolds + production-ready Python backends for FastAPI, Flask, and Django REST + Framework. Generated projects include JWT auth, a database choice + (PostgreSQL / MongoDB / both), migrations, rate limiting, tests, linting, + and optional AI-assistant config files. + + Stack: Python 3.11+, typer, rich, questionary. Package manager: uv (or pip). + + Commands: + - uv sync + - uv run pytest -q + - uv run ruff check src tests + - uv run ruff format --check src tests + - uv run mypy src + + Layout: src/backendctl/{main.py, cli/new.py, core/{config,checks,console}.py, + generators/, templates/}. Generators write files; templates are pure + functions returning file contents (f-strings). + + Invariants: + - Never use shell=True; subprocess always takes a list. + - Secrets are generated with secrets.token_hex/token_urlsafe and written + only into the gitignored .env; committed files use placeholders only. + - The real database password must never appear in .env.example, README.md, + or any committed template output. + - Project names are validated against ^[a-zA-Z][a-zA-Z0-9_-]*$ and must not + escape the current directory (path-traversal guard in BaseGenerator). + - Scaffolding refuses a non-empty directory unless --force is set; .env is + preserved on --force. + - Use conventional commits. diff --git a/openspec/specs/scaffolding/spec.md b/openspec/specs/scaffolding/spec.md new file mode 100644 index 0000000..ba7f2eb --- /dev/null +++ b/openspec/specs/scaffolding/spec.md @@ -0,0 +1,245 @@ +## Purpose + +The scaffolding capability defines what `backendctl new` guarantees: a safe, +valid, batteries-included project is generated for the selected framework and +options, without path traversal, silent data loss, or leaked secrets. + +## Requirements + +### Requirement: Project names are validated and cannot escape the working directory + +The project name, whether from a CLI argument or the wizard, must match a safe +slug and must resolve to a direct child of the current working directory. + +#### Scenario: Traversal or absolute paths are rejected +- **WHEN** a user passes a name like `../evil`, `/tmp/evil`, `sub/dir`, or `1app` +- **THEN** the CLI exits non-zero, writes nothing outside the working directory, and no directory is created + +#### Scenario: Valid names are accepted +- **WHEN** a user passes a name of letters, digits, hyphens, or underscores starting with a letter +- **THEN** the project is scaffolded under `./` + +### Requirement: Scaffolding never silently overwrites user data + +A non-empty target directory must not be modified without explicit `--force`, +and secrets in an existing `.env` must be preserved even with `--force`. + +#### Scenario: Non-empty directory without force +- **WHEN** the target directory exists and is non-empty and `--force` is not set +- **THEN** generation is refused and existing files are left untouched + +#### Scenario: Existing .env is preserved with force +- **WHEN** `--force` is set and the target contains an existing `.env` +- **THEN** the existing `.env` is left intact and not overwritten + +### Requirement: Database credentials flow into generated files safely + +Resolved database name, user, and password flow into `.env` and +`docker-compose.yml`, while committed files only ever carry a placeholder. + +#### Scenario: Real credentials in .env, placeholder in .env.example +- **WHEN** a project is generated with explicit database credentials +- **THEN** `.env` contains the real URL and `.env.example` contains the placeholder `change-me-db-password` + +#### Scenario: Defaults resolve from the project slug +- **WHEN** no database name or user is provided +- **THEN** both default to the project slug and a random password is generated + +### Requirement: Generated Python files are syntactically valid across the option matrix + +Every generated `.py` file must compile, for every framework crossed with +representative option combinations. + +#### Scenario: Matrix generation compiles +- **WHEN** generation runs for fastapi/flask/django across postgres/mongodb/both and auth variants +- **THEN** every generated `.py` file passes `py_compile` with no syntax errors + +### Requirement: Non-interactive mode respects provided flags + +CLI flags must be authoritative: any field set via flag is never re-asked, and +`--yes` accepts defaults for everything else without prompting. + +#### Scenario: Flags skip the matching wizard steps +- **WHEN** the user passes `--framework`, `--db`, `--pm`, `--auth`, or `--ai` +- **THEN** the wizard does not prompt for those fields and does not overwrite them + +#### Scenario: --yes is fully non-interactive +- **WHEN** `--yes` is passed with a project name +- **THEN** no prompts are shown and the project is generated with defaults + +### Requirement: SHALL remove JWT auth artifacts from generated Flask projects when auth=none + +When `auth=none` is selected for Flask, the generator SHALL NOT emit any JWT +auth files or dependencies. + +#### Scenario: Flask auth=none skips auth files +- **WHEN** a user runs `backendctl new demo --framework flask --auth none --yes` +- **THEN** no `blueprints/auth/`, `blueprints/users/`, `models/user.py`, or + `tests/test_auth.py` are created, and `pyproject.toml` does not contain + `flask-jwt-extended` + +#### Scenario: Flask auth=none omits JWT from config +- **WHEN** the Flask config template is rendered with `auth=none` +- **THEN** `config.py` does not contain `JWT_SECRET_KEY` or JWT expiry fields, + and `.env.example` does not contain JWT settings + +### Requirement: SHALL remove JWT auth artifacts from generated Django projects when auth=none + +When `auth=none` is selected for Django, the generator SHALL NOT emit JWT auth +files or dependencies, but MUST keep the custom User model. + +#### Scenario: Django auth=none skips auth files +- **WHEN** a user runs `backendctl new demo --framework django --auth none --yes` +- **THEN** no `apps/authentication/` content (except migrations/__init__.py), + no `apps/users/serializers.py`, `views.py`, or `urls.py` are created, + and `pyproject.toml` does not contain `djangorestframework-simplejwt` + +#### Scenario: Django auth=none sets permissive default permissions +- **WHEN** the Django settings template is rendered with `auth=none` +- **THEN** `REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]` is set to + `AllowAny` and `DEFAULT_AUTHENTICATION_CLASSES` is omitted + +### Requirement: SHALL include a health test in all generated projects + +Every generated project MUST contain a `tests/test_health.py` that asserts +`/health` returns 200, giving `auth=none` suites a non-empty, boot-proving test. + +#### Scenario: Health test exists for all frameworks +- **WHEN** generation completes for any framework +- **THEN** `tests/test_health.py` exists and compiles + +### Requirement: SHALL include a working items CRUD module in FastAPI projects with MongoDB + +When `uses_mongo` is true for FastAPI, the generator MUST emit a functional +items module and wire it into the API router. + +#### Scenario: FastAPI MongoDB items module exists +- **WHEN** a user runs `backendctl new demo --framework fastapi --db mongodb --yes` +- **THEN** `src//modules/items/__init__.py`, `router.py`, and + `tests/test_items.py` exist and compile + +#### Scenario: FastAPI MongoDB health check endpoint exists +- **WHEN** the FastAPI main template is rendered with `uses_mongo=true` +- **THEN** a `/health/db` route is registered that pings MongoDB + +### Requirement: SHALL use plain pymongo instead of flask-pymongo in Flask projects with MongoDB + +When `uses_mongo` is true for Flask, the generator MUST use `pymongo` directly +with a `get_db()` helper and teardown. + +#### Scenario: Flask MongoDB uses pymongo +- **WHEN** a user runs `backendctl new demo --framework flask --db mongodb --yes` +- **THEN** `pyproject.toml` contains `pymongo` but not `flask-pymongo`, + `src//mongo.py` exists with `get_db()` and `close_mongo()`, + and `src//blueprints/items/routes.py` provides GET/POST + +#### Scenario: Flask MongoDB health check endpoint exists +- **WHEN** the Flask app template is rendered with `uses_mongo=true` +- **THEN** a `/health/db` route is registered that pings MongoDB via `mongo.db.command("ping")` + +### Requirement: SHALL run MongoDB tests without a live server + +Generated test suites MUST use `mongomock` (Flask) or `mongomock-motor` (FastAPI) +so CI does not need a running MongoDB instance. + +#### Scenario: FastAPI MongoDB tests patch the client +- **WHEN** `tests/conftest.py` is rendered for FastAPI with `uses_mongo=true` +- **THEN** it monkeypatches `core.mongo._client` with `AsyncMongoMockClient` + +#### Scenario: Flask MongoDB tests patch the client +- **WHEN** `tests/conftest.py` is rendered for Flask with `uses_mongo=true` +- **THEN** it monkeypatches `pymongo.MongoClient` with `mongomock.MongoClient()` + +### Requirement: SHALL NOT trigger ruff E402 in generated Django settings + +The `settings_base()` template MUST have all imports at the top of the file. + +#### Scenario: No mid-file imports in Django settings +- **WHEN** the Django settings template is rendered +- **THEN** `config/settings/base.py` compiles without `E402` violations + +### Requirement: SHALL return JSON 500 errors from generated FastAPI apps + +FastAPI apps MUST have a generic exception handler that returns JSON instead of +HTML tracebacks. + +#### Scenario: FastAPI unhandled exception returns JSON +- **WHEN** an unhandled exception occurs in a generated FastAPI app +- **THEN** the response is `{"detail": "Internal server error"}` with status 500 + +### Requirement: SHALL return JSON 500 errors from generated Flask apps + +Flask apps MUST have a generic error handler that returns JSON instead of HTML +tracebacks. + +#### Scenario: Flask unhandled exception returns JSON +- **WHEN** an unhandled exception occurs in a generated Flask app +- **THEN** the response is `{"detail": "Internal server error"}` with status 500 + +### Requirement: SHALL wire custom exception handler in generated Django DRF apps + +Django settings MUST wire `core.exceptions.custom_exception_handler` into +`REST_FRAMEWORK`. + +#### Scenario: Django DRF exception handler is configured +- **WHEN** the Django settings template is rendered +- **THEN** `REST_FRAMEWORK["EXCEPTION_HANDLER"]` equals `"core.exceptions.custom_exception_handler"` + +### Requirement: SHALL configure development logging in generated apps + +When `DEBUG=true`, generated apps MUST configure basic logging to stdout. + +#### Scenario: FastAPI logs in debug mode +- **WHEN** a generated FastAPI app starts with `DEBUG=true` +- **THEN** `logging.basicConfig(level=logging.INFO)` is called + +#### Scenario: Flask logs in debug mode +- **WHEN** a generated Flask app starts with `DEBUG=true` +- **THEN** `logging.basicConfig(level=logging.INFO)` is called + +#### Scenario: Django logging dict is present +- **WHEN** the Django settings template is rendered +- **THEN** a `LOGGING` dict with a console handler is defined + +### Requirement: SHALL validate generated projects in end-to-end CI + +Every generated project MUST be created, installed, and tested in CI across +representative framework and database combinations. + +#### Scenario: Matrix e2e job succeeds for each framework/database combo +- **WHEN** the e2e CI matrix runs for `{fastapi,flask,django} × postgres`, + `{fastapi,flask} × mongodb`, and `auth=none` for each framework +- **THEN** `backendctl new`, `uv sync`, and `uv run pytest -q` all succeed + in the generated project directory + +#### Scenario: FastAPI migrations generate in e2e +- **WHEN** the e2e job runs for FastAPI +- **THEN** `DATABASE_URL=sqlite:///./app.db uv run alembic revision --autogenerate -m init` succeeds + +#### Scenario: Django migrations run in e2e +- **WHEN** the e2e job runs for Django +- **THEN** `manage.py makemigrations --noinput` and `migrate --noinput` succeed against SQLite + +### Requirement: SHALL declare the tool package as typed + +A `py.typed` marker MUST be present and included in the built wheel. + +#### Scenario: py.typed exists and is packaged +- **WHEN** the tool is built with hatchling +- **THEN** `src/backendctl/py.typed` is included in the wheel + +### Requirement: SHALL enforce mypy in CI + +The CI pipeline MUST run mypy and fail on type errors. + +#### Scenario: CI runs mypy +- **WHEN** a PR is opened or pushed to main +- **THEN** the `mypy` job in `ci.yml` runs `uv run mypy src` and passes + +### Requirement: SHALL support --verbose for debugging generation failures + +The `new` command MUST accept `--verbose` and propagate exceptions when set. + +#### Scenario: --verbose re-raises exceptions +- **WHEN** generation fails and `--verbose` is passed +- **THEN** the full traceback is propagated to the caller instead of being swallowed diff --git a/src/backendctl/cli/new.py b/src/backendctl/cli/new.py index d928a88..47374f7 100644 --- a/src/backendctl/cli/new.py +++ b/src/backendctl/cli/new.py @@ -339,6 +339,11 @@ def new_command( "--no-ai", help="Skip AI assistant setup.", ), + verbose: bool = typer.Option( + False, + "--verbose", + help="Show full tracebacks on failure.", + ), ) -> None: print_banner() @@ -432,6 +437,8 @@ def new_command( print_error(str(exc)) raise typer.Exit(1) except Exception as exc: # noqa: BLE001 + if verbose: + raise print_error(f"Generation failed: {exc}") raise typer.Exit(1) diff --git a/src/backendctl/generators/__init__.py b/src/backendctl/generators/__init__.py index a79c73c..732e8f5 100644 --- a/src/backendctl/generators/__init__.py +++ b/src/backendctl/generators/__init__.py @@ -9,7 +9,7 @@ def get_generator(config: ProjectConfig) -> BaseGenerator: from backendctl.generators.fastapi_gen import FastAPIGenerator from backendctl.generators.flask_gen import FlaskGenerator - mapping = { + mapping: dict[Framework, type[BaseGenerator]] = { Framework.FASTAPI: FastAPIGenerator, Framework.FLASK: FlaskGenerator, Framework.DJANGO: DjangoGenerator, diff --git a/src/backendctl/generators/django_gen.py b/src/backendctl/generators/django_gen.py index 4664437..2f4fbd3 100644 --- a/src/backendctl/generators/django_gen.py +++ b/src/backendctl/generators/django_gen.py @@ -42,18 +42,20 @@ def _scaffold(self) -> None: self._write("apps/users/__init__.py", "") self._write("apps/users/apps.py", t.users_apps(c)) self._write("apps/users/models.py", t.users_model(c)) - self._write("apps/users/serializers.py", t.users_serializers(c)) - self._write("apps/users/views.py", t.users_views()) - self._write("apps/users/urls.py", t.users_urls()) + if c.auth.value != "none": + self._write("apps/users/serializers.py", t.users_serializers(c)) + self._write("apps/users/views.py", t.users_views()) + self._write("apps/users/urls.py", t.users_urls()) self._write("apps/users/migrations/__init__.py", "") # apps/authentication/ - self._write("apps/authentication/__init__.py", "") - self._write("apps/authentication/apps.py", t.auth_apps()) - self._write("apps/authentication/serializers.py", t.auth_serializers(c)) - self._write("apps/authentication/views.py", t.auth_views(c)) - self._write("apps/authentication/urls.py", t.auth_urls()) - self._write("apps/authentication/migrations/__init__.py", "") + if c.auth.value != "none": + self._write("apps/authentication/__init__.py", "") + self._write("apps/authentication/apps.py", t.auth_apps()) + self._write("apps/authentication/serializers.py", t.auth_serializers(c)) + self._write("apps/authentication/views.py", t.auth_views(c)) + self._write("apps/authentication/urls.py", t.auth_urls()) + self._write("apps/authentication/migrations/__init__.py", "") # core/ self._write("core/__init__.py", "") @@ -63,7 +65,9 @@ def _scaffold(self) -> None: # Tests self._write("tests/__init__.py", "") self._write("tests/conftest.py", t.tests_conftest(c)) - self._write("tests/test_auth.py", t.tests_auth()) + self._write("tests/test_health.py", t.tests_health(c)) + if c.auth.value != "none": + self._write("tests/test_auth.py", t.tests_auth()) from backendctl.core.console import print_info diff --git a/src/backendctl/generators/fastapi_gen.py b/src/backendctl/generators/fastapi_gen.py index 758ec93..a476c1a 100644 --- a/src/backendctl/generators/fastapi_gen.py +++ b/src/backendctl/generators/fastapi_gen.py @@ -60,6 +60,11 @@ def _scaffold(self) -> None: self._write(f"src/{s}/modules/users/__init__.py", "") self._write(f"src/{s}/modules/users/router.py", t.users_router(c)) + if c.uses_mongo: + # items module + self._write(f"src/{s}/modules/items/__init__.py", "") + self._write(f"src/{s}/modules/items/router.py", t.items_router(c)) + # Alembic self._write("alembic.ini", t.alembic_ini(c)) self._write("alembic/env.py", t.alembic_env(c)) @@ -69,8 +74,11 @@ def _scaffold(self) -> None: # Tests self._write("tests/__init__.py", "") self._write("tests/conftest.py", t.tests_conftest(c)) + self._write("tests/test_health.py", t.tests_health(c)) if c.auth.value != "none": self._write("tests/test_auth.py", t.tests_auth(c)) + if c.uses_mongo: + self._write("tests/test_items.py", t.tests_items(c)) from backendctl.core.console import print_info diff --git a/src/backendctl/generators/flask_gen.py b/src/backendctl/generators/flask_gen.py index 4780a49..f50c856 100644 --- a/src/backendctl/generators/flask_gen.py +++ b/src/backendctl/generators/flask_gen.py @@ -26,24 +26,35 @@ def _scaffold(self) -> None: self._write(f"src/{s}/__init__.py", t.app_init(c)) self._write(f"src/{s}/extensions.py", t.extensions(c)) self._write(f"src/{s}/config.py", t.config_py(c)) + if c.uses_mongo: + self._write(f"src/{s}/mongo.py", t.mongo_py(c)) # Models self._write(f"src/{s}/models/__init__.py", "") - self._write(f"src/{s}/models/user.py", t.user_model(c)) + if c.auth.value != "none": + self._write(f"src/{s}/models/user.py", t.user_model(c)) # Blueprints self._write(f"src/{s}/blueprints/__init__.py", "") - self._write(f"src/{s}/blueprints/auth/__init__.py", "") - self._write(f"src/{s}/blueprints/auth/schemas.py", t.auth_schemas(c)) - self._write(f"src/{s}/blueprints/auth/routes.py", t.auth_routes(c)) - self._write(f"src/{s}/blueprints/auth/service.py", t.auth_service(c)) - self._write(f"src/{s}/blueprints/users/__init__.py", "") - self._write(f"src/{s}/blueprints/users/routes.py", t.users_routes(c)) + if c.auth.value != "none": + self._write(f"src/{s}/blueprints/auth/__init__.py", "") + self._write(f"src/{s}/blueprints/auth/schemas.py", t.auth_schemas(c)) + self._write(f"src/{s}/blueprints/auth/routes.py", t.auth_routes(c)) + self._write(f"src/{s}/blueprints/auth/service.py", t.auth_service(c)) + self._write(f"src/{s}/blueprints/users/__init__.py", "") + self._write(f"src/{s}/blueprints/users/routes.py", t.users_routes(c)) + if c.uses_mongo: + self._write(f"src/{s}/blueprints/items/__init__.py", "") + self._write(f"src/{s}/blueprints/items/routes.py", t.items_routes(c)) # Tests self._write("tests/__init__.py", "") self._write("tests/conftest.py", t.tests_conftest(c)) - self._write("tests/test_auth.py", t.tests_auth(c)) + self._write("tests/test_health.py", t.tests_health(c)) + if c.auth.value != "none": + self._write("tests/test_auth.py", t.tests_auth(c)) + if c.uses_mongo: + self._write("tests/test_items.py", t.tests_items(c)) # Migrations placeholder (Flask-Migrate creates this on first run) self._touch("migrations/.gitkeep") diff --git a/src/backendctl/py.typed b/src/backendctl/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/backendctl/templates/django.py b/src/backendctl/templates/django.py index 5edfb99..0897df2 100644 --- a/src/backendctl/templates/django.py +++ b/src/backendctl/templates/django.py @@ -17,6 +17,7 @@ def pyproject_toml(c: ProjectConfig) -> str: # MongoDB is intentionally not supported for Django: djongo is unmaintained # and incompatible with Django 5. The CLI blocks the combination upstream. pg_dep = ' "psycopg[binary]>=3.1.0",\n' if c.uses_sql else "" + jwt_dep = ' "djangorestframework-simplejwt>=5.3.0",\n' if c.auth.value != "none" else "" ai_dep = _ai_dep(c) return f"""\ @@ -33,12 +34,11 @@ def pyproject_toml(c: ProjectConfig) -> str: dependencies = [ "django>=5.0.0", "djangorestframework>=3.15.0", - "djangorestframework-simplejwt>=5.3.0", - "django-cors-headers>=4.3.0", +{jwt_dep} "django-cors-headers>=4.3.0", "django-environ>=0.11.0", "django-filter>=24.0", "gunicorn>=22.0.0", -{pg_dep}{ai_dep}] + {pg_dep}{ai_dep}] # Dev deps are declared twice on purpose: [dependency-groups] for uv, # [project.optional-dependencies] so `pip install -e .[dev]` also works. @@ -82,6 +82,15 @@ def env_example(c: ProjectConfig, db_password: str | None = None) -> str: from backendctl.templates.common import DB_PASSWORD_PLACEHOLDER db_url = c.db_credentials.url("postgres", password=db_password or DB_PASSWORD_PLACEHOLDER) + jwt_block = ( + """ +# JWT +ACCESS_TOKEN_LIFETIME_MINUTES=30 +REFRESH_TOKEN_LIFETIME_DAYS=7 +""" + if c.auth.value != "none" + else "" + ) return f"""\ DJANGO_SECRET_KEY=change-me-to-a-long-random-string DJANGO_DEBUG=True @@ -93,11 +102,7 @@ def env_example(c: ProjectConfig, db_password: str | None = None) -> str: # CORS CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 - -# JWT -ACCESS_TOKEN_LIFETIME_MINUTES=30 -REFRESH_TOKEN_LIFETIME_DAYS=7 -""" +{jwt_block}""" def manage_py(c: ProjectConfig) -> str: @@ -124,7 +129,41 @@ def main(): def settings_base(c: ProjectConfig) -> str: - return f"""\ + jwt_installed_apps = ( + ' "rest_framework_simplejwt",\n' + " # Required for BLACKLIST_AFTER_ROTATION to actually revoke rotated\n" + " # refresh tokens (run `manage.py migrate` to create its tables).\n" + ' "rest_framework_simplejwt.token_blacklist",\n' + if c.auth.value != "none" + else "" + ) + auth_installed_apps = ' "apps.authentication",\n' if c.auth.value != "none" else "" + auth_classes = ( + ' "rest_framework_simplejwt.authentication.JWTAuthentication",\n' + if c.auth.value != "none" + else "" + ) + permission_classes = ( + ' "rest_framework.permissions.IsAuthenticated",\n' + if c.auth.value == "jwt" + else ' "rest_framework.permissions.AllowAny",\n' + ) + simple_jwt = ( + "\n" + "from datetime import timedelta\n" + "\n" + "SIMPLE_JWT = {\n" + ' "ACCESS_TOKEN_LIFETIME": timedelta(minutes=env.int("ACCESS_TOKEN_LIFETIME_MINUTES", default=30)),\n' + ' "REFRESH_TOKEN_LIFETIME": timedelta(days=env.int("REFRESH_TOKEN_LIFETIME_DAYS", default=7)),\n' + ' "ROTATE_REFRESH_TOKENS": True,\n' + ' "BLACKLIST_AFTER_ROTATION": True,\n' + ' "AUTH_HEADER_TYPES": ("Bearer",),\n' + "}\n" + if c.auth.value != "none" + else "" + ) + return ( + f"""\ from pathlib import Path import environ @@ -146,15 +185,10 @@ def settings_base(c: ProjectConfig) -> str: "django.contrib.contenttypes", "django.contrib.auth", "rest_framework", - "rest_framework_simplejwt", - # Required for BLACKLIST_AFTER_ROTATION to actually revoke rotated - # refresh tokens (run `manage.py migrate` to create its tables). - "rest_framework_simplejwt.token_blacklist", - "corsheaders", +{jwt_installed_apps} "corsheaders", "django_filters", "apps.users", - "apps.authentication", -] +{auth_installed_apps}] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -193,9 +227,9 @@ def settings_base(c: ProjectConfig) -> str: # staticfiles config that this scaffold intentionally omits. "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", - ), - "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), +{auth_classes} ), + "DEFAULT_PERMISSION_CLASSES": ( +{permission_classes} ), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination", "PAGE_SIZE": 20, @@ -207,20 +241,34 @@ def settings_base(c: ProjectConfig) -> str: "anon": "100/day", "user": "1000/day", }}, -}} - -from datetime import timedelta - -SIMPLE_JWT = {{ - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=env.int("ACCESS_TOKEN_LIFETIME_MINUTES", default=30)), - "REFRESH_TOKEN_LIFETIME": timedelta(days=env.int("REFRESH_TOKEN_LIFETIME_DAYS", default=7)), - "ROTATE_REFRESH_TOKENS": True, - "BLACKLIST_AFTER_ROTATION": True, - "AUTH_HEADER_TYPES": ("Bearer",), + "EXCEPTION_HANDLER": "core.exceptions.custom_exception_handler", +}}{simple_jwt} + +LOGGING = {{ + "version": 1, + "disable_existing_loggers": False, + "formatters": {{ + "verbose": {{ + "format": "{{levelname}} {{asctime}} {{module}} {{message}}", + "style": "{{", + }}, + }}, + "handlers": {{ + "console": {{ + "class": "logging.StreamHandler", + "formatter": "verbose", + }}, + }}, + "root": {{ + "handlers": ["console"], + "level": "INFO" if DEBUG else "WARNING", + }}, }} CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[]) """ + "" + ) def settings_development() -> str: @@ -264,12 +312,18 @@ def settings_test() -> str: def config_urls(c: ProjectConfig) -> str: + auth_urls = ( + ' path("api/v1/auth/", include("apps.authentication.urls")),\n' + ' path("api/v1/users/", include("apps.users.urls")),\n' + if c.auth.value != "none" + else "" + ) return f"""\ from django.urls import include, path +from django.http import JsonResponse urlpatterns = [ - path("api/v1/auth/", include("apps.authentication.urls")), - path("api/v1/users/", include("apps.users.urls")), +{auth_urls} path("health/", lambda r: JsonResponse({{"status": "ok"}})), ] """ @@ -552,3 +606,16 @@ def test_get_me(auth_client): assert r.status_code == 200 assert r.data["email"] == "test@example.com" """ + + +def tests_health(c: ProjectConfig) -> str: + return """\ +import pytest + + +@pytest.mark.django_db +def test_health(api_client): + r = api_client.get("/health/") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} +""" diff --git a/src/backendctl/templates/fastapi.py b/src/backendctl/templates/fastapi.py index cfdf1ec..81fc230 100644 --- a/src/backendctl/templates/fastapi.py +++ b/src/backendctl/templates/fastapi.py @@ -20,6 +20,7 @@ def pyproject_toml(c: ProjectConfig) -> str: mongo_dep = ' "motor>=3.4.0",\n' if c.uses_mongo else "" pg_dep = ' "psycopg[binary]>=3.1.0",\n' if c.uses_sql else "" ai_dep = _ai_dep(c) + mongo_dev = ' "mongomock-motor>=0.3.0",\n' if c.uses_mongo else "" return f"""\ [build-system] @@ -41,17 +42,17 @@ def pyproject_toml(c: ProjectConfig) -> str: "pwdlib[argon2]>=0.2.1", "slowapi>=0.1.9", "python-multipart>=0.0.9", -{pg_dep}{mongo_dep}{ai_dep}] + {pg_dep}{mongo_dep}{ai_dep}] # Dev deps are declared twice on purpose: [dependency-groups] for uv, # [project.optional-dependencies] so `pip install -e .[dev]` also works. [dependency-groups] dev = [ -{_DEV_DEPS}] +{mongo_dev}{_DEV_DEPS}] [project.optional-dependencies] dev = [ -{_DEV_DEPS}] +{mongo_dev}{_DEV_DEPS}] [tool.hatch.build.targets.wheel] packages = ["src/{c.slug}"] @@ -83,6 +84,21 @@ def _ai_dep(c: ProjectConfig) -> str: return sdk_map.get(c.ai.provider.value, "") +def _health_db(c: ProjectConfig) -> str: + if not c.uses_mongo: + return "" + return f"""\ + from {c.slug}.core.mongo import get_mongo_db + + @app.get("/health/db", tags=["health"]) + async def health_db(): + db = get_mongo_db() + await db.command("ping") + return {{"mongodb": "ok"}} + +""" + + # ─── .env.example ──────────────────────────────────────────────────────────── @@ -141,10 +157,13 @@ def app_main(c: ProjectConfig) -> str: mongo_shutdown = "\n close_mongo()" if c.uses_mongo else "" return f"""\ +import logging + from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded @@ -154,6 +173,10 @@ def app_main(c: ProjectConfig) -> str: {mongo_import}from {c.slug}.middleware.rate_limit import limiter +if settings.DEBUG: + logging.basicConfig(level=logging.INFO) + + @asynccontextmanager async def lifespan(app: FastAPI): # Schema is owned by Alembic in production (run `alembic upgrade head`). @@ -176,6 +199,10 @@ def create_app() -> FastAPI: app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + @app.exception_handler(Exception) + async def generic_exception_handler(request, exc): + return JSONResponse(status_code=500, content={{"detail": "Internal server error"}}) + # CORS app.add_middleware( CORSMiddleware, @@ -192,7 +219,7 @@ def create_app() -> FastAPI: async def health_check(): return {{"status": "ok"}} - return app +{_health_db(c)} return app app = create_app() @@ -383,15 +410,23 @@ def api_v1_router(c: ProjectConfig) -> str: if c.auth.value != "none" else "" ) + mongo_import = ( + f"from {c.slug}.modules.items.router import router as items_router\n" + if c.uses_mongo + else "" + ) + mongo_include = ( + 'api_router.include_router(items_router, prefix="/items", tags=["items"])\n' + if c.uses_mongo + else "" + ) return f"""\ from fastapi import APIRouter -{auth_import} -api_router = APIRouter() +{auth_import}{mongo_import}api_router = APIRouter() -{auth_include} -""" +{auth_include}{mongo_include}""" # ─── modules/auth/models.py ────────────────────────────────────────────────── @@ -794,6 +829,16 @@ def alembic_script_mako() -> str: def tests_conftest(c: ProjectConfig) -> str: + mongo_patch = ( + "\n\n@pytest.fixture(autouse=True)\ndef mock_mongo(monkeypatch):\n" + " import mongomock_motor\n" + " monkeypatch.setattr(\n" + f' "{c.slug}.core.mongo._client",\n' + " mongomock_motor.AsyncMongoMockClient(),\n" + " )\n" + if c.uses_mongo + else "" + ) return f"""\ import pytest from fastapi.testclient import TestClient @@ -825,7 +870,7 @@ def get_session_override(): client = TestClient(app) yield client app.dependency_overrides.clear() -""" +{mongo_patch}""" def tests_auth(c: ProjectConfig) -> str: @@ -892,3 +937,67 @@ def test_get_me(client): assert response.status_code == 200 assert response.json()["email"] == "me@example.com" """ + + +def tests_health(c: ProjectConfig) -> str: + db_test = ( + "\n\n" + "def test_health_db(client):\n" + ' r = client.get("/health/db")\n' + " assert r.status_code == 200\n" + ' assert r.json() == {"mongodb": "ok"}\n' + if c.uses_mongo + else "" + ) + return f"""\ +from fastapi.testclient import TestClient + +from {c.slug}.main import app + +client = TestClient(app) + + +def test_health(): + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {{"status": "ok"}} +{db_test}""" + + +def tests_items(c: ProjectConfig) -> str: + return f"""\ +def test_create_and_list_item(client): + r = client.post("/api/v1/items", json={{"name": "Widget"}}) + assert r.status_code == 201 + r = client.get("/api/v1/items") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + assert len(data) == 1 +""" + + +def items_router(c: ProjectConfig) -> str: + return f"""\ +from fastapi import APIRouter + +from {c.slug}.core.mongo import get_mongo_db + +router = APIRouter() + + +@router.get("/items") +async def list_items(): + items = [] + async for doc in get_mongo_db().items.find(): + doc["id"] = str(doc.pop("_id")) + items.append(doc) + return items + + +@router.post("/items") +async def create_item(): + data = {{}} + get_mongo_db().items.insert_one(data) + return data, 201 +""" diff --git a/src/backendctl/templates/flask.py b/src/backendctl/templates/flask.py index 3a30c4f..24e2a65 100644 --- a/src/backendctl/templates/flask.py +++ b/src/backendctl/templates/flask.py @@ -13,7 +13,9 @@ def pyproject_toml(c: ProjectConfig) -> str: pg_dep = ' "psycopg[binary]>=3.1.0",\n' if c.uses_sql else "" - mongo_dep = ' "pymongo>=4.8.0",\n "flask-pymongo>=2.3.0",\n' if c.uses_mongo else "" + mongo_dep = ' "pymongo>=4.8.0",\n' if c.uses_mongo else "" + jwt_dep = ' "flask-jwt-extended>=4.6.0",\n' if c.auth.value != "none" else "" + mongo_dev = ' "mongomock>=4.3.0",\n' if c.uses_mongo else "" ai_dep = _ai_dep(c) return f"""\ @@ -31,24 +33,23 @@ def pyproject_toml(c: ProjectConfig) -> str: "flask>=3.0.0", "flask-sqlalchemy>=3.1.0", "flask-migrate>=4.0.0", - "flask-jwt-extended>=4.6.0", - "flask-limiter>=3.7.0", +{jwt_dep} "flask-limiter>=3.7.0", "pydantic[email]>=2.7.0", "pydantic-settings>=2.5.0", "pwdlib[argon2]>=0.2.1", "python-dotenv>=1.0.0", "gunicorn>=22.0.0", -{pg_dep}{mongo_dep}{ai_dep}] + {pg_dep}{mongo_dep}{ai_dep}] # Dev deps are declared twice on purpose: [dependency-groups] for uv, # [project.optional-dependencies] so `pip install -e .[dev]` also works. [dependency-groups] dev = [ -{_DEV_DEPS}] +{mongo_dev}{_DEV_DEPS}] [project.optional-dependencies] dev = [ -{_DEV_DEPS}] +{mongo_dev}{_DEV_DEPS}] [tool.hatch.build.targets.wheel] packages = ["src/{c.slug}"] @@ -84,6 +85,13 @@ def env_example(c: ProjectConfig, db_password: str | None = None) -> str: db_url = creds.url("postgresql+psycopg", password=db_password or DB_PASSWORD_PLACEHOLDER) else: db_url = "sqlite:///app.db" + jwt_block = ( + "\n# JWT\nJWT_SECRET_KEY=another-long-random-secret\n" + "JWT_ACCESS_TOKEN_EXPIRES=1800\n" + "JWT_REFRESH_TOKEN_EXPIRES=604800\n" + if c.auth.value != "none" + else "" + ) return f"""\ FLASK_ENV=development SECRET_KEY=change-me-to-a-long-random-string @@ -92,13 +100,7 @@ def env_example(c: ProjectConfig, db_password: str | None = None) -> str: # Database (PostgreSQL) DATABASE_URL={db_url} TEST_DATABASE_URL=sqlite:///test.db -{mongo_block} -# JWT -JWT_SECRET_KEY=another-long-random-secret -JWT_ACCESS_TOKEN_EXPIRES=1800 -JWT_REFRESH_TOKEN_EXPIRES=604800 - -# Rate limiting — in-memory storage is per-process; use Redis when running +{mongo_block}{jwt_block}# Rate limiting — in-memory storage is per-process; use Redis when running # multiple workers, e.g. RATELIMIT_STORAGE_URI=redis://localhost:6379 RATELIMIT_DEFAULT=100 per minute RATELIMIT_STORAGE_URI=memory:// @@ -108,32 +110,53 @@ def env_example(c: ProjectConfig, db_password: str | None = None) -> str: def app_init(c: ProjectConfig) -> str: mongo_name = ", mongo" if c.uses_mongo else "" mongo_init = " mongo.init_app(app)\n" if c.uses_mongo else "" + jwt_import = f"from {c.slug}.extensions import jwt\n" if c.auth.value != "none" else "" + jwt_init = " jwt.init_app(app)\n" if c.auth.value != "none" else "" + auth_bp = ( + f" from {c.slug}.blueprints.auth.routes import auth_bp\n" + f" from {c.slug}.blueprints.users.routes import users_bp\n\n" + ' app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")\n' + ' app.register_blueprint(users_bp, url_prefix="/api/v1/users")\n' + if c.auth.value != "none" + else "" + ) + items_bp = ( + f" from {c.slug}.blueprints.items.routes import items_bp\n" + ' app.register_blueprint(items_bp, url_prefix="/api/v1/items")\n' + if c.uses_mongo + else "" + ) + mongo_teardown = ( + f" from {c.slug}.mongo import close_mongo\n app.teardown_appcontext(close_mongo)\n" + if c.uses_mongo + else "" + ) return f"""\ -from flask import Flask - -from {c.slug}.config import Config -from {c.slug}.extensions import db, jwt, limiter, migrate{mongo_name} +import logging +from flask import Flask, jsonify -def create_app(config_class: type = Config) -> Flask: +from {c.slug}.config import Config +from {c.slug}.extensions import db, limiter, migrate{mongo_name} +{jwt_import}def create_app(config_class: type = Config) -> Flask: app = Flask(__name__) app.config.from_object(config_class) + if app.config["DEBUG"]: + logging.basicConfig(level=logging.INFO) + + @app.errorhandler(Exception) + def handle_exception(e): + logging.getLogger(__name__).exception("Unhandled exception") + return jsonify({{"detail": "Internal server error"}}), 500 + # Extensions db.init_app(app) migrate.init_app(app, db) - jwt.init_app(app) - limiter.init_app(app) +{jwt_init} limiter.init_app(app) {mongo_init} - - # Blueprints - from {c.slug}.blueprints.auth.routes import auth_bp - from {c.slug}.blueprints.users.routes import users_bp - - app.register_blueprint(auth_bp, url_prefix="/api/v1/auth") - app.register_blueprint(users_bp, url_prefix="/api/v1/users") - - @app.get("/health") +{mongo_teardown} # Blueprints +{auth_bp}{items_bp} @app.get("/health") def health(): return {{"status": "ok"}} @@ -142,24 +165,33 @@ def health(): def extensions(c: ProjectConfig) -> str: - mongo_import = "from flask_pymongo import PyMongo\n" if c.uses_mongo else "" - mongo_ext = "mongo = PyMongo()\n" if c.uses_mongo else "" + jwt_import = "from flask_jwt_extended import JWTManager\n" if c.auth.value != "none" else "" + jwt_ext = "jwt = JWTManager()\n" if c.auth.value != "none" else "" return f"""\ -from flask_jwt_extended import JWTManager -from flask_limiter import Limiter +{jwt_import}from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_migrate import Migrate -{mongo_import}from flask_sqlalchemy import SQLAlchemy +from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() migrate = Migrate() -jwt = JWTManager() -limiter = Limiter(key_func=get_remote_address) -{mongo_ext}""" +{jwt_ext}limiter = Limiter(key_func=get_remote_address) +""" def config_py(c: ProjectConfig) -> str: mongo_field = '\n MONGO_URI: str = os.environ["MONGO_URI"]\n' if c.uses_mongo else "" + jwt_block = ( + '\n JWT_SECRET_KEY: str = os.environ["JWT_SECRET_KEY"]\n' + " JWT_ACCESS_TOKEN_EXPIRES: timedelta = timedelta(\n" + ' seconds=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES", "1800"))\n' + " )\n" + " JWT_REFRESH_TOKEN_EXPIRES: timedelta = timedelta(\n" + ' seconds=int(os.getenv("JWT_REFRESH_TOKEN_EXPIRES", "604800"))\n' + " )\n" + if c.auth.value != "none" + else "" + ) return f"""\ import os from datetime import timedelta @@ -175,24 +207,13 @@ class Config: SQLALCHEMY_DATABASE_URI: str = os.environ["DATABASE_URL"] SQLALCHEMY_TRACK_MODIFICATIONS: bool = False -{mongo_field} - - JWT_SECRET_KEY: str = os.environ["JWT_SECRET_KEY"] - JWT_ACCESS_TOKEN_EXPIRES: timedelta = timedelta( - seconds=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES", "1800")) - ) - JWT_REFRESH_TOKEN_EXPIRES: timedelta = timedelta( - seconds=int(os.getenv("JWT_REFRESH_TOKEN_EXPIRES", "604800")) - ) - - RATELIMIT_DEFAULT: str = os.getenv("RATELIMIT_DEFAULT", "100 per minute") +{mongo_field}{jwt_block} RATELIMIT_DEFAULT: str = os.getenv("RATELIMIT_DEFAULT", "100 per minute") RATELIMIT_STORAGE_URI: str = os.getenv("RATELIMIT_STORAGE_URI", "memory://") class TestConfig(Config): TESTING: bool = True SQLALCHEMY_DATABASE_URI: str = os.getenv("TEST_DATABASE_URL", "sqlite:///test.db") - JWT_SECRET_KEY: str = "test-secret-key-0123456789abcdef0123456789abcdef0123456789abcdef" SECRET_KEY: str = "test-secret-key-0123456789abcdef0123456789abcdef0123456789abcdef" RATELIMIT_ENABLED: bool = False """ @@ -360,6 +381,16 @@ def get_me(): def tests_conftest(c: ProjectConfig) -> str: + mongo_patch = ( + "\n\n@pytest.fixture(autouse=True)\ndef mock_mongo(monkeypatch):\n" + " import mongomock\n" + " monkeypatch.setattr(\n" + ' "pymongo.MongoClient",\n' + " lambda *a, **k: mongomock.MongoClient(),\n" + " )\n" + if c.uses_mongo + else "" + ) return f"""\ import pytest from {c.slug} import create_app @@ -379,7 +410,7 @@ def app(): @pytest.fixture() def client(app): return app.test_client() -""" +{mongo_patch}""" def tests_auth(c: ProjectConfig) -> str: @@ -417,3 +448,83 @@ def test_login_invalid(client): r = client.post("/api/v1/auth/login", json={"email": "x@b.com", "password": "wrongpass"}) assert r.status_code == 401 """ + + +def tests_health(c: ProjectConfig) -> str: + db_test = ( + "\n\n" + "def test_health_db(client):\n" + ' r = client.get("/health/db")\n' + " assert r.status_code == 200\n" + ' assert r.get_json() == {"mongodb": "ok"}\n' + if c.uses_mongo + else "" + ) + return f"""\ +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.get_json() == {{"status": "ok"}} +{db_test}""" + + +def tests_items(c: ProjectConfig) -> str: + return """\ +def test_create_and_list_item(client): + r = client.post("/api/v1/items", json={"name": "Widget"}) + assert r.status_code == 201 + r = client.get("/api/v1/items") + assert r.status_code == 200 + data = r.get_json() + assert isinstance(data, list) + assert len(data) == 1 +""" + + +def mongo_py(c: ProjectConfig) -> str: + return """\ +from pymongo import MongoClient +from flask import current_app + +_client = None + + +def get_db(): + global _client + if _client is None: + _client = MongoClient(current_app.config["MONGO_URI"]) + return _client.get_default_database() + + +def close_mongo(e=None): + global _client + if _client is not None: + _client.close() + _client = None +""" + + +def items_routes(c: ProjectConfig) -> str: + return f"""\ +from flask import Blueprint, jsonify, request + +from {c.slug}.mongo import get_db + +items_bp = Blueprint("items", __name__) + + +@items_bp.get("/items") +def list_items(): + items = [] + for doc in get_db().items.find(): + doc["id"] = str(doc.pop("_id")) + items.append(doc) + return jsonify(items) + + +@items_bp.post("/items") +def create_item(): + data = request.get_json(force=True) or {{}} + get_db().items.insert_one(data) + return jsonify(data), 201 +""" diff --git a/tests/test_cli.py b/tests/test_cli.py index f49b9b9..6a39070 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -130,3 +130,26 @@ def test_success_panel_is_framework_aware(tmp_path, monkeypatch) -> None: assert "flask" in result.output assert "cp .env.example" not in result.output assert "fastapi dev" not in result.output + + +def test_verbose_flag_shows_traceback(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _stub_generation(monkeypatch) + + from backendctl.generators.django_gen import DjangoGenerator + from backendctl.generators.fastapi_gen import FastAPIGenerator + from backendctl.generators.flask_gen import FlaskGenerator + + def boom(self): + raise RuntimeError("kaboom") + + for cls in (FastAPIGenerator, FlaskGenerator, DjangoGenerator): + monkeypatch.setattr(cls, "_scaffold", boom) + + result = runner.invoke( + app, ["new", "demo", "--framework", "fastapi", "--yes", "--verbose", "--no-git", "--no-ai"] + ) + + assert result.exception is not None + assert isinstance(result.exception, RuntimeError) + assert str(result.exception) == "kaboom" diff --git a/tests/test_generators.py b/tests/test_generators.py index a3ae765..6a1f2d3 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -61,10 +61,12 @@ def _make_config(framework: Framework, **overrides) -> ProjectConfig: {"user_model": UserModelConfig(has_name=True)}, ), (Framework.FLASK, {}), + (Framework.FLASK, {"auth": AuthType.NONE}), (Framework.FLASK, {"database": Database.MONGODB}), (Framework.FLASK, {"user_model": UserModelConfig(has_name=True)}), (Framework.FASTAPI, {"database": Database.MONGODB}), (Framework.DJANGO, {}), + (Framework.DJANGO, {"auth": AuthType.NONE}), (Framework.DJANGO, {"user_model": UserModelConfig(has_name=True)}), ( Framework.FASTAPI, @@ -125,6 +127,74 @@ def test_no_auth_skips_auth_module(tmp_path: Path, monkeypatch: pytest.MonkeyPat assert not (root / f"src/{config.slug}/modules/auth").exists() +def test_flask_auth_none_skips_auth_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.FLASK, auth=AuthType.NONE) + + root = get_generator(config).generate() + + assert not (root / f"src/{config.slug}/blueprints/auth").exists() + assert not (root / f"src/{config.slug}/blueprints/users").exists() + assert not (root / f"src/{config.slug}/models/user.py").exists() + assert not (root / "tests/test_auth.py").exists() + assert (root / "tests/test_health.py").exists() + + +def test_django_auth_none_skips_auth_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + config = _make_config(Framework.DJANGO, auth=AuthType.NONE) + + root = get_generator(config).generate() + + assert not (root / "apps/authentication").exists() + assert not (root / "apps/users/serializers.py").exists() + assert not (root / "apps/users/views.py").exists() + assert not (root / "apps/users/urls.py").exists() + assert not (root / "tests/test_auth.py").exists() + assert (root / "tests/test_health.py").exists() + assert "health" in (root / "config/urls.py").read_text() + + +def test_flask_auth_none_no_jwt_in_pyproject( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FLASK, auth=AuthType.NONE)).generate() + + assert "flask-jwt-extended" not in (root / "pyproject.toml").read_text() + assert "JWT_SECRET_KEY" not in (root / "src/demo_app/config.py").read_text() + + +def test_django_auth_none_no_simplejwt(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.DJANGO, auth=AuthType.NONE)).generate() + + assert "djangorestframework-simplejwt" not in (root / "pyproject.toml").read_text() + assert "SIMPLE_JWT" not in (root / "config/settings/base.py").read_text() + assert "rest_framework.permissions.AllowAny" in (root / "config/settings/base.py").read_text() + + +def test_flask_mongo_wiring(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FLASK, database=Database.MONGODB)).generate() + + assert (root / "src/demo_app/mongo.py").is_file() + assert (root / "src/demo_app/blueprints/items/__init__.py").is_file() + assert (root / "src/demo_app/blueprints/items/routes.py").is_file() + assert "flask-pymongo" not in (root / "pyproject.toml").read_text() + assert "pymongo" in (root / "pyproject.toml").read_text() + assert "mongomock" in (root / "pyproject.toml").read_text() + + +def test_fastapi_mongo_wiring(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + root = get_generator(_make_config(Framework.FASTAPI, database=Database.MONGODB)).generate() + + assert (root / "src/demo_app/modules/items/__init__.py").is_file() + assert (root / "src/demo_app/modules/items/router.py").is_file() + assert "mongomock-motor" in (root / "pyproject.toml").read_text() + + # ─── safety guards (C1/C2/M3 regression tests) ──────────────────────────────