From ba8de1253ed46a27f5caad58590d38389e4e821a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 16:24:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20folder=20scan=20=E2=80=94=20depth-limit?= =?UTF-8?q?ed=20natural-sorted=20image=20listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- gates/scan.py | 30 ++++++++++++++++++++++++++++++ tests/test_scan.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 gates/scan.py create mode 100644 tests/test_scan.py diff --git a/gates/scan.py b/gates/scan.py new file mode 100644 index 0000000..08f7adb --- /dev/null +++ b/gates/scan.py @@ -0,0 +1,30 @@ +"""Pure folder-scan layer for Folder Image Loader. Stdlib only — no torch.""" +import os +import re +from pathlib import Path + +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"} + + +def natural_key(s): + return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)] + + +def list_images(folder, depth=0): + root = Path(folder) + if not root.is_dir(): + raise NotADirectoryError(f"Not a folder: {folder}") + root_depth = len(root.parts) + results = [] + for dirpath, dirnames, filenames in os.walk(root): + cur = Path(dirpath) + rel_depth = len(cur.parts) - root_depth + if depth >= 0 and rel_depth >= depth: + dirnames[:] = [] # don't descend past `depth` + if depth >= 0 and rel_depth > depth: + continue + for name in filenames: + if Path(name).suffix.lower() in IMAGE_EXTS: + results.append(str(cur / name)) + results.sort(key=lambda p: natural_key(os.path.relpath(p, root))) + return results diff --git a/tests/test_scan.py b/tests/test_scan.py new file mode 100644 index 0000000..e3e76c8 --- /dev/null +++ b/tests/test_scan.py @@ -0,0 +1,39 @@ +# tests/test_scan.py +from gates import scan + +def _touch(p, data=b"x"): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(data) + +def test_natural_sort_orders_numerically(): + items = ["img10.png", "img2.png", "img1.png"] + assert sorted(items, key=scan.natural_key) == ["img1.png", "img2.png", "img10.png"] + +def test_list_images_top_level_only_default(tmp_path): + _touch(tmp_path / "a.png"); _touch(tmp_path / "b.jpg"); _touch(tmp_path / "note.txt") + _touch(tmp_path / "sub" / "c.png") + got = [p.split("/")[-1] for p in scan.list_images(str(tmp_path))] + assert got == ["a.png", "b.jpg"] # depth 0: no sub/, no .txt + +def test_list_images_depth_one(tmp_path): + _touch(tmp_path / "a.png") + _touch(tmp_path / "sub" / "c.png") + _touch(tmp_path / "sub" / "deep" / "d.png") + got = [p.split("/")[-1] for p in scan.list_images(str(tmp_path), depth=1)] + assert got == ["a.png", "c.png"] # depth 1: include sub/, not sub/deep/ + +def test_list_images_unlimited_depth(tmp_path): + _touch(tmp_path / "a.png"); _touch(tmp_path / "sub" / "deep" / "d.png") + got = scan.list_images(str(tmp_path), depth=-1) + assert len(got) == 2 + +def test_list_images_natural_sort_by_relpath(tmp_path): + for n in ["img1.png", "img2.png", "img10.png"]: + _touch(tmp_path / n) + got = [p.split("/")[-1] for p in scan.list_images(str(tmp_path))] + assert got == ["img1.png", "img2.png", "img10.png"] + +def test_list_images_bad_path_raises(tmp_path): + import pytest + with pytest.raises(NotADirectoryError): + scan.list_images(str(tmp_path / "nope"))