From e92497cff36e7815bb6675fa83fd277c9f02b7fe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 5 Nov 2026 10:34:14 -0500 Subject: [PATCH] test: set Celery config through a celery_conf fixture, not monkeypatch pytest 9.2.0 (released 2026-10-27, changelog #10644) changed how monkeypatch.setattr restores an attribute the object does not hold in its own __dict__: undo now deletes it instead of assigning the old value back. Celery's conf serves every key through __getattr__, so undo called delattr, the delete raised AttributeError, and pytest skipped the rest of that test's undos. That made 53 teardown errors across six files (cleanup API, task-run signals, tasks admin, backup, library audit, the soft-limit salvage test), and could leak patches into later tests. CI installs pytest unpinned, so the first run after the release picked it up: the weekly base refresh of main, 2026-11-01, and every run since. The gate held, so nothing was published. tests/conftest.py gains `celery_conf(**values)`: it records each key's value through conf[key], writes with conf.update(), and restores the saved values after the test. The six files use it. Task #6864. Co-Authored-By: Claude Opus 5.5 --- tests/conftest.py | 23 +++++++++++++++++++++++ tests/test_api_cleanup.py | 5 ++--- tests/test_download_source_task.py | 5 ++--- tests/test_task_run_signals.py | 5 ++--- tests/test_tasks_admin.py | 5 ++--- tests/test_tasks_backup.py | 6 ++---- tests/test_tasks_library_audit.py | 13 ++++++------- 7 files changed, 39 insertions(+), 23 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1877e1e..5157dfa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,6 +85,29 @@ def db_sync(sync_engine): session.rollback() +@pytest.fixture +def celery_conf(): + """Set Celery config keys for one test: `celery_conf(task_always_eager=True)`. + The values they had before the test come back after it. + + Use this, not `monkeypatch.setattr(celery.conf, ...)`. Celery serves config + keys through `__getattr__`, so monkeypatch sees no attribute to restore and + deletes the key on undo instead; the delete raises, and every later undo of + that test is skipped, leaking its patches into the tests after it. + """ + from backend.app.celery_app import celery + + saved: dict[str, object] = {} + + def _set(**values: object) -> None: + for key in values: + saved.setdefault(key, celery.conf[key]) + celery.conf.update(values) + + yield _set + celery.conf.update(saved) + + @pytest_asyncio.fixture async def app(): return create_app() diff --git a/tests/test_api_cleanup.py b/tests/test_api_cleanup.py index 2456101..0fa8ccb 100644 --- a/tests/test_api_cleanup.py +++ b/tests/test_api_cleanup.py @@ -11,15 +11,14 @@ import pytest from PIL import Image from sqlalchemy import func, select -from backend.app.celery_app import celery from backend.app.models import ImageRecord, LibraryAuditRun pytestmark = pytest.mark.integration @pytest.fixture(autouse=True) -def disable_celery_eager(monkeypatch): - monkeypatch.setattr(celery.conf, "task_always_eager", False) +def disable_celery_eager(celery_conf): + celery_conf(task_always_eager=False) def _sha256_min_dim_token(min_w: int, min_h: int) -> str: diff --git a/tests/test_download_source_task.py b/tests/test_download_source_task.py index 6f31167..07582d6 100644 --- a/tests/test_download_source_task.py +++ b/tests/test_download_source_task.py @@ -167,7 +167,7 @@ async def test_finalize_soft_limited_is_noop_without_running_event(db_sync): @pytest.mark.integration @pytest.mark.asyncio async def test_download_source_catches_soft_limit_and_salvages_event( - db_sync, monkeypatch, + db_sync, monkeypatch, celery_conf, ): """End-to-end wiring: when the inner run raises SoftTimeLimitExceeded, download_source's handler must flip the in-flight event to error @@ -178,8 +178,7 @@ async def test_download_source_catches_soft_limit_and_salvages_event( import backend.app.tasks.download as dl from backend.app.models import DownloadEvent - monkeypatch.setattr(celery.conf, "task_always_eager", True) - monkeypatch.setattr(celery.conf, "task_eager_propagates", False) + celery_conf(task_always_eager=True, task_eager_propagates=False) # This test exercises the soft-limit salvage path, not the per-platform # serialization. Neutralize the Redis lock so it can't defer/recurse under # eager mode (and so it doesn't contend with test_platform_lock). diff --git a/tests/test_task_run_signals.py b/tests/test_task_run_signals.py index 991debd..7de5e04 100644 --- a/tests/test_task_run_signals.py +++ b/tests/test_task_run_signals.py @@ -20,10 +20,9 @@ pytestmark = pytest.mark.integration @pytest.fixture(autouse=True) -def _eager_celery(monkeypatch): +def _eager_celery(celery_conf): """task_always_eager so signals fire in-process during the test.""" - monkeypatch.setattr(celery.conf, "task_always_eager", True) - monkeypatch.setattr(celery.conf, "task_eager_propagates", False) + celery_conf(task_always_eager=True, task_eager_propagates=False) @pytest.mark.asyncio diff --git a/tests/test_tasks_admin.py b/tests/test_tasks_admin.py index ed54402..586bf3b 100644 --- a/tests/test_tasks_admin.py +++ b/tests/test_tasks_admin.py @@ -15,9 +15,8 @@ pytestmark = pytest.mark.integration @pytest.fixture(autouse=True) -def _eager_celery(monkeypatch): - monkeypatch.setattr(celery.conf, "task_always_eager", True) - monkeypatch.setattr(celery.conf, "task_eager_propagates", False) +def _eager_celery(celery_conf): + celery_conf(task_always_eager=True, task_eager_propagates=False) @pytest.fixture(autouse=True) diff --git a/tests/test_tasks_backup.py b/tests/test_tasks_backup.py index 583a202..c179b41 100644 --- a/tests/test_tasks_backup.py +++ b/tests/test_tasks_backup.py @@ -10,16 +10,14 @@ from pathlib import Path import pytest from sqlalchemy import select -from backend.app.celery_app import celery from backend.app.models import BackupRun, ImportSettings pytestmark = pytest.mark.integration @pytest.fixture(autouse=True) -def _eager_celery(monkeypatch): - monkeypatch.setattr(celery.conf, "task_always_eager", True) - monkeypatch.setattr(celery.conf, "task_eager_propagates", False) +def _eager_celery(celery_conf): + celery_conf(task_always_eager=True, task_eager_propagates=False) @pytest.fixture(autouse=True) diff --git a/tests/test_tasks_library_audit.py b/tests/test_tasks_library_audit.py index f26d528..17d3ce2 100644 --- a/tests/test_tasks_library_audit.py +++ b/tests/test_tasks_library_audit.py @@ -9,7 +9,6 @@ from PIL import Image from sqlalchemy import select import backend.app.tasks.library_audit # noqa: F401 — celery registration -from backend.app import celery_app from backend.app.models import ImageRecord, LibraryAuditRun pytestmark = pytest.mark.integration @@ -37,7 +36,7 @@ def _mk_image(db_sync, tmp_path, *, mode, color, name): def test_scan_library_for_rule_populates_matched_ids_for_transparency( - db_sync, tmp_path, monkeypatch, + db_sync, tmp_path, celery_conf, ): transparent_rec, _ = _mk_image( db_sync, tmp_path, mode="RGBA", color=(0, 0, 0, 0), name="trans.png", @@ -53,10 +52,10 @@ def test_scan_library_for_rule_populates_matched_ids_for_transparency( db_sync.commit() audit_id = audit.id - monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", True) + celery_conf(task_always_eager=True) from backend.app.tasks.library_audit import scan_library_for_rule scan_library_for_rule.run(audit_id) - monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", False) + celery_conf(task_always_eager=False) matched = db_sync.execute( select(LibraryAuditRun.matched_ids).where(LibraryAuditRun.id == audit_id) @@ -124,7 +123,7 @@ def test_scan_resumes_and_accumulates_matched(db_sync, tmp_path, monkeypatch): def test_scan_library_for_rule_skips_missing_files_gracefully( - db_sync, tmp_path, monkeypatch, + db_sync, tmp_path, celery_conf, ): rec, path = _mk_image( db_sync, tmp_path, mode="RGBA", color=(0, 0, 0, 0), name="ghost.png", @@ -138,10 +137,10 @@ def test_scan_library_for_rule_skips_missing_files_gracefully( db_sync.commit() audit_id = audit.id - monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", True) + celery_conf(task_always_eager=True) from backend.app.tasks.library_audit import scan_library_for_rule scan_library_for_rule.run(audit_id) - monkeypatch.setattr(celery_app.celery.conf, "task_always_eager", False) + celery_conf(task_always_eager=False) status = db_sync.execute( select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit_id)