Report kept env.py failures and skip empty autogenerated revisions

MA
15s ago
+516-42

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • This change requires a documentation update

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

#7797, which this PR stacked on, is merged; the PR is rebased onto main.

Fixes FINDING-005, FINDING-006 and FINDING-017 from the reflex 0.11.0a1 pre-release testing (artifacts on branch claude/vibrant-pasteur-3921g3, prerelease-testing/runs/2026-11-11-v0.11.0a1/). FINDING-012 is fixed separately in #7815, which is also stacked on #7797.

Defect

  • FINDING-005 (MEDIUM, regression): Say a 0.10.x user edited alembic/env.py by hand (target_metadata = SQLModel.metadata) and left the driver:// placeholder URL in alembic.ini. 0.11 keeps that file, because legacy detection only looks for the literal target_metadata = None, and then runs it. The result: reflex run dies at startup with NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver, reflex db status/migrate/makemigrations exit 1 with a raw traceback, and reflex db init only says "already initialized". 0.10.1 never ran env.py and worked. The stock env.py also calls fileConfig(alembic.ini), which disables every reflex.* logger, so Reflex's own messages vanished for the rest of the process.
  • FINDING-006 (MEDIUM, regression): With such a kept env.py, every reflex db makemigrations wrote an empty pass revision. rx.model.migrate(autogenerate=True) at import chained several of them on each dev start, because only the template env.py skipped empty revisions.
  • FINDING-017 (LOW): A second legacy env.py replacement silently overwrote alembic/env.old.

Fix

  • migrations.env_kind() sorts env.py into one of three kinds:

    • template: it contains the docstring marker Generated by reflex db init . Every template since 0.11.0a1 has it, so 0.11.0a1 templates count as the template.
    • legacy: it contains target_metadata = None.
    • custom: anything else.

    check_env() logs a warning once per process (via the dedupe log filter) when env.py is custom: "alembic/env.py is not the Reflex template, so what the template adds may be missing: ... Move it aside and run reflex db init to adopt the template; existing revisions are kept."

  • Any exception from an alembic run, not only CommandError, is now reported by migrations.report_failure(). The message is Alembic failed with <Type>: <message>. When the failure comes from a custom env.py itself (the innermost traceback frame in the alembic script directory is env.py), it adds the hint "alembic/env.py is not the Reflex template: move it aside and run reflex db init to write the current template." and the traceback goes to the debug log. Any other failure, such as a failing revision script under either env.py, is logged with its traceback and without the hint. Each caller handles it differently:

    • check_schema_up_to_date (startup and after db migrate) logs it, and the app still starts. This restores the 0.10.1 behaviour of starting.
    • _alembic_errors (reflex db ...) exits 1 with the message.
    • migrate() logs it and returns False when env.py is custom. Under the template it still raises: there the error is real, and a script calling migrate() must see it.
  • migrations.run() restores the root handlers and level after each run, and re-enables the loggers that an env.py's fileConfig disabled.

  • migrations.autogenerate() runs alembic check first and writes a revision only on AutogenerateDiffsDetected. This is what 0.10.1 and the deprecated alembic_autogenerate already did. reflex db makemigrations, migrate(autogenerate=True) and alembic_autogenerate now all use it, so the result no longer depends on env.py. When nothing changed, makemigrations logs "No model changes detected; no revision was written."

  • update_legacy_env() saves the backup under the first free name: env.old, then env.old.1, and so on.

  • Docs: docs/database/overview.md (upgrade note and the empty-revision sentence).

Known behaviour

These were confirmed by the release owner.

  • With a custom env.py, migrate() logs and continues. An import-time rx.model.migrate() logs any non-CommandError failure of a custom env.py and returns False instead of raising, so the app still starts. That includes a failing revision script, which is logged with its traceback. Explicit callers who need the error can check the False return. Under the template env.py, migrate() raises as before.
  • No hint for a failing template env.py. The message names the exception only. Moving the template aside and re-running reflex db init would just write the same file again.

Test plan

New tests in tests/units/utils/test_migrations.py use real alembic and sqlite. They are parametrized over sqlmodel and sqlalchemy where the fixture supports it. All of them failed before the fix:

FAILED test_custom_env_failure_is_reported_with_the_fix        (NoSuchModuleError raised out of get_compiled_app)
FAILED test_template_env_failure_is_reported_without_the_hint  (RuntimeError raised out of reflex db migrate)
FAILED test_custom_env_warns_once_that_it_is_not_the_template[sqlmodel|sqlalchemy]
FAILED test_custom_env_writes_no_empty_revision[sqlmodel|sqlalchemy]   (2nd makemigrations: empty revision 1 not applied -> exit 1; no log captured, fileConfig had removed the handlers)
FAILED test_custom_env_import_time_autogenerate_writes_no_empty_revision   (assert 4 == 1 revisions)
FAILED test_second_legacy_env_keeps_the_first_backup             (env.old held "# third")

All of them pass after the fix.

test_revision_script_failure_keeps_its_traceback[template|custom] covers a revision script whose upgrade() raises: the error is logged with its traceback and without the hint. Both cases failed before the third commit.

CI fix (second commit): the first push failed test_custom_env_writes_no_empty_revision on the Windows legs and on ubuntu 3.14, with near "ALTER": syntax error. Under the stock env.py, which has no render_as_batch, that test's model change produced a plain ALTER TABLE thing ALTER COLUMN t1 DROP NOT NULL. Only recent SQLite builds accept that statement: the SQLite 3.53 in uv's Pythons does, while the system Pythons on those runners have an older build. I reproduced it locally with SQLite 3.45.1 (/usr/bin/python3.12). The test now adds a nullable column and asserts the revision has no alter_column. It passes on SQLite 3.45 and 3.53. Product code is unaffected: the Reflex template uses batch mode.

  • uv run pytest tests/units --cov (without tests/units/reflex_bench/drivers/test_browser.py, since there is no Chromium in this sandbox): 15652 passed, 66 skipped, coverage 81.45%. After the rebase onto 93beac9a3, the migration and model tests pass again: 50/50 locally, and test_migrations 37/37 on SQLite 3.45.
  • uv run ruff check . and uv run ruff format --check . are clean. uv run pyright reflex tests gives 0 errors.
  • End to end: I adapted the verifier's verify_dbu/scripts/run_all.sh to an editable install of this branch, using form-designer seeded on 0.10.1:
    • Stock env.py: reflex run comes up, env.old matches 0.10.1, and no revision is written.
    • Edited env.py with the placeholder URL, with or without import-time migrate(): reflex run comes UP (8–10 s) with no traceback, and logs the warning plus Alembic failed with NoSuchModuleError ... move it aside and run reflex db init. db status/migrate/makemigrations exit 1 with the same message and no traceback. db init shows the warning before "already initialized". The remedy (move it aside, then db init) works.
    • Edited env.py with a working URL: two makemigrations add 0 revisions (before: 2). migrate(autogenerate=True) at import plus reflex run adds 0 (before: 3). The railway-style env.py adds 0 (before: 2).
    • Second legacy replacement: env.old keeps the custom edit and env.old.1 holds the stock file.

Notes

  • makemigrations now runs env.py twice when there are changes (check, then revision), as 0.10.1 did.
  • The "not the template" warning is once per process, not persisted. reflex run shows it in the main process and again in the backend worker.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01Dxjadq3qkhKnqZR2b9WBFi