Skip to content

feat: add a flask adapter - #20

Merged
ttncode merged 16 commits into
mainfrom
feat/flask-adapter
Sep 13, 2026
Merged

ttncode merged 16 commits into
mainfrom
feat/flask-adapter

Conversation

@ttncode

@ttncode ttncode commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What this changes

scaffold new demo --api flask now produces a Python project. It is the fifth
adapter and the first Python one, so it brings a new ADAPTER_FAMILY and, with
it, a driver in every service.

The adapter (adapters/flask/) is generated by uv init --bare, which
writes one file — pyproject.toml — and leaves everything else to the overlay,
the way every other adapter works. The default uv init template was not
usable: it writes a .git directory inside the application, a src/<dir-name>
package keyed to the directory it lands in, a README.md and a
[project.scripts] entry, four of which the overlay would have had to delete.

The application is Flask's own documented shape — an application factory and one
blueprint — with gunicorn in front of it, and install, format, format-fix,
lint, check, test, build, ci-unit and checklist implemented through
uv, ruff, mypy --strict and pytest. The toolchain comes from
immich/machine-learning/mise.toml, immich's only Python service, which already
writes the ADR-0011 vocabulary. The framework does not: immich runs FastAPI.

Python is pinned by .python-version, not by mise.toml. uv resolves its
own managed interpreter, 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 takes php from outside (ADR-0016), and it keeps
the root mise.toml unaware the project contains Python. No CI step and no
ambient installation is needed: ADAPTER_GENERATOR reaches uv through
mise x uv@0.12.13, so nothing had to be added to .github/workflows/.

Four drivers, not three. The intent was to ship the SQL pair plus redis and
add mongodb later, but lint_services (lib/lint.sh:207) collects every family
any adapter declares and fails a service that has no driver for one of them.
Declaring a combination unsupported would have been new mechanism; the pymongo
driver was less code. The split copies laravel exactly: a shared SQLAlchemy
body for postgres and mysql, mongodb and redis self-contained.

The plan was wrong about migrations, and this fixes it forward. Both said
service_driver_compose_migrate prints nothing, on the reasoning that this
adapter ships no models. common/install.sh's run_migrations — the installer
that ships to every generated project — refuses to start a stack that has a
database service and no migrate service ("refusing to start with unapplied
schema"), and scripts/deploy-check.sh:298 enforces the same invariant under
ADR-0021. So a flask project with a database would not have started at all, and
a client would have had to add migrations by hand on day one, which no other
adapter requires. Neither gate was weakened: postgres and mysql get alembic, and
mongodb ships app/migrate.py and runs it — the same honesty as nest+mongodb's
prisma db push --skip-generate against an empty schema, plus a named seam for a
client's own indexes.

ADAPTER_TIER is A, measured rather than assumed, per ADR-0012:
tests/new-flask.bats runs in 3m47s against tests/new-laravel-api.bats's
8m58s.

How it was verified

Structural checks, run here rather than taken on report:

  • mise run lint and ./scaffold lint — clean. scaffold lint is the one that
    proves all four drivers define the four required functions, including the
    mongodb driver, which inherits two of them from the shared body it sources.
  • mise run test-runner — both lanes under CI=true GITHUB_ACTIONS=true MISE_YES=1, the environment a runner actually has.
  • bats tests/new-flask.bats — 8 tests. Two of them were added after review:
    the generated application running its own ci-unit, which every sibling suite
    has and this one did not, and an assertion that the api is wired to the
    project's database. The second is what would have caught the splice guard
    below.
  • ./scripts/deploy-check.sh flask against postgres, mysql and mongodb — each
    builds the image, starts the released stack, applies migrations, and gets 200
    from both /health/live and /health/ready.
  • Regenerated one project per database and ran each application's own ci-unit
    — ruff, mypy --strict and pytest against the files the drivers spliced, not
    only against the files the adapter ships.

Four defects the review found and this branch fixes, recorded because each is
the kind that passes every check until it reaches a client:

  • splice_flask_probe's verification grep could never fail — it looked for
    return jsonify(status="ok"), which the live() handler already contains
    before any splice runs. It now proves the unconfigured sentinel is gone. A
    drifted raise line would otherwise have shipped a readiness probe that was
    permanently 503, silently. services/shared/nest.sh:96 has the identical hole
    for the identical reason and is deliberately untouched here.
  • A % in a generated DB_PASSWORD broke alembic upgrade head:
    set_main_option writes through ConfigParser, which applies pyformat
    interpolation on read. Escaped.
  • The Dockerfile's deps stage never copied .python-version, so the one place
    the interpreter pin was withheld was the image build.
  • chown -R app:app /app made the application's own source and venv writable by
    the process running it. Removed — nothing under /app is written at runtime.

Checklist

  • mise run lint passes
  • mise run test-runner passes
  • New behaviour has a test that fails without the change
  • Docs that describe changed behaviour were updated in the same commit
  • No unrelated changes

iam-truongtrungnghia added 16 commits September 13, 2026 22:36
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.
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.
flask (2m9s) is faster than laravel-api (9m28s), so ADAPTER_TIER stays A.
…ext 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.
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.
… runs

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.
… vars

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.
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.
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.
@ttncode
ttncode merged commit cae279e into main Sep 13, 2026
21 checks passed
@ttncode
ttncode deleted the feat/flask-adapter branch September 13, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant