425 lines
15 KiB
Python
425 lines
15 KiB
Python
"""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")) == []
|