From a5015a4d9d0cefbe4a603e1e3e53a39f9ee7e01f Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 22:36:27 +0700 Subject: [PATCH 01/16] docs: design the flask adapter --- .../specs/2026-09-13-flask-adapter-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-13-flask-adapter-design.md diff --git a/docs/superpowers/specs/2026-09-13-flask-adapter-design.md b/docs/superpowers/specs/2026-09-13-flask-adapter-design.md new file mode 100644 index 0000000..3716d6f --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-flask-adapter-design.md @@ -0,0 +1,258 @@ +# Flask Adapter Design + +**Goal:** add a Python API adapter, `flask`, so `scaffold new demo --api flask` +produces a project that lints, type-checks, tests, builds an image and answers +both health probes — the same guarantees the four existing adapters carry. + +**Scope:** a new adapter, a new `ADAPTER_FAMILY`, three service drivers, one +smoke suite, and the three places outside `adapters/` that name the families by +hand. No existing adapter changes. + +## What was measured before this was written + +Every claim below was run, not assumed. The toolchain probe generated a project +with `uv`, wrote the application, and exercised it end to end: + +| Step | Result | +| --- | --- | +| `uv init --bare --vcs none --author-from none --no-workspace --python 3.13` | exactly one file, `pyproject.toml` | +| `uv add flask gunicorn`, `uv add --dev ruff mypy pytest` | resolves, writes `uv.lock` | +| `uv run ruff format --check .` | passes | +| `uv run ruff check .` | passes once the readiness handler carries `# noqa: BLE001` | +| `uv run mypy --strict app tests` | passes | +| `uv run pytest -q` | passes only with a `conftest.py` at the application root | +| `gunicorn "app:create_app()"` | `GET /health/live` → 200, `GET /health/ready` → 503 with no database | + +Four of those results shaped the design and would not have been guessed: + +- **`--bare` is the only usable template.** The default `uv init --app` writes a + `.git` directory inside the application, a `src//` package keyed to the + directory name, a `README.md` and a `[project.scripts]` entry. The overlay + would have had to delete four of those. `--bare` writes one file and leaves + the rest to the overlay, which is what every other adapter already does. +- **`--bare` writes no `.python-version`,** with or without `--no-pin-python`, + so `requires-python` is a floor and uv resolves the newest interpreter it can + find — 3.14 during the probe. The adapter ships `.python-version` itself. +- **ruff 0.16 rejects `except Exception`** under its default rule set (BLE001). + A readiness probe catches everything by design, so the handler carries a + `noqa` pragma rather than a narrowed clause no driver could satisfy. +- **pytest cannot import the application without a root `conftest.py`.** Under + the default `prepend` import mode pytest inserts `tests/`, not the project + root, so `from app import create_app` fails. immich's + `machine-learning/conftest.py` exists for the same reason. + +## Decisions + +**`uv`, because immich already chose it.** `immich/machine-learning/mise.toml` +pins `python` and `uv` and defines `install`, `lint`, `test`, `format`, `check`, +`ci-unit` and `checklist` — the ADR-0011 vocabulary, already in this +repository's contract. Its tools (uv, ruff, mypy `--strict`, pytest) carry over +unchanged. What does not carry over is the framework: immich's Python service is +FastAPI. The application shape here comes from Flask's own documentation — +application factory plus blueprint — not from a third-party template. + +**Python is pinned by `.python-version`, not by `mise.toml`.** uv defaults to +its own managed interpreters, so a `python` entry in the application's +`mise.toml` would be installed and then ignored — two pins, one of them a lie. +`.python-version` is the file uv actually reads, and the application's +`mise.toml` pins `uv` alone. This mirrors `adapters/laravel-api/mise.toml`, +which pins composer alone and lets php come from outside (ADR-0016), and it +keeps the root `mise.toml` unaware that the project contains Python. + +**The generator runs uv through `mise x`.** `ADAPTER_GENERATOR` executes in the +project root, where the root `mise.toml` pins node and pnpm and nothing else. +`mise x uv@ -- uv init ...` fetches the pinned uv for that one command; +from `ADAPTER_POST_GENERATE` onward the application's own `mise.toml` is in +place and `mise exec` resolves uv from it. No CI step and no ambient +installation is required — the opposite of php, which needs `shivammathur/setup-php` +on every job. + +**No MongoDB driver.** Python has no Prisma: SQLAlchemy covers postgres and +mysql, mongodb needs pymongo, and redis needs its own client. `laravel` already +ships exactly this subset, so the precedent and the shape both exist. A pymongo +driver is a later addition if a project ever asks for one. + +**Tier A, subject to its own measurement.** `uv sync` installs about ten wheels +and compiles nothing, so the smoke run should land well under `laravel-api`'s. +ADR-0012 assigns tiers by measurement, so the PR that adds this adapter reports +its own `smoke (flask)` and `deploy (flask)` durations; if either exceeds +`laravel-api`'s, `ADAPTER_TIER` moves to `B` before merge. + +## Work items + +### A — The adapter + +`adapters/flask/adapter.env`: + +```sh +ADAPTER_NAME="flask" +ADAPTER_ROLE="api" +ADAPTER_TIER="A" +ADAPTER_LANGUAGE="python" +ADAPTER_FAMILY="flask" +ADAPTER_GENERATOR='mise x uv@0.12.13 -- uv init --bare --vcs none --author-from none --no-workspace --python 3.13 "$APP_DIR"' +ADAPTER_POST_GENERATE='uv add flask gunicorn && uv add --dev ruff mypy pytest' +ADAPTER_LIVENESS_PATH="/health/live" +ADAPTER_READINESS_PATH="/health/ready" +``` + +`ADAPTER_POST_GENERATE` ends with the same guard pattern the other adapters use: +a `grep` over `pyproject.toml` for `flask` and `gunicorn` that turns a silent +resolution failure into a build failure. + +Files shipped by the overlay: + +| File | What it is | +| --- | --- | +| `mise.toml` | pins `uv`; the nine ADR-0011 tasks | +| `.python-version` | `3.13` — the pin `--bare` does not write | +| `Dockerfile` | `uv` build stage, `python:3.13-slim` runtime, `# @SERVICE_SETUP@` anchor, non-root, `EXPOSE 8080`, healthcheck on `/health/live` | +| `.dockerignore` | `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.env*`, `.git` | +| `app/__init__.py` | `create_app()`, the application factory | +| `app/health.py` | the blueprint, carrying the `# @DB_ENGINE@` and `# @DB_PROBE@` anchors | +| `conftest.py` | empty; puts the application root on `sys.path` for pytest | +| `tests/test_health.py` | two tests that hold with or without a database | +| `.env.example` | `FLASK_DEBUG=0` | +| `lefthook.fragment.yml` | `ruff format` on staged `*.py` | +| `README.md` | as the other adapters have one | + +There is no `Dockerfile.workspace`: Python is not a pnpm workspace member, so +`join_typescript_workspace` never sees this adapter and +`assert_workspace_filter_name` returns early on the missing file. + +`app/health.py` is the only file a service driver edits: + +```python +from flask import Blueprint, Response, jsonify + +# @DB_ENGINE@ + +health = Blueprint("health", __name__) + +Reply = Response | tuple[Response, int] + + +@health.get("/health/live") +def live() -> Reply: + return jsonify(status="ok") + + +@health.get("/health/ready") +def ready() -> Reply: + try: + # @DB_PROBE@ + raise RuntimeError("no database is configured for this project") + except Exception as error: # noqa: BLE001 + return jsonify(status="unavailable", reason=str(error)), 503 +``` + +The `# @DB_ENGINE@` anchor sits above the first statement so a driver's imports +land before executable code and do not trip ruff's E402. A `--db none` project +keeps the `raise` and reports 503, exactly as `adapters/laravel-api/routes/health.php` +does. + +The `build` task follows `laravel-api`'s: a production sync, then a restoring +sync, so the pre-commit hook's `ruff` binary survives. + +```toml +[tasks.build] +run = "uv sync --locked --no-dev; status=$?; uv sync --locked --quiet; exit $status" +``` + +### B — Service drivers + +`services/shared/flask.sh` holds the SQLAlchemy body, parameterised the way +`services/shared/laravel.sh` is: + +| Variable | Meaning | +| --- | --- | +| `FLASK_DIALECT` | the SQLAlchemy URL scheme, e.g. `postgresql+psycopg` | +| `FLASK_PACKAGES` | the DBAPI packages to `uv add` | +| `FLASK_PORT` | the host-side port for `.env.example` | +| `FLASK_COMPOSE_URL` | the same DSN against the compose network | + +`service_driver_apply` runs `uv add sqlalchemy `, writes +`DATABASE_URL` into `.env.example`, and splices both anchors: + +```python +# @DB_ENGINE@ becomes +import os +from functools import cache + +from sqlalchemy import Engine, create_engine, text + + +@cache +def _engine() -> Engine: + return create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True) +``` + +`@cache` rather than an engine built inside the handler: SQLAlchemy's engine +owns a connection pool, and one per request exhausts the database's connection +limit under a polling probe — the failure `services/shared/nest.sh` records +measuring against Postgres. + +`# @DB_PROBE@` and the `raise` line below it become the probe plus a success +return, and the driver greps for both afterwards. + +Per-service files: + +| File | Contents | +| --- | --- | +| `services/postgres/drivers/flask.sh` | `postgresql+psycopg`, `psycopg[binary]`, 5432 | +| `services/mysql/drivers/flask.sh` | `mysql+pymysql`, `PyMySQL`, 3306 | +| `services/redis/drivers/flask.sh` | self-contained, as `services/redis/drivers/laravel.sh` is | + +`service_driver_dockerfile` prints nothing for all three: `psycopg[binary]` and +`PyMySQL` need no system library, so there is no `apk add` counterpart to the +Laravel drivers' `LARAVEL_SETUP`. + +`service_driver_compose_migrate` prints nothing. Flask ships no migration tool +of its own and this adapter adds no ORM models, so there is no schema to apply — +the same reason the redis drivers print nothing. + +### C — Everything outside `adapters/` and `services/` + +- `tests/service.bats:142` — the regex `^ADAPTER_FAMILY="(laravel|nest|next)"$` + gains `flask`. It is the one place the family list is written out by hand. +- `tests/new-flask.bats` — a new suite shaped like `tests/new-laravel-api.bats`: + the app lands at `apps/api`, `uv.lock` exists, python is pinned in the app and + nowhere near the root `mise.toml`, the ruff hook is merged and suffixed + `ruff-apps-api`, and a mixed-language project keeps `pnpm-workspace.yaml`'s + `allowBuilds` while growing no `packages/types`. +- `mise.toml` — `tests/new-flask.bats` needs no entry: the `test-unit` and + `test-integration` lanes list suites that do not generate an adapter, and the + per-adapter smoke suites are already run by `.github/workflows/adapters.yml` + from the tier matrix. +- `README.md` — the adapter table gains a row. +- `docs/PROVENANCE.md` — one row: `adapters/flask/mise.toml` is **adapted** from + `machine-learning/mise.toml` (the task vocabulary and the uv shape); every + other file under `adapters/flask/` is **original**, since immich has no Flask + application and no application factory to adapt. + +No new ADR. ADR-0003 already grants the overlay any stack, ADR-0011 already +fixes the task names, ADR-0012 already puts tier on the adapter, and ADR-0016 +speaks only about php — `mise x uv` contradicts none of them. + +## Not doing + +| | Why | +| --- | --- | +| A mongodb driver for `flask` | pymongo is a second shared body for a combination nothing has asked for; `laravel` ships the same subset. | +| Flask-SQLAlchemy | The extension binds an ORM to the app object; the readiness probe needs one connection and one `SELECT 1`. Plain SQLAlchemy is the smaller dependency. | +| Alembic and a `migrate` task | ADR-0011 keeps `migrate` out of the contract, and this adapter ships no models to migrate. | +| FastAPI as well | The request was Flask. A second Python adapter is a separate decision with its own tier cost. | +| `python` in the root `mise.toml` | ADR-0004's boundary, and the same reason php is absent from it. | + +## Verification + +- `mise run lint` +- `mise run test-runner` — both lanes +- `scaffold lint` — the adapter and all three drivers against the contract +- `bats tests/new-flask.bats` +- `./scripts/deploy-check.sh flask --db postgres` and `--db mysql` +- `mise exec -- zizmor --min-severity medium .github/workflows/` +- the PR's own CI, which reports the `smoke (flask)` and `deploy (flask)` + durations that decide `ADAPTER_TIER` +- a real project generated from the merged toolbox: `scaffold new`, then its own + `mise run ci-unit` From 877695704217ee6ec734e8876113f6aea770ce1a Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 22:37:38 +0700 Subject: [PATCH 02/16] docs: the linter requires a driver per family, so flask ships four --- .../specs/2026-09-13-flask-adapter-design.md | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-09-13-flask-adapter-design.md b/docs/superpowers/specs/2026-09-13-flask-adapter-design.md index 3716d6f..24fc0f6 100644 --- a/docs/superpowers/specs/2026-09-13-flask-adapter-design.md +++ b/docs/superpowers/specs/2026-09-13-flask-adapter-design.md @@ -67,10 +67,15 @@ place and `mise exec` resolves uv from it. No CI step and no ambient installation is required — the opposite of php, which needs `shivammathur/setup-php` on every job. -**No MongoDB driver.** Python has no Prisma: SQLAlchemy covers postgres and -mysql, mongodb needs pymongo, and redis needs its own client. `laravel` already -ships exactly this subset, so the precedent and the shape both exist. A pymongo -driver is a later addition if a project ever asks for one. +**Four drivers, because the linter requires four.** Python has no Prisma — +SQLAlchemy covers postgres and mysql, mongodb needs pymongo, redis needs its own +client — so the intent was to ship the SQL pair plus redis and add mongodb +later. `lint_services` (`lib/lint.sh:207`) forbids that: it collects every +family declared by any adapter and fails a service that has no driver for one of +them. Skipping mongodb would mean new mechanism for declaring a combination +unsupported, which is more code than the driver it would save. `laravel` is the +shape to copy exactly: a shared body for the two SQL services, and mongodb and +redis each self-contained. **Tier A, subject to its own measurement.** `uv sync` installs about ten wheels and compiles nothing, so the smoke run should land well under `laravel-api`'s. @@ -201,15 +206,17 @@ Per-service files: | --- | --- | | `services/postgres/drivers/flask.sh` | `postgresql+psycopg`, `psycopg[binary]`, 5432 | | `services/mysql/drivers/flask.sh` | `mysql+pymysql`, `PyMySQL`, 3306 | +| `services/mongodb/drivers/flask.sh` | self-contained: `pymongo`, a cached `MongoClient`, `admin.command("ping")` | | `services/redis/drivers/flask.sh` | self-contained, as `services/redis/drivers/laravel.sh` is | -`service_driver_dockerfile` prints nothing for all three: `psycopg[binary]` and -`PyMySQL` need no system library, so there is no `apk add` counterpart to the -Laravel drivers' `LARAVEL_SETUP`. +`service_driver_dockerfile` prints nothing for all four. `psycopg[binary]`, +`PyMySQL` and `pymongo` all ship wheels that need no system library, so there is +no counterpart to the Laravel drivers' `LARAVEL_SETUP` or to the `pecl install` +the Laravel MongoDB driver needs. -`service_driver_compose_migrate` prints nothing. Flask ships no migration tool -of its own and this adapter adds no ORM models, so there is no schema to apply — -the same reason the redis drivers print nothing. +`service_driver_compose_migrate` prints nothing for all four. Flask ships no +migration tool of its own and this adapter adds no ORM models, so there is no +schema to apply — the same reason the redis drivers print nothing. ### C — Everything outside `adapters/` and `services/` @@ -238,7 +245,6 @@ speaks only about php — `mise x uv` contradicts none of them. | | Why | | --- | --- | -| A mongodb driver for `flask` | pymongo is a second shared body for a combination nothing has asked for; `laravel` ships the same subset. | | Flask-SQLAlchemy | The extension binds an ORM to the app object; the readiness probe needs one connection and one `SELECT 1`. Plain SQLAlchemy is the smaller dependency. | | Alembic and a `migrate` task | ADR-0011 keeps `migrate` out of the contract, and this adapter ships no models to migrate. | | FastAPI as well | The request was Flask. A second Python adapter is a separate decision with its own tier cost. | @@ -250,7 +256,7 @@ speaks only about php — `mise x uv` contradicts none of them. - `mise run test-runner` — both lanes - `scaffold lint` — the adapter and all three drivers against the contract - `bats tests/new-flask.bats` -- `./scripts/deploy-check.sh flask --db postgres` and `--db mysql` +- `./scripts/deploy-check.sh flask` against each of `postgres`, `mysql` and `mongodb` - `mise exec -- zizmor --min-severity medium .github/workflows/` - the PR's own CI, which reports the `smoke (flask)` and `deploy (flask)` durations that decide `ADAPTER_TIER` From d49b462451a97b585ecf82b03b5194aa85c32ffc Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 22:39:30 +0700 Subject: [PATCH 03/16] docs: plan the flask adapter --- .../plans/2026-09-13-flask-adapter.md | 668 ++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-flask-adapter.md diff --git a/docs/superpowers/plans/2026-09-13-flask-adapter.md b/docs/superpowers/plans/2026-09-13-flask-adapter.md new file mode 100644 index 0000000..8f1db50 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-flask-adapter.md @@ -0,0 +1,668 @@ +# Flask Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `scaffold new demo --api flask` produces a Python project that lints, +type-checks, tests, builds an image, and answers both health probes against +each of the four services. + +**Architecture:** a new overlay adapter at `adapters/flask/` declaring +`ADAPTER_FAMILY="flask"`, generated by `uv init --bare` and completed by the +overlay's own files. Four service drivers teach the existing services how to +talk to it. Nothing outside `adapters/`, `services/`, `tests/` and two docs +files changes. + +**Tech Stack:** uv 0.12.13, Python 3.13, Flask 3, gunicorn, SQLAlchemy 2, +ruff, mypy `--strict`, pytest. + +**Spec:** `docs/superpowers/specs/2026-09-13-flask-adapter-design.md` + +## Global Constraints + +- **Comments are the exception, not the rule.** This repository just finished + three passes cutting comment density. Write a comment only when it records a + fact the code cannot: a landmine, a version pin's reason, a measured failure. + Never restate a line. The `comment-code` skill is the full rule. +- Every value below is exact and copied verbatim: `uv = "0.12.13"`, + `.python-version` = `3.13`, `--python 3.13`, image digests resolved at + implementation time. +- Task names come from ADR-0011 and are checked by `lib/lint.sh`: + `install`, `format`, `format-fix`, `lint`, `check`, `test`, `build`, + `ci-unit`, `checklist`. `format`, `lint` and `check` must not carry + `--write`, `--fix`, `-w`, `--in-place` or `--overwrite`. +- `local -r` for every local assigned once in new bash. Never file-level + `readonly` — it breaks re-sourcing into child processes. +- Every driver defines all four of `service_driver_apply`, + `service_driver_dockerfile`, `service_driver_compose_env`, + `service_driver_compose_migrate`. +- Every sed-spliced anchor is followed by a `grep` that fails loudly when the + anchor stops matching, as every existing driver does. +- Shell files carry the repository's header box (`Script:` / `Description:` / + `Author: ttncode`) and `# shellcheck shell=bash`. +- `mise run lint` and `scaffold lint` must pass at the end of every task. + +--- + +### Task 1: The flask adapter + +**Files:** +- Create: `adapters/flask/adapter.env` +- Create: `adapters/flask/mise.toml` +- Create: `adapters/flask/.python-version` +- Create: `adapters/flask/Dockerfile` +- Create: `adapters/flask/.dockerignore` +- Create: `adapters/flask/.env.example` +- Create: `adapters/flask/lefthook.fragment.yml` +- Create: `adapters/flask/README.md` +- Create: `adapters/flask/app/__init__.py` +- Create: `adapters/flask/app/health.py` +- Create: `adapters/flask/conftest.py` +- Create: `adapters/flask/tests/test_health.py` +- Modify: `tests/service.bats:142` + +**Interfaces:** +- Consumes: nothing. +- Produces: `ADAPTER_FAMILY="flask"`, which Task 2's drivers are keyed on. The + two anchors `# @DB_ENGINE@` and `# @DB_PROBE@` in `app/health.py`, and the + `raise RuntimeError("no database is configured for this project")` line + directly under `# @DB_PROBE@` — Task 2 replaces all three by exact text. + `DATABASE_URL` is the environment variable every driver writes. + +- [ ] **Step 1: Write `adapters/flask/adapter.env`** + +```sh +ADAPTER_NAME="flask" +ADAPTER_ROLE="api" +ADAPTER_TIER="A" +ADAPTER_LANGUAGE="python" +ADAPTER_FAMILY="flask" +# --bare writes pyproject.toml and nothing else; the default template adds a +# .git directory inside the app, a src/ package and a README the +# overlay would have to delete. uv comes through `mise x` because the generator +# runs in the project root, whose mise.toml pins node and pnpm only. +ADAPTER_GENERATOR='mise x uv@0.12.13 -- uv init --bare --vcs none --author-from none --no-workspace --python 3.13 "$APP_DIR"' +# The grep pair turns a resolution that reported success but wrote nothing into +# a build failure instead of an ImportError at container start. +ADAPTER_POST_GENERATE='uv add flask gunicorn && uv add --dev ruff mypy pytest && { grep -q "flask" pyproject.toml && grep -q "gunicorn" pyproject.toml; } || { echo "post-generate: flask or gunicorn missing from pyproject.toml after uv add" >&2; exit 1; }' +ADAPTER_LIVENESS_PATH="/health/live" +ADAPTER_READINESS_PATH="/health/ready" +``` + +- [ ] **Step 2: Write `adapters/flask/.python-version`** + +One line, no trailing content: + +``` +3.13 +``` + +- [ ] **Step 3: Write `adapters/flask/mise.toml`** + +```toml +# uv alone: uv resolves its own managed interpreter, so a `python` entry here +# would be installed and then ignored. .python-version is the pin uv reads. +[tools] +uv = "0.12.13" + +[tasks.install] +run = "uv sync --locked" + +[tasks.format] +run = "uv run ruff format --check ." + +[tasks."format-fix"] +run = "uv run ruff format ." + +[tasks.lint] +run = "uv run ruff check ." + +[tasks.check] +run = "uv run mypy --strict app tests" + +[tasks.test] +run = "uv run pytest -q" + +[tasks.build] +# The production sync drops ruff, which the pre-commit hook calls directly, so +# the second sync puts it back; `status=$?` keeps the production exit code. +run = "uv sync --locked --no-dev; status=$?; uv sync --locked --quiet; exit $status" + +[tasks.ci-unit] +run = [ + { task = ":install" }, + { task = ":format" }, + { task = ":lint" }, + { task = ":check" }, + { task = ":test" }, +] + +[tasks.checklist] +run = [{ task = ":ci-unit" }, { task = ":build" }] +``` + +- [ ] **Step 4: Write `adapters/flask/app/health.py`** + +Exactly this, byte for byte — Task 2 matches these lines by text: + +```python +from flask import Blueprint, Response, jsonify + +# @DB_ENGINE@ + +health = Blueprint("health", __name__) + +Reply = Response | tuple[Response, int] + + +@health.get("/health/live") +def live() -> Reply: + return jsonify(status="ok") + + +@health.get("/health/ready") +def ready() -> Reply: + try: + # @DB_PROBE@ + raise RuntimeError("no database is configured for this project") + except Exception as error: # noqa: BLE001 + return jsonify(status="unavailable", reason=str(error)), 503 +``` + +The `noqa` is required: ruff 0.16's default rule set rejects `except Exception` +(BLE001), and a readiness probe catches everything by design. + +- [ ] **Step 5: Write `adapters/flask/app/__init__.py`** + +```python +from flask import Flask + +from .health import health + + +def create_app() -> Flask: + app = Flask(__name__) + app.register_blueprint(health) + return app +``` + +- [ ] **Step 6: Write `adapters/flask/conftest.py`** + +The file is empty. It exists so pytest's default `prepend` import mode puts the +application root on `sys.path`; without it `from app import create_app` raises +`ModuleNotFoundError`. Create it with zero bytes, then record why in +`adapters/flask/README.md` — an empty file cannot carry its own comment. + +- [ ] **Step 7: Write `adapters/flask/tests/test_health.py`** + +Both tests must hold whether or not a database driver ran, so nothing asserts +`/health/ready`'s status code: + +```python +from app import create_app + + +def test_live_reports_ok() -> None: + response = create_app().test_client().get("/health/live") + assert response.status_code == 200 + assert response.get_json() == {"status": "ok"} + + +def test_ready_route_is_registered() -> None: + rules = create_app().url_map.iter_rules() + assert any(rule.rule == "/health/ready" for rule in rules) +``` + +- [ ] **Step 8: Write `adapters/flask/Dockerfile`** + +Resolve both image digests with `docker buildx imagetools inspect ` (or +`docker pull` then `docker inspect --format '{{index .RepoDigests 0}}'`) and +write them in. Do not invent a digest. + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM ghcr.io/astral-sh/uv:0.12.13-python3.13-bookworm-slim@sha256: AS deps +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --no-install-project + +FROM python:3.13-slim@sha256: AS runtime +# @SERVICE_SETUP@ +WORKDIR /app +COPY --from=deps /app/.venv ./.venv +COPY . . +ENV PATH="/app/.venv/bin:${PATH}" +RUN useradd --create-home --uid 10001 app && chown -R app:app /app +USER app +EXPOSE 8080 +# python, not wget or curl: the slim image ships neither. +HEALTHCHECK --interval=30s --timeout=3s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health/live')" || exit 1 +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:create_app()"] +``` + +The `# @SERVICE_SETUP@` line must be present and exactly that — `tests/service.bats` +asserts every adapter Dockerfile carries it, and `apply_service_dockerfile` +removes it when no service was selected. + +- [ ] **Step 9: Write `adapters/flask/.dockerignore`** + +No comments; this file is self-evident. Model it on +`adapters/nestjs/.dockerignore` for style, with Python's artefacts: + +``` +.venv +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +.env +.env.* +.git +``` + +- [ ] **Step 10: Write `adapters/flask/.env.example`** + +``` +# off by default: Flask's debug console executes arbitrary code from the browser +FLASK_DEBUG=0 +``` + +- [ ] **Step 11: Write `adapters/flask/lefthook.fragment.yml`** + +```yaml +pre-commit: + commands: + ruff: + glob: "*.py" + root: "@APP_ROOT@" + run: uv run ruff format {staged_files} + stage_fixed: true +``` + +- [ ] **Step 12: Write `adapters/flask/README.md`** + +Follow `adapters/nestjs/README.md` for length and tone. It must record the two +facts no file in the adapter can carry itself: why `conftest.py` is empty, and +why python is pinned in `.python-version` rather than in `mise.toml`. + +- [ ] **Step 13: Teach `tests/service.bats` the new family** + +Modify line 142 only: + +```bash + grep -Eq '^ADAPTER_FAMILY="(laravel|nest|next|flask)"$' "${adapter}adapter.env" \ +``` + +- [ ] **Step 14: Run the linters — expect one specific failure** + +Run: `mise run lint && ./scaffold lint` +Expected: `mise run lint` passes. `./scaffold lint` FAILS with four lines of +the form `: no driver for flask`, one per service. That failure is +Task 2's work and is the correct state at the end of Task 1. Every other check +must pass; if `scaffold lint` reports anything else, fix it here. + +- [ ] **Step 15: Verify the generated application really works** + +This is the gate for the whole task. Run, from a scratch directory: + +```bash +mise x uv@0.12.13 -- uv init --bare --vcs none --author-from none --no-workspace --python 3.13 demo +cd demo +cp -r /adapters/flask/app /adapters/flask/tests . +cp /adapters/flask/conftest.py /adapters/flask/.python-version . +mise x uv@0.12.13 -- bash -c 'uv add flask gunicorn && uv add --dev ruff mypy pytest' +mise x uv@0.12.13 -- bash -c 'uv run ruff format --check . && uv run ruff check . && uv run mypy --strict app tests && uv run pytest -q' +``` + +Expected: every command exits 0. This exact sequence was run while the spec was +written and passed; a failure means a file was transcribed wrong. + +- [ ] **Step 16: Commit** + +```bash +git add adapters/flask tests/service.bats +git commit -m "feat(adapters): add a flask adapter" +``` + +--- + +### Task 2: The four flask service drivers + +**Files:** +- Create: `services/shared/flask.sh` +- Create: `services/postgres/drivers/flask.sh` +- Create: `services/mysql/drivers/flask.sh` +- Create: `services/mongodb/drivers/flask.sh` +- Create: `services/redis/drivers/flask.sh` + +**Interfaces:** +- Consumes: Task 1's `adapters/flask/app/health.py` anchors — `# @DB_ENGINE@`, + `# @DB_PROBE@`, and the `raise RuntimeError(...)` line beneath the second. + `write_env_lines`, `die` and `SCAFFOLD_ROOT` come from the sourcing context, + as they do for every existing driver. +- Produces: nothing later tasks consume. + +Read `services/shared/laravel.sh`, `services/postgres/drivers/laravel.sh`, +`services/mongodb/drivers/laravel.sh` and `services/redis/drivers/laravel.sh` +first. This task is the same four shapes with Python in place of PHP. + +- [ ] **Step 1: Write `services/shared/flask.sh`** + +The parameterised SQLAlchemy body. Its contract, stated in the header: + +- `FLASK_DIALECT` — the SQLAlchemy URL scheme, e.g. `postgresql+psycopg` +- `FLASK_PACKAGES` — the DBAPI package to `uv add` alongside sqlalchemy +- `FLASK_PORT` — the host-side port written into `.env.example` +- `FLASK_COMPOSE_URL` — the same DSN against the compose network + +`service_driver_apply` does four things, each with `|| return 1`: + +```bash +service_driver_apply() { + uv add sqlalchemy "$FLASK_PACKAGES" || return 1 + + write_env_lines .env.example \ + "DATABASE_URL=${FLASK_DIALECT}://app:app@localhost:${FLASK_PORT}/app" \ + || return 1 + + splice_flask_probe \ + 'import os +from functools import cache + +from sqlalchemy import Engine, create_engine, text + + +@cache +def _engine() -> Engine: + return create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)' \ + ' with _engine().connect() as connection: + connection.execute(text("SELECT 1"))' +} +``` + +`@cache`, not an engine built inside the handler: a SQLAlchemy engine owns a +connection pool, and one per request exhausts the database's connection limit +under a polling probe — the failure `services/shared/nest.sh` records measuring +against Postgres. + +`splice_flask_probe ` is a helper in this same file, +used by all four drivers, so the anchor names and the verification live in one +place: + +```bash +splice_flask_probe() { + local -r engine="$1" probe="$2" + local -r file=app/health.py + + ENGINE="$engine" PROBE="$probe" awk ' + $0 == "# @DB_ENGINE@" { print ENVIRON["ENGINE"]; next } + $0 == " # @DB_PROBE@" { print ENVIRON["PROBE"]; next } + $0 == " raise RuntimeError(\"no database is configured for this project\")" { + print " return jsonify(status=\"ok\")"; next + } + { print } + ' "$file" > "${file}.tmp" || return 1 + mv "${file}.tmp" "$file" + + grep -q 'return jsonify(status="ok")' "$file" \ + && ! grep -q '@DB_ENGINE@' "$file" \ + && ! grep -q '@DB_PROBE@' "$file" \ + || die "could not splice the database probe into app/health.py — has the anchor moved?" +} +``` + +awk, not sed: both blocks are multi-line and carry `/`, `"` and backslashes that +sed's replacement syntax would eat. `ENVIRON`, not `-v`, for the same reason +`write_env_lines` uses it. + +The remaining three functions: + +```bash +service_driver_dockerfile() { + : +} + +service_driver_compose_env() { + printf 'DATABASE_URL: ${DATABASE_URL:-%s}\n' "$FLASK_COMPOSE_URL" +} + +service_driver_compose_migrate() { + : +} +``` + +`service_driver_migrate` printing nothing is deliberate and belongs in a +comment: this adapter ships no models and Flask has no migration tool of its +own, so there is no schema to apply. + +- [ ] **Step 2: Write `services/postgres/drivers/flask.sh`** + +```bash +# shellcheck disable=SC2034 # read by services/shared/flask.sh, sourced below +FLASK_DIALECT="postgresql+psycopg" +# the [binary] extra ships a wheel with libpq inside, so the image needs no +# system package and service_driver_dockerfile stays empty +FLASK_PACKAGES="psycopg[binary]" +FLASK_PORT="5432" +FLASK_COMPOSE_URL='postgresql+psycopg://${DB_USERNAME:-app}:${DB_PASSWORD}@database:5432/${DB_DATABASE:-app}' +# shellcheck source=/dev/null +. "${SCAFFOLD_ROOT}/services/shared/flask.sh" +``` + +Single-quote `FLASK_COMPOSE_URL`: `${DB_PASSWORD}` is compose's interpolation, +not this shell's. Check `services/postgres/drivers/nest.sh` for how +`PRISMA_COMPOSE_URL` spells the same thing and match it. + +- [ ] **Step 3: Write `services/mysql/drivers/flask.sh`** + +Same shape: + +```bash +FLASK_DIALECT="mysql+pymysql" +# pure python, so no build stage and no system package +FLASK_PACKAGES="PyMySQL" +FLASK_PORT="3306" +FLASK_COMPOSE_URL='mysql+pymysql://${DB_USERNAME:-app}:${DB_PASSWORD}@database:3306/${DB_DATABASE:-app}' +``` + +- [ ] **Step 4: Write `services/mongodb/drivers/flask.sh`** + +Self-contained, like `services/mongodb/drivers/laravel.sh`: a DSN, not +decomposed credentials. It sources `services/shared/flask.sh` only to reuse +`splice_flask_probe`, then defines its own `service_driver_apply` after the +source so the shared definition is overridden, and sets no `FLASK_*` variables. +If that ordering proves fragile, copy `splice_flask_probe`'s four lines instead +and say why in the header. + +```bash +service_driver_apply() { + uv add pymongo || return 1 + + write_env_lines .env.example \ + "DATABASE_URL=mongodb://app:app@localhost:27017/app?authSource=admin" \ + || return 1 + + splice_flask_probe \ + 'import os +from functools import cache +from typing import Any + +from pymongo import MongoClient + + +@cache +def _client() -> MongoClient[dict[str, Any]]: + return MongoClient(os.environ["DATABASE_URL"])' \ + ' _client().admin.command("ping")' +} +``` + +`MongoClient` is generic and mypy `--strict` rejects the bare name; the +parameter is the document type. If `--strict` still objects, fix the annotation +— do not add `# type: ignore`. + +`service_driver_compose_env` prints the compose-network DSN: + +```bash +service_driver_compose_env() { + printf 'DATABASE_URL: ${DATABASE_URL:-mongodb://${DB_USERNAME:-app}:${DB_PASSWORD}@database:27017/${DB_DATABASE:-app}?authSource=admin}\n' +} +``` + +- [ ] **Step 5: Write `services/redis/drivers/flask.sh`** + +Self-contained, mirroring `services/redis/drivers/laravel.sh`. A cache is not a +database: it touches no anchor in `app/health.py`, and +`service_driver_compose_migrate` prints nothing because there is no schema. + +```bash +service_driver_apply() { + uv add redis || return 1 + + write_env_lines .env.example \ + "REDIS_HOST=localhost" \ + "REDIS_PORT=6379" \ + "REDIS_PASSWORD=app" \ + || return 1 +} + +service_driver_dockerfile() { + : +} + +service_driver_compose_env() { + printf 'REDIS_HOST: cache\n' +} + +service_driver_compose_migrate() { + : +} +``` + +Check `services/redis/drivers/laravel.sh` for which variables compose already +supplies through `env_file` — only the host needs adding here, and the comment +there says so. + +- [ ] **Step 6: Run the linters — now expect them clean** + +Run: `mise run lint && ./scaffold lint` +Expected: both pass, with no `no driver for flask` lines. + +- [ ] **Step 7: Prove a real project generates and serves** + +Run each, and read the output rather than the exit code alone: + +```bash +./scripts/deploy-check.sh flask --db postgres +./scripts/deploy-check.sh flask --db mysql +./scripts/deploy-check.sh flask --db mongodb +``` + +Expected: each reports the stack healthy, including the readiness probe. A 503 +on `/health/ready` means the splice landed but the probe failed; an +`@DB_PROBE@` still in the generated `app/health.py` means the splice missed. + +- [ ] **Step 8: Commit** + +```bash +git add services +git commit -m "feat(services): teach every service to talk to flask" +``` + +--- + +### Task 3: The smoke suite + +**Files:** +- Create: `tests/new-flask.bats` + +**Interfaces:** +- Consumes: the adapter from Task 1 and the drivers from Task 2. +- Produces: nothing. + +Read `tests/new-laravel-api.bats` first — this suite is its counterpart and +should assert the same categories of thing, adjusted for Python. Do not copy +assertions that have no Python meaning. + +- [ ] **Step 1: Write `tests/new-flask.bats`** + +Use the same `setup`/`teardown` as `tests/new-laravel-api.bats`. The tests: + +1. `flask generates an app at apps/api` — `apps/api/pyproject.toml`, + `apps/api/uv.lock` and `apps/api/mise.toml` all exist. +2. `python is pinned in the app and never at the project root` — + `apps/api/.python-version` contains `3.13`, and the root `mise.toml` matches + neither `python` nor `uv`. Include a comment recording why the pin is a + `.python-version` and not a `mise.toml` tools entry; the laravel suite has + the equivalent note about php and ADR-0016. +3. `the flask lefthook fragment is merged with the common hooks` — + `.pre-commit.commands` has `ruff-apps-api` and still has `gitleaks`. +4. `the fragment resolves the app root` — `.pre-commit.commands.ruff-apps-api.root` + is `apps/api/`. +5. `a mixed-language project has no packages/types` — generate + `--api flask --web nextjs`; neither `packages/types` nor `packages-types` + exists. +6. `a mixed-language project keeps the supply-chain policy` — the same + generation still has `pnpm-workspace.yaml` with its `allowBuilds` keys. + +- [ ] **Step 2: Run it** + +Run: `bats tests/new-flask.bats` +Expected: all six pass. This suite generates real projects and takes minutes. + +- [ ] **Step 3: Record the tier** + +`ADAPTER_TIER="A"` was set on the spec's reasoning that `uv sync` compiles +nothing. Time `bats tests/new-flask.bats` and compare against +`bats tests/new-laravel-api.bats`. If flask is slower, change `ADAPTER_TIER` to +`B` in `adapters/flask/adapter.env` and say so in the commit message. If it is +faster, leave it and note the two timings in the commit message. + +- [ ] **Step 4: Commit** + +```bash +git add tests/new-flask.bats adapters/flask/adapter.env +git commit -m "test: smoke the flask adapter" +``` + +--- + +### Task 4: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/PROVENANCE.md` + +**Interfaces:** +- Consumes: the finished adapter. +- Produces: nothing. + +- [ ] **Step 1: Add the adapter to `README.md`** + +Find the adapter table and add a `flask` row in the existing format, with the +tier Task 3 settled on. Change nothing else — this is a one-row edit. + +- [ ] **Step 2: Add the provenance row** + +In `docs/PROVENANCE.md`'s table, one row: + +| File | Upstream path | Status | Notes | +| --- | --- | --- | --- | +| `adapters/flask/mise.toml` | `machine-learning/mise.toml` | adapted | the ADR-0011 task vocabulary and the uv shape (`uv sync --locked`, `uv run ruff`, `uv run mypy --strict`, `uv run pytest`) come from immich's only Python service; the tools differ — uv is pinned here and python is not, because uv resolves its own interpreter. | + +Then add a sentence to that row or a second row making the rest explicit: +every other file under `adapters/flask/` and all four +`services/*/drivers/flask.sh` are **original** — immich runs FastAPI, has no +Flask application, no application factory and no service-driver mechanism, so +there is no upstream file to adapt. Check each claim with `diff` against the +pinned upstream clone before writing it, as the document's own preamble +requires. + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/PROVENANCE.md +git commit -m "docs: record the flask adapter and its provenance" +``` From 04a84042cda23d4f204444e8dea223776b28fd83 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 22:48:52 +0700 Subject: [PATCH 04/16] feat(adapters): add a flask adapter --- adapters/flask/.dockerignore | 9 +++++++ adapters/flask/.env.example | 2 ++ adapters/flask/.python-version | 1 + adapters/flask/Dockerfile | 23 ++++++++++++++++ adapters/flask/README.md | 22 ++++++++++++++++ adapters/flask/adapter.env | 15 +++++++++++ adapters/flask/app/__init__.py | 9 +++++++ adapters/flask/app/health.py | 21 +++++++++++++++ adapters/flask/conftest.py | 0 adapters/flask/lefthook.fragment.yml | 7 +++++ adapters/flask/mise.toml | 39 ++++++++++++++++++++++++++++ adapters/flask/tests/test_health.py | 12 +++++++++ tests/service.bats | 2 +- 13 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 adapters/flask/.dockerignore create mode 100644 adapters/flask/.env.example create mode 100644 adapters/flask/.python-version create mode 100644 adapters/flask/Dockerfile create mode 100644 adapters/flask/README.md create mode 100644 adapters/flask/adapter.env create mode 100644 adapters/flask/app/__init__.py create mode 100644 adapters/flask/app/health.py create mode 100644 adapters/flask/conftest.py create mode 100644 adapters/flask/lefthook.fragment.yml create mode 100644 adapters/flask/mise.toml create mode 100644 adapters/flask/tests/test_health.py diff --git a/adapters/flask/.dockerignore b/adapters/flask/.dockerignore new file mode 100644 index 0000000..633d5a7 --- /dev/null +++ b/adapters/flask/.dockerignore @@ -0,0 +1,9 @@ +.venv +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +.env +.env.* +.git diff --git a/adapters/flask/.env.example b/adapters/flask/.env.example new file mode 100644 index 0000000..0812081 --- /dev/null +++ b/adapters/flask/.env.example @@ -0,0 +1,2 @@ +# off by default: Flask's debug console executes arbitrary code from the browser +FLASK_DEBUG=0 diff --git a/adapters/flask/.python-version b/adapters/flask/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/adapters/flask/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/adapters/flask/Dockerfile b/adapters/flask/Dockerfile new file mode 100644 index 0000000..0e9be79 --- /dev/null +++ b/adapters/flask/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +# python3.13-bookworm-slim is not published for this uv release — only +# python3.13-trixie-slim is; python:3.13-slim below already resolves to +# slim-trixie, so this keeps both stages on the same Debian release. +FROM ghcr.io/astral-sh/uv:0.12.13-python3.13-trixie-slim@sha256:c0ba49559fc5622531fd05a5747b52afb49ffa883574bbf8eb719ebd103efb84 AS deps +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --no-install-project + +FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS runtime +# @SERVICE_SETUP@ +WORKDIR /app +COPY --from=deps /app/.venv ./.venv +COPY . . +ENV PATH="/app/.venv/bin:${PATH}" +RUN useradd --create-home --uid 10001 app && chown -R app:app /app +USER app +EXPOSE 8080 +# python, not wget or curl: the slim image ships neither. +HEALTHCHECK --interval=30s --timeout=3s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health/live')" || exit 1 +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:create_app()"] diff --git a/adapters/flask/README.md b/adapters/flask/README.md new file mode 100644 index 0000000..e4c0827 --- /dev/null +++ b/adapters/flask/README.md @@ -0,0 +1,22 @@ +# API + +A Flask application. It implements this project's task contract, so every +check runs the same way here as in any other config root: + +```sh +mise run //:ci-unit # install, format, lint, check, test +``` + +`mise.toml` in this directory is the whole story of how those tasks are wired. + +## conftest.py is intentionally empty + +It exists only so pytest's default `prepend` import mode adds this directory +to `sys.path`; without it, `from app import create_app` in the tests raises +`ModuleNotFoundError`. There is nothing to configure, so the file is empty. + +## Why python is pinned in .python-version, not mise.toml + +uv resolves its own managed interpreter and reads `.python-version` for the +pin; a `python` tool entry in `mise.toml` would install a second interpreter +that uv never uses. diff --git a/adapters/flask/adapter.env b/adapters/flask/adapter.env new file mode 100644 index 0000000..977afe5 --- /dev/null +++ b/adapters/flask/adapter.env @@ -0,0 +1,15 @@ +ADAPTER_NAME="flask" +ADAPTER_ROLE="api" +ADAPTER_TIER="A" +ADAPTER_LANGUAGE="python" +ADAPTER_FAMILY="flask" +# --bare writes pyproject.toml and nothing else; the default template adds a +# .git directory inside the app, a src/ package and a README the +# overlay would have to delete. uv comes through `mise x` because the generator +# runs in the project root, whose mise.toml pins node and pnpm only. +ADAPTER_GENERATOR='mise x uv@0.12.13 -- uv init --bare --vcs none --author-from none --no-workspace --python 3.13 "$APP_DIR"' +# The grep pair turns a resolution that reported success but wrote nothing into +# a build failure instead of an ImportError at container start. +ADAPTER_POST_GENERATE='uv add flask gunicorn && uv add --dev ruff mypy pytest && { grep -q "flask" pyproject.toml && grep -q "gunicorn" pyproject.toml; } || { echo "post-generate: flask or gunicorn missing from pyproject.toml after uv add" >&2; exit 1; }' +ADAPTER_LIVENESS_PATH="/health/live" +ADAPTER_READINESS_PATH="/health/ready" diff --git a/adapters/flask/app/__init__.py b/adapters/flask/app/__init__.py new file mode 100644 index 0000000..d2cdf13 --- /dev/null +++ b/adapters/flask/app/__init__.py @@ -0,0 +1,9 @@ +from flask import Flask + +from .health import health + + +def create_app() -> Flask: + app = Flask(__name__) + app.register_blueprint(health) + return app diff --git a/adapters/flask/app/health.py b/adapters/flask/app/health.py new file mode 100644 index 0000000..a0eea16 --- /dev/null +++ b/adapters/flask/app/health.py @@ -0,0 +1,21 @@ +from flask import Blueprint, Response, jsonify + +# @DB_ENGINE@ + +health = Blueprint("health", __name__) + +Reply = Response | tuple[Response, int] + + +@health.get("/health/live") +def live() -> Reply: + return jsonify(status="ok") + + +@health.get("/health/ready") +def ready() -> Reply: + try: + # @DB_PROBE@ + raise RuntimeError("no database is configured for this project") + except Exception as error: # noqa: BLE001 + return jsonify(status="unavailable", reason=str(error)), 503 diff --git a/adapters/flask/conftest.py b/adapters/flask/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/flask/lefthook.fragment.yml b/adapters/flask/lefthook.fragment.yml new file mode 100644 index 0000000..4a23dfb --- /dev/null +++ b/adapters/flask/lefthook.fragment.yml @@ -0,0 +1,7 @@ +pre-commit: + commands: + ruff: + glob: "*.py" + root: "@APP_ROOT@" + run: uv run ruff format {staged_files} + stage_fixed: true diff --git a/adapters/flask/mise.toml b/adapters/flask/mise.toml new file mode 100644 index 0000000..cc87a60 --- /dev/null +++ b/adapters/flask/mise.toml @@ -0,0 +1,39 @@ +# uv alone: uv resolves its own managed interpreter, so a `python` entry here +# would be installed and then ignored. .python-version is the pin uv reads. +[tools] +uv = "0.12.13" + +[tasks.install] +run = "uv sync --locked" + +[tasks.format] +run = "uv run ruff format --check ." + +[tasks."format-fix"] +run = "uv run ruff format ." + +[tasks.lint] +run = "uv run ruff check ." + +[tasks.check] +run = "uv run mypy --strict app tests" + +[tasks.test] +run = "uv run pytest -q" + +[tasks.build] +# The production sync drops ruff, which the pre-commit hook calls directly, so +# the second sync puts it back; `status=$?` keeps the production exit code. +run = "uv sync --locked --no-dev; status=$?; uv sync --locked --quiet; exit $status" + +[tasks.ci-unit] +run = [ + { task = ":install" }, + { task = ":format" }, + { task = ":lint" }, + { task = ":check" }, + { task = ":test" }, +] + +[tasks.checklist] +run = [{ task = ":ci-unit" }, { task = ":build" }] diff --git a/adapters/flask/tests/test_health.py b/adapters/flask/tests/test_health.py new file mode 100644 index 0000000..54f8e70 --- /dev/null +++ b/adapters/flask/tests/test_health.py @@ -0,0 +1,12 @@ +from app import create_app + + +def test_live_reports_ok() -> None: + response = create_app().test_client().get("/health/live") + assert response.status_code == 200 + assert response.get_json() == {"status": "ok"} + + +def test_ready_route_is_registered() -> None: + rules = create_app().url_map.iter_rules() + assert any(rule.rule == "/health/ready" for rule in rules) diff --git a/tests/service.bats b/tests/service.bats index 9f62af7..9fa8882 100644 --- a/tests/service.bats +++ b/tests/service.bats @@ -139,7 +139,7 @@ setup() { @test "every adapter declares a framework family" { for adapter in "${SCAFFOLD_ROOT}"/adapters/*/; do - grep -Eq '^ADAPTER_FAMILY="(laravel|nest|next)"$' "${adapter}adapter.env" \ + grep -Eq '^ADAPTER_FAMILY="(laravel|nest|next|flask)"$' "${adapter}adapter.env" \ || { echo "no ADAPTER_FAMILY in ${adapter}adapter.env"; false; } done } From b592d128062608c77c4bdbce326b55b2c312a1ed Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 23:11:43 +0700 Subject: [PATCH 05/16] feat(services): teach every service to talk to flask --- services/mongodb/drivers/flask.sh | 39 ++++++++++++++ services/mysql/drivers/flask.sh | 14 +++++ services/postgres/drivers/flask.sh | 15 ++++++ services/redis/drivers/flask.sh | 33 ++++++++++++ services/shared/flask.sh | 84 ++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 services/mongodb/drivers/flask.sh create mode 100644 services/mysql/drivers/flask.sh create mode 100644 services/postgres/drivers/flask.sh create mode 100644 services/redis/drivers/flask.sh create mode 100644 services/shared/flask.sh diff --git a/services/mongodb/drivers/flask.sh b/services/mongodb/drivers/flask.sh new file mode 100644 index 0000000..a4e0f0d --- /dev/null +++ b/services/mongodb/drivers/flask.sh @@ -0,0 +1,39 @@ +# shellcheck shell=bash +# ═══════════════════════════════════════════════════════════════════════════ +# Script : services/mongodb/drivers/flask.sh +# Description : How Flask talks to MongoDB. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ +# Self-contained, like services/mongodb/drivers/laravel.sh: a DSN, not +# decomposed credentials. Sources services/shared/flask.sh only to reuse +# splice_flask_probe, then overrides service_driver_apply and +# service_driver_compose_env; no FLASK_* variables are set. +# shellcheck source=/dev/null +. "${SCAFFOLD_ROOT}/services/shared/flask.sh" + +service_driver_apply() { + uv add pymongo || return 1 + + write_env_lines .env.example \ + "DATABASE_URL=mongodb://app:app@localhost:27017/app?authSource=admin" \ + || return 1 + + # MongoClient is generic; mypy --strict rejects the bare name, so the + # parameter names the document type. + splice_flask_probe \ + 'import os +from functools import cache +from typing import Any + +from pymongo import MongoClient + + +@cache +def _client() -> MongoClient[dict[str, Any]]: + return MongoClient(os.environ["DATABASE_URL"])' \ + ' _client().admin.command("ping")' +} + +service_driver_compose_env() { + printf 'DATABASE_URL: ${DATABASE_URL:-mongodb://${DB_USERNAME:-app}:${DB_PASSWORD}@database:27017/${DB_DATABASE:-app}?authSource=admin}\n' +} diff --git a/services/mysql/drivers/flask.sh b/services/mysql/drivers/flask.sh new file mode 100644 index 0000000..9ee4675 --- /dev/null +++ b/services/mysql/drivers/flask.sh @@ -0,0 +1,14 @@ +# shellcheck shell=bash +# ═══════════════════════════════════════════════════════════════════════════ +# Script : services/mysql/drivers/flask.sh +# Description : MySQL parameters for the shared Flask driver. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ +# shellcheck disable=SC2034 # read by services/shared/flask.sh, sourced below +FLASK_DIALECT="mysql+pymysql" +# pure python, so no build stage and no system package +FLASK_PACKAGES="PyMySQL" +FLASK_PORT="3306" +FLASK_COMPOSE_URL='mysql+pymysql://${DB_USERNAME:-app}:${DB_PASSWORD}@database:3306/${DB_DATABASE:-app}' +# shellcheck source=/dev/null +. "${SCAFFOLD_ROOT}/services/shared/flask.sh" diff --git a/services/postgres/drivers/flask.sh b/services/postgres/drivers/flask.sh new file mode 100644 index 0000000..63454ed --- /dev/null +++ b/services/postgres/drivers/flask.sh @@ -0,0 +1,15 @@ +# shellcheck shell=bash +# ═══════════════════════════════════════════════════════════════════════════ +# Script : services/postgres/drivers/flask.sh +# Description : PostgreSQL parameters for the shared Flask driver. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ +# shellcheck disable=SC2034 # read by services/shared/flask.sh, sourced below +FLASK_DIALECT="postgresql+psycopg" +# the [binary] extra ships a wheel with libpq inside, so the image needs no +# system package and service_driver_dockerfile stays empty +FLASK_PACKAGES="psycopg[binary]" +FLASK_PORT="5432" +FLASK_COMPOSE_URL='postgresql+psycopg://${DB_USERNAME:-app}:${DB_PASSWORD}@database:5432/${DB_DATABASE:-app}' +# shellcheck source=/dev/null +. "${SCAFFOLD_ROOT}/services/shared/flask.sh" diff --git a/services/redis/drivers/flask.sh b/services/redis/drivers/flask.sh new file mode 100644 index 0000000..9a12178 --- /dev/null +++ b/services/redis/drivers/flask.sh @@ -0,0 +1,33 @@ +# shellcheck shell=bash +# ═══════════════════════════════════════════════════════════════════════════ +# Script : services/redis/drivers/flask.sh +# Description : How Flask talks to Redis. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ +# Self-contained: redis is the only cache, so a shared body would have exactly +# one caller. Extract one when a second cache arrives. +service_driver_apply() { + uv add redis || return 1 + + write_env_lines .env.example \ + "REDIS_HOST=localhost" \ + "REDIS_PORT=6379" \ + "REDIS_PASSWORD=app" \ + || return 1 +} + +service_driver_dockerfile() { + : +} + +# REDIS_PASSWORD already reaches the container through compose.yaml's env_file, +# so only the host needs adding here. +service_driver_compose_env() { + printf 'REDIS_HOST: cache\n' +} + +# a cache has no schema to migrate — printing nothing keeps the migrate +# service absent from a project that selected only a cache. +service_driver_compose_migrate() { + : +} diff --git a/services/shared/flask.sh b/services/shared/flask.sh new file mode 100644 index 0000000..865b4a1 --- /dev/null +++ b/services/shared/flask.sh @@ -0,0 +1,84 @@ +# shellcheck shell=bash +# ═══════════════════════════════════════════════════════════════════════════ +# Script : services/shared/flask.sh +# Description : The shared Flask SQLAlchemy driver body. +# Author : ttncode +# ═══════════════════════════════════════════════════════════════════════════ +# A service's drivers/flask.sh sets these and sources this. mysql and postgres +# only — mongodb is self-contained: pymongo has no SQLAlchemy dialect, so its +# driver defines its own service_driver_apply after sourcing this file, kept +# only for splice_flask_probe. +# +# FLASK_DIALECT the SQLAlchemy URL scheme, e.g. postgresql+psycopg +# FLASK_PACKAGES the DBAPI package to `uv add` alongside sqlalchemy +# FLASK_PORT the host-side port written into .env.example +# FLASK_COMPOSE_URL the same DSN against the compose network + +service_driver_apply() { + uv add sqlalchemy "$FLASK_PACKAGES" || return 1 + + write_env_lines .env.example \ + "DATABASE_URL=${FLASK_DIALECT}://app:app@localhost:${FLASK_PORT}/app" \ + || return 1 + + splice_flask_probe \ + 'import os +from functools import cache + +from sqlalchemy import Engine, create_engine, text + + +@cache +def _engine() -> Engine: + return create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)' \ + ' with _engine().connect() as connection: + connection.execute(text("SELECT 1"))' +} + +service_driver_dockerfile() { + : +} + +service_driver_compose_env() { + printf 'DATABASE_URL: ${DATABASE_URL:-%s}\n' "$FLASK_COMPOSE_URL" +} + +# this adapter ships no models and Flask has no migration tool of its own, so +# there is no schema to apply. +service_driver_compose_migrate() { + : +} + +# splice_flask_probe +# Used by all four drivers, so the anchor names and the verification live in +# one place. +# +# awk, not sed: both blocks are multi-line and carry /, " and backslashes that +# sed's replacement syntax would eat. ENVIRON, not -v, for the same reason +# write_env_lines uses it. +splice_flask_probe() { + local -r engine="$1" probe="$2" + local -r file=app/health.py + + ENGINE="$engine" PROBE="$probe" awk ' + $0 == "# @DB_ENGINE@" { print ENVIRON["ENGINE"]; next } + $0 == " # @DB_PROBE@" { print ENVIRON["PROBE"]; next } + $0 == " raise RuntimeError(\"no database is configured for this project\")" { + print " return jsonify(status=\"ok\")"; next + } + { print } + ' "$file" > "${file}.tmp" || return 1 + mv "${file}.tmp" "$file" + + grep -q 'return jsonify(status="ok")' "$file" \ + && ! grep -q '@DB_ENGINE@' "$file" \ + && ! grep -q '@DB_PROBE@' "$file" \ + || die "could not splice the database probe into app/health.py — has the anchor moved?" + + # The engine block's stdlib imports land below flask's own import, which + # ruff's isort rule (I001) and its formatter both reject — reformat once + # rather than hand-ordering imports per driver, the same move + # services/shared/nest.sh makes with prettier. + uv run ruff check --fix "$file" || return 1 + uv run ruff format "$file" || return 1 +} From b07011bd502d886a3cca07526a13797ffa57948d Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 23:11:49 +0700 Subject: [PATCH 06/16] fix(service): put uv on PATH so the flask drivers can run uv add --- lib/service.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index 9ab525b..8a28e68 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -414,15 +414,18 @@ apply_service_compose_migrate() { # a driver runs pnpm add, and pnpm turns the frozen lockfile on whenever CI is # set. # -# pnpm/node go in by PATH, not `mise exec -C`: this script also calls yq, which -# the project's mise.toml does not pin, and `mise exec` resolves PATH from -# scratch. composer stays ambient either way (ADR-0016). +# pnpm/node/uv go in by PATH, not `mise exec -C`: this script also calls yq, +# which the project's mise.toml does not pin, and `mise exec` resolves PATH +# from scratch. composer stays ambient either way (ADR-0016). run_driver_apply() { local -r app="$1" project="$2" family="$3" service="$4" driver="$5" - local pnpm_bin node_bin + local pnpm_bin node_bin uv_bin="" pnpm_bin="$(dirname "$(mise which pnpm -C "$app")")" node_bin="$(dirname "$(mise which node -C "$app")")" + # uv is declared only in adapters/flask/mise.toml, not the project root's, so + # resolving it for every family would fail a laravel/nest/nextjs app outright. + [ "$family" = flask ] && uv_bin="$(dirname "$(mise which uv -C "$app")")" # Held in a variable so it reaches `bash -c` through `env` intact. Its # `$1`/`$2` and ${SCAFFOLD_ROOT} are the child's to expand. @@ -437,7 +440,7 @@ run_driver_apply() { step "wiring ${service} into $(app_service_key "$app")" run_quietly "wiring ${service} into $(app_service_key "$app") (the ${family} driver)" \ - env PATH="${pnpm_bin}:${node_bin}:${PATH}" \ + env PATH="${uv_bin:+${uv_bin}:}${pnpm_bin}:${node_bin}:${PATH}" \ npm_config_frozen_lockfile=false npm_config_verify_deps_before_run=false \ SCAFFOLD_PROJECT_ROOT="$project" \ bash -euo pipefail -c "$driver_script" _ "$app" "$driver" From a2f1b6009637276f0e80b4034437b5a8f1c21afb Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 23:27:47 +0700 Subject: [PATCH 07/16] feat(services): give flask real migrations for every database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alembic for postgres/mysql, a app/migrate.py index-ensure step for mongodb — common/install.sh refuses to start a project with a database service and no migrate service, so an empty migrate command was never a valid shape. --- adapters/flask/mise.toml | 6 ++++++ services/mongodb/drivers/flask.sh | 36 +++++++++++++++++++++++++++++-- services/shared/flask.sh | 34 +++++++++++++++++++++++++---- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/adapters/flask/mise.toml b/adapters/flask/mise.toml index cc87a60..2dc57f2 100644 --- a/adapters/flask/mise.toml +++ b/adapters/flask/mise.toml @@ -26,6 +26,12 @@ run = "uv run pytest -q" # the second sync puts it back; `status=$?` keeps the production exit code. run = "uv sync --locked --no-dev; status=$?; uv sync --locked --quiet; exit $status" +[tasks.migrate] +# alembic.ini exists only for postgres/mysql; mongodb ships app/migrate.py +# instead, and --db none ships neither, matching compose.yaml's own migrate +# service, which likewise exists for exactly those two shapes. +run = "if [ -f alembic.ini ]; then uv run alembic upgrade head; else uv run python -m app.migrate; fi" + [tasks.ci-unit] run = [ { task = ":install" }, diff --git a/services/mongodb/drivers/flask.sh b/services/mongodb/drivers/flask.sh index a4e0f0d..a076a86 100644 --- a/services/mongodb/drivers/flask.sh +++ b/services/mongodb/drivers/flask.sh @@ -6,8 +6,9 @@ # ═══════════════════════════════════════════════════════════════════════════ # Self-contained, like services/mongodb/drivers/laravel.sh: a DSN, not # decomposed credentials. Sources services/shared/flask.sh only to reuse -# splice_flask_probe, then overrides service_driver_apply and -# service_driver_compose_env; no FLASK_* variables are set. +# splice_flask_probe, then overrides service_driver_apply, +# service_driver_compose_env and service_driver_compose_migrate; no FLASK_* +# variables are set. # shellcheck source=/dev/null . "${SCAFFOLD_ROOT}/services/shared/flask.sh" @@ -32,8 +33,39 @@ from pymongo import MongoClient def _client() -> MongoClient[dict[str, Any]]: return MongoClient(os.environ["DATABASE_URL"])' \ ' _client().admin.command("ping")' + + # alembic is SQL-only; this is mongodb's equivalent of it having zero + # revisions — a real command against the real database, with an empty seam + # for a client's own indexes. + cat > app/migrate.py <<'EOF' || return 1 +from typing import Any + +from app.health import _client + +# (collection, keys, kwargs) for pymongo's create_index — add one per index as +# the schema grows; empty ships correctly, same as alembic with no revisions. +INDEXES: list[tuple[str, Any, dict[str, Any]]] = [] + + +def main() -> None: + database = _client().get_default_database() + for collection, keys, kwargs in INDEXES: + database[collection].create_index(keys, **kwargs) + + +if __name__ == "__main__": + main() +EOF } service_driver_compose_env() { printf 'DATABASE_URL: ${DATABASE_URL:-mongodb://${DB_USERNAME:-app}:${DB_PASSWORD}@database:27017/${DB_DATABASE:-app}?authSource=admin}\n' } + +# common/install.sh's run_migrations refuses to start a project that has a +# database service but no migrate service; python, not `mise exec -C`: the +# runtime image has no mise, and the Dockerfile already puts /app/.venv/bin on +# PATH. +service_driver_compose_migrate() { + printf 'command: ["python", "-m", "app.migrate"]\n' +} diff --git a/services/shared/flask.sh b/services/shared/flask.sh index 865b4a1..f7ebfe9 100644 --- a/services/shared/flask.sh +++ b/services/shared/flask.sh @@ -15,7 +15,7 @@ # FLASK_COMPOSE_URL the same DSN against the compose network service_driver_apply() { - uv add sqlalchemy "$FLASK_PACKAGES" || return 1 + uv add sqlalchemy alembic "$FLASK_PACKAGES" || return 1 write_env_lines .env.example \ "DATABASE_URL=${FLASK_DIALECT}://app:app@localhost:${FLASK_PORT}/app" \ @@ -33,6 +33,8 @@ def _engine() -> Engine: return create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)' \ ' with _engine().connect() as connection: connection.execute(text("SELECT 1"))' + + init_flask_alembic } service_driver_dockerfile() { @@ -43,10 +45,14 @@ service_driver_compose_env() { printf 'DATABASE_URL: ${DATABASE_URL:-%s}\n' "$FLASK_COMPOSE_URL" } -# this adapter ships no models and Flask has no migration tool of its own, so -# there is no schema to apply. +# common/install.sh's run_migrations refuses to start a project that has a +# database service but no migrate service, so an empty command here — this +# adapter shipping no models — is not an option; zero revisions is. +# +# alembic, not `mise exec -C`: the runtime image has no mise, and the +# Dockerfile already puts /app/.venv/bin on PATH. service_driver_compose_migrate() { - : + printf 'command: ["alembic", "upgrade", "head"]\n' } # splice_flask_probe @@ -82,3 +88,23 @@ splice_flask_probe() { uv run ruff check --fix "$file" || return 1 uv run ruff format "$file" || return 1 } + +# init_flask_alembic — postgres and mysql only, called from service_driver_apply +# above. `alembic init` writes a placeholder `sqlalchemy.url` into alembic.ini; +# that's a runtime secret, so env.py is pointed at DATABASE_URL instead, the +# same variable the probe reads. +init_flask_alembic() { + uv run alembic init migrations || return 1 + + sed -i.bak 's|^from logging.config import fileConfig$|import os\n\nfrom logging.config import fileConfig|' \ + migrations/env.py || return 1 + sed -i.bak 's|^config = context.config$|config = context.config\n\nconfig.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])|' \ + migrations/env.py || return 1 + rm -f migrations/env.py.bak + + grep -q 'config.set_main_option("sqlalchemy.url", os.environ\["DATABASE_URL"\])' migrations/env.py \ + || die "could not point alembic at DATABASE_URL — has alembic init's generated env.py changed shape?" + + uv run ruff check --fix migrations/env.py || return 1 + uv run ruff format migrations/env.py || return 1 +} From d3fe0f37fd755537f3eeb1aa87f016cb2e6f4c77 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 23:37:17 +0700 Subject: [PATCH 08/16] fix(services): verify the import-os splice into alembic's env.py sed exits 0 whether or not it matched, so only the sqlalchemy.url splice was guarded; the import splice could silently no-op and leave a NameError behind. --- services/shared/flask.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/shared/flask.sh b/services/shared/flask.sh index f7ebfe9..76486a8 100644 --- a/services/shared/flask.sh +++ b/services/shared/flask.sh @@ -102,7 +102,8 @@ init_flask_alembic() { migrations/env.py || return 1 rm -f migrations/env.py.bak - grep -q 'config.set_main_option("sqlalchemy.url", os.environ\["DATABASE_URL"\])' migrations/env.py \ + grep -q '^import os$' migrations/env.py \ + && grep -q 'config.set_main_option("sqlalchemy.url", os.environ\["DATABASE_URL"\])' migrations/env.py \ || die "could not point alembic at DATABASE_URL — has alembic init's generated env.py changed shape?" uv run ruff check --fix migrations/env.py || return 1 From 6fe373668c7c6404769abb343ba71209325157dd Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 23:52:09 +0700 Subject: [PATCH 09/16] test: smoke the flask adapter flask (2m9s) is faster than laravel-api (9m28s), so ADAPTER_TIER stays A. --- tests/new-flask.bats | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/new-flask.bats diff --git a/tests/new-flask.bats b/tests/new-flask.bats new file mode 100644 index 0000000..7fac787 --- /dev/null +++ b/tests/new-flask.bats @@ -0,0 +1,56 @@ +setup() { + load 'helpers/setup' + WORKDIR="$(mktemp -d)" + PROJECT="${WORKDIR}/demo" +} + +teardown() { + rm -rf "$WORKDIR" +} + +@test "flask generates an app at apps/api" { + run scaffold new "$PROJECT" --api flask + assert_ok + [ -f "${PROJECT}/apps/api/pyproject.toml" ] + [ -f "${PROJECT}/apps/api/uv.lock" ] + [ -f "${PROJECT}/apps/api/mise.toml" ] +} + +# uv resolves its own managed interpreter, so a mise-pinned python would be +# installed and then ignored; .python-version is the pin uv itself reads. The +# root mise.toml pins uv alone. See adapters/flask/mise.toml. +@test "python is pinned in the app and never at the project root" { + scaffold new "$PROJECT" --api flask + run grep -q '3.13' "${PROJECT}/apps/api/.python-version" + assert_ok + run grep -q -e 'python' -e 'uv' "${PROJECT}/mise.toml" + [ "$status" -ne 0 ] +} + +@test "the flask lefthook fragment is merged with the common hooks" { + scaffold new "$PROJECT" --api flask + run yq '.pre-commit.commands | has("ruff-apps-api")' "${PROJECT}/lefthook.yml" + [ "$output" = "true" ] + run yq '.pre-commit.commands | has("gitleaks")' "${PROJECT}/lefthook.yml" + [ "$output" = "true" ] +} + +@test "the fragment resolves the app root" { + scaffold new "$PROJECT" --api flask + run yq '.pre-commit.commands.ruff-apps-api.root' "${PROJECT}/lefthook.yml" + [ "$output" = "apps/api/" ] +} + +@test "a mixed-language project has no packages/types" { + scaffold new "$PROJECT" --api flask --web nextjs + [ ! -e "${PROJECT}/packages/types" ] + [ ! -e "${PROJECT}/packages-types" ] +} + +@test "a mixed-language project keeps the supply-chain policy" { + scaffold new "$PROJECT" --api flask --web nextjs + [ -f "${PROJECT}/pnpm-workspace.yaml" ] + run yq -r '.allowBuilds | keys | .[]' "${PROJECT}/pnpm-workspace.yaml" + assert_ok + [[ "$output" == *"unrs-resolver"* ]] +} From 353201af206132f30cec105a34f2480726c4114c Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Sun, 13 Sep 2026 23:57:18 +0700 Subject: [PATCH 10/16] docs: record the flask adapter and its provenance --- README.md | 2 +- docs/PROVENANCE.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b6c0427..2adaf77 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ from each adapter's own `ADAPTER_TIER` (`adapters/*/adapter.env`) — see | Tier | Adapters | CI runs it | Guarantee | | --- | --- | --- | --- | -| A | `nextjs`, `nestjs`, `laravel-api` | every pull request, and nightly | stays green through every dependency bump | +| A | `nextjs`, `nestjs`, `laravel-api`, `flask` | every pull request, and nightly | stays green through every dependency bump | | B | `laravel-inertia` | when `adapters/laravel-inertia/**` changes, and weekly | verified regularly, not on every push — a full generation measures ~5 minutes per test | | C | none currently | not automatically verified | may rot; no guarantee at all | diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md index 53a210d..01e7dc8 100644 --- a/docs/PROVENANCE.md +++ b/docs/PROVENANCE.md @@ -78,6 +78,8 @@ Excluded, and why: | `common/.dockerignore` | `.dockerignore` | adapted | same excludes as the per-adapter `.dockerignore` files (`node_modules`, build output, `.env*`, `.git`), only read when the build context is the workspace root; immich's own root `.dockerignore` excludes the same categories (`**/node_modules/`, `**/dist/`, `.env*`, `.git/`) for the same reason. | | `adapters/*/Dockerfile`'s base image, `USER`/`EXPOSE`/`HEALTHCHECK` shape, `adapters/*/.env.example`, `adapters/*/adapter.env`, `adapters/*/lefthook.fragment.yml`, `adapters/{nestjs,nextjs}/.prettierignore` | — | original | per-stack overlay files invoking each framework's own generator (ADR-0003), not vendored from immich's own `Dockerfile`/`docker/example.env`/`.prettierignore` — checked against all three, no meaningful resemblance; the `.prettierignore` pair shares only one coincidental line (`pnpm-lock.yaml`) with immich's much larger ignore lists. The two laravel Dockerfiles build with composer, not pnpm, and are untouched by the row above. | | `scaffold`, `lib/*.sh` | — | original | immich has no scaffolding tool. | +| `adapters/flask/mise.toml` | `machine-learning/mise.toml` | adapted | the ADR-0011 task vocabulary and the uv shape (`uv sync --locked`, `uv run ruff`, `uv run mypy --strict`, `uv run pytest`) come from immich's only Python service; the tools differ — uv is pinned here and python is not, because uv resolves its own interpreter. | +| `adapters/flask/` (every other file), `services/{mysql,postgres,mongodb,redis}/drivers/flask.sh`, `services/shared/flask.sh` | — | original | immich runs FastAPI, has no Flask application, no application factory and no service-driver mechanism at all, so none of these has an upstream file to adapt — checked by name and content against `machine-learning/`, including `grep -r flask`, which surfaces the name only as a transitive `locust` dev-dependency in `uv.lock`, never imported by immich's own code. One exception: `adapters/flask/.python-version` is byte-identical to `machine-learning/.python-version` (`3.13`) — both independently pin the current stable interpreter; coincidence, not a copy, and not the same relationship the `mise.toml` row above describes. | ## Out of scope, checked and rejected as rows From 09f95f7e0244dc0e8488e1579bd2643639166366 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 14 Sep 2026 00:30:48 +0700 Subject: [PATCH 11/16] fix(services): verify the splice sentinel is gone, not that success text already was splice_flask_probe's guard greps for jsonify(status="ok"), which live() already contains before any splice runs, so the check passes even if the raise substitution silently fails to fire. Grep for the removed sentinel instead. Also escapes a raw % in DATABASE_URL before alembic's set_main_option, whose ConfigParser backing applies pyformat interpolation on read. --- services/shared/flask.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/services/shared/flask.sh b/services/shared/flask.sh index 76486a8..d6609cb 100644 --- a/services/shared/flask.sh +++ b/services/shared/flask.sh @@ -10,12 +10,12 @@ # only for splice_flask_probe. # # FLASK_DIALECT the SQLAlchemy URL scheme, e.g. postgresql+psycopg -# FLASK_PACKAGES the DBAPI package to `uv add` alongside sqlalchemy +# FLASK_PACKAGE the DBAPI package to `uv add` alongside sqlalchemy # FLASK_PORT the host-side port written into .env.example # FLASK_COMPOSE_URL the same DSN against the compose network service_driver_apply() { - uv add sqlalchemy alembic "$FLASK_PACKAGES" || return 1 + uv add sqlalchemy alembic "$FLASK_PACKAGE" || return 1 write_env_lines .env.example \ "DATABASE_URL=${FLASK_DIALECT}://app:app@localhost:${FLASK_PORT}/app" \ @@ -76,7 +76,7 @@ splice_flask_probe() { ' "$file" > "${file}.tmp" || return 1 mv "${file}.tmp" "$file" - grep -q 'return jsonify(status="ok")' "$file" \ + ! grep -q 'no database is configured for this project' "$file" \ && ! grep -q '@DB_ENGINE@' "$file" \ && ! grep -q '@DB_PROBE@' "$file" \ || die "could not splice the database probe into app/health.py — has the anchor moved?" @@ -98,12 +98,15 @@ init_flask_alembic() { sed -i.bak 's|^from logging.config import fileConfig$|import os\n\nfrom logging.config import fileConfig|' \ migrations/env.py || return 1 - sed -i.bak 's|^config = context.config$|config = context.config\n\nconfig.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])|' \ + # set_main_option writes through ConfigParser.set, which applies pyformat + # interpolation on read — a raw % in DATABASE_URL (from DB_PASSWORD) would + # raise InterpolationSyntaxError on the first `alembic upgrade head`. + sed -i.bak 's|^config = context.config$|config = context.config\n\nconfig.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"].replace("%", "%%"))|' \ migrations/env.py || return 1 rm -f migrations/env.py.bak grep -q '^import os$' migrations/env.py \ - && grep -q 'config.set_main_option("sqlalchemy.url", os.environ\["DATABASE_URL"\])' migrations/env.py \ + && grep -q 'config.set_main_option("sqlalchemy.url", os.environ\["DATABASE_URL"\].replace("%", "%%"))' migrations/env.py \ || die "could not point alembic at DATABASE_URL — has alembic init's generated env.py changed shape?" uv run ruff check --fix migrations/env.py || return 1 From 30578f55e005c113196de6320e03cd6948ed1afc Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 14 Sep 2026 00:30:58 +0700 Subject: [PATCH 12/16] test: run flask's own ci-unit and check its database splice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other tier A smoke suite already asserts the generated app passes its own ci-unit; flask's whole promise is staying green through dependency bumps and had no such test. Also asserts the generated app is wired to its database — the DATABASE_URL the mysql driver writes, the spliced health probe with the unconfigured sentinel gone, and no leftover @SERVICE_SETUP@ anchor — the test that would have caught the vacuous splice guard. --- tests/new-flask.bats | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/new-flask.bats b/tests/new-flask.bats index 7fac787..617854f 100644 --- a/tests/new-flask.bats +++ b/tests/new-flask.bats @@ -54,3 +54,26 @@ teardown() { assert_ok [[ "$output" == *"unrs-resolver"* ]] } + +@test "the generated api passes its own ci-unit" { + scaffold new "$PROJECT" --api flask + cd "$PROJECT" + run mise run //apps/api:ci-unit + assert_ok +} + +@test "the api is configured for the project's database" { + scaffold new "$PROJECT" --api flask + run grep -q '^DATABASE_URL=mysql+pymysql://' "${PROJECT}/apps/api/.env.example" + assert_ok + + # The sentinel the unconfigured probe raises — still present means the + # splice silently left the readiness check permanently unavailable. + run grep -q 'no database is configured for this project' "${PROJECT}/apps/api/app/health.py" + [ "$status" -ne 0 ] + run grep -q 'create_engine' "${PROJECT}/apps/api/app/health.py" + assert_ok + + run grep -q '@SERVICE_SETUP@' "${PROJECT}/apps/api/Dockerfile" + [ "$status" -ne 0 ] +} From 5eae4297b547069b8fbc8b0c32a6b8fceb64fe14 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 14 Sep 2026 00:31:08 +0700 Subject: [PATCH 13/16] fix(adapters): pin the deps stage to .python-version, chown only what runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv resolved the deps stage's interpreter against requires-python alone, withholding the same .python-version pin the rest of the adapter's story depends on; it could pick a managed interpreter the runtime image's system python does not match. chown -R app:app /app also made the source tree and venv writable by the process that runs them for no reason this adapter has — nothing under /app is written at runtime, so narrow it to none rather than laravel-api's targeted chown of the paths that actually need it. --- adapters/flask/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adapters/flask/Dockerfile b/adapters/flask/Dockerfile index 0e9be79..9297939 100644 --- a/adapters/flask/Dockerfile +++ b/adapters/flask/Dockerfile @@ -5,7 +5,7 @@ # slim-trixie, so this keeps both stages on the same Debian release. FROM ghcr.io/astral-sh/uv:0.12.13-python3.13-trixie-slim@sha256:c0ba49559fc5622531fd05a5747b52afb49ffa883574bbf8eb719ebd103efb84 AS deps WORKDIR /app -COPY pyproject.toml uv.lock ./ +COPY pyproject.toml uv.lock .python-version ./ RUN uv sync --locked --no-dev --no-install-project FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 AS runtime @@ -14,7 +14,7 @@ WORKDIR /app COPY --from=deps /app/.venv ./.venv COPY . . ENV PATH="/app/.venv/bin:${PATH}" -RUN useradd --create-home --uid 10001 app && chown -R app:app /app +RUN useradd --create-home --uid 10001 app USER app EXPOSE 8080 # python, not wget or curl: the slim image ships neither. From b8e971d73cbc267946ac5655c95c17d8da9cd38f Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 14 Sep 2026 00:31:18 +0700 Subject: [PATCH 14/16] fix(adapters): let migrate no-op on --db none, note who writes the DB vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mise.toml's migrate task assumed alembic.ini or app/migrate.py was always present and died with ModuleNotFoundError when neither is — legitimate for --db none or a cache-only project. Give it a third arm that says there is nothing to migrate and exits 0. .env.example also lacked the note laravel-api, laravel-inertia and nestjs all carry, that the database variables come from the selected service's driver. --- adapters/flask/.env.example | 1 + adapters/flask/mise.toml | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/adapters/flask/.env.example b/adapters/flask/.env.example index 0812081..9a32ea8 100644 --- a/adapters/flask/.env.example +++ b/adapters/flask/.env.example @@ -1,2 +1,3 @@ # off by default: Flask's debug console executes arbitrary code from the browser FLASK_DEBUG=0 +# the database variables are written by the selected service's driver diff --git a/adapters/flask/mise.toml b/adapters/flask/mise.toml index 2dc57f2..286fdf4 100644 --- a/adapters/flask/mise.toml +++ b/adapters/flask/mise.toml @@ -27,10 +27,10 @@ run = "uv run pytest -q" run = "uv sync --locked --no-dev; status=$?; uv sync --locked --quiet; exit $status" [tasks.migrate] -# alembic.ini exists only for postgres/mysql; mongodb ships app/migrate.py -# instead, and --db none ships neither, matching compose.yaml's own migrate -# service, which likewise exists for exactly those two shapes. -run = "if [ -f alembic.ini ]; then uv run alembic upgrade head; else uv run python -m app.migrate; fi" +# alembic.ini exists only for postgres/mysql, app/migrate.py only for mongodb; +# --db none or a cache-only project ships neither, so this must not assume one +# of the two is always there. +run = "if [ -f alembic.ini ]; then uv run alembic upgrade head; elif [ -f app/migrate.py ]; then uv run python -m app.migrate; else echo 'nothing to migrate'; fi" [tasks.ci-unit] run = [ From e55c9c30534085958a6ab77fd5db76b39d89b0f4 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 14 Sep 2026 00:31:26 +0700 Subject: [PATCH 15/16] fix(services): rename FLASK_PACKAGES to FLASK_PACKAGE, singular MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's consumed as a single uv add argument — "$FLASK_PACKAGES" would pass "a b" as one bogus package name the day a service needs two. Matches the sibling LARAVEL_PACKAGE and the header comment, which was already singular. --- services/mysql/drivers/flask.sh | 2 +- services/postgres/drivers/flask.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/mysql/drivers/flask.sh b/services/mysql/drivers/flask.sh index 9ee4675..df7179c 100644 --- a/services/mysql/drivers/flask.sh +++ b/services/mysql/drivers/flask.sh @@ -7,7 +7,7 @@ # shellcheck disable=SC2034 # read by services/shared/flask.sh, sourced below FLASK_DIALECT="mysql+pymysql" # pure python, so no build stage and no system package -FLASK_PACKAGES="PyMySQL" +FLASK_PACKAGE="PyMySQL" FLASK_PORT="3306" FLASK_COMPOSE_URL='mysql+pymysql://${DB_USERNAME:-app}:${DB_PASSWORD}@database:3306/${DB_DATABASE:-app}' # shellcheck source=/dev/null diff --git a/services/postgres/drivers/flask.sh b/services/postgres/drivers/flask.sh index 63454ed..d9e3805 100644 --- a/services/postgres/drivers/flask.sh +++ b/services/postgres/drivers/flask.sh @@ -8,7 +8,7 @@ FLASK_DIALECT="postgresql+psycopg" # the [binary] extra ships a wheel with libpq inside, so the image needs no # system package and service_driver_dockerfile stays empty -FLASK_PACKAGES="psycopg[binary]" +FLASK_PACKAGE="psycopg[binary]" FLASK_PORT="5432" FLASK_COMPOSE_URL='postgresql+psycopg://${DB_USERNAME:-app}:${DB_PASSWORD}@database:5432/${DB_DATABASE:-app}' # shellcheck source=/dev/null From 5f89b25beaf42085a8325f9ee085b29775b6504f Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 14 Sep 2026 00:31:34 +0700 Subject: [PATCH 16/16] docs: list flask among the driver families services ship flask is a fourth adapter family alongside laravel, nest and next; the services section's driver-per-family sentence had not been updated to say so. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2adaf77..932069e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ from each adapter's own `ADAPTER_TIER` (`adapters/*/adapter.env`) — see A database or cache is a directory under `services/`, not an adapter — see [ADR-0019](docs/decisions/0019-services-are-not-adapters.md). Each ships a -driver per adapter family (`laravel`, `nest`, `next`); `scaffold lint` +driver per adapter family (`laravel`, `nest`, `next`, `flask`); `scaffold lint` requires the full matrix before an adapter in a new family can merge. | Slot | Services | Default |