- 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>
325 lines
10 KiB
Python
Executable File
325 lines
10 KiB
Python
Executable File
# ##### BEGIN GPL LICENSE BLOCK #####
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software Foundation,
|
|
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
#
|
|
# Copyright (C) 2014-2018 Blender Foundation
|
|
#
|
|
# ##### END GPL LICENSE BLOCK #####
|
|
|
|
# <pep8 compliant>
|
|
|
|
__version__ = '1.15'
|
|
|
|
### --- BAT used as an addon with following code ---
|
|
|
|
bl_info = {
|
|
"name": "Blender Asset Tracer",
|
|
"author": "Campbell Barton, Sybren A. Stüvel, Loïc Charrière and Clément Ducarteron",
|
|
"version": (1, 15, 0),
|
|
"blender": (2, 80, 0),
|
|
"location": "File > External Data > BAT",
|
|
"description": "Utility for packing blend files",
|
|
"warning": "",
|
|
"wiki_url": "https://developer.blender.org/project/profile/79/",
|
|
"category": "Import-Export",
|
|
}
|
|
|
|
|
|
## Reset root module name
|
|
## if folder has an unexpected name (like "blender_asset_tracer-main" from zip-dl)
|
|
import sys
|
|
if __name__ != "blender_asset_tracer":
|
|
sys.modules["blender_asset_tracer"] = sys.modules[__name__]
|
|
|
|
import bpy
|
|
from bpy.types import Operator
|
|
from bpy_extras.io_utils import ExportHelper
|
|
import zipfile
|
|
from blender_asset_tracer.pack import zipped
|
|
from pathlib import Path, PurePath
|
|
|
|
import os
|
|
import re
|
|
# import sys
|
|
import subprocess
|
|
import tempfile
|
|
from blender_asset_tracer.trace import deps
|
|
from . import preferences
|
|
|
|
class ExportBatPack(Operator, ExportHelper):
|
|
bl_idname = "export_bat.pack"
|
|
bl_label = "Export to Archive using BAT"
|
|
|
|
# ExportHelper
|
|
filename_ext = ".zip"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return bpy.data.is_saved
|
|
|
|
|
|
def execute(self, context):
|
|
import os
|
|
outfname = bpy.path.ensure_ext(self.filepath, ".zip")
|
|
scn = bpy.context.scene
|
|
|
|
print(f'outfname {outfname}')
|
|
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:
|
|
inzip.testzip()
|
|
|
|
self.report({'INFO'}, 'Written to %s' % outfname)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
class BAT_OT_export_zip(Operator, ExportHelper):
|
|
"""Export current blendfile as .ZIP"""
|
|
bl_label = "Zip Pack - keep hierachy" # Export File to .ZIP
|
|
bl_idname = "bat.export_zip"
|
|
|
|
filename_ext = '.zip'
|
|
|
|
|
|
root_dir : bpy.props.StringProperty(
|
|
name="Root",
|
|
description='Top Level Folder of your project.'
|
|
'\nFor now Copy/Paste correct folder by hand if default is incorrect.'
|
|
'\n!!! Everything outside won\'t be zipped !!!',
|
|
)
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return bpy.data.is_saved
|
|
|
|
## ExportHelper has it's own invoke so overriding it with this one create error
|
|
# def invoke(self, context, event):
|
|
# prefs = preferences.get_addon_prefs()
|
|
# if prefs.root_default:
|
|
# self.root_dir = prefs.root_default
|
|
# return self.execute(context)
|
|
|
|
def execute(self, context):
|
|
|
|
root_dir = self.root_dir
|
|
print('root_dir: ', root_dir)
|
|
|
|
def open_folder(folderpath):
|
|
"""
|
|
open the folder at the path given
|
|
with cmd relative to user's OS
|
|
"""
|
|
from shutil import which
|
|
my_os = sys.platform
|
|
if my_os.startswith(('linux','freebsd')):
|
|
cmd = 'xdg-open'
|
|
elif my_os.startswith('win'):
|
|
cmd = 'explorer'
|
|
if not folderpath:
|
|
return('/')
|
|
else:
|
|
cmd = 'open'
|
|
|
|
if not folderpath:
|
|
return('//')
|
|
|
|
if os.path.isfile(folderpath): # When pointing to a file
|
|
select = False
|
|
if my_os.startswith('win'):
|
|
# Keep same path but add "/select" the file (windows cmd option)
|
|
cmd = 'explorer /select,'
|
|
select = True
|
|
|
|
elif my_os.startswith(('linux','freebsd')):
|
|
if which('nemo'):
|
|
cmd = 'nemo --no-desktop'
|
|
select = True
|
|
elif which('nautilus'):
|
|
cmd = 'nautilus --no-desktop'
|
|
select = True
|
|
|
|
if not select:
|
|
# Use directory of the file
|
|
folderpath = os.path.dirname(folderpath)
|
|
|
|
folderpath = os.path.normpath(folderpath)
|
|
fullcmd = cmd.split() + [folderpath]
|
|
# print('use opening command :', fullcmd)
|
|
subprocess.Popen(fullcmd)
|
|
|
|
|
|
def zip_with_structure(zip, filelist, root=None, compressed=True):
|
|
'''
|
|
Zip passed filelist into a zip with root path as toplevel tree
|
|
If root is not passed, the shortest path in filelist becomes the root
|
|
|
|
:zip: output fullpath of the created zip
|
|
:filelist: list of filepaht as string or Path object (converted anyway)
|
|
:root: top level of the created hierarchy (not included), file that are not inside root are discarded
|
|
:compressed: Decide if zip is compressed or not
|
|
'''
|
|
|
|
filelist = [Path(f) for f in filelist] # ensure pathlib
|
|
if not filelist:
|
|
return
|
|
if not root:
|
|
# autodetect the path thats is closest to root
|
|
#root = sorted(filelist, key=lambda f: f.as_posix().count('/'))[0].parent
|
|
filelist_abs = [str(fl) for fl in filelist]
|
|
root = Path(os.path.commonpath(filelist_abs))
|
|
#print('root: ', root)
|
|
else:
|
|
root = Path(root)
|
|
|
|
compress_type = zipfile.ZIP_DEFLATED if compressed else zipfile.ZIP_STORED
|
|
with zipfile.ZipFile(zip, 'w',compress_type) as zipObj:
|
|
for f in filelist:
|
|
#print('f: ', f, type(f))
|
|
if not f.exists():
|
|
print(f'Not exists: {f.name}')
|
|
continue
|
|
if str(root) not in str(f):
|
|
print(f'{f} is out of root {root}')
|
|
continue
|
|
|
|
##
|
|
arcname = f.as_posix().replace(root.as_posix(), '').lstrip('/')
|
|
print(f'adding: {arcname}')
|
|
zipObj.write(f, arcname)
|
|
|
|
return zip, root
|
|
|
|
|
|
links = []
|
|
|
|
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
|
|
|
|
links.append(l.abspath)
|
|
if l.is_sequence:
|
|
split_path = PurePath(l.abspath).parts
|
|
file_dir = os.path.join(*split_path[:-1])
|
|
file_name = split_path[-1]
|
|
|
|
pattern = '[0-9]+\.[a-zA-Z]+$'
|
|
file_name = re.sub(pattern, '', file_name)
|
|
|
|
for im in os.listdir(Path(f"{file_dir}/")):
|
|
if im.startswith(file_name) and re.search(pattern, im):
|
|
links.append(os.path.join(file_dir, im))
|
|
|
|
links = list(set(links))
|
|
|
|
#output_name = current_file.name
|
|
output = self.filepath
|
|
#print('output: ', output)
|
|
|
|
root_dir = zip_with_structure(output, links, root_dir)
|
|
root_dir = str(root_dir[1])
|
|
|
|
log_output = Path(tempfile.gettempdir(),'README.txt')
|
|
with open(log_output, 'w') as log:
|
|
log.write("File is located here:")
|
|
log.write(f"\n - /{str(current_file).replace(root_dir,'')}")
|
|
|
|
with zipfile.ZipFile(output, 'a') as zipObj:
|
|
zipObj.write(log_output, log_output.name)
|
|
|
|
### Same as
|
|
#zipObj = zipfile.ZipFile(output, 'a')
|
|
#zipObj.write(log_output, log_output.name)
|
|
#zipObj.close()
|
|
|
|
open_folder(Path(output).parent)
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
def menu_func(self, context):
|
|
layout = self.layout
|
|
layout.separator()
|
|
layout.operator(ExportBatPack.bl_idname)
|
|
filepath = layout.operator(BAT_OT_export_zip.bl_idname)
|
|
|
|
prefs = preferences.get_addon_prefs()
|
|
|
|
root_dir_env = None
|
|
if prefs.use_env_root:
|
|
root_dir_env = os.getenv('ZIP_ROOT')
|
|
if not root_dir_env: # first fallback to PROJECT_ROOT
|
|
root_dir_env = os.getenv('PROJECT_ROOT')
|
|
|
|
if not root_dir_env: # if no env, use prefs instead
|
|
root_dir_env = prefs.root_default
|
|
|
|
filepath.root_dir = '' if root_dir_env == None else root_dir_env
|
|
|
|
|
|
classes = (
|
|
ExportBatPack,
|
|
BAT_OT_export_zip,
|
|
)
|
|
|
|
|
|
def register():
|
|
preferences.register()
|
|
for cls in classes:
|
|
bpy.utils.register_class(cls)
|
|
|
|
bpy.types.TOPBAR_MT_file_external_data.append(menu_func)
|
|
|
|
|
|
def unregister():
|
|
for cls in classes:
|
|
bpy.utils.unregister_class(cls)
|
|
|
|
preferences.unregister()
|
|
bpy.types.TOPBAR_MT_file_external_data.remove(menu_func)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
register()
|
|
|