Harden BAT against missing/broken libraries and zstandard failures

- ExportBatPack and BAT_OT_export_zip: catch EnvironmentError (typically
  raised when zstandard is missing or the installed wheel is incompatible
  with the embedded Python) and any other exception, and report it via
  self.report instead of crashing the operator.
- file2blocks.BlockIterator: skip libraries that fail to open with a
  warning instead of aborting the whole trace, so production blends with
  one stale linked library still pack.
- preferences: use __package__ (stable, full dotted path) for the
  AddonPreferences bl_idname instead of __name__.split('.')[0], which
  breaks when the add-on is loaded under an unexpected module name.
- magic_compression: zstandard handling tweaks alongside the operator
  error reports above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph HENRY 2026-04-28 15:24:32 +02:00
parent bfdd86bfb7
commit c298e448b0
4 changed files with 72 additions and 36 deletions

View File

@ -79,12 +79,20 @@ class ExportBatPack(Operator, ExportHelper):
self.report({'INFO'},'Executing ZipPacker ...')
try:
with zipped.ZipPacker(
Path(bpy.data.filepath),
Path(bpy.data.filepath).parent,
str(self.filepath)) as packer:
packer.strategise()
packer.execute()
except EnvironmentError as err:
self.report({'ERROR'}, "BAT packing failed: %s\n"
"The zstandard Python module may be missing or incompatible." % err)
return {'CANCELLED'}
except Exception as err:
self.report({'ERROR'}, "BAT packing failed: %s" % err)
return {'CANCELLED'}
self.report({'INFO'},'Packing successful !')
with zipfile.ZipFile(str(self.filepath)) as inzip:
@ -216,7 +224,15 @@ class BAT_OT_export_zip(Operator, ExportHelper):
current_file = Path(bpy.data.filepath)
links.append(str(current_file))
try:
file_link = list(deps(current_file))
except EnvironmentError as err:
self.report({'ERROR'}, "BAT dependency tracing failed: %s\n"
"The zstandard Python module may be missing or incompatible." % err)
return {'CANCELLED'}
except Exception as err:
self.report({'ERROR'}, "BAT dependency tracing failed: %s" % err)
return {'CANCELLED'}
for l in file_link:
if Path(l.abspath).exists() == False:
continue

View File

@ -87,22 +87,24 @@ def open(path: pathlib.Path, mode: str, buffer_size: int) -> DecompressedFileInf
)
log.debug("%s-compressed blendfile detected: %s", compression.name, path)
print("BAT: %s-compressed blendfile: %s" % (compression.name, path))
# Decompress to a temporary file.
tmpfile = tempfile.NamedTemporaryFile()
fileobj.seek(0, os.SEEK_SET)
decompressor = _decompressor(fileobj, mode, compression)
_decompress_to_file(fileobj, tmpfile, mode, compression, buffer_size)
with decompressor as compressed_file:
magic = compressed_file.read(len(BLENDFILE_MAGIC))
if magic != BLENDFILE_MAGIC:
raise exceptions.BlendFileError("Compressed file is not a blend file", path)
data = magic
while data:
tmpfile.write(data)
data = compressed_file.read(buffer_size)
# Verify the decompressed content is a valid blend file.
tmpfile.seek(0, os.SEEK_SET)
header_bytes = tmpfile.read(12)
log.debug("Decompressed header (%s, %s): %r", compression.name, path, header_bytes)
print("BAT: Decompressed header: %r" % header_bytes)
if not header_bytes.startswith(BLENDFILE_MAGIC):
raise exceptions.BlendFileError(
"Decompressed file is not a blend file (header: %r)" % header_bytes, path
)
tmpfile.seek(0, os.SEEK_SET)
# Further interaction should be done with the uncompressed file.
fileobj.close()
@ -113,6 +115,37 @@ def open(path: pathlib.Path, mode: str, buffer_size: int) -> DecompressedFileInf
)
def _decompress_to_file(
fileobj: typing.IO[bytes],
tmpfile: typing.IO[bytes],
mode: str,
compression: Compression,
buffer_size: int,
) -> None:
"""Decompress fileobj into tmpfile."""
if compression == Compression.GZIP:
with gzip.GzipFile(fileobj=fileobj, mode=mode) as gz:
while True:
data = gz.read(buffer_size)
if not data:
break
tmpfile.write(data)
elif compression == Compression.ZSTD:
if not has_zstandard:
raise EnvironmentError(
"File is compressed with ZStandard, install the `zstandard` module to support this."
)
dctx = zstandard.ZstdDecompressor()
dctx.copy_stream(fileobj, tmpfile)
else:
raise ValueError("Unsupported compression type: %s" % compression)
tmpfile.flush()
def find_compression_type(fileobj: typing.IO[bytes]) -> Compression:
fileobj.seek(0, os.SEEK_SET)
@ -150,20 +183,3 @@ def _matches_magic(value: bytes, magic: bytes) -> bool:
return value[: len(magic)] == magic
def _decompressor(
fileobj: typing.IO[bytes], mode: str, compression: Compression
) -> typing.IO[bytes]:
if compression == Compression.GZIP:
decompressor = gzip.GzipFile(fileobj=fileobj, mode=mode)
return typing.cast(typing.IO[bytes], decompressor)
if compression == Compression.ZSTD:
if not has_zstandard:
# The required module was not loaded, raise an exception about this.
raise EnvironmentError(
"File is compressed with ZStandard, install the `zstandard` module to support this."
)
dctx = zstandard.ZstdDecompressor()
return dctx.stream_reader(fileobj)
raise ValueError("Unsupported compression type: %s" % compression)

View File

@ -11,7 +11,7 @@ def get_addon_prefs():
return prefs.addons[__package__].preferences
class myaddonPrefs(bpy.types.AddonPreferences):
bl_idname = __name__.split('.')[0]
bl_idname = __package__
root_default : bpy.props.StringProperty(
name='Default Root',

View File

@ -133,7 +133,11 @@ class BlockIterator:
continue
log.debug("Expanding %d blocks in %s", len(idblocks), lib_path)
try:
libfile = self.open_blendfile(lib_path)
except Exception:
log.warning("Failed to open library %s, skipping", lib_path, exc_info=True)
continue
yield from self.iter_blocks(libfile, idblocks)
def _queue_all_blocks(self, bfile: blendfile.BlendFile):