"""Tests for the folder scanner and the 'bat pack-folder' subcommand.""" import argparse import pathlib import shutil import zipfile from shutil import copyfile import pytest from blender_asset_tracer import blendfile from blender_asset_tracer.pack import folder as folder_scan BLENDFILES = pathlib.Path(__file__).with_name("blendfiles") def make_blend(path: pathlib.Path) -> pathlib.Path: """Create a file that passes the blend file magic check.""" path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"BLENDER-v303RENDH" + b"\0" * 32) return path def names(paths, relative_to: pathlib.Path): return sorted(str(p.relative_to(relative_to)) for p in paths) @pytest.fixture(autouse=True) def close_cached_blendfiles(): yield blendfile.close_all_cached() class TestFindBlendFiles: def test_finds_files_recursively_in_sorted_order(self, tmp_path): make_blend(tmp_path / "b.blend") make_blend(tmp_path / "a.blend") make_blend(tmp_path / "sub" / "deep" / "c.blend") scan = folder_scan.find_blend_files(tmp_path) assert names(scan.blendfiles, tmp_path) == [ "a.blend", "b.blend", "sub/deep/c.blend", ] assert scan.skipped == [] assert scan.unreadable_dirs == [] def test_ignores_backup_and_other_files(self, tmp_path): make_blend(tmp_path / "scene.blend") make_blend(tmp_path / "scene.blend1") make_blend(tmp_path / "scene.blend2") (tmp_path / "notes.txt").write_text("hi") scan = folder_scan.find_blend_files(tmp_path) assert names(scan.blendfiles, tmp_path) == ["scene.blend"] # Non-.blend files aren't even considered, so they're not 'skipped'. assert scan.skipped == [] def test_uppercase_suffix_is_found(self, tmp_path): make_blend(tmp_path / "SCENE.BLEND") scan = folder_scan.find_blend_files(tmp_path) assert names(scan.blendfiles, tmp_path) == ["SCENE.BLEND"] def test_hidden_directories_are_pruned_by_default(self, tmp_path): make_blend(tmp_path / "scene.blend") make_blend(tmp_path / ".git" / "hidden.blend") make_blend(tmp_path / "__pycache__" / "cached.blend") scan = folder_scan.find_blend_files(tmp_path) assert names(scan.blendfiles, tmp_path) == ["scene.blend"] def test_custom_ignore_dirs(self, tmp_path): make_blend(tmp_path / "scene.blend") make_blend(tmp_path / "backup_2024" / "old.blend") make_blend(tmp_path / ".git" / "hidden.blend") scan = folder_scan.find_blend_files(tmp_path, ignore_dirs=["backup_*"]) # Only the custom pattern is applied, so '.git' is no longer pruned. assert names(scan.blendfiles, tmp_path) == [".git/hidden.blend", "scene.blend"] def test_non_blendfile_content_is_skipped(self, tmp_path): make_blend(tmp_path / "good.blend") (tmp_path / "bad.blend").write_bytes(b"this is not a blend file") scan = folder_scan.find_blend_files(tmp_path) assert names(scan.blendfiles, tmp_path) == ["good.blend"] assert len(scan.skipped) == 1 skipped_path, reason = scan.skipped[0] assert skipped_path == tmp_path / "bad.blend" assert reason == "not a blend file" def test_validation_can_be_disabled(self, tmp_path): make_blend(tmp_path / "good.blend") (tmp_path / "bad.blend").write_bytes(b"this is not a blend file") scan = folder_scan.find_blend_files(tmp_path, validate=False) assert names(scan.blendfiles, tmp_path) == ["bad.blend", "good.blend"] assert scan.skipped == [] def test_compressed_blendfile_is_accepted(self, tmp_path): copyfile( str(BLENDFILES / "basic_file_compressed.blend"), str(tmp_path / "compressed.blend"), ) scan = folder_scan.find_blend_files(tmp_path) assert names(scan.blendfiles, tmp_path) == ["compressed.blend"] def test_unreadable_directory_is_reported(self, tmp_path): make_blend(tmp_path / "scene.blend") bad_dir = tmp_path / "locked" make_blend(bad_dir / "hidden.blend") bad_dir.chmod(0o000) try: scan = folder_scan.find_blend_files(tmp_path) finally: bad_dir.chmod(0o755) assert names(scan.blendfiles, tmp_path) == ["scene.blend"] assert len(scan.unreadable_dirs) == 1 assert scan.unreadable_dirs[0][0] == bad_dir def test_symlinked_dir_skipped_by_default(self, tmp_path): make_blend(tmp_path / "root" / "scene.blend") make_blend(tmp_path / "elsewhere" / "linked.blend") (tmp_path / "root" / "link").symlink_to(tmp_path / "elsewhere") scan = folder_scan.find_blend_files(tmp_path / "root") assert names(scan.blendfiles, tmp_path / "root") == ["scene.blend"] def test_symlinked_dir_followed_on_request(self, tmp_path): make_blend(tmp_path / "root" / "scene.blend") make_blend(tmp_path / "elsewhere" / "linked.blend") (tmp_path / "root" / "link").symlink_to(tmp_path / "elsewhere") scan = folder_scan.find_blend_files(tmp_path / "root", follow_symlinks=True) assert names(scan.blendfiles, tmp_path / "root") == [ "link/linked.blend", "scene.blend", ] def test_symlink_loop_does_not_hang(self, tmp_path): root = tmp_path / "root" make_blend(root / "scene.blend") (root / "loop").symlink_to(root) scan = folder_scan.find_blend_files(root, follow_symlinks=True) assert names(scan.blendfiles, root) == ["scene.blend"] class TestLatestOnly: def test_keeps_highest_version(self, tmp_path): for version in (0, 3, 12, 7): make_blend(tmp_path / ("shot_v%03d.blend" % version)) scan = folder_scan.find_blend_files(tmp_path, latest_only=True) assert names(scan.blendfiles, tmp_path) == ["shot_v012.blend"] assert len(scan.skipped) == 3 assert all(reason == "superseded by a newer version" for _, reason in scan.skipped) def test_keeps_unversioned_files(self, tmp_path): make_blend(tmp_path / "shot_v001.blend") make_blend(tmp_path / "shot_v002.blend") make_blend(tmp_path / "shot.blend") make_blend(tmp_path / "no_version_here.blend") scan = folder_scan.find_blend_files(tmp_path, latest_only=True) assert names(scan.blendfiles, tmp_path) == [ "no_version_here.blend", "shot.blend", "shot_v002.blend", ] def test_variants_are_their_own_series(self, tmp_path): for name in ( "shot_v001.blend", "shot_v003.blend", "shot_v001_light.blend", "shot_v002_light.blend", "shot_v005_work.blend", ): make_blend(tmp_path / name) scan = folder_scan.find_blend_files(tmp_path, latest_only=True) assert names(scan.blendfiles, tmp_path) == [ "shot_v002_light.blend", "shot_v003.blend", "shot_v005_work.blend", ] def test_last_version_marker_wins(self, tmp_path): make_blend(tmp_path / "char_v001_rig_v002.blend") make_blend(tmp_path / "char_v001_rig_v007.blend") make_blend(tmp_path / "char_v002_rig_v001.blend") scan = folder_scan.find_blend_files(tmp_path, latest_only=True) assert names(scan.blendfiles, tmp_path) == [ "char_v001_rig_v007.blend", "char_v002_rig_v001.blend", ] def test_versions_are_grouped_per_directory_and_name(self, tmp_path): make_blend(tmp_path / "anim" / "shot_v001.blend") make_blend(tmp_path / "anim" / "shot_v002.blend") make_blend(tmp_path / "light" / "shot_v001.blend") make_blend(tmp_path / "light" / "other_v005.blend") scan = folder_scan.find_blend_files(tmp_path, latest_only=True) assert names(scan.blendfiles, tmp_path) == [ "anim/shot_v002.blend", "light/other_v005.blend", "light/shot_v001.blend", ] def test_case_insensitive_version_match(self, tmp_path): make_blend(tmp_path / "SHOT_V001.BLEND") make_blend(tmp_path / "SHOT_V002.BLEND") scan = folder_scan.find_blend_files(tmp_path, latest_only=True) assert names(scan.blendfiles, tmp_path) == ["SHOT_V002.BLEND"] def folder_parser(): from blender_asset_tracer.cli import pack as cli_pack parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() cli_pack.add_folder_parser(subparsers) return parser class TestCLIFolderParsing: def test_requires_target(self): with pytest.raises(SystemExit): folder_parser().parse_args(["pack-folder", "/some/folder"]) def test_requires_folder(self): with pytest.raises(SystemExit): folder_parser().parse_args(["pack-folder", "-t", "out.zip"]) def test_defaults(self): args = folder_parser().parse_args( ["pack-folder", "/some/folder", "-t", "out.zip"] ) assert args.folder == pathlib.Path("/some/folder") assert args.target == "out.zip" assert args.project is None assert args.latest_only is False assert args.keep_hierarchy is False assert args.follow_symlinks is False assert args.validate is True assert args.ignore_dir == list(folder_scan.DEFAULT_IGNORE_DIRS) def test_flags(self): args = folder_parser().parse_args([ "pack-folder", "/some/folder", "-t", "out.zip", "--latest-only", "--keep-hierarchy", "--no-validate", "--ignore-dir", "backup*", "old*", ]) assert args.latest_only is True assert args.keep_hierarchy is True assert args.validate is False assert args.ignore_dir == ["backup*", "old*"] def run_pack_folder(argv): args = folder_parser().parse_args(argv) args.func(args) def make_linked_pair(folder: pathlib.Path) -> None: """Copy linked_cube.blend + the basic_file.blend it links to into `folder`.""" folder.mkdir(parents=True, exist_ok=True) for name in ("linked_cube.blend", "basic_file.blend"): copyfile(str(BLENDFILES / name), str(folder / name)) def make_absolute_link(blendpath: pathlib.Path, target: pathlib.Path) -> None: """Rewrite the library path of `blendpath` to the absolute path of `target`.""" bfile = blendfile.BlendFile(blendpath, mode="r+b") try: library = bfile.code_index[b"LI"][0] abspath = str(target).encode() library[b"filepath"] = abspath library[b"name"] = abspath finally: bfile.close() blendfile.close_all_cached() class TestPackFolderEndToEnd: def test_packs_folder_to_directory(self, tmp_path): source = tmp_path / "project" make_linked_pair(source) target = tmp_path / "pack" run_pack_folder(["pack-folder", str(source), "-t", str(target)]) assert (target / "linked_cube.blend").exists() assert (target / "basic_file.blend").exists() assert (target / "pack-info.txt").exists() # Everything was inside the project, so nothing is packed outside it. assert not (target / "_outside_project").exists() info = (target / "pack-info.txt").read_text() assert "2 blend files" in info def test_packs_folder_to_zip(self, tmp_path): source = tmp_path / "project" make_linked_pair(source) target = tmp_path / "pack.zip" run_pack_folder(["pack-folder", str(source), "-t", str(target)]) with zipfile.ZipFile(str(target)) as inzip: assert inzip.testzip() is None arcnames = set(inzip.namelist()) assert "linked_cube.blend" in arcnames assert "basic_file.blend" in arcnames assert "pack-info.txt" in arcnames def test_nested_blendfiles_keep_their_relative_place(self, tmp_path): source = tmp_path / "project" make_linked_pair(source / "shots" / "sq01") target = tmp_path / "pack" run_pack_folder(["pack-folder", str(source), "-t", str(target)]) assert (target / "shots" / "sq01" / "linked_cube.blend").exists() assert (target / "shots" / "sq01" / "basic_file.blend").exists() def test_dependency_that_is_also_packed_stays_in_place(self, tmp_path): """A blend file linked by absolute path is packed only at its own spot.""" source = tmp_path / "project" make_linked_pair(source) make_absolute_link(source / "linked_cube.blend", source / "basic_file.blend") target = tmp_path / "pack" run_pack_folder(["pack-folder", str(source), "-t", str(target)]) assert (target / "linked_cube.blend").exists() assert (target / "basic_file.blend").exists() # The absolute path was rewritten to the in-pack file, rather than # duplicating basic_file.blend into _outside_project. assert not (target / "_outside_project").exists() def test_latest_only_through_cli(self, tmp_path): source = tmp_path / "project" make_linked_pair(source) copyfile( str(BLENDFILES / "basic_file.blend"), str(source / "extra_v001.blend") ) copyfile( str(BLENDFILES / "basic_file.blend"), str(source / "extra_v002.blend") ) target = tmp_path / "pack" run_pack_folder(["pack-folder", str(source), "-t", str(target), "--latest-only"]) assert (target / "extra_v002.blend").exists() assert not (target / "extra_v001.blend").exists() def test_missing_folder_exits(self, tmp_path): with pytest.raises(SystemExit) as exc: run_pack_folder([ "pack-folder", str(tmp_path / "nope"), "-t", str(tmp_path / "pack") ]) assert exc.value.code == 3 def test_file_instead_of_folder_exits(self, tmp_path): somefile = make_blend(tmp_path / "scene.blend") with pytest.raises(SystemExit) as exc: run_pack_folder( ["pack-folder", str(somefile), "-t", str(tmp_path / "pack")] ) assert exc.value.code == 3 def test_empty_folder_exits(self, tmp_path): source = tmp_path / "empty" source.mkdir() with pytest.raises(SystemExit) as exc: run_pack_folder( ["pack-folder", str(source), "-t", str(tmp_path / "pack")] ) assert exc.value.code == 3 def test_exclude_globs_are_honoured(self, tmp_path): source = tmp_path / "project" source.mkdir() shutil.copytree( str(BLENDFILES / "textures"), str(source / "textures"), dirs_exist_ok=True ) copyfile( str(BLENDFILES / "material_textures.blend"), str(source / "material_textures.blend"), ) target = tmp_path / "pack" run_pack_folder( ["pack-folder", str(source), "-t", str(target), "-e", "*.jpg"] ) assert (target / "material_textures.blend").exists() assert list(target.rglob("*.jpg")) == []