Report kept env.py failures and skip empty autogenerated revisions
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.pyby hand (target_metadata = SQLModel.metadata) and left thedriver://placeholder URL in alembic.ini. 0.11 keeps that file, because legacy detection only looks for the literaltarget_metadata = None, and then runs it. The result:reflex rundies at startup withNoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver,reflex db status/migrate/makemigrationsexit 1 with a raw traceback, andreflex db initonly says "already initialized". 0.10.1 never ran env.py and worked. The stock env.py also callsfileConfig(alembic.ini), which disables everyreflex.*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 makemigrationswrote an emptypassrevision.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 markerGenerated byreflex db init. Every template since 0.11.0a1 has it, so 0.11.0a1 templates count as the template.legacy: it containstarget_metadata = None.custom: anything else.
check_env()logs a warning once per process (via thededupelog 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 bymigrations.report_failure(). The message isAlembic failed with <Type>: <message>. When the failure comes from a custom env.py itself (the innermost traceback frame in the alembic script directory isenv.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 afterdb 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 returnsFalsewhen env.py is custom. Under the template it still raises: there the error is real, and a script callingmigrate()must see it.
-
migrations.run()restores the root handlers and level after each run, and re-enables the loggers that an env.py'sfileConfigdisabled. -
migrations.autogenerate()runsalembic checkfirst and writes a revision only onAutogenerateDiffsDetected. This is what 0.10.1 and the deprecatedalembic_autogeneratealready did.reflex db makemigrations,migrate(autogenerate=True)andalembic_autogeneratenow all use it, so the result no longer depends on env.py. When nothing changed,makemigrationslogs "No model changes detected; no revision was written." -
update_legacy_env()saves the backup under the first free name:env.old, thenenv.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-timerx.model.migrate()logs any non-CommandErrorfailure of a custom env.py and returnsFalseinstead 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 theFalsereturn. 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 initwould 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(withouttests/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 .anduv run ruff format --check .are clean.uv run pyright reflex testsgives 0 errors.- End to end: I adapted the verifier's
verify_dbu/scripts/run_all.shto an editable install of this branch, using form-designer seeded on 0.10.1:- Stock env.py:
reflex runcomes 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 runcomes UP (8β10 s) with no traceback, and logs the warning plusAlembic failed with NoSuchModuleError ... move it aside and run reflex db init.db status/migrate/makemigrationsexit 1 with the same message and no traceback.db initshows the warning before "already initialized". The remedy (move it aside, thendb init) works. - Edited env.py with a working URL: two
makemigrationsadd 0 revisions (before: 2).migrate(autogenerate=True)at import plusreflex runadds 0 (before: 3). The railway-style env.py adds 0 (before: 2). - Second legacy replacement:
env.oldkeeps the custom edit andenv.old.1holds the stock file.
- Stock env.py:
Notes
makemigrationsnow 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 runshows it in the main process and again in the backend worker.
π€ Generated with Claude Code