unsloth/studio/backend/tests/test_image_gallery.py
Daniel Han 7ec9e77a5d Studio: fix diffusion install ownership, dataset upload atomicity, gallery pagination, and teardown races
install_sd_cpp_prebuilt: only write the .unsloth-studio-owned marker when the
install created the target directory or it was empty. Adopting a pre-existing,
unowned, non-empty directory (a user's own stable-diffusion.cpp checkout) made
it eligible for the uninstaller's recursive delete.

routes/training upload: make the multi-file promotion transactional. Back up
each displaced original and roll every destination back on any failure, so a
mid-loop rename error can no longer partially overwrite the live dataset.

routes/training _resolve_dataset_folder: reject a symlinked dataset directory
and prove the resolved folder stays under the datasets root, so image
read/caption/delete cannot escape the root through a link.

routes/training delete: escape glob metacharacters in the thumbnail filename so
deleting an image named like [ab].png removes only its own thumbnails.

image_gallery / video_gallery listing: filter records against the response
schema inside the pager via a valid callback, so offset/limit/has_more all count
over accepted records. A leading schema-invalid record no longer returns an
empty page with has_more=true and stalls infinite scroll at offset 0.

image_gallery / video_gallery save: publish via a temp file plus atomic rename
(the sidecar is the video pair's commit marker) and clean up on failure, so a
partial write never surfaces a truncated PNG or strands an orphan MP4.

diffusion_train_common discovery: treat an empty caption sidecar as a metadata
tombstone that still falls through to the dreambooth instance prompt, so
clearing every metadata caption no longer fails with no captioned images found.

diffusion backend unload: wait for an in-flight denoise to exit before tearing
down process-wide patches and state, mirroring the load path.

diffusion_engine_router: serialize the whole check/unload/publish transition so
a concurrent selection cannot return the engine being unloaded.

uninstall.ps1: gate the default sd.cpp process stop on the owner marker so a
user's own sd-server is not terminated for a directory we then keep.
2026-07-13 01:22:54 +00:00

197 lines
7.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Unit tests for the disk-backed image gallery: PNG-embedded recipe round-trips,
listing order, safe id handling, and delete/clear."""
from __future__ import annotations
import base64
import io
import os
import pytest
import core.inference.image_gallery as gallery
PIL = pytest.importorskip("PIL")
from PIL import Image # noqa: E402
@pytest.fixture(autouse = True)
def _tmp_gallery(monkeypatch, tmp_path):
# Point the gallery at a throwaway root instead of ~/.unsloth/studio.
monkeypatch.setattr(gallery, "studio_root", lambda: tmp_path)
def _img(color = (10, 20, 30)):
return Image.new("RGB", (16, 16), color)
def _meta(**over):
base = {
"prompt": "a sloth",
"negative_prompt": None,
"width": 1024,
"height": 1024,
"steps": 9,
"guidance": 0.0,
"seed": 7,
"model": "unsloth/Z-Image-Turbo-GGUF",
"created_at": 100.0,
}
base.update(over)
return base
def test_save_embeds_recipe_and_round_trips():
record = gallery.save(_img(), _meta())
assert record["id"] and record["url"].endswith(f"{record['id']}/file")
# The recipe is embedded in the PNG itself (portable), not just in a sidecar.
raw = base64.b64decode(gallery.image_b64(record["id"]))
with Image.open(io.BytesIO(raw)) as im:
assert im.text["unsloth"]
assert "Negative prompt" not in im.text["parameters"] # none given
assert "Steps: 9" in im.text["parameters"]
listed = gallery.list_images()
assert len(listed) == 1
assert listed[0]["prompt"] == "a sloth" and listed[0]["seed"] == 7
def _save_with_mtime(prompt: str, t: float) -> dict:
record = gallery.save(_img(), _meta(prompt = prompt, created_at = t))
# Listing orders by mtime; set it explicitly so a tight test loop can't tie it.
os.utime(gallery.gallery_dir() / f"{record['id']}.png", (t, t))
return record
def test_list_is_newest_first():
old = _save_with_mtime("old", 100.0)
new = _save_with_mtime("new", 200.0)
assert [r["id"] for r in gallery.list_images()] == [new["id"], old["id"]]
def test_list_paginates_with_limit_offset():
# 5 images, newest (t=4) first.
for i in range(5):
_save_with_mtime(f"p{i}", float(i))
page1 = gallery.list_images(limit = 2, offset = 0)
page2 = gallery.list_images(limit = 2, offset = 2)
assert [r["prompt"] for r in page1] == ["p4", "p3"]
assert [r["prompt"] for r in page2] == ["p2", "p1"]
# limit=None still returns everything from the offset.
assert len(gallery.list_images()) == 5
assert len(gallery.list_images(offset = 4)) == 1
def test_negative_prompt_recorded_in_parameters():
record = gallery.save(_img(), _meta(negative_prompt = "blurry"))
raw = base64.b64decode(gallery.image_b64(record["id"]))
with Image.open(io.BytesIO(raw)) as im:
assert "Negative prompt: blurry" in im.text["parameters"]
def test_delete_and_clear():
a = gallery.save(_img(), _meta(prompt = "a"))
gallery.save(_img(), _meta(prompt = "b"))
assert gallery.delete(a["id"]) is True
assert gallery.delete(a["id"]) is False # already gone
assert len(gallery.list_images()) == 1
assert gallery.clear() == 1
assert gallery.list_images() == []
def test_image_path_rejects_unsafe_ids():
# Traversal / bad chars never resolve to a path.
assert gallery.image_path("../../etc/passwd") is None
assert gallery.image_path("a/b") is None
assert gallery.image_path("missing") is None
def test_list_skips_foreign_pngs(tmp_path):
# A PNG without our recipe chunk (user dropped a file) is ignored.
foreign = gallery.gallery_dir() / "foreign.png"
_img().save(foreign, format = "PNG")
gallery.save(_img(), _meta(prompt = "ours"))
listed = gallery.list_images()
assert [r["prompt"] for r in listed] == ["ours"]
def test_foreign_png_in_window_does_not_drop_valid_images():
# A foreign PNG sorting INTO the requested page must not consume a window slot and
# drop a valid image that sorts after it: paging is over readable records, not files.
_save_with_mtime("p2", 100.0)
foreign = gallery.gallery_dir() / "zzz_foreign.png"
_img().save(foreign, format = "PNG") # newest by mtime (set below), sorts first
os.utime(foreign, (300.0, 300.0))
_save_with_mtime("p1", 200.0)
# First page of 2 must still return both real images, not [p1] (foreign eating a slot).
page1 = gallery.list_images(limit = 2, offset = 0)
assert [r["prompt"] for r in page1] == ["p1", "p2"]
def test_list_skips_recipe_missing_required_fields(tmp_path):
# A PNG carrying our chunk but an incomplete/older-schema recipe (no seed etc.)
# must be skipped, not crash the whole listing when the route builds GalleryImage.
import json
from PIL.PngImagePlugin import PngInfo
info = PngInfo()
info.add_text("unsloth", json.dumps({"prompt": "partial"})) # missing width/seed/...
_img().save(gallery.gallery_dir() / "partial.png", format = "PNG", pnginfo = info)
gallery.save(_img(), _meta(prompt = "ours"))
listed = gallery.list_images()
assert [r["prompt"] for r in listed] == ["ours"]
def test_valid_callback_paginates_over_accepted_records():
# A record that passes _read_meta (every required key present) but fails the caller's stricter
# schema check must be filtered BEFORE pagination, so offset/limit/has_more all count over the
# accepted domain. Otherwise a leading bad record returns an empty/short page with more still
# remaining, and the frontend (which advances by valid records) stalls at offset 0.
_save_with_mtime("BAD", 300.0) # newest, sorts first
_save_with_mtime("g1", 200.0)
_save_with_mtime("g2", 100.0)
def _valid(rec):
return rec.get("prompt") != "BAD"
# First page of 2 over VALID records returns both good ones -- not [g1] (bad eating a slot)
# and not [] (bad filling the whole window).
page = gallery.list_images(limit = 2, offset = 0, valid = _valid)
assert [r["prompt"] for r in page] == ["g1", "g2"]
# The has_more probe (limit + 1) sees no extra VALID record beyond the two returned.
assert len(gallery.list_images(limit = 3, offset = 0, valid = _valid)) == 2
def test_valid_callback_leading_bad_record_does_not_stall_at_offset_zero():
# Reproduces the exact stall: every record in the first window is schema-invalid. Without
# in-pager filtering the route returned images=[] with has_more=True at offset 0 forever.
for i in range(3):
_save_with_mtime(f"BAD{i}", 300.0 - i) # newest three are all invalid
_save_with_mtime("good", 10.0)
def _valid(rec):
return not str(rec.get("prompt", "")).startswith("BAD")
# limit+1 = 3: the pager must look PAST the invalid leaders and return the one good record,
# so has_more (len > limit) is False and the client advances off offset 0.
records = gallery.list_images(limit = 2, offset = 0, valid = _valid)
assert [r["prompt"] for r in records] == ["good"]
def test_save_is_atomic_no_partial_png_on_publish_failure(monkeypatch):
# A crash between writing the bytes and publishing the file must leave neither a truncated
# {id}.png nor a leftover temp: the listing only ever sees fully-written records.
def _boom(*a, **k):
raise OSError("simulated rename failure")
monkeypatch.setattr(gallery.os, "replace", _boom)
with pytest.raises(OSError, match = "simulated rename failure"):
gallery.save(_img(), _meta())
# No final PNG surfaced, and the hidden temp was cleaned up.
assert list(gallery.gallery_dir().glob("*.png")) == []
assert list(gallery.gallery_dir().iterdir()) == []