import collections import functools import logging import sys import typing from typing import Optional from blender_asset_tracer import trace, blendfile from blender_asset_tracer.blendfile import dna from tests.abstract_test import AbstractBlendFileTest # Mimicks a BlockUsage, but without having to set the block to an expected value. Expect = collections.namedtuple( "Expect", "type full_field dirname_field basename_field asset_path is_sequence" ) class AbstractTracerTest(AbstractBlendFileTest): @classmethod def setUpClass(cls): super().setUpClass() logging.getLogger("blender_asset_tracer.tracer").setLevel(logging.DEBUG) class AssetHoldingBlocksTest(AbstractTracerTest): def setUp(self): self.bf = blendfile.BlendFile(self.blendfiles / "basic_file.blend") def test_simple_file(self): # This file should not depend on external assets. blocks_seen = 0 seen_scene = seen_ob = False for block in trace.asset_holding_blocks(self.bf.blocks): assert isinstance(block, blendfile.BlendFileBlock) blocks_seen += 1 # The four-letter-code blocks don't refer to assets, so they # shouldn't be yielded. self.assertEqual(2, len(block.code)) # World blocks should not yielded either. self.assertNotEqual(b"WO", block.code) # Do some arbitrary tests that convince us stuff is read well. if block.code == b"SC": seen_scene = True self.assertEqual(b"SCScene", block.id_name) continue if block.code == b"OB": seen_ob = True self.assertEqual("OBümlaut", block.get((b"id", b"name"), as_str=True)) continue self.assertTrue(seen_scene) self.assertTrue(seen_ob) # Many of the data blocks are skipped, because asset_holding_blocks() only # yields top-level, directly-understandable blocks. # # The numbers here are taken from whatever the code does now; I didn't # count the actual blocks in the actual blend file. self.assertEqual(965, len(self.bf.blocks)) self.assertEqual(4, blocks_seen) class DepsTest(AbstractTracerTest): @staticmethod def field_name(field: Optional[dna.Field]) -> typing.Optional[str]: if field is None: return None return field.name.name_full.decode() def assert_deps(self, blend_fname, expects: dict): for dep in trace.deps(self.blendfiles / blend_fname): actual_type = dep.block.dna_type.dna_type_id.decode() actual_full_field = self.field_name(dep.path_full_field) actual_dirname = self.field_name(dep.path_dir_field) actual_basename = self.field_name(dep.path_base_field) actual = Expect( actual_type, actual_full_field, actual_dirname, actual_basename, dep.asset_path, dep.is_sequence, ) exp = expects.get(dep.block_name, None) if isinstance(exp, (set, list)): self.assertIn(actual, exp, msg="for block %r" % dep.block_name) exp.remove(actual) if not exp: # Don't leave empty sets in expects. del expects[dep.block_name] elif exp is None: self.assertIsNone( actual, msg="unexpected dependency of block %r" % dep.block_name ) del expects[dep.block_name] else: self.assertEqual(exp, actual, msg="for block %r" % dep.block_name) del expects[dep.block_name] # All expected uses should have been seen. self.assertEqual(expects, {}, "Expected results were not seen.") def test_no_deps(self): self.assert_deps("basic_file.blend", {}) def test_ob_mat_texture(self): expects = { b"IMbrick_dotted_04-bump": Expect( "Image", "name[1024]", None, None, b"//textures/Bricks/brick_dotted_04-bump.jpg", False, ), b"IMbrick_dotted_04-color": Expect( "Image", "name[1024]", None, None, b"//textures/Bricks/brick_dotted_04-color.jpg", False, ), # This data block is in there, but the image is packed, so it # shouldn't be in the results. # b'IMbrick_dotted_04-specular': Expect( # 'Image', 'name[1024]', None, None, # b'//textures/Bricks/brick_dotted_04-specular.jpg', False), b"IMbuildings_roof_04-color": Expect( "Image", "name[1024]", None, None, b"//textures/Textures/Buildings/buildings_roof_04-color.png", False, ), } self.assert_deps("material_textures.blend", expects) def test_seq_image_sequence(self): expects = { b"-unnamed-": [ Expect( "Strip", None, "dir[768]", "name[256]", b"//imgseq/000210.png", True ), # Video strip reference. Expect( "Strip", None, "dir[768]", "name[256]", b"//../../../../cloud/pillar/testfiles/video-tiny.mkv", False, ), # The sound will be referenced twice, from the sequence strip and an SO data block. Expect( "Strip", None, "dir[768]", "name[256]", b"//../../../../cloud/pillar/testfiles/video-tiny.mkv", False, ), ], b"SOvideo-tiny.mkv": Expect( "bSound", "name[1024]", None, None, b"//../../../../cloud/pillar/testfiles/video-tiny.mkv", False, ), } self.assert_deps("image_sequencer.blend", expects) # Test the filename expansion. expected = [ self.blendfiles / ("imgseq/%06d.png" % num) for num in range(210, 215) ] for dep in trace.deps(self.blendfiles / "image_sequencer.blend"): if dep.block_name != b"SQ000210.png": continue actual = list(dep.files()) self.assertEqual(actual, expected) def test_seq_image_udim_sequence(self): expects = { b"IMcube_UDIM.color": Expect( "Image", "name[1024]", None, None, b"//cube_UDIM.color..png", True, ), } self.assert_deps("udim/v01_UDIM_BAT_debugging.blend", expects) def test_block_cf(self): self.assert_deps( "alembic-user.blend", { b"CFclothsim.abc": Expect( "CacheFile", "filepath[1024]", None, None, b"//clothsim.abc", False ), }, ) def test_alembic_sequence(self): self.assert_deps( "alembic-sequence-user.blend", { b"CFclothsim_alembic": Expect( "CacheFile", "filepath[1024]", None, None, b"//clothsim.030.abc", True, ), }, ) # Test the filename expansion. expected = [ self.blendfiles / ("clothsim.%03d.abc" % num) for num in range(30, 36) ] performed_test = False for dep in trace.deps(self.blendfiles / "alembic-sequence-user.blend"): if dep.block_name != b"CFclothsim_alembic": continue actual = list(dep.files()) self.assertEqual(actual, expected) performed_test = True self.assertTrue(performed_test) def test_block_mc(self): self.assert_deps( "movieclip.blend", { b"MCvideo.mov": Expect( "MovieClip", "name[1024]", None, None, b"//../../../../cloud/pillar/testfiles/video.mov", False, ), }, ) def test_block_me(self): self.assert_deps( "multires_external.blend", { b"MECube": Expect( "Mesh", "filename[1024]", None, None, b"//Cube.btx", False ), }, ) def test_ocean(self): self.assert_deps( "ocean_modifier.blend", { b"OBPlane.modifiers[0]": Expect( "OceanModifierData", "cachepath[1024]", None, None, b"//cache_ocean", True, ), }, ) def test_particles(self): # This file has an empty name for the cache, which should result in some hex magic # to create a name. See ptcache_filename() in pointcache.c. self.assert_deps( "T55539-particles/particle.blend", { b"OBCube.modifiers[0]": Expect( "PointCache", "name[64]", None, None, b"//blendcache_particle/43756265_*.bphys", True, ), }, ) def test_smoke(self): # This file has an empty name for the cache, which should result in some hex magic # to create a name. See ptcache_filename() in pointcache.c. self.assert_deps( "T55542-smoke/smoke_cache.blend", { b"OBSmoke Domain.modifiers[0]": Expect( "PointCache", "name[64]", None, None, b"//blendcache_smoke_cache/536D6F6B6520446F6D61696E_*.bphys", True, ), }, ) self.assert_deps( "T55542-smoke/smoke_cache_vdb.blend", { b"OBSmoke Domain.modifiers[0]": Expect( "PointCache", "name[64]", None, None, b"//blendcache_smoke_cache_vdb/536D6F6B6520446F6D61696E_*.vdb", True, ), }, ) def test_mesh_cache(self): self.assert_deps( "meshcache-user.blend", { b"OBPlane.modifiers[0]": Expect( "MeshCacheModifierData", "filepath[1024]", None, None, b"//meshcache.mdd", False, ), }, ) def test_block_vf(self): self.assert_deps( "with_font.blend", { b"VFHack-Bold": Expect( "VFont", "name[1024]", None, None, b"/usr/share/fonts/truetype/hack/Hack-Bold.ttf", False, ), }, ) def test_block_li(self): self.assert_deps( "linked_cube.blend", { b"LILib": Expect( "Library", "name[1024]", None, None, b"//basic_file.blend", False ), }, ) def test_block_li_packed(self): # Packed libraries should not be traced. self.assert_deps("74871-packed-libraries.blend", {}) def test_deps_recursive(self): self.assert_deps( "doubly_linked.blend", { b"LILib": { # From doubly_linked.blend Expect( "Library", "name[1024]", None, None, b"//linked_cube.blend", False, ), # From linked_cube.blend Expect( "Library", "name[1024]", None, None, b"//basic_file.blend", False, ), }, b"LILib.002": Expect( "Library", "name[1024]", None, None, b"//material_textures.blend", False, ), # From material_texture.blend b"IMbrick_dotted_04-bump": Expect( "Image", "name[1024]", None, None, b"//textures/Bricks/brick_dotted_04-bump.jpg", False, ), b"IMbrick_dotted_04-color": Expect( "Image", "name[1024]", None, None, b"//textures/Bricks/brick_dotted_04-color.jpg", False, ), # This data block is in the basic_file.blend file, but not used by # any of the objects linked in from linked_cube.blend or # doubly_linked.blend, hence it should *not* be reported: # b'IMbuildings_roof_04-color': Expect( # 'Image', 'name[1024]', None, None, # b'//textures/Textures/Buildings/buildings_roof_04-color.png', False), }, ) def test_geometry_nodes(self): self.assert_deps( "geometry-nodes/file_to_pack.blend", { b"LInode_lib.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//node_lib.blend", is_sequence=False, ), b"LIobject_lib.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//object_lib.blend", is_sequence=False, ), }, ) def test_geometry_nodes_modifier_input(self): """Test linked collection as input to geom nodes modifier. Here a Geometry Nodes modifier references a collection that is not instanced into the scene, which caused it to be missed. """ self.assert_deps( "geometry-nodes-2/shot_file.blend", { b"LIset_file.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//set_file.blend", is_sequence=False, ), b"LIlib_trash.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//lib_trash.blend", is_sequence=False, ), }, ) def test_geometry_nodes_node_input(self): """Test linked collection as input to a geom node. Here a Geometry Nodes node references a collection that is not instanced into the scene, which caused it to be missed. """ self.assert_deps( "geometry-nodes-3/shot_file.blend", { b"LIset_file.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//set_file.blend", is_sequence=False, ), b"LIlib_trash.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//lib_trash.blend", is_sequence=False, ), }, ) def test_compositor_nodes(self) -> None: """Test compositor node trees. Since Blender 5.0 these use a different DNA field, and can also be linked from other files. """ self.assert_deps( "compositor_nodes/compositor_nodes_blender500_workfile.blend", { b"LIcompositor_nodes_blender500_library.blend": Expect( type="Library", full_field="name[1024]", dirname_field=None, basename_field=None, asset_path=b"//compositor_nodes_blender500_library.blend", is_sequence=False, ), }, ) def test_usage_abspath(self): deps = [ dep for dep in trace.deps(self.blendfiles / "doubly_linked.blend") if dep.asset_path == b"//material_textures.blend" ] usage = deps[0] expect = self.blendfiles / "material_textures.blend" self.assertEqual(expect, usage.abspath) def test_sim_data(self): self.assert_deps( "T53562/bam_pack_bug.blend", { b"OBEmitter.modifiers[0]": Expect( "PointCache", "name[64]", None, None, b"//blendcache_bam_pack_bug/particles_*.bphys", True, ), }, ) def test_geonodes_sim_data(self) -> None: # Simplify the rest of the code by putting the values that are the same of all cases here: expect_bake = functools.partial( Expect, dirname_field=None, basename_field=None, is_sequence=True, ) expects = { # Two objects that use "Inherit from Modifer": b"OBCustom Bake Path.modifiers[0].bakes[0]": [ # Custom path set on the sim node, so this is sim node data. expect_bake( type="NodesModifierBake", full_field="*directory", asset_path=b"//bakePath", ), ], b"OBDefault Bake Path.modifiers[0].bakes[0]": [ # NO custom path set on the sim node, so this follows the modifier data. expect_bake( type="NodesModifierData", full_field="*simulation_bake_directory", asset_path=b"//config-on-sim-node", ), ], # Two objects that have the config only on the node itself: b"OBCustom Bake Path.001.modifiers[0].bakes[0]": [ expect_bake( type="NodesModifierBake", full_field="*directory", asset_path=b"//set-on-node", ), ], b"OBDefault Bake Path.001.modifiers[0].bakes[0]": [ expect_bake( type="NodesModifierData", full_field="*simulation_bake_directory", asset_path=b"//only-set-on-modifier", ), ], } # NOTE: there are two more objects in the scene, 'Packed Bake' and # 'Packed Bake.001'. But, because those use packed data (on the modifier # resp. bake level), they should not be listed as dependencies. self.maxDiff = None self.assert_deps("geometry-nodes-sim/geonodes-sim-cache.blend", expects) def test_recursion_loop(self): infinite_bfile = self.blendfiles / "recursive_dependency_1.blend" reclim = sys.getrecursionlimit() try: sys.setrecursionlimit(100) # This should finish without hitting the recursion limit. for _ in trace.deps(infinite_bfile): pass finally: sys.setrecursionlimit(reclim)