| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- """Archives written before #2989 get their bed temperature read back off disk.
- The extractor looked for a ``bed_temperature`` key BambuStudio does not write,
- so every archive from a Bambu slice stored NULL -- 0 of 455 real 3MFs resolved
- on the install this was measured on. The forward fix reads the array the fitted
- plate points at, but only for archives made after it; everything already in the
- library stays blank, and preheat keeps falling back to the keep-warm bed
- temperature whenever one of those jobs is reprinted from the queue.
- This one-shot re-reads the 3MF that is already on disk. It fills NULLs and
- nothing else: no value is invented, none is overwritten, and an archive whose
- file is gone stays NULL rather than being guessed at.
- The 3MFs here are real zips rather than a patched extractor, because
- ``extract_bed_temperature_from_3mf`` is itself new code and stubbing it would
- leave the only thing this migration depends on untested.
- """
- import json
- import logging
- import zipfile
- import pytest
- from sqlalchemy import text
- from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- from backend.app.core import database as database_module
- from backend.app.core.database import Base, _backfill_archive_bed_temperature
- from backend.app.models.archive import PrintArchive
- # A Textured PEI slice of a two-filament project, in the shape BambuStudio
- # writes: an array per plate type, and the fitted plate named separately.
- _PEI_55 = {
- "curr_bed_type": "Textured PEI Plate",
- "cool_plate_temp": ["0", "0"],
- "eng_plate_temp": ["0", "0"],
- "hot_plate_temp": ["0", "0"],
- "textured_plate_temp_initial_layer": ["55", "55"],
- "textured_plate_temp": ["55", "55"],
- "supertack_plate_temp": ["0", "0"],
- }
- @pytest.fixture
- async def engine(tmp_path):
- eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
- async with eng.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- try:
- yield eng
- finally:
- await eng.dispose()
- @pytest.fixture
- def data_dir(tmp_path, monkeypatch):
- monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
- return tmp_path
- def _write_3mf(data_dir, relative: str, config: dict | None) -> str:
- """A 3MF on disk. ``config=None`` writes a file that is not a zip at all."""
- path = data_dir / relative
- path.parent.mkdir(parents=True, exist_ok=True)
- if config is None:
- path.write_bytes(b"not a zip")
- return relative
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr("Metadata/project_settings.config", json.dumps(config))
- return relative
- async def _archive(db, file_path: str, *, bed_temperature=None) -> PrintArchive:
- archive = PrintArchive(
- filename="Benchy.gcode.3mf",
- file_path=file_path,
- file_size=1,
- status="completed",
- bed_temperature=bed_temperature,
- )
- db.add(archive)
- await db.flush()
- return archive
- async def _bed_temperature(engine, archive_id: int):
- async with engine.begin() as conn:
- return (
- await conn.execute(text("SELECT bed_temperature FROM print_archives WHERE id = :id"), {"id": archive_id})
- ).scalar_one()
- class TestItFillsWhatItCan:
- @pytest.mark.asyncio
- async def test_a_null_is_read_from_the_plate_the_project_is_sliced_for(self, engine, data_dir):
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- relative = _write_3mf(data_dir, "archive/1/20260828_Benchy/Benchy.gcode.3mf", _PEI_55)
- archive = await _archive(db, relative)
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) == 55
- @pytest.mark.asyncio
- async def test_an_orca_export_still_resolves(self, engine, data_dir):
- """The generic spelling is the fallback, not a second-class citizen."""
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", {"bed_temperature": 60})
- archive = await _archive(db, relative)
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) == 60
- @pytest.mark.asyncio
- async def test_several_archives_in_one_pass(self, engine, data_dir):
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
- second = await _archive(
- db,
- _write_3mf(
- data_dir, "archive/1/b/y.3mf", {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["100"]}
- ),
- )
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, first.id) == 55
- assert await _bed_temperature(engine, second.id) == 100
- class TestWhatItRefusesToTouch:
- @pytest.mark.asyncio
- async def test_a_value_already_recorded_is_left_alone(self, engine, data_dir):
- """Only NULLs. A temperature somebody set, or one a later archive read
- correctly, must not be rewritten from the file."""
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", _PEI_55)
- archive = await _archive(db, relative, bed_temperature=90)
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) == 90
- @pytest.mark.asyncio
- async def test_a_no_3mf_archive_stays_null(self, engine, data_dir):
- """``file_path == ""`` is the ordinary shape of a Studio-sent H2 print.
- There is no file to read, and inventing one is the whole bug."""
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- archive = await _archive(db, "")
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) is None
- @pytest.mark.asyncio
- async def test_a_file_that_is_gone_stays_null(self, engine, data_dir):
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- archive = await _archive(db, "archive/1/a/deleted.gcode.3mf")
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) is None
- @pytest.mark.asyncio
- async def test_a_file_that_is_not_a_zip_stays_null(self, engine, data_dir):
- """A truncated or corrupted 3MF must not take the whole boot down."""
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- archive = await _archive(db, _write_3mf(data_dir, "archive/1/a/broken.3mf", None))
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) is None
- @pytest.mark.asyncio
- async def test_an_all_zero_plate_array_stays_null(self, engine, data_dir):
- """0 means no filament in the project prints on this plate. Recording
- it would read as a cold bed, which is worse than nothing."""
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- relative = _write_3mf(
- data_dir,
- "archive/1/a/zero.3mf",
- {"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0", "0"]},
- )
- archive = await _archive(db, relative)
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, archive.id) is None
- class TestItCannotStopBambuddyBooting:
- """The migration sequence has no handler above it.
- ``run_migrations`` is awaited straight from ``init_db`` with no try/except,
- so anything escaping this function stops startup -- and keeps stopping it,
- because the one-shot flag is written inside the transaction that just rolled
- back. Measured: two consecutive boots, same failure, flag never written. So
- the guards here are load-bearing rather than tidy.
- """
- @pytest.mark.asyncio
- async def test_an_unexpected_exception_costs_one_archive_not_the_boot(self, engine, data_dir, monkeypatch, caplog):
- """Not a listed zip error -- the point is that the guard does not depend
- on having predicted which exception a bad file raises."""
- import backend.app.utils.threemf_tools as tools
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
- await db.commit()
- def _explode(_path):
- raise RecursionError("boom")
- monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode)
- with caplog.at_level(logging.WARNING):
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, bad.id) is None
- assert any("could not read" in r.getMessage() for r in caplog.records)
- @pytest.mark.asyncio
- async def test_one_bad_archive_does_not_stop_the_others(self, engine, data_dir, monkeypatch):
- """The guard is per row, so the rest of the library is still repaired."""
- import backend.app.utils.threemf_tools as tools
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
- good = await _archive(db, _write_3mf(data_dir, "archive/1/b/good.3mf", _PEI_55))
- await db.commit()
- real = tools.extract_bed_temperature_from_3mf
- def _explode_on_bad(path):
- if path.name == "bad.3mf":
- raise RecursionError("boom")
- return real(path)
- monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode_on_bad)
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, bad.id) is None
- assert await _bed_temperature(engine, good.id) == 55
- @pytest.mark.asyncio
- async def test_the_extractor_swallows_anything_a_file_can_throw(self, tmp_path):
- """Its callers are inside startup, so None is the only outcome."""
- from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
- missing = tmp_path / "nope.3mf"
- directory = tmp_path / "adir.3mf"
- directory.mkdir()
- truncated = tmp_path / "cut.3mf"
- truncated.write_bytes(b"PK\x03\x04 and then nothing")
- empty = tmp_path / "empty.3mf"
- empty.write_bytes(b"")
- for candidate in (missing, directory, truncated, empty):
- assert extract_bed_temperature_from_3mf(candidate) is None
- @pytest.mark.asyncio
- async def test_the_extractor_swallows_what_no_one_predicted(self, tmp_path, monkeypatch):
- """The four cases above all raise OSError or BadZipFile, so on their own
- they would still pass with the guard narrowed back to that pair. This
- one forces something outside it, which is the whole reason the catch is
- broad -- the failure being guarded is an exception nobody listed."""
- import backend.app.utils.threemf_tools as tools
- good = tmp_path / "ok.3mf"
- with zipfile.ZipFile(good, "w") as zf:
- zf.writestr("Metadata/project_settings.config", json.dumps(_PEI_55))
- assert tools.extract_bed_temperature_from_3mf(good) == 55
- class _Exploding:
- def __init__(self, *a, **k):
- raise RecursionError("boom")
- monkeypatch.setattr(tools.zipfile, "ZipFile", _Exploding)
- assert tools.extract_bed_temperature_from_3mf(good) is None
- @pytest.mark.asyncio
- async def test_a_flag_row_with_an_empty_value_does_not_re_run(self, engine, data_dir):
- """``if already:`` would treat "" as not-done, re-run, and then fail the
- unique key on the INSERT -- a boot loop from a single odd row."""
- async with engine.begin() as conn:
- await conn.execute(
- text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
- {"k": "_backfill_2989_bed_temperature_done", "v": ""},
- )
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- class TestItRunsExactlyOnce:
- @pytest.mark.asyncio
- async def test_the_flag_is_written_even_when_nothing_matched(self, engine, data_dir):
- """The rows it cannot fill are the ones it would reopen every boot."""
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- async with engine.begin() as conn:
- flag = (
- await conn.execute(
- text('SELECT value FROM settings WHERE "key" = :k'),
- {"k": "_backfill_2989_bed_temperature_done"},
- )
- ).scalar_one_or_none()
- assert flag == "true"
- @pytest.mark.asyncio
- async def test_a_second_boot_does_not_rescan(self, engine, data_dir):
- """An archive added after the one-shot has run is left to the forward
- fix, which is what writes bed_temperature for anything new."""
- sm = async_sessionmaker(engine, expire_on_commit=False)
- async with sm() as db:
- first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, first.id) == 55
- async with sm() as db:
- later = await _archive(db, _write_3mf(data_dir, "archive/1/b/y.3mf", _PEI_55))
- await db.commit()
- async with engine.begin() as conn:
- await _backfill_archive_bed_temperature(conn)
- assert await _bed_temperature(engine, later.id) is None
- # And the flag was not written twice, which the settings table's unique
- # key would refuse anyway -- the guard is the SELECT, not the database.
- async with engine.begin() as conn:
- count = (
- await conn.execute(
- text('SELECT COUNT(*) FROM settings WHERE "key" = :k'),
- {"k": "_backfill_2989_bed_temperature_done"},
- )
- ).scalar_one()
- assert count == 1
|