Files
ausboss-ComfyUI-AusBoss/tests/test_image_folder.py
ausboss 604bd005d5 Registry: the description fits its 1000 byte limit
The 2.8.0 upload was refused with "description is too long": naming
Image Folder and Callout took the description to 1024 bytes and the
Registry takes 1000. It is 989 now, and release_preflight.py fails on a
description over the limit, so this is caught before a version lands.

The Registry's scan also warned about two statements on one line in
tests/test_image_folder.py and says that becomes an error. They are two
lines now.
2026-10-06 18:07:28 -05:00

300 lines
14 KiB
Python

"""Image Folder: which pictures a folder holds, which are picked, what a run
loads, and that no folder name ever leads out of ComfyUI's own folders. The
root is a temp folder, so these run without ComfyUI."""
from __future__ import annotations
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes import node_image_folder # noqa: E402
from nodes._image_folder_helpers import ( # noqa: E402
AT_THE_END,
BAD_PICKS,
NO_MASK_SIZE,
NONE_LEFT,
NONE_PICKED,
OUTSIDE,
RUNS,
THE_END,
choose,
clean_folder,
fingerprint,
list_folders,
list_pictures,
load_picture,
natural_key,
output_name,
parse_picked,
place_inside,
safe_folder,
thumbnail,
which,
)
def picture(path: Path, size=(40, 30), color=(200, 40, 40), mode="RGB"):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new(mode, size, color if mode == "RGB" else (*color, 255)).save(path)
class FolderCase(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name) / "input"
self.outside = Path(self.temp.name) / "private"
picture(self.root / "people" / "photo_10.png")
picture(self.root / "people" / "photo_2.png")
picture(self.root / "people" / "Anna.jpg")
picture(self.root / "people" / "trips" / "beach.png")
picture(self.root / "top.png")
picture(self.outside / "secret.png")
(self.root / "people" / "notes.txt").write_text("not a picture")
(self.root / "people" / ".hidden.png").write_bytes(b"x")
real = node_image_folder._root
node_image_folder._root = lambda source: self.root.resolve()
self.addCleanup(setattr, node_image_folder, "_root", real)
def names(self, *args, **kwargs):
return [item["name"] for item in list_pictures(self.root, *args, **kwargs)]
def run_node(self, **overrides):
values = {"source": "input", "folder": "people", "subfolders": False, "sort": "name", "pictures": "",
"run": RUNS[0], "position": 1, "after_run": "next", "at_the_end": AT_THE_END[0]}
values.update(overrides)
return node_image_folder.AusBossImageFolder().load(**values)
class ListingTests(FolderCase):
def test_a_folder_lists_its_pictures_in_natural_order(self):
self.assertEqual(self.names("people"), ["Anna.jpg", "photo_2.png", "photo_10.png"])
self.assertEqual(natural_key("photo_2") < natural_key("photo_10"), True)
def test_subfolders_are_included_only_when_asked(self):
self.assertEqual(self.names("people", True), ["Anna.jpg", "photo_2.png", "photo_10.png", "trips/beach.png"])
self.assertEqual(self.names(""), ["top.png"])
self.assertEqual(len(self.names("", True)), 5)
def test_newest_first_and_oldest_first_follow_the_files(self):
base = self.root / "people"
for age, name in enumerate(["photo_2.png", "Anna.jpg", "photo_10.png"]):
os.utime(base / name, (1000 + age, 1000 + age))
self.assertEqual(self.names("people", sort="newest first"), ["photo_10.png", "Anna.jpg", "photo_2.png"])
self.assertEqual(self.names("people", sort="oldest first"), ["photo_2.png", "Anna.jpg", "photo_10.png"])
def test_the_folders_inside_are_listed_for_browse(self):
self.assertEqual(list_folders(self.root, ""), ["people"])
self.assertEqual(list_folders(self.root, "people"), ["trips"])
def test_no_folder_name_leads_out_of_the_root(self):
for bad in ("..", "../private", "people/../../private", "/etc", "~/Pictures", "C:/Windows", "//server/share",
"people\\..\\..\\private"):
with self.assertRaises(ValueError, msg=bad) as refused:
safe_folder(self.root, bad)
self.assertEqual(str(refused.exception), OUTSIDE, bad)
self.assertEqual(clean_folder("./people//trips/"), "people/trips")
def test_a_link_that_leads_out_is_refused_and_never_listed(self):
(self.root / "way_out").symlink_to(self.outside, target_is_directory=True)
(self.root / "people" / "linked.png").symlink_to(self.outside / "secret.png")
with self.assertRaises(ValueError) as refused:
safe_folder(self.root, "way_out")
self.assertEqual(str(refused.exception), OUTSIDE)
self.assertNotIn("way_out", list_folders(self.root, ""))
self.assertNotIn("linked.png", self.names("people"))
self.assertFalse(any("secret" in name for name in self.names("", True)))
def test_a_pasted_whole_path_inside_comfyui_is_read_as_its_folder(self):
roots = {"input": self.root, "output": Path(self.temp.name) / "output"}
self.assertEqual(place_inside(str(self.root / "people" / "trips"), roots), ("input", "people/trips"))
self.assertEqual(place_inside(str(self.root) + "/", roots), ("input", ""))
self.assertEqual(place_inside(str(roots["output"] / "set"), roots), ("output", "set"))
self.assertEqual(place_inside(f'"{self.root / "people"}"', roots), ("input", "people"), "quotes from a copied path")
def test_a_whole_path_anywhere_else_is_not_a_place(self):
roots = {"input": self.root, "output": Path(self.temp.name) / "output"}
self.assertIsNone(place_inside(str(self.outside), roots))
self.assertIsNone(place_inside(str(self.root) + "_other/x", roots), "a neighbour whose name only starts the same")
self.assertIsNone(place_inside("//server/share/x", roots), "another machine is refused from its text")
self.assertIsNone(place_inside("people/trips", roots), "a folder name is not a whole path")
self.assertIsNone(place_inside(str(self.root / "people" / ".." / ".." / "private"), roots))
def test_a_folder_that_is_not_there_says_so(self):
with self.assertRaises(ValueError) as missing:
safe_folder(self.root, "nowhere")
self.assertIn('no folder "nowhere"', str(missing.exception))
class PickingTests(FolderCase):
def test_empty_means_every_picture_and_a_list_keeps_the_folder_order(self):
listed = list_pictures(self.root, "people")
self.assertIsNone(parse_picked(""))
self.assertEqual(len(choose(listed, None)), 3)
picked = parse_picked(json.dumps(["photo_10.png", "Anna.jpg", "Anna.jpg"]))
self.assertEqual(picked, ["photo_10.png", "Anna.jpg"])
self.assertEqual([item["name"] for item in choose(listed, picked)], ["Anna.jpg", "photo_10.png"])
def test_picked_names_are_compared_never_opened(self):
listed = list_pictures(self.root, "people")
with self.assertRaises(ValueError) as gone:
choose(listed, ["../../private/secret.png"])
self.assertEqual(str(gone.exception), NONE_LEFT)
self.assertEqual([item["name"] for item in choose(listed, ["../../private/secret.png", "Anna.jpg"])], ["Anna.jpg"])
def test_nothing_picked_says_so(self):
listed = list_pictures(self.root, "people")
with self.assertRaises(ValueError) as nothing:
choose(listed, parse_picked("[]"))
self.assertEqual(str(nothing.exception), NONE_PICKED)
def test_a_damaged_list_is_refused(self):
for bad in ("{", '"photo.png"', "[1, 2]", '{"a": 1}'):
with self.assertRaises(ValueError) as refused:
parse_picked(bad)
self.assertEqual(str(refused.exception), BAD_PICKS)
def test_all_in_one_run_takes_every_picture_and_one_per_run_takes_one(self):
self.assertEqual(which(3, 2, RUNS[0], AT_THE_END[0]), [0, 1, 2])
self.assertEqual(which(3, 2, RUNS[1], AT_THE_END[0]), [1])
self.assertEqual(which(3, 0, RUNS[1], AT_THE_END[0]), [0])
def test_after_the_last_picture_it_stops_or_starts_over(self):
with self.assertRaises(RuntimeError) as stopped:
which(3, 4, RUNS[1], AT_THE_END[0])
self.assertEqual(str(stopped.exception), THE_END.format(count=3))
self.assertEqual(which(3, 4, RUNS[1], AT_THE_END[1]), [0])
self.assertEqual(which(3, 9, RUNS[1], AT_THE_END[1]), [2])
def test_the_saved_name_drops_the_ending_and_carries_the_folder(self):
self.assertEqual(output_name("photo_2.png"), "photo_2")
self.assertEqual(output_name("trips/beach.final.png"), "trips_beach.final")
self.assertEqual(output_name("v1.2/portrait"), "v1.2_portrait")
class LoadingTests(FolderCase):
def test_a_picture_loads_as_image_and_the_no_mask_stand_in(self):
image, mask = load_picture(self.root / "people" / "photo_2.png")
self.assertEqual(tuple(image.shape), (1, 30, 40, 3))
self.assertAlmostEqual(float(image[0, 0, 0, 0]), 200 / 255, places=4)
self.assertEqual(tuple(mask.shape), (1, *NO_MASK_SIZE))
self.assertEqual(float(mask.sum()), 0.0)
def test_a_see_through_part_is_white_in_the_mask(self):
path = self.root / "cut.png"
cut = Image.new("RGBA", (20, 10), (10, 20, 30, 255))
cut.paste((0, 0, 0, 0), (0, 0, 10, 10))
cut.save(path)
image, mask = load_picture(path)
self.assertEqual(tuple(mask.shape), (1, 10, 20))
self.assertEqual((float(mask[0, 5, 2]), float(mask[0, 5, 15])), (1.0, 0.0))
self.assertEqual(tuple(image.shape), (1, 10, 20, 3))
def test_a_turned_photo_comes_out_upright(self):
path = self.root / "turned.jpg"
photo = Image.new("RGB", (40, 20), (5, 5, 5))
exif = photo.getexif()
exif[0x0112] = 6
photo.save(path, exif=exif)
image, _mask = load_picture(path)
self.assertEqual(tuple(image.shape), (1, 40, 20, 3))
def test_a_thumbnail_is_small_and_follows_the_file(self):
path = self.root / "big.png"
picture(path, size=(900, 600))
first = thumbnail(path, 160)
small = Image.open(__import__("io").BytesIO(first))
self.assertEqual(max(small.size), 160)
self.assertIs(thumbnail(path, 160), first, "the same file gives the cached copy")
picture(path, size=(900, 600), color=(0, 200, 0))
os.utime(path, (2000, 2000))
self.assertIsNot(thumbnail(path, 160), first, "a rewritten file gets a new one")
def test_the_fingerprint_changes_with_the_files(self):
before = fingerprint(list_pictures(self.root, "people"))
self.assertEqual(before, fingerprint(list_pictures(self.root, "people")))
picture(self.root / "people" / "new.png")
self.assertNotEqual(before, fingerprint(list_pictures(self.root, "people")))
class NodeTests(FolderCase):
def test_all_in_one_run_gives_a_list_of_everything(self):
out = self.run_node()
images, masks, names, numbers, count = out["result"]
self.assertEqual((len(images), len(masks), count), (3, 3, 3))
self.assertEqual(names, ["Anna", "photo_2", "photo_10"])
self.assertEqual(numbers, [1, 2, 3])
self.assertEqual(out["ui"]["ausboss_image_folder"][0]["count"], 3)
def test_picked_pictures_only(self):
out = self.run_node(pictures=json.dumps(["photo_10.png", "Anna.jpg"]))
self.assertEqual(out["result"][2], ["Anna", "photo_10"])
self.assertEqual(out["result"][4], 2)
def test_one_per_run_loads_the_picture_at_its_place(self):
out = self.run_node(run=RUNS[1], position=2)
_images, _masks, names, numbers, count = out["result"]
self.assertEqual((names, numbers, count), (["photo_2"], [2], 3))
self.assertEqual(out["ui"]["ausboss_image_folder"][0], {"count": 3, "loaded": [2], "names": ["photo_2.png"]})
def test_one_per_run_stops_after_the_last_or_starts_over(self):
with self.assertRaises(RuntimeError) as stopped:
self.run_node(run=RUNS[1], position=4)
self.assertEqual(str(stopped.exception), THE_END.format(count=3))
self.assertEqual(self.run_node(run=RUNS[1], position=4, at_the_end=AT_THE_END[1])["result"][2], ["Anna"])
def test_a_pasted_path_inside_the_input_folder_loads(self):
out = self.run_node(folder=str(self.root / "people"))
self.assertEqual(out["result"][2], ["Anna", "photo_2", "photo_10"])
with self.assertRaises(ValueError) as refused:
self.run_node(folder=str(self.outside))
self.assertEqual(str(refused.exception), OUTSIDE)
self.assertIn("Drop your folder on the node", OUTSIDE)
def test_subfolders_carry_their_folder_in_the_name(self):
out = self.run_node(subfolders=True)
self.assertEqual(out["result"][2], ["Anna", "photo_2", "photo_10", "trips_beach"])
def test_an_empty_folder_says_what_to_do(self):
(self.root / "empty").mkdir()
with self.assertRaises(ValueError) as empty:
self.run_node(folder="empty")
self.assertIn('no pictures in "empty"', str(empty.exception))
self.assertIn("no pictures", node_image_folder.AusBossImageFolder.VALIDATE_INPUTS("input", "empty"))
self.assertIs(node_image_folder.AusBossImageFolder.VALIDATE_INPUTS("input", "people"), True)
self.assertEqual(node_image_folder.AusBossImageFolder.VALIDATE_INPUTS("input", "../private"), OUTSIDE)
self.assertIs(node_image_folder.AusBossImageFolder.VALIDATE_INPUTS("input", None), True)
def test_it_reruns_when_the_folder_changes_and_per_step(self):
changed = node_image_folder.AusBossImageFolder.IS_CHANGED
args = dict(source="input", folder="people", subfolders=False, sort="name", pictures="", run=RUNS[0], position=1, after_run="next", at_the_end=AT_THE_END[0])
first = changed(**args)
self.assertEqual(first, changed(**{**args, "position": 2}), "all in one run does not depend on the place")
self.assertNotEqual(changed(**{**args, "run": RUNS[1]}), changed(**{**args, "run": RUNS[1], "position": 2}))
picture(self.root / "people" / "added.png")
self.assertNotEqual(first, changed(**args))
def test_the_outputs_are_lists_except_the_count(self):
node = node_image_folder.AusBossImageFolder
self.assertEqual(node.RETURN_NAMES, ("image", "mask", "filename", "index", "count"))
self.assertEqual(node.OUTPUT_IS_LIST, (True, True, True, True, False))
def test_the_messages_are_ascii(self):
for text in (OUTSIDE, NONE_LEFT, NONE_PICKED, BAD_PICKS, THE_END.format(count=3)):
text.encode("ascii")
if __name__ == "__main__":
unittest.main()