Files
blender/tests/python/bl_blendfile_versioning.py
Jacques Lucke f0c7e52ff2 Core: extract blendfile_header.py as common utility for parsing .blend files
This new file can parse the file header (first few bytes) as well as the block
headers.

Right now, this is used by two places:
* `blendfile.py` which is used by `blend2json.py`
* `blend_render_info.py`

This new module is shipped with Blender because it's needed for
`blend_render_info.py` which is shipped with Blender too. This makes using it in
`blendfile.py` (which is not shipped with Blender) a bit more annoying. However,
this is already not ideal, because e.g. `blend2json` also has to add to
`sys.path` already to be able to import `blendfile.py`.

This new file could also be used by blender-asset-tracer (BAT).

The new `BlendFileHeader` and `BlockHeader` types may be subclassed by code
using it, because it wants to store additional derived data (`blendfile.py` and
BAT need this).

New tests have been added that check that the file and block header is parsed
correctly for different kinds of .blend files.

Pull Request: https://projects.blender.org/blender/blender/pulls/140341
2025-06-23 12:53:55 +02:00

476 lines
21 KiB
Python

# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
# ./blender.bin --background --python tests/python/bl_blendfile_versioning.py ..
# WARNING(@ideasman42): some blend files causes the tests to fail (seemingly) at random (on Linux & macOS at least).
# Take care when adding new files as they may break on other platforms, frequently but not on every execution.
#
# This needs to be investigated!
__all__ = (
"main",
)
import os
import platform
import sys
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from bl_blendfile_utils import TestHelper
class TestBlendFileOpenLinkSaveAllTestFiles(TestHelper):
def __init__(self, args):
self.args = args
# Some files are known broken currently for opening or linking.
# They cannot be opened, or will generate some error (e.g. memleaks).
# Each file in this list should either be the source of a bug report,
# or removed from tests repo.
self.excluded_open_link_paths = {
# modifier_stack/explode_modifier.blend
# BLI_assert failed: source/blender/blenlib/BLI_ordered_edge.hh:41, operator==(), at 'e1.v_low < e1.v_high'
"explode_modifier.blend",
# depsgraph/deg_anim_camera_dof_driving_material.blend
# ERROR (bke.fcurve):
# source/blender/blenkernel/intern/fcurve_driver.cc:188 dtar_get_prop_val:
# Driver Evaluation Error: cannot resolve target for OBCamera ->
# data.dof_distance
"deg_anim_camera_dof_driving_material.blend",
# depsgraph/deg_driver_shapekey_same_datablock.blend
# Error: Not freed memory blocks: 4, total unfreed memory 0.000427 MB
"deg_driver_shapekey_same_datablock.blend",
# physics/fluidsim.blend
# Error: Not freed memory blocks: 3, total unfreed memory 0.003548 MB
"fluidsim.blend",
# opengl/ram_glsl.blend
# Error: Not freed memory blocks: 4, total unfreed memory 0.000427 MB
"ram_glsl.blend",
}
# Directories to exclude relative to `./tests/files/`.
self.excluded_open_link_dirs = ()
# Some files are known broken currently on re-saving & re-opening.
# Each file in this list should either be the source of a bug report,
# or removed from tests repo.
self.excluded_save_reload_paths = {
# gameengine_bullet_softbody/softbody_constraints.blend
# Error: on save,
# 'Unable to pack file, source path '.../gameengine_bullet_softbody/marble_256.jpg' not found'
"softbody_constraints.blend",
# files/libraries_and_linking/library_test_scene.blend
# Error: on save and/or reload, creates memleaks.
"library_test_scene.blend",
# files/libraries_and_linking/libraries/main_scene.blend
# Error: on save and/or reload, creates memleaks.
"main_scene.blend",
# modeling/geometry_nodes/import/import_obj.blend
# Error: on reload,
# "OBJParser: Cannot read from OBJ file:
# '/home/guest/blender/main/build_main_release/tests/blendfile_io/data_files/icosphere.obj'"
"import_obj.blend",
# grease_pencil/grease_pencil_paper_pig.blend
# Error: on save and/or reload, creates memleaks.
"grease_pencil_paper_pig.blend",
# modeling/geometry_nodes/import/import_ply.blend
# Error: on reload, 'read_ply_to_mesh: PLY Importer: icosphere: Invalid PLY header.'
"import_ply.blend",
# modeling/geometry_nodes/import/import_stl.blend
# Error: on reload,
# 'read_stl_file: Failed to open STL file:'...tests/blendfile_io/data_files/icosphere.stl'.'
"import_stl.blend",
}
# Directories to exclude relative to `./tests/files/`.
self.excluded_save_reload_dirs = ()
# Some files are expected to be invalid.
# This mapping stores filenames as keys, and expected error message as value.
self.invalid_paths = {
# animation/driver-object-eyes.blend
# File generated from a big endian build of Blender.
"driver-object-eyes.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# modeling/faceselectmode.blend
# File generated from a big endian build of Blender.
"faceselectmode.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# modeling/weight-paint_test.blend
# File generated from a big endian build of Blender.
"weight-paint_test.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/1.62/glass.blend
# File generated from a big endian build of Blender.
"glass.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/1.62/room.blend
# File generated from a big endian build of Blender.
"room.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/1.69/s-mesh.blend
# File generated from a big endian build of Blender.
"s-mesh.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/1.70/escher.blend
# File generated from a big endian build of Blender.
"escher.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/1.98/egypt.blend
# File generated from a big endian build of Blender.
"egypt.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.25/FroggyPacked.blend
# File generated from a big endian build of Blender.
"FroggyPacked.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.30/CtrlObject.blend
# File generated from a big endian build of Blender.
"CtrlObject.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.30/demofile.blend
# File generated from a big endian build of Blender.
"demofile.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.30/dolphin.blend
# File generated from a big endian build of Blender.
"dolphin.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.30/mball.blend
# File generated from a big endian build of Blender.
"mball.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.30/motor9.blend
# File generated from a big endian build of Blender.
"motor9.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.30/relative.blend
# File generated from a big endian build of Blender.
"relative.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/Raptor.blend
# File generated from a big endian build of Blender.
"Raptor.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/allselect.blend
# File generated from a big endian build of Blender.
"allselect.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/arealight.blend
# File generated from a big endian build of Blender.
"arealight.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/hairball.blend
# File generated from a big endian build of Blender.
"hairball.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/luxo.blend
# File generated from a big endian build of Blender.
"luxo.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/monkey_cornelius.blend
# File generated from a big endian build of Blender.
"monkey_cornelius.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/refract_monkey.blend
# File generated from a big endian build of Blender.
"refract_monkey.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.31/robo.blend
# File generated from a big endian build of Blender.
"robo.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.34/flippedmatrixes.blend
# File generated from a big endian build of Blender.
"flippedmatrixes.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.34/lostride.blend
# File generated from a big endian build of Blender.
"lostride.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.34/tapercurve.blend
# File generated from a big endian build of Blender.
"tapercurve.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.36/pathdist.blend
# File generated from a big endian build of Blender.
"pathdist.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_big_endian/2.76/bird_sintel.blend
# File generated from a big endian build of Blender.
"bird_sintel.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_parsing/BHead4_big_endian.blend
# File generated from a big endian build of Blender.
"BHead4_big_endian.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
}
assert all(p.endswith("/") for p in self.excluded_open_link_dirs)
self.excluded_open_link_dirs = tuple(p.replace("/", os.sep) for p in self.excluded_open_link_dirs)
assert all(p.endswith("/") for p in self.excluded_save_reload_dirs)
self.excluded_save_reload_dirs = tuple(p.replace("/", os.sep) for p in self.excluded_save_reload_dirs)
# Generate the slice of blendfile paths that this instance of the test should process.
blendfile_paths = [p for p in self.iter_blendfiles_from_directory(self.args.src_test_dir)]
# `os.scandir()` used by `iter_blendfiles_from_directory` does not
# guarantee any form of order.
blendfile_paths.sort()
slice_indices = self.generate_slice_indices(len(blendfile_paths), self.args.slice_range, self.args.slice_index)
self.blendfile_paths = blendfile_paths[slice_indices[0]:slice_indices[1]]
@classmethod
def iter_blendfiles_from_directory(cls, root_path):
for dir_entry in os.scandir(root_path):
if dir_entry.is_dir(follow_symlinks=False):
yield from cls.iter_blendfiles_from_directory(dir_entry.path)
elif dir_entry.is_file(follow_symlinks=False):
if os.path.splitext(dir_entry.path)[1] == ".blend":
yield dir_entry.path
@staticmethod
def generate_slice_indices(total_len, slice_range, slice_index):
slice_stride_base = total_len // slice_range
slice_stride_remain = total_len % slice_range
def gen_indices(i):
return (
(i * (slice_stride_base + 1))
if i < slice_stride_remain else
(slice_stride_remain * (slice_stride_base + 1)) + ((i - slice_stride_remain) * slice_stride_base)
)
slice_indices = [(gen_indices(i), gen_indices(i + 1)) for i in range(slice_range)]
return slice_indices[slice_index]
def skip_path_check(self, bfp, excluded_paths, excluded_dirs):
if os.path.basename(bfp) in excluded_paths:
return True
if excluded_dirs:
assert bfp.startswith(self.args.src_test_dir)
bfp_relative = bfp[len(self.args.src_test_dir):].rstrip(os.sep)
if bfp_relative.startswith(*excluded_dirs):
return True
return False
def skip_open_link_path_check(self, bfp):
return self.skip_path_check(bfp, self.excluded_open_link_paths, self.excluded_open_link_dirs)
def skip_save_reload_path_check(self, bfp):
return self.skip_path_check(bfp, self.excluded_save_reload_paths, self.excluded_save_reload_dirs)
def invalid_path_exception_process(self, bfp, exception):
expected_failure = self.invalid_paths.get(os.path.basename(bfp), None)
if not expected_failure:
raise exception
# Check expected exception type(s).
if not isinstance(exception, expected_failure[0]):
raise exception
# Check expected exception (partial) message.
if expected_failure[1] not in str(exception):
raise exception
print(f"\tExpected failure: '{exception}'", flush=True)
def save_reload(self, bfp, prefix):
if self.skip_save_reload_path_check(bfp):
return
# Use a hash to deduplicate the few blendfiles that have a same name,
# but a different path (e.g. currently, `flip_faces.blend`).
tmp_save_path = os.path.join(self.args.output_dir, prefix + hex(hash(bfp)) + "_" + os.path.basename(bfp))
if not self.args.is_quiet:
print(f"Trying to save to {tmp_save_path}", flush=True)
bpy.ops.wm.save_as_mainfile(filepath=tmp_save_path, compress=True)
if not self.args.is_quiet:
print(f"Trying to reload from {tmp_save_path}", flush=True)
bpy.ops.wm.revert_mainfile()
if not self.args.is_quiet:
print(f"Removing {tmp_save_path}", flush=True)
bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True)
os.remove(tmp_save_path)
def test_open(self):
for bfp in self.blendfile_paths:
if self.skip_open_link_path_check(bfp):
continue
if not self.args.is_quiet:
print(f"Trying to open {bfp}", flush=True)
bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True)
try:
bpy.ops.wm.open_mainfile(filepath=bfp, load_ui=False)
self.save_reload(bfp, "OPENED_")
except BaseException as e:
self.invalid_path_exception_process(bfp, e)
def link_append(self, do_link):
operation_name = "link" if do_link else "append"
for bfp in self.blendfile_paths:
if self.skip_open_link_path_check(bfp):
continue
bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True)
try:
with bpy.data.libraries.load(bfp, link=do_link) as (lib_in, lib_out):
if len(lib_in.collections):
if not self.args.is_quiet:
print(f"Trying to {operation_name} {bfp}/Collection/{lib_in.collections[0]}", flush=True)
lib_out.collections.append(lib_in.collections[0])
elif len(lib_in.objects):
if not self.args.is_quiet:
print(f"Trying to {operation_name} {bfp}/Object/{lib_in.objects[0]}", flush=True)
lib_out.objects.append(lib_in.objects[0])
self.save_reload(bfp, f"{operation_name.upper()}_")
except BaseException as e:
self.invalid_path_exception_process(bfp, e)
def test_link(self):
self.link_append(do_link=True)
def test_append(self):
self.link_append(do_link=False)
TESTS = (
TestBlendFileOpenLinkSaveAllTestFiles,
)
def argparse_create():
import argparse
# When --help or no args are given, print this help
description = ("Test basic versioning and writing code by opening, linking from, saving and reloading"
"all blend files in `--src-test-dir` directory (typically the `tests/files` one).")
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
"--src-test-dir",
dest="src_test_dir",
default="..",
help="Root tests directory to search for blendfiles",
required=False,
)
parser.add_argument(
"--output-dir",
dest="output_dir",
default=".",
help="Where to output temp saved blendfiles",
required=False,
)
parser.add_argument(
"--quiet",
dest="is_quiet",
type=bool,
default=False,
help="Whether to quiet prints of all blendfile read/link attempts",
required=False,
)
parser.add_argument(
"--slice-range",
dest="slice_range",
type=int,
default=1,
help="How many instances of this test are launched in parallel, the list of available blendfiles is then sliced "
"and each instance only processes the part matching its given `--slice-index`.",
required=False,
)
parser.add_argument(
"--slice-index",
dest="slice_index",
type=int,
default=0,
help="The index of the slice in blendfiles that this instance should process."
"Should always be specified when `--slice-range` > 1",
required=False,
)
return parser
def main():
args = argparse_create().parse_args()
assert args.slice_range > 0
assert 0 <= args.slice_index < args.slice_range
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()