Files
test2/tests/python/bl_blendfile_relationships.py
Sybren A. Stüvel d33a6a1723 Python: add function for file iteration/remapping bpy.data.file_path_foreach()
Add a function `bpy.data.file_path_foreach()` with a callback function
`visit_path_fn`:

```py
def visit_path_fn(
    owner_id: bpy.types.ID,
    file_path: str,
    _meta: typing.Any,
) -> str | None:
    return file_path.replace('xxx', 'yyy') or None

bpy.data.file_path_foreach(
    visit_path_fn,
    *,
    subset=None,
    visit_types=None,
    flags={},
)
```

When the function returns `None`, nothing happens. When it returns a
string, the reported file path is replaced with the returned string.

`subset` and `visit_types` have the same meaning as `subset` resp.
`key_types` in `bpy.data.file_path_map()`. Since the latter parameter
doesn't affect keys in a map, but rather the visited ID types, I named
it `visit_types` instead.

`flags` wraps most of `eBPathForeachFlag` as `set[str]`, with their
prefixes removed. `BKE_BPATH_FOREACH_PATH_RESOLVE_TOKEN` becomes
`RESOLVE_TOKEN`, etc. `BKE_BPATH_FOREACH_PATH_ABSOLUTE` is excluded,
as it only affects a field in the C++ callback structure that is not
passed to Python at all. If it turns out to be useful at some point,
we can always add it.

`_meta` is for future use, and is intended to give metadata of the
path (like whether it's an input or an output path, a single file or
the first file in a sequence, supports variables, etc.). By adding
this to the API now, we avoid a breaking change when this feature is
actually implemented.

Design task: #145734

Pull Request: https://projects.blender.org/blender/blender/pulls/146261
2025-10-06 17:10:49 +02:00

338 lines
13 KiB
Python

# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
"""
blender -b -P tests/python/bl_blendfile_relationships.py --src-test-dir tests/files --output-dir /tmp/blendfile_io/
"""
__all__ = (
"main",
)
import bpy
import os
import sys
from pathlib import Path
from bpy.path import native_pathsep
_my_dir = Path(__file__).resolve().parent
sys.path.append(str(_my_dir))
from bl_blendfile_utils import TestBlendLibLinkHelper, TestHelper
class TestBlendUserMap(TestBlendLibLinkHelper):
def __init__(self, args):
super().__init__(args)
def test_user_map(self):
output_dir = self.args.output_dir
output_blendfile_path = self.init_lib_data_indirect_lib()
# Simple link of a single ObData.
self.reset_blender()
bpy.ops.wm.open_mainfile(filepath=output_blendfile_path)
self.assertEqual(len(bpy.data.images), 1)
self.assertIsNotNone(bpy.data.images[0].library)
self.assertEqual(len(bpy.data.materials), 1)
self.assertIsNotNone(bpy.data.materials[0].library)
self.assertEqual(len(bpy.data.meshes), 1)
self.assertEqual(len(bpy.data.objects), 1)
self.assertEqual(len(bpy.data.collections), 1)
user_map = bpy.data.user_map()
# Note: Workspaces and screens are ignored here.
expected_map = {
bpy.data.images[0]: {bpy.data.materials[0]},
bpy.data.materials[0]: {bpy.data.meshes[0]},
bpy.data.scenes[0]: {bpy.data.window_managers[0]},
bpy.data.collections[0]: {bpy.data.scenes[0]},
bpy.data.libraries[0]: set(),
bpy.data.meshes[0]: {bpy.data.objects[0]},
bpy.data.objects[0]: {bpy.data.scenes[0],
bpy.data.collections[0]},
bpy.data.window_managers[0]: set(),
}
for k, v in expected_map.items():
self.assertIn(k, user_map)
self.assertEqual(user_map[k], v, msg=f"ID {k.name} has unexpected user map")
user_map = bpy.data.user_map(subset=[bpy.data.objects[0], bpy.data.meshes[0]])
expected_map = {
bpy.data.meshes[0]: {bpy.data.objects[0]},
bpy.data.objects[0]: {bpy.data.scenes[0],
bpy.data.collections[0]},
}
for k, v in expected_map.items():
self.assertIn(k, user_map)
self.assertEqual(user_map[k], v, msg=f"ID {k.name} has unexpected user map")
user_map = bpy.data.user_map(key_types={'OBJECT', 'MESH'})
for k, v in expected_map.items():
self.assertIn(k, user_map)
self.assertEqual(user_map[k], v, msg=f"ID {k.name} has unexpected user map")
user_map = bpy.data.user_map(value_types={'SCENE'})
expected_map = {
bpy.data.collections[0]: {bpy.data.scenes[0]},
bpy.data.objects[0]: {bpy.data.scenes[0]},
}
for k, v in expected_map.items():
self.assertIn(k, user_map)
self.assertEqual(user_map[k], v, msg=f"ID {k.name} has unexpected user map")
# Test handling of invalid parameters
self.assertRaises(ValueError, bpy.data.user_map, value_types={'FOOBAR'})
self.assertRaises(TypeError, bpy.data.user_map, subset=[bpy.data.objects[0], bpy.data.meshes[0], "FooBar"])
class TestBlendFilePathMap(TestBlendLibLinkHelper):
def __init__(self, args):
super().__init__(args)
def test_file_path_map(self):
def abspaths(file_path_map):
return {k: {os.path.normpath(bpy.path.abspath(p)) for p in v}
for k, v in file_path_map.items()}
output_dir = self.args.output_dir
output_blendfile_path = self.init_lib_data_indirect_lib()
# Simple link of a single ObData.
self.reset_blender()
bpy.ops.wm.open_mainfile(filepath=output_blendfile_path)
self.assertEqual(len(bpy.data.images), 1)
self.assertIsNotNone(bpy.data.images[0].library)
self.assertEqual(len(bpy.data.materials), 1)
self.assertIsNotNone(bpy.data.materials[0].library)
self.assertEqual(len(bpy.data.meshes), 1)
self.assertEqual(len(bpy.data.objects), 1)
self.assertEqual(len(bpy.data.collections), 1)
blendlib_path = os.path.normpath(bpy.path.abspath(bpy.data.materials[0].library.filepath))
image_path = os.path.join(native_pathsep(self.args.src_test_dir),
native_pathsep('imbuf_io/reference/jpeg-rgb-90__from__rgba08.jpg'))
file_path_map = abspaths(bpy.data.file_path_map())
# Note: Workspaces and screens are ignored here.
expected_map = {
bpy.data.images[0]: {image_path},
bpy.data.materials[0]: set(),
bpy.data.scenes[0]: set(),
bpy.data.collections[0]: set(),
bpy.data.libraries[0]: {blendlib_path},
bpy.data.meshes[0]: set(),
bpy.data.objects[0]: set(),
bpy.data.window_managers[0]: set(),
}
for k, v in expected_map.items():
self.assertIn(k, file_path_map)
self.assertEqual(file_path_map[k], v, msg=f"ID {k.name} has unexpected filepath map")
file_path_map = abspaths(bpy.data.file_path_map(include_libraries=True))
# Note: Workspaces and screens are ignored here.
expected_map = {
bpy.data.images[0]: {image_path, blendlib_path},
bpy.data.materials[0]: {blendlib_path},
bpy.data.scenes[0]: set(),
bpy.data.collections[0]: set(),
bpy.data.libraries[0]: {blendlib_path},
bpy.data.meshes[0]: set(),
bpy.data.objects[0]: set(),
bpy.data.window_managers[0]: set(),
}
for k, v in expected_map.items():
self.assertIn(k, file_path_map)
self.assertEqual(file_path_map[k], v, msg=f"ID {k.name} has unexpected filepath map")
file_path_map = abspaths(bpy.data.file_path_map(subset=[bpy.data.images[0], bpy.data.materials[0]]))
expected_map = {
bpy.data.images[0]: {image_path},
bpy.data.materials[0]: set(),
}
for k, v in expected_map.items():
self.assertIn(k, file_path_map)
self.assertEqual(file_path_map[k], v, msg=f"ID {k.name} has unexpected filepath map")
partial_map = abspaths(bpy.data.file_path_map(key_types={'IMAGE', 'MATERIAL'}))
for k, v in expected_map.items():
self.assertIn(k, file_path_map)
self.assertEqual(file_path_map[k], v, msg=f"ID {k.name} has unexpected filepath map")
# Test handling of invalid parameters
self.assertRaises(ValueError, bpy.data.file_path_map, key_types={'FOOBAR'})
self.assertRaises(TypeError, bpy.data.file_path_map, subset=[bpy.data.objects[0], bpy.data.images[0], "FooBar"])
class TestBlendFilePathForeach(TestHelper):
testdir: Path
def setUp(self) -> None:
super().setUp()
# File paths can get long, and thus also so can diffs when things go wrong.
self.maxDiff = 10240
self.testdir = Path(self.args.src_test_dir) / "libraries_and_linking"
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "library_test_scene.blend"))
# Name the library data-blocks after their filename, so that they can be reliably identified later.
# Otherwise we're stuck with 'Lib', 'Lib.001', etc.
for lib_id in bpy.data.libraries[:]:
abspath = Path(str(bpy.path.abspath(lib_id.filepath)))
lib_id.name = abspath.stem
def test_without_args(self) -> None:
"""Test file_path_foreach() without any arguments, it should report everything but packed files."""
visited_paths = self._file_path_foreach()
self.assertEqual({
(bpy.data.libraries['direct_linked_A'], self.testdir / "libraries/direct_linked_A.blend"),
(bpy.data.libraries['direct_linked_B'], self.testdir / "libraries/direct_linked_B.blend"),
(bpy.data.libraries['indirect_datablocks'], self.testdir / "libraries/indirect_datablocks.blend"),
}, visited_paths)
def test_with_nondefault_flag(self) -> None:
"""Test file_path_foreach() with a non-default flag.
If any non-default flag works, it's enough as a test to assume the flags
are parsed and passed to the C++ code correctly. There is no need to
exhaustively test all the flags here.
"""
# The default value for `flags` is {'SKIP_PACKED',
# 'SKIP_WEAK_REFERENCES'}, so to also see the packed files, only pass
# `SKIP_WEAK_REFERENCES`.
visited_paths = self._file_path_foreach(flags={'SKIP_WEAK_REFERENCES'})
self.assertEqual({
(bpy.data.libraries['direct_linked_A'], self.testdir / "libraries/direct_linked_A.blend"),
(bpy.data.libraries['direct_linked_B'], self.testdir / "libraries/direct_linked_B.blend"),
(bpy.data.libraries['indirect_datablocks'], self.testdir / "libraries/indirect_datablocks.blend"),
(bpy.data.images['pack.png'], self.testdir / "libraries/pack.png"),
}, visited_paths, "testing without SKIP_PACKED")
def test_filepath_rewriting(self) -> None:
# Store the pre-modification value, as its use of (back)slashes is platform-dependent.
image_filepath_before = str(bpy.data.images['pack.png'].filepath)
def visit_path_fn(owner_id: bpy.types.ID, path: str, _meta: None) -> str | None:
return "//{}-rewritten.blend".format(owner_id.name)
bpy.data.file_path_foreach(visit_path_fn)
libs = bpy.data.libraries
self.assertEqual(libs['direct_linked_A'].filepath, "//direct_linked_A-rewritten.blend")
self.assertEqual(libs['direct_linked_B'].filepath, "//direct_linked_B-rewritten.blend")
self.assertEqual(libs['indirect_datablocks'].filepath, "//indirect_datablocks-rewritten.blend")
self.assertEqual(
bpy.data.images['pack.png'].filepath,
image_filepath_before,
"Packed file should not have changed")
def test_exception_passing(self) -> None:
"""Python exceptions in the callback function should be raised by file_path_foreach()."""
# Any Python exception should work, not just built-in ones.
class CustomException(Exception):
pass
def visit_path_fn(_owner_id: bpy.types.ID, _path: str, meta: None) -> str | None:
raise CustomException("arg0", 1, "arg2")
try:
bpy.data.file_path_foreach(visit_path_fn)
except CustomException as ex:
self.assertEqual(("arg0", 1, "arg2"), ex.args, "Parameters passed to the exception should be retained")
else:
self.fail("Expected exception not thrown")
def test_meta_parameter(self) -> None:
def visit_path_fn(_owner_id: bpy.types.ID, _path: str, meta: None) -> str | None:
# This is proven to work by the `test_exception_passing()` test above.
self.assertIsNone(
meta,
"The meta parameter is expected to be None; this test is expected to fail "
"once the metadata feature is actually getting implemented, and should then "
"be replaced with a proper test.")
bpy.data.file_path_foreach(visit_path_fn)
@staticmethod
def _file_path_foreach(
subtypes: set[str] | None = None,
visit_keys: set[str] | None = None,
flags: set[str] | None = None,
) -> set[tuple[bpy.types.ID, Path]]:
"""Call bpy.data.file_path_foreach(), returning the visited paths as set of (datablock, path) tuples.
A set is used because the order of visiting is not relevant.
"""
visisted_paths: set[tuple[bpy.types.ID, Path]] = set()
def visit_path_fn(owner_id: bpy.types.ID, path: str, _meta: None) -> str | None:
abspath = Path(str(bpy.path.abspath(path, library=owner_id.library)))
visisted_paths.add((owner_id, abspath))
# Dynamically build the keyword arguments, because the None values are not
# valid, and should be encoded as not having the kwarg.
kwargs = {}
if subtypes is not None:
kwargs['subtypes'] = subtypes
if visit_keys is not None:
kwargs['visit_keys'] = visit_keys
if flags is not None:
kwargs['flags'] = flags
bpy.data.file_path_foreach(visit_path_fn, **kwargs)
return visisted_paths
TESTS = (
TestBlendUserMap,
TestBlendFilePathMap,
TestBlendFilePathForeach,
)
def argparse_create():
import argparse
# When --help or no args are given, print this help
description = "Test basic realtionship info of loaded data."
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
"--src-test-dir",
dest="src_test_dir",
default=".",
help="Where to find test/data root directory",
required=True,
)
parser.add_argument(
"--output-dir",
dest="output_dir",
default=".",
help="Where to output temp saved blendfiles",
required=False,
)
return parser
def main():
args = argparse_create().parse_args()
# Don't write thumbnails into the home directory.
bpy.context.preferences.filepaths.file_preview_type = 'NONE'
for Test in TESTS:
Test(args).run_all_tests()
if __name__ == '__main__':
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
main()