Merge remote-tracking branch 'origin/feat/332-image-categories-pr1' into feat/331-collections-pr1

Keep both features: a duplicate removal moves collection memberships and
copies the image category; both migrations and schema checks run; the
legacy route snapshot now records the six collection routes beside the
image category routes.
This commit is contained in:
Vito Sansevero
2026-10-28 04:34:24 -07:00
24 changed files with 1878 additions and 12 deletions
+1
View File
@@ -270,6 +270,7 @@ def create_app(root: Path) -> web.Application:
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -181,6 +181,7 @@ _TABLES = (
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -33,6 +33,7 @@ _TABLES = (
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -85,6 +85,7 @@ _TABLES = (
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -51,6 +51,7 @@ _TABLES = (
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -119,6 +119,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
@@ -86,6 +86,7 @@ _TABLES = (
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_categories",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
+23
View File
@@ -140,3 +140,26 @@ test('an output saved without metadata links once its recorded run names a store
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
}
});
// #332 on the real host: the migration ran at startup and the image category routes
// set, list and remove an own category on a generated output.
test('an output gets its own image category and loses it again', async ({ execution }, testInfo) => {
const request = execution.request;
const filename = await saved(request, graph({ text: `category host ${randomUUID()}`, prefix: 'pm332/category' }));
let image;
await expect.poll(async () => {
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&q=pm332&rescan=1')).json();
image = (listing.images || []).find(entry => entry.filename === filename || entry.path?.endsWith(`/${filename}`));
return Boolean(image);
}, { timeout: 20000, message: `${filename} is listed by Images` }).toBe(true);
const items = [{ root_index: image.root_index, root_scope: image.root_scope, path: image.path }];
const value = `PM332 host ${testInfo.project.name}`;
const set = await request.put('/prompt_manager/images/category', { data: { items, category: value } });
expect(set.status()).toBe(200);
expect(await set.json()).toEqual({ success: true, category: value, updated: 1, unchanged: 0, not_found: 0 });
expect((await (await request.get('/prompt_manager/images/categories')).json()).categories).toContain(value);
const cleared = await request.delete('/prompt_manager/images/category', { data: { items } });
expect(cleared.status()).toBe(200);
expect(await cleared.json()).toEqual({ success: true, cleared: 1 });
expect((await (await request.get('/prompt_manager/images/categories')).json()).categories).not.toContain(value);
});
+9
View File
@@ -1,7 +1,10 @@
{
"routes": [
"DELETE /prompt_manager/collections/items",
"DELETE /prompt_manager/collections/{collection_id}",
"DELETE /prompt_manager/delete/{prompt_id}",
"DELETE /prompt_manager/image-annotations/capture/{token}",
"DELETE /prompt_manager/images/category",
"DELETE /prompt_manager/images/favorite",
"DELETE /prompt_manager/images/{image_id}",
"DELETE /prompt_manager/pairs/{pair_id}",
@@ -14,6 +17,8 @@
"GET /prompt_manager/backup",
"GET /prompt_manager/backup/status",
"GET /prompt_manager/categories",
"GET /prompt_manager/collections",
"GET /prompt_manager/collections/membership",
"GET /prompt_manager/diagnostics",
"GET /prompt_manager/export",
"GET /prompt_manager/gallery",
@@ -26,6 +31,7 @@
"GET /prompt_manager/history/runs/{run_id}/outputs",
"GET /prompt_manager/image-annotations/capture/{token}",
"GET /prompt_manager/images/all",
"GET /prompt_manager/images/categories",
"GET /prompt_manager/images/inspect/{image_path}",
"GET /prompt_manager/images/output",
"GET /prompt_manager/images/prompt/{image_path}",
@@ -90,6 +96,7 @@
"GET /prompt_manager/vocabulary/tags/{tag_id}/impact",
"GET /prompt_manager/web",
"GET /prompt_manager/workspace",
"PATCH /prompt_manager/collections/{collection_id}",
"PATCH /prompt_manager/library/{positive_id}",
"PATCH /prompt_manager/pairs/{pair_id}",
"PATCH /prompt_manager/saved-views/{view_id}",
@@ -104,6 +111,7 @@
"POST /prompt_manager/bulk/delete",
"POST /prompt_manager/bulk/tags",
"POST /prompt_manager/cleanup",
"POST /prompt_manager/collections/items",
"POST /prompt_manager/delete_duplicate_images",
"POST /prompt_manager/diagnostics/test-link",
"POST /prompt_manager/gallery/rescan",
@@ -156,6 +164,7 @@
"POST /prompt_manager/vocabulary/tags",
"POST /prompt_manager/vocabulary/tags/{tag_id}/delete",
"PUT /prompt_manager/image-annotations/tags",
"PUT /prompt_manager/images/category",
"PUT /prompt_manager/images/favorite",
"PUT /prompt_manager/prompts/{prompt_id}",
"PUT /prompt_manager/prompts/{prompt_id}/rating",
+375
View File
@@ -0,0 +1,375 @@
"""Image category routes (#332): PUT/DELETE /images/category, GET /images/categories."""
import json
import ntpath
import sqlite3
from unittest.mock import patch
try:
from test_api_images import ImageAPITestCase, make_png
from open_handles import assert_closed
from test_image_categories import category_checksum
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
from tests.open_handles import assert_closed
from tests.test_image_categories import category_checksum
from database.image_categories import MAX_KEY_LENGTH, TABLE
from py.api.images import _favorite_root_key, image_category_key
CATEGORY = "/prompt_manager/images/category"
CATEGORIES = "/prompt_manager/images/categories"
class ImageCategoryTestCase(ImageAPITestCase):
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
def target(self, path, index=0):
roots = self.api._get_all_output_dirs()
return {
"root_index": index,
"root_scope": self.api._gallery_root_scope(roots[index], index),
"path": path,
}
async def send(self, method, body, status=200):
response = await self.client.request(method, CATEGORY, json=body)
text = await response.text()
self.assertEqual(response.status, status, text)
return json.loads(text)
async def put(self, paths, category, status=200):
items = [p if isinstance(p, dict) else self.target(p) for p in paths]
return await self.send("PUT", {"items": items, "category": category}, status)
async def delete(self, paths, status=200):
items = [p if isinstance(p, dict) else self.target(p) for p in paths]
return await self.send("DELETE", {"items": items}, status)
async def listed(self, status=200):
response = await self.client.get(CATEGORIES)
text = await response.text()
self.assertEqual(response.status, status, text)
return json.loads(text)
def rows(self):
return [
tuple(row)
for row in self.api.db.model.get_connection().execute(
f"SELECT root_key, relative_path, category FROM {TABLE} "
"ORDER BY root_key, relative_path"
)
]
def key(self, relative, root=None):
roots = self.api._get_all_output_dirs()
return image_category_key(roots, (root or self.output_dir) / relative)
class TestSetAndClear(ImageCategoryTestCase):
async def test_put_sets_listed_and_counts_missing(self):
make_png(self.output_dir / "a.png")
make_png(self.output_dir / "sub" / "b.png")
data = await self.put(["a.png", "sub/b.png", "missing.png"], " Portraits ")
self.assertEqual(
data,
{
"success": True,
"category": "Portraits",
"updated": 2,
"unchanged": 0,
"not_found": 1,
},
)
root_key = _favorite_root_key(self.output_dir)
self.assertEqual(
self.rows(),
[(root_key, "a.png", "Portraits"), (root_key, "sub/b.png", "Portraits")],
)
data = await self.put(["a.png", "sub/b.png"], "Portraits")
self.assertEqual((data["updated"], data["unchanged"]), (0, 2))
data = await self.put(["a.png"], "Scans")
self.assertEqual((data["updated"], data["unchanged"]), (1, 0))
self.assertEqual(self.api.db.image_category(*self.key("a.png")), "Scans")
async def test_put_with_no_listed_file_is_404_and_writes_nothing(self):
data = await self.put(["missing.png", "gone/x.png"], "Scans", status=404)
self.assertEqual(data["code"], "files-not-found")
self.assertEqual(self.rows(), [])
async def test_duplicate_targets_are_written_and_counted_once(self):
make_png(self.output_dir / "a.png")
item = self.target("a.png")
data = await self.put([item, dict(item)], "Scans")
self.assertEqual((data["updated"], data["not_found"]), (1, 0))
async def test_delete_clears_listed_and_missing_files(self):
make_png(self.output_dir / "a.png")
await self.put(["a.png"], "Scans")
self.api.db.set_image_categories([self.key("gone.png")], "Scans")
data = await self.delete(["a.png", "gone.png", "never.png"])
self.assertEqual(data, {"success": True, "cleared": 2})
self.assertEqual(self.rows(), [])
self.assertEqual((await self.delete(["a.png"]))["cleared"], 0)
async def test_put_never_writes_prompts(self):
make_png(self.output_dir / "a.png")
make_png(self.output_dir / "b.png")
positive = self._save_prompt("castle at dusk", category="Landscapes")
self.api.db.link_image_to_prompt(positive, str(self.output_dir / "a.png"))
negative = self._save_prompt("blurry", category="Negatives")
self.api.db.save_prompt_pair("castle at dusk", "blurry", category="Recipes")
conn = self.api.db.model.get_connection()
before = category_checksum(conn)
links = conn.execute("SELECT * FROM generated_images").fetchall()
await self.put(["a.png", "b.png"], "Portraits")
await self.listed()
await self.delete(["a.png"])
self.assertEqual(category_checksum(conn), before)
self.assertEqual(
conn.execute("SELECT * FROM generated_images").fetchall(), links
)
self.assertIsNotNone(negative)
async def test_two_sources_in_one_request(self):
second = self.comfy_dir / "renders"
make_png(self.output_dir / "a.png")
make_png(second / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
data = await self.put([self.target("a.png", 0), self.target("a.png", 1)], "X")
self.assertEqual(data["updated"], 2)
self.assertEqual(
{row[0] for row in self.rows()},
{_favorite_root_key(self.output_dir), _favorite_root_key(second)},
)
self.assertEqual((await self.delete([self.target("a.png", 1)]))["cleared"], 1)
async def test_nested_sources_key_by_the_deepest(self):
inner = self.output_dir / "nested"
make_png(inner / "a.png")
self.api._get_all_output_dirs = lambda: [self.output_dir, inner]
# Named through the outer source, stored under the inner one.
data = await self.put([self.target("nested/a.png", 0)], "Scans")
self.assertEqual(data["updated"], 1)
self.assertEqual(self.rows(), [(_favorite_root_key(inner), "a.png", "Scans")])
# The same file through the inner source is the same key.
data = await self.put([self.target("a.png", 1)], "Scans")
self.assertEqual(data["unchanged"], 1)
self.assertEqual((await self.listed())["categories"], ["Scans"])
self.assertEqual((await self.delete([self.target("a.png", 1)]))["cleared"], 1)
async def test_video_files_can_have_a_category(self):
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42")
data = await self.put(["clip.mp4"], "Clips")
self.assertEqual(data["updated"], 1)
async def test_windows_case_folds_the_key(self):
make_png(self.output_dir / "Sub" / "A.png")
with patch("py.api.images._path_flavor", ntpath):
data = await self.put(["Sub/A.png"], "Scans")
self.assertEqual(data["updated"], 1)
data = await self.put(["sub/a.PNG"], "Scans")
self.assertEqual(data["unchanged"], 1)
self.assertEqual(self.rows()[0][1], "sub/a.png")
self.assertEqual((await self.delete(["SUB/A.png"]))["cleared"], 1)
async def test_a_key_beyond_the_stored_bound_is_403(self):
make_png(self.output_dir / "a.png")
too_long = "/" + "r" * MAX_KEY_LENGTH
with patch("py.api.images._favorite_root_key", return_value=too_long):
data = await self.put(["a.png"], "Scans", status=403)
self.assertNotIn(too_long, json.dumps(data))
await self.delete(["a.png"], status=403)
self.assertEqual(self.rows(), [])
async def test_an_unexpected_failure_is_a_logged_500(self):
make_png(self.output_dir / "a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("secret detail")
self.api.db.set_image_categories = broken
self.api.db.clear_image_categories = broken
with self.assertLogs("prompt_manager", level="ERROR"):
data = await self.put(["a.png"], "Scans", status=500)
self.assertEqual(data["error"], "The category could not be changed.")
self.assertNotIn("secret", json.dumps(data))
with self.assertLogs("prompt_manager", level="ERROR"):
data = await self.delete(["a.png"], status=500)
self.assertNotIn("secret", json.dumps(data))
async def test_a_symlinked_output_folder_keys_like_its_favorites(self):
"""A detected output folder can be a symlink: keys use its real path."""
real = self.comfy_dir / "real-output"
make_png(real / "a.png")
link = self.comfy_dir / "linked-output"
self._make_symlink(real, link, target_is_directory=True)
self.api._get_all_output_dirs = lambda: [link]
data = await self.put(["a.png"], "Scans")
self.assertEqual(data["updated"], 1)
self.assertEqual(self.rows(), [(_favorite_root_key(link), "a.png", "Scans")])
self.assertEqual((await self.listed())["categories"], ["Scans"])
self.assertEqual((await self.delete(["a.png"]))["cleared"], 1)
async def test_lone_surrogates_are_400_not_500(self):
make_png(self.output_dir / "a.png")
await self.send(
"PUT", {"items": [self.target("a.png")], "category": "x\ud800"}, 400
)
await self.send("DELETE", {"items": [self.target("a\ud800.png")]}, 400)
self.assertEqual(self.rows(), [])
async def test_delete_category_never_reaches_delete_image(self):
make_png(self.output_dir / "a.png")
calls = []
async def recorded(request):
calls.append(request.match_info)
raise AssertionError("delete_image reached")
self.api.delete_image = recorded
await self.put(["a.png"], "Scans")
data = await self.delete(["a.png"])
self.assertEqual(data["cleared"], 1)
self.assertEqual(calls, [])
class TestRefusals(ImageCategoryTestCase):
async def test_malformed_bodies_are_400(self):
make_png(self.output_dir / "a.png")
item = self.target("a.png")
cases = [
{"items": [item]},
{"category": "x"},
{"items": [item], "category": "x", "extra": 1},
{"items": item, "category": "x"},
{"items": [], "category": "x"},
{"items": [item] * 501, "category": "x"},
{"items": [item], "category": 3},
{"items": [item], "category": None},
{"items": [item], "category": " \t "},
{"items": [item], "category": "x" * 101},
{"items": [item], "category": "a\x07b"},
{"items": [{**item, "root_index": "0"}], "category": "x"},
{"items": [{**item, "root_scope": "nothex"}], "category": "x"},
{"items": [{"root_index": 0, "path": "a.png"}], "category": "x"},
{"items": [{**item, "root_index": 1}], "category": "x"},
]
for body in cases:
with self.subTest(body=str(body)[:80]):
data = await self.send("PUT", body, status=400)
self.assertFalse(data["success"])
await self.send("DELETE", {"items": [item], "category": "x"}, status=400)
await self.send("DELETE", {"items": []}, status=400)
response = await self.client.request(
"PUT", CATEGORY, data="not json", headers={"Content-Type": "text/plain"}
)
self.assertEqual(response.status, 400)
self.assertEqual(self.rows(), [])
async def test_unsafe_paths_are_403(self):
(self.output_dir / "notes.txt").write_text("not media")
for path in ("../outside.png", "/a.png", "C:/a.png", "a\\b.png", "a/./b.png"):
with self.subTest(path=path):
await self.put([self.target(path)], "x", status=403)
data = await self.put([self.target("notes.txt")], "x", status=403)
self.assertEqual(data["error"], "Only media files can have a category")
await self.delete([self.target("notes.txt")], status=403)
async def test_a_stale_scope_is_409(self):
make_png(self.output_dir / "a.png")
item = {**self.target("a.png"), "root_scope": "0" * 64}
data = await self.put([item], "x", status=409)
self.assertEqual(data["code"], "source-changed")
data = await self.delete([item], status=409)
self.assertEqual(data["code"], "source-changed")
async def test_an_oversized_body_is_413(self):
response = await self.client.request(
"PUT",
CATEGORY,
data=b'{"items": [], "category": "' + b"x" * (2 * 1024 * 1024) + b'"}',
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.status, 413)
class TestCategoryList(ImageCategoryTestCase):
async def test_lists_only_values_whose_file_is_listed(self):
make_png(self.output_dir / "a.png")
make_png(self.output_dir / "c.png")
await self.put(["a.png"], "B")
await self.put(["c.png"], "A")
self.api.db.set_image_categories([self.key("gone.png")], "Z")
self.assertEqual(
await self.listed(), {"success": True, "categories": ["A", "B"]}
)
async def test_empty_library_lists_nothing(self):
self.assertEqual((await self.listed())["categories"], [])
async def test_a_failed_read_is_500(self):
make_png(self.output_dir / "a.png")
def broken(*_args, **_kwargs):
raise sqlite3.OperationalError("secret detail")
self.api.db.own_categories_of = broken
with self.assertLogs("prompt_manager", level="ERROR"):
data = await self.listed(status=500)
self.assertEqual(data["error"], "Image categories could not be read.")
self.assertNotIn("secret", json.dumps(data))
class TestAllMatchingFilter(ImageCategoryTestCase):
"""#392 "Select all N matching": the listing filter, resolved now."""
async def test_filter_sets_and_clears_every_match(self):
for name in ("bulk/a.png", "bulk/b.png", "other/c.png"):
make_png(self.output_dir / name)
data = await self.send(
"PUT", {"filter": {"subfolder": "bulk"}, "category": "Scans"}
)
self.assertEqual((data["updated"], data["not_found"]), (2, 0))
self.assertEqual(
sorted(row[1] for row in self.rows()), ["bulk/a.png", "bulk/b.png"]
)
data = await self.send("DELETE", {"filter": {"subfolder": "bulk"}})
self.assertEqual(data["cleared"], 2)
data = await self.send(
"PUT", {"filter": {"subfolder": "none"}, "category": "Scans"}, 404
)
self.assertEqual(data["code"], "files-not-found")
self.assertEqual(
(await self.send("DELETE", {"filter": {"q": "zz"}}))["cleared"], 0
)
async def test_filter_refusals(self):
make_png(self.output_dir / "a.png")
item = self.target("a.png")
for body in (
{"filter": {}, "items": [item], "category": "x"},
{"filter": {"unknown": "1"}, "category": "x"},
{"filter": "q=a", "category": "x"},
{"filter": {"root": "0", "root_scope": "nothex"}, "category": "x"},
):
with self.subTest(body=body):
await self.send("PUT", body, 400)
data = await self.send(
"PUT",
{"filter": {"root": "0", "root_scope": "0" * 64}, "category": "x"},
409,
)
self.assertEqual(data["code"], "source-changed")
self.assertEqual(self.rows(), [])
async def test_more_than_500_matching_is_refused(self):
for number in range(501):
make_png(self.output_dir / f"many/{number:03}.png", size=(1, 1))
data = await self.send("PUT", {"filter": {}, "category": "x"}, 400)
self.assertEqual(data["code"], "too-many-images")
self.assertEqual(data["error"], "Select at most 500 images.")
self.assertEqual(self.rows(), [])
+151
View File
@@ -0,0 +1,151 @@
"""Duplicate cleanup keeps images' own categories (#332 MC11).
The kept copy's own category wins; a kept copy without one receives the
removed copy's. The removed copy's row is never deleted, so a give-back
(the kept copy changed) and a failed delete lose nothing.
"""
import asyncio
import os
from unittest.mock import patch
try:
from tests.test_api_duplicate_files import WAIT, DuplicateFilesTestCase
except ImportError: # discovered with tests/ as the top-level directory
from test_api_duplicate_files import WAIT, DuplicateFilesTestCase
from py.api import duplicate_files # noqa: E402
from py.api.images import image_category_key # noqa: E402
from py.config import GalleryConfig # noqa: E402
class TestDuplicateCleanupKeepsCategories(DuplicateFilesTestCase):
def key(self, path):
return image_category_key(self.api._get_all_output_dirs(), path)
def category(self, path):
return self.api.db.image_category(*self.key(path))
def set_category(self, path, value):
self.api.db.set_image_categories([self.key(path)], value)
async def run_cleanup(self, keep="a.png"):
job, group = await self.found_group()
plan = await self.review(job.id, [self.keep_of(group, keep)])
body = await self.apply(plan["token"])
cleanup = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(cleanup.task), WAIT)
return cleanup
async def test_kept_copy_without_a_category_receives_the_removed_one(self):
self.trio()
b = self.output_dir / "b.png"
self.set_category(b, "Portraits")
cleanup = await self.run_cleanup()
self.assertFalse(b.exists())
self.assertEqual(self.category(self.output_dir / "a.png"), "Portraits")
# The removed copy's row stays (hidden while its file is gone).
self.assertEqual(self.category(b), "Portraits")
self.assertIn(
"Kept 1 image category on the kept copies", "\n".join(cleanup.log)
)
async def test_kept_copy_keeps_its_own_category(self):
self.trio()
self.set_category(self.output_dir / "a.png", "Landscapes")
self.set_category(self.output_dir / "b.png", "Portraits")
self.set_category(self.output_dir / "sub" / "c.png", "Scans")
cleanup = await self.run_cleanup()
self.assertEqual(self.category(self.output_dir / "a.png"), "Landscapes")
self.assertNotIn("image categor", "\n".join(cleanup.log))
async def test_two_removed_copies_give_the_first_category(self):
self.trio()
self.set_category(self.output_dir / "b.png", "Portraits")
self.set_category(self.output_dir / "sub" / "c.png", "Scans")
cleanup = await self.run_cleanup()
self.assertIn(self.category(self.output_dir / "a.png"), {"Portraits", "Scans"})
self.assertIn(
"Kept 1 image category on the kept copies", "\n".join(cleanup.log)
)
async def test_give_back_loses_no_category(self):
self.trio()
self.set_category(self.output_dir / "b.png", "Portraits")
with patch.object(duplicate_files, "still_kept", return_value=False):
cleanup = await self.run_cleanup()
self.assertEqual(cleanup.counters["removed"], 0)
rows = {row["path"]: row["detail"] for row in cleanup.outcomes["kept"]}
self.assertEqual(
rows["b.png"], duplicate_files.ROW_DETAILS[duplicate_files.KEEPER_GONE]
)
self.assertTrue((self.output_dir / "b.png").is_file())
self.assertEqual(self.category(self.output_dir / "b.png"), "Portraits")
async def test_a_failed_delete_keeps_both_categories(self):
self.trio()
self.set_category(self.output_dir / "b.png", "Portraits")
real = duplicate_files._unlink
def unlink(path):
if os.path.basename(path) == "b.png":
raise PermissionError(13, "denied")
real(path)
with patch.object(duplicate_files, "_unlink", side_effect=unlink):
await self.run_cleanup()
self.assertTrue((self.output_dir / "b.png").is_file())
self.assertEqual(self.category(self.output_dir / "b.png"), "Portraits")
self.assertEqual(self.category(self.output_dir / "a.png"), "Portraits")
async def test_nested_sources_use_the_deepest_source_keys(self):
self.write("sub/a.png", b"same", mtime=1)
self.write("sub/b.png", b"same", mtime=2)
# The subfolder is listed first, so the cleanup's listed root is the
# file key's source; the outer source still holds the files.
GalleryConfig.MONITORING_DIRECTORIES = [
self._root(self.output_dir / "sub"),
self._root(self.output_dir),
]
sub_key = duplicate_files.favorite_root_key(self.output_dir / "sub")
self.api.db.set_image_categories([(sub_key, "b.png")], "Portraits")
await self.run_cleanup()
self.assertFalse((self.output_dir / "sub" / "b.png").exists())
self.assertEqual(self.api.db.image_category(sub_key, "a.png"), "Portraits")
outer_key = duplicate_files.favorite_root_key(self.output_dir)
self.assertIsNone(self.api.db.image_category(outer_key, "sub/a.png"))
async def test_outer_source_listed_first_still_keys_by_the_deepest(self):
self.write("sub/a.png", b"same", mtime=1)
self.write("sub/b.png", b"same", mtime=2)
GalleryConfig.MONITORING_DIRECTORIES = [
self._root(self.output_dir),
self._root(self.output_dir / "sub"),
]
sub_key = duplicate_files.favorite_root_key(self.output_dir / "sub")
self.api.db.set_image_categories([(sub_key, "b.png")], "Portraits")
job, group = await self.found_group()
[kept] = [f for f in group["files"] if f["path"].endswith("a.png")][:1]
plan = await self.review(
job.id, [self.keep_of(group, kept["path"], kept["root_index"])]
)
body = await self.apply(plan["token"])
cleanup = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(cleanup.task), WAIT)
self.assertFalse((self.output_dir / "sub" / "b.png").exists())
self.assertEqual(self.api.db.image_category(sub_key, "a.png"), "Portraits")
async def test_a_symlinked_output_folder_still_moves_the_category(self):
link = self.output_dir.parent / "linked-output"
try:
os.symlink(self.output_dir, link, target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks are not available")
self.trio()
with patch.object(self.api, "_get_all_output_dirs", return_value=[link]):
key = image_category_key([link], link / "b.png")
self.api.db.set_image_categories([key], "Portraits")
await self.run_cleanup()
kept = image_category_key([link], link / "a.png")
self.assertFalse((self.output_dir / "b.png").exists())
self.assertEqual(self.api.db.image_category(*kept), "Portraits")
+523
View File
@@ -0,0 +1,523 @@
"""Image categories (#332): schema, migration, restore check, store and file key."""
import hashlib
import ntpath
import os
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
try:
from open_handles import assert_closed
except ImportError:
from tests.open_handles import assert_closed
from database.image_categories import (
COPY_SQL,
INDEX,
STATEMENTS,
TABLE,
copy_image_category,
image_categories_schema_error,
migrate_image_categories,
)
from database.models import PromptModel
from database.operations import PromptDatabase
from py.api.images import image_category_key
_DDL = (
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
"category TEXT NOT NULL, updated_at TEXT NOT NULL, "
"PRIMARY KEY (root_key, relative_path))"
)
_INDEX_DDL = f"CREATE INDEX {INDEX} ON {TABLE}(category)"
def category_checksum(conn) -> tuple:
"""sha256 of prompts and prompt_pairs ``(id, category, updated_at)``."""
sums = []
for table in ("prompts", "prompt_pairs"):
digest = hashlib.sha256()
for row in conn.execute( # nosec B608 - fixed table names
f"SELECT id, category, updated_at FROM {table} ORDER BY id"
):
digest.update(repr(tuple(row)).encode())
sums.append(digest.hexdigest())
return tuple(sums)
def table_checksums(conn) -> dict:
"""``{name: (schema sql, sha256 of rows)}`` of every table but ours."""
found = {}
for name, sql in conn.execute(
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name != ?",
(TABLE,),
).fetchall():
digest = hashlib.sha256()
rows = conn.execute(f'SELECT * FROM "{name}"') # nosec B608
for text in sorted(repr(tuple(row)) for row in rows):
digest.update(text.encode())
found[name] = (sql, digest.hexdigest())
return found
class _TempDir(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.path = os.path.join(self.tmpdir, "categories.db")
def tearDown(self):
PromptModel.close_all_instances()
assert_closed(self, self.tmpdir)
for name in os.listdir(self.tmpdir):
os.unlink(os.path.join(self.tmpdir, name))
os.rmdir(self.tmpdir)
class TestImageCategoriesSchema(_TempDir):
def test_migration_creates_table_and_index(self):
db = PromptDatabase(self.path)
db.close_all()
PromptModel.close_all_instances()
with closing(sqlite3.connect(self.path)) as conn:
names = {
(kind, name)
for kind, name in conn.execute(
"SELECT type, name FROM sqlite_master WHERE tbl_name=?", (TABLE,)
)
}
self.assertEqual(
names,
{
("table", TABLE),
("index", INDEX),
("index", f"sqlite_autoindex_{TABLE}_1"),
},
)
self.assertEqual(
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 0
)
self.assertIsNone(image_categories_schema_error(conn, allow_missing=False))
def test_migration_leaves_prompt_categories_unchanged(self):
"""23,000 links; the migration reads and writes only its own objects."""
db = PromptDatabase(self.path)
db.close_all()
PromptModel.close_all_instances()
with closing(sqlite3.connect(self.path)) as conn:
conn.execute(f"DROP TABLE {TABLE}") # a library from before #332
categories = ["Landscapes", "Portraits", None, " ", "Scans"]
conn.executemany(
"INSERT INTO prompts (text, category, hash, updated_at) "
"VALUES (?, ?, ?, ?)",
[
(f"p{n}", categories[n % 5], f"h{n}", f"2026-01-01 00:00:{n % 60}")
for n in range(200)
],
)
conn.executemany(
"INSERT INTO prompt_pairs (positive_prompt_id, negative_prompt_id, "
"category) VALUES (?, ?, ?)",
[(n, n + 100, categories[(n + 1) % 5]) for n in range(1, 50)],
)
conn.executemany(
"INSERT INTO generated_images (prompt_id, image_path, filename, "
"file_path) VALUES (?, ?, ?, ?)",
[
(n % 200 + 1, f"/out/{n}.png", f"{n}.png", f"/out/{n}.png")
for n in range(23000)
],
)
conn.commit()
before = table_checksums(conn)
prompts_before = category_checksum(conn)
seen = []
def authorize(action, arg1, arg2, _db, _trigger):
seen.append((action, arg1, arg2))
return sqlite3.SQLITE_OK
conn.set_authorizer(authorize)
migrate_image_categories(conn)
conn.commit()
conn.set_authorizer(None)
own = {TABLE, "sqlite_master", "sqlite_schema", INDEX}
touched = set()
for action, arg1, arg2 in seen:
if action in (sqlite3.SQLITE_READ, sqlite3.SQLITE_INSERT):
touched.add(arg1)
if action == sqlite3.SQLITE_READ:
self.assertIn(arg1, own, (action, arg1, arg2))
elif action in (
sqlite3.SQLITE_INSERT,
sqlite3.SQLITE_UPDATE,
sqlite3.SQLITE_DELETE,
):
self.assertIn(arg1, own, (action, arg1, arg2))
elif action == sqlite3.SQLITE_CREATE_TABLE:
self.assertEqual(arg1, TABLE)
elif action == sqlite3.SQLITE_CREATE_INDEX:
self.assertIn(arg1, (INDEX, f"sqlite_autoindex_{TABLE}_1"))
self.assertEqual(arg2, TABLE)
elif action == sqlite3.SQLITE_PRAGMA:
self.assertEqual((arg1, arg2), ("table_info", TABLE))
self.assertIn(TABLE, touched) # the authorizer saw the work
self.assertEqual(table_checksums(conn), before)
self.assertEqual(category_checksum(conn), prompts_before)
schema = conn.execute(
"SELECT sql FROM sqlite_master ORDER BY name"
).fetchall()
migrate_image_categories(conn) # a second run changes nothing
conn.commit()
self.assertEqual(
conn.execute("SELECT sql FROM sqlite_master ORDER BY name").fetchall(),
schema,
)
# The whole startup sequence keeps them too.
PromptDatabase(self.path).close_all()
PromptModel.close_all_instances()
with closing(sqlite3.connect(self.path)) as conn:
self.assertEqual(category_checksum(conn), prompts_before)
self.assertEqual(
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 0
)
def test_structure_accepted_and_refused(self):
variants = {
"missing": None,
"extra column": (
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT "
"NOT NULL, category TEXT NOT NULL, updated_at TEXT NOT NULL, x TEXT, "
"PRIMARY KEY (root_key, relative_path))",
_INDEX_DDL,
),
"wrong index": (_DDL, f"CREATE INDEX {INDEX} ON {TABLE}(updated_at)"),
"extra index": (
_DDL,
_INDEX_DDL,
f"CREATE INDEX extra ON {TABLE}(root_key)",
),
"trigger": (
_DDL,
_INDEX_DDL,
f"CREATE TRIGGER t AFTER INSERT ON {TABLE} BEGIN SELECT 1; END",
),
"wrong ddl": (
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
"category TEXT, updated_at TEXT, "
"PRIMARY KEY (root_key, relative_path))",
_INDEX_DDL,
),
"view": (f"CREATE VIEW {TABLE} AS SELECT 1",),
}
for label, statements in variants.items():
with self.subTest(label), closing(sqlite3.connect(":memory:")) as conn:
for sql in statements or ():
conn.execute(sql)
error = image_categories_schema_error(conn)
if statements is None:
self.assertIsNone(error)
self.assertIsNotNone(
image_categories_schema_error(conn, allow_missing=False)
)
continue
self.assertIsNotNone(error)
self.assertIsNotNone(
image_categories_schema_error(conn, check_values=False)
)
with self.assertRaises(sqlite3.DatabaseError):
migrate_image_categories(conn)
# A table without its category index (never written by this code) is
# refused by the check but repaired by the install.
with closing(sqlite3.connect(":memory:")) as conn:
conn.execute(_DDL)
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'Scans', 'now')")
self.assertIsNotNone(image_categories_schema_error(conn))
migrate_image_categories(conn)
self.assertIsNone(image_categories_schema_error(conn, allow_missing=False))
self.assertEqual(
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 1
)
with closing(sqlite3.connect(":memory:")) as conn:
migrate_image_categories(conn)
migrate_image_categories(conn) # idempotent
self.assertIsNone(image_categories_schema_error(conn, allow_missing=False))
def test_values_refused_but_startup_checks_structure_only(self):
bad = {
"blank": "",
"101 characters": "x" * 101,
"control character": "a\tb",
"nul": "a\x00b",
"leading space": " Landscapes",
"trailing space": "Landscapes ",
}
for label, value in bad.items():
with self.subTest(label), closing(sqlite3.connect(":memory:")) as conn:
migrate_image_categories(conn)
conn.execute(
f"INSERT INTO {TABLE} VALUES ('r', 'a.png', ?, 'now')", (value,)
)
self.assertIsNotNone(image_categories_schema_error(conn))
self.assertIsNone(
image_categories_schema_error(conn, check_values=False)
)
migrate_image_categories(conn) # startup still opens it
with closing(sqlite3.connect(":memory:")) as conn:
migrate_image_categories(conn)
conn.execute(
f"INSERT INTO {TABLE} VALUES ('r', ?, 'Scans', 'now')", ("x" * 5000,)
)
self.assertIsNotNone(image_categories_schema_error(conn))
conn.execute(f"DELETE FROM {TABLE}")
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'Scans', 'now')")
self.assertIsNotNone(image_categories_schema_error(conn))
conn.execute(f"DELETE FROM {TABLE}")
for value in ("x" * 100, "Scans", "Café · 風景", "a b"):
conn.execute(
f"INSERT OR REPLACE INTO {TABLE} VALUES ('r', 'a.png', ?, 'now')",
(value,),
)
self.assertIsNone(image_categories_schema_error(conn), value)
def test_restore_check(self):
PromptDatabase(self.path).close_all()
PromptModel.close_all_instances()
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertTrue(ok, reason)
with closing(sqlite3.connect(self.path)) as conn:
conn.execute(f"DROP TABLE {TABLE}")
conn.commit()
# A backup from before #332 is accepted ...
ok, reason = PromptModel.verify_database_file(self.path)
self.assertTrue(ok, reason)
# ... but a current database must have the table.
ok, reason = PromptModel.verify_database_file(
self.path, require_library_state=True
)
self.assertFalse(ok)
self.assertIn("image categories", reason.lower())
with closing(sqlite3.connect(self.path)) as conn:
conn.execute(_DDL)
conn.execute(_INDEX_DDL)
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', ' x', 'now')")
conn.commit()
ok, reason = PromptModel.verify_database_file(self.path)
self.assertFalse(ok)
self.assertIn("image categories", reason.lower())
class TestImageCategoryStore(_TempDir):
def setUp(self):
super().setUp()
self.db = PromptDatabase(self.path)
def tearDown(self):
self.db.close_all()
super().tearDown()
def rows(self):
return (
self.db.model.get_connection()
.execute(
f"SELECT root_key, relative_path, category, updated_at FROM {TABLE} "
"ORDER BY root_key, relative_path"
)
.fetchall()
)
def test_set_counts_updated_and_unchanged(self):
keys = [("/out", "a.png"), ("/other", "a.png"), ("/out", "a.png")]
self.assertEqual(
self.db.set_image_categories(keys, "Portraits"),
{"updated": 2, "unchanged": 0},
)
stamps = {(r[0], r[1]): r[3] for r in self.rows()}
self.assertEqual(
self.db.set_image_categories(
[("/out", "a.png"), ("/out", "b.png")], "Portraits"
),
{"updated": 1, "unchanged": 1},
)
# An unchanged file keeps its updated_at.
self.assertEqual(
{(r[0], r[1]): r[3] for r in self.rows()}[("/out", "a.png")],
stamps[("/out", "a.png")],
)
self.assertEqual(
self.db.set_image_categories([("/out", "a.png")], "Scans"),
{"updated": 1, "unchanged": 0},
)
self.assertEqual(self.db.image_category("/out", "a.png"), "Scans")
self.assertEqual(self.db.image_category("/other", "a.png"), "Portraits")
self.assertIsNone(self.db.image_category("/out", "missing.png"))
self.assertEqual(
self.db.own_categories_of(["/out", "/out", "/none"]),
{("/out", "a.png"): "Scans", ("/out", "b.png"): "Portraits"},
)
def test_clear_counts_removed_rows(self):
self.db.set_image_categories([("/out", "a.png"), ("/b", "c.png")], "Scans")
self.assertEqual(
self.db.clear_image_categories(
[("/out", "a.png"), ("/b", "c.png"), ("/out", "gone.png")]
),
2,
)
self.assertEqual(self.rows(), [])
self.assertEqual(self.db.clear_image_categories([("/out", "a.png")]), 0)
def test_a_failed_write_writes_nothing(self):
self.db.set_image_categories([("/out", "a.png")], "Scans")
keys = [("/out", "b.png"), ("/out", None)] # NOT NULL fails the second
with self.assertRaises(sqlite3.IntegrityError):
self.db.set_image_categories(keys, "Portraits")
self.assertEqual([r[:3] for r in self.rows()], [("/out", "a.png", "Scans")])
def test_store_never_touches_prompt_categories(self):
conn = self.db.model.get_connection()
with conn:
conn.execute(
"INSERT INTO prompts (text, category, hash) VALUES ('p', 'L', 'h')"
)
before = category_checksum(conn)
seen = set()
conn.set_authorizer(
lambda action, arg1, *_: seen.add(arg1) or sqlite3.SQLITE_OK
)
try:
self.db.set_image_categories([("/out", "a.png")], "Scans")
self.db.own_categories_of(["/out"])
self.db.image_category("/out", "a.png")
self.db.clear_image_categories([("/out", "a.png")])
finally:
conn.set_authorizer(None)
self.assertFalse(seen & {"prompts", "prompt_pairs", "generated_images"}, seen)
self.assertEqual(category_checksum(conn), before)
def test_copy_keeps_kept_copy_category(self):
self.db.set_image_categories([("/out", "removed.png")], "Scans")
self.db.set_image_categories([("/out", "kept.png")], "Portraits")
with self.db._pair_transaction() as conn:
inserted = copy_image_category(
conn, ("/out", "removed.png"), ("/out", "kept.png")
)
self.assertEqual(inserted, 0)
self.assertEqual(self.db.image_category("/out", "kept.png"), "Portraits")
with self.db._pair_transaction() as conn:
inserted = copy_image_category(
conn, ("/out", "removed.png"), ("/out", "new.png")
)
self.assertEqual(inserted, 1)
self.assertEqual(self.db.image_category("/out", "new.png"), "Scans")
# The removed copy's row is never deleted.
self.assertEqual(self.db.image_category("/out", "removed.png"), "Scans")
with self.db._pair_transaction() as conn:
self.assertEqual(
copy_image_category(conn, ("/out", "none.png"), ("/out", "x.png")), 0
)
self.assertEqual(
copy_image_category(
conn, ("/out", "removed.png"), ("/out", "removed.png")
),
0,
)
def test_statements_never_scan_the_table(self):
conn = self.db.model.get_connection()
for sql in STATEMENTS:
with self.subTest(sql):
params = (None,) * sql.count("?")
plan = " ".join(
row[-1] for row in conn.execute(f"EXPLAIN QUERY PLAN {sql}", params)
)
self.assertNotRegex(plan, rf"SCAN {TABLE}\b", plan)
plan = " ".join(
row[-1]
for row in conn.execute(
f"EXPLAIN QUERY PLAN SELECT rowid FROM {TABLE} WHERE category=? "
"ORDER BY rowid",
("x",),
)
)
self.assertIn(INDEX, plan)
self.assertIn(COPY_SQL, STATEMENTS)
class TestImageCategoryKey(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.base = Path(os.path.realpath(self._tmp.name))
self.outer = self.base / "output"
self.inner = self.outer / "nested"
(self.inner / "sub").mkdir(parents=True)
def tearDown(self):
self._tmp.cleanup()
def test_equals_the_favorite_key_without_nesting(self):
from py.api.images import _favorite_relative_key, _favorite_root_key
key = image_category_key([self.outer], self.outer / "sub" / "a.png")
self.assertEqual(
key, (_favorite_root_key(self.outer), _favorite_relative_key("sub/a.png"))
)
def test_deepest_source_wins_in_any_order(self):
from py.api.images import _favorite_root_key
path = self.inner / "sub" / "a.png"
for sources in ([self.outer, self.inner], [self.inner, self.outer]):
with self.subTest(sources=sources):
self.assertEqual(
image_category_key(sources, path),
(_favorite_root_key(self.inner), "sub/a.png"),
)
# A file of the outer source only is keyed by it.
self.assertEqual(
image_category_key([self.outer, self.inner], self.outer / "b.png"),
(_favorite_root_key(self.outer), "b.png"),
)
def test_outside_every_source_has_no_key(self):
self.assertIsNone(image_category_key([self.inner], self.outer / "a.png"))
self.assertIsNone(image_category_key([], self.outer / "a.png"))
# A sibling sharing a name prefix is not inside.
self.assertIsNone(
image_category_key([self.outer], self.base / "output2" / "a.png")
)
self.assertIsNone(image_category_key([self.outer], self.outer))
def test_symlinked_subfolder_is_not_resolved(self):
target = self.base / "elsewhere"
target.mkdir()
link = self.outer / "linked"
try:
os.symlink(target, link, target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks are not available")
key = image_category_key([self.outer], link / "a.png")
self.assertEqual(key[1], "linked/a.png")
def test_windows_folds_case_of_the_relative_key(self):
with patch("py.api.images._path_flavor", ntpath):
upper = image_category_key([self.outer], self.outer / "Sub" / "A.PNG")
lower = image_category_key([self.outer], self.outer / "sub" / "a.png")
self.assertEqual(upper[1], "sub/a.png")
self.assertEqual(upper, lower)
def test_posix_keeps_case_distinct(self):
if os.path.normcase("A") == "a":
self.skipTest("this OS folds case")
upper = image_category_key([self.outer], self.outer / "A.png")
lower = image_category_key([self.outer], self.outer / "a.png")
self.assertNotEqual(upper, lower)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -33,14 +33,15 @@ test('execution script discovers only the new matrix and preserves all 78 origin
const executions = list('tools/vendor/playwright.execution.config.mjs');
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
// + the fresh-host library location case, + #342 image attribution, + #343 linked text
assert.match(executions, /Total: 90 tests in 5 files/);
// + the fresh-host library location case, + #342 image attribution, + #343 linked text,
// + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects)
assert.match(executions, /Total: 92 tests in 5 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 2);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3);
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 19);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);