blender/scripts/startup/bl_operators/project.py
Brecht Van Lommel ec00e6cf37 Projects: Auto use {project_root} for asset library paths
Add a PROP_VARIABLES_PROJECT to indicate a string property can use project
variables, and use it for the asset library path. The file browser will
make such paths automatically project relative when writing back to the RNA
property.

To make it so this works for project asset libraries but has no effect
on preferences libraries, there are now two separate UserAssetLibrary
subclasses with and without PROP_VARIABLES_PROJECT on the path.

Pull Request: https://projects.blender.org/blender/blender/pulls/163777
2026-09-16 23:39:09 +02:00

905 lines
29 KiB
Python

# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"register",
"unregister",
)
import os
import logging
from dataclasses import dataclass
from enum import Enum
import bpy
from bpy.types import Operator
from bpy.app.translations import (
pgettext_rpt as rpt_,
pgettext_data as data_,
)
logger = logging.getLogger(__name__)
# The schema version of the Project Config, for managing breaking schema changes.
#
# Only increment this when there are breaking schema changes.
#
# Project versioning should generally follow the same approach as blend file
# versioning. In short:
#
# - When reasonably possible, change the schema in ways that fully preserve
# backwards compatibility and that avoid critical forwards compatibility
# breaks.
# - When that's not possible, plan ahead and only make the needed breaking
# changes on major version bumps of Blender (e.g. 5.x -> 6.x).
# - In the version just before a major version bump, add forwards compatibility
# code for the anticipated breaking changes, so that e.g. the last 5.x release
# can still open projects from Blender 6.0.
#
# For more details, see:
# https://developer.blender.org/docs/handbook/guidelines/compatibility_handling_for_blend_files/
#
# NOTE: when incrementing this version number, ensure that the versioning code
# in `read_project_toml_config()` is appropriately expanded to auto-upgrade
# between versions
PROJECT_SCHEMA_VERSION = 1
# Directory and file name where the project is read/written to disk.
PROJECT_DIR = ".blender_project"
PROJECT_CONFIG = "project.toml"
PROJECT_DEFAULT_NAME = "Untitled Project"
ASSET_LIBRARY_DEFAULT_NAME = "Untitled Asset Library"
# -------------------------------------------------------------
# TOML Schema
#
# Types that define the schema for reading/writing project config TOML files.
class VariableType(Enum):
INTEGER = 'INTEGER'
FLOAT = 'FLOAT'
STRING = 'STRING'
class VariableSubtype(Enum):
NONE = 'NONE'
FILEPATH = 'FILEPATH'
@dataclass
class AssetLibraryDefinition:
name: str
path: str
use_relative_path: bool
import_method: str
uuid: str | None = None
@dataclass
class ProjectVariable:
name: str
type: VariableType
value: int | str | float
subtype: VariableSubtype | None = None
description: str = ""
@staticmethod
def new_from_real(project_variable):
"""Create a ProjectVariable config object from an existing real project variable."""
subtype = None
if project_variable.type == 'STRING':
subtype = VariableSubtype(project_variable.subtype)
return ProjectVariable(
name=project_variable.name,
type=VariableType(project_variable.type),
value=project_variable.value,
description=project_variable.description,
subtype=subtype,
)
def add_as_real(self, variables):
"""Adds this as a real variable to the given real project variables list."""
variable = variables.new(name=self.name, type=self.type.value)
variable.value = self.value
if self.subtype is not None and self.type == VariableType.STRING:
variable.subtype = self.subtype.value
variable.description = self.description
def __post_init__(self):
"""Validation of invariants that cattrs doesn't check."""
import re
if self.name == "":
raise ValueError("Invalid variable name '{:s}': variable names cannot be empty.")
if re.match("^[a-zA-Z_][a-zA-Z0-9_]*$", self.name) is None:
raise ValueError(
"Invalid variable name '{:s}': variable names must not start with a digit, and "
"must contain only alphanumeric characters and underscores.")
# Check that value matches the declared variable type.
match (self.type, self.value):
case (VariableType.INTEGER, int()):
pass
case (VariableType.FLOAT, float()):
pass
case (VariableType.STRING, str()):
pass
case _:
raise ValueError("Actual and declared type of project variable '{:s}' do not match.".format(self.name))
# Check that value matches the declared variable type.
match (self.type, self.subtype):
case (VariableType.INTEGER, None):
pass
case (VariableType.FLOAT, None):
pass
case (VariableType.STRING, None) | (VariableType.STRING, VariableSubtype.NONE) \
| (VariableType.STRING, VariableSubtype.FILEPATH):
pass
case _:
raise ValueError("Invalid subtype '{:s}' for variable type '{:s}' of variable '{:s}'.".format(
self.type.value,
self.subtype.value,
self.name,
))
@dataclass
class ProjectConfig:
schema_version: int
name: str
variables: list[ProjectVariable] | None = None
asset_libraries: list[AssetLibraryDefinition] | None = None
@staticmethod
def new_from_real(project):
"""Create a ProjectConfig object from an existing real project."""
variables = None
if len(project.variables) > 0:
variables = [ProjectVariable.new_from_real(var) for var in project.variables]
asset_list = None
if len(project.asset_libraries) > 0:
asset_list = []
for asset_lib in project.asset_libraries:
uuid_str = asset_lib.uuid
if not uuid_str:
# No uuid string present, the uuid must be invalid
uuid_str = asset_lib.invalid_uuid
asset_data = AssetLibraryDefinition(
asset_lib.name,
asset_lib.path,
asset_lib.use_relative_path,
asset_lib.import_method,
uuid_str)
asset_list.append(asset_data)
return ProjectConfig(
schema_version=PROJECT_SCHEMA_VERSION,
name=project.name,
variables=variables,
asset_libraries=asset_list,
)
def populate_real(self, project):
"""Fills in an existing real project's data from this ProjectConfig object."""
if self.variables is not None:
for config_var in self.variables:
config_var.add_as_real(bpy.data.project.variables)
if self.asset_libraries is None:
return
# Populate the project asset libraries (if any)
for asset_lib in self.asset_libraries:
if asset_lib.uuid:
# Use the pre-generated UUID
lib = project.asset_libraries.new(name=asset_lib.name, directory=asset_lib.path, uuid=asset_lib.uuid)
else:
# Generate a new UUID for the asset library
lib = project.asset_libraries.new(name=asset_lib.name, directory=asset_lib.path)
if asset_lib.import_method:
lib.import_method = asset_lib.import_method
if asset_lib.use_relative_path:
lib.use_relative_path = asset_lib.use_relative_path
def __post_init__(self):
"""Validation of invariants that cattrs doesn't check."""
if self.name == "":
raise ValueError("Project name cannot be empty.")
if self.variables is not None:
var_names = set()
for var in self.variables:
if var.name in var_names:
raise ValueError("Duplicate project variable names are not allowed.")
var_names.add(var.name)
# -------------------------------------------------------------
# Helpers for cattrs
def structure_int_float_str(obj: int | float | str, cl: type) -> int | float | str:
if isinstance(obj, int) or isinstance(obj, float) or isinstance(obj, str):
return obj
else:
raise ValueError(f"Cannot structure {obj!r} as int | float | str.")
# -------------------------------------------------------------
# Exceptions
#
# Custom exception types for anticipated errors that should be reported to the
# user.
class ProjectSaveException(Exception):
pass
class ProjectLoadException(Exception):
pass
# -------------------------------------------------------------
# Internal Utilities
def save_project(project, report=None):
"""
Save the passed project to disk.
Throws a ProjectSaveException in any of the following cases:
- There is no project to save.
- The project's root path is relative or doesn't exist.
- The project can't be written due to any of a number of file-system
issues (directory isn't writable, etc.).
Optionally takes an `Operator.report` for reporting errors to the user.
"""
import cattrs
import tomli_w
from pathlib import Path
if project is None:
if report:
report({'ERROR'}, "Cannot save project because there is no project to save.")
raise ProjectSaveException
logger.info("Saving project '{:s}' at '{:s}'...".format(project.name, project.root_path))
# Get and validate the root path.
root_path = Path(project.root_path)
try:
if not root_path.is_absolute():
if report:
report({'ERROR'}, "Cannot write project to non-absolute path.")
raise ProjectSaveException
if not root_path.is_dir():
if report:
report({'ERROR'}, "Cannot save project: root directory does not exist.")
raise ProjectSaveException
except PermissionError:
if report:
report({'ERROR'}, rpt_("Cannot access '{:s}' due to file-system permissions.").format(PROJECT_DIR))
raise ProjectSaveException
except Exception as e:
if report:
report({'ERROR'}, str(e))
raise ProjectSaveException
config_dir_path = root_path.joinpath(PROJECT_DIR)
# Ensure the project config directory exists.
try:
config_dir_path.mkdir(parents=True, exist_ok=True)
except FileExistsError:
if report:
report(
{'ERROR'},
rpt_("A file named '{:s}' already exists, but it needs to be a directory.").format(PROJECT_DIR),
)
raise ProjectSaveException
except PermissionError:
if report:
report(
{'ERROR'},
rpt_("Cannot create '{:s}' directory due to file-system permissions.").format(PROJECT_DIR),
)
raise ProjectSaveException
except Exception as e:
if report:
report({'ERROR'}, str(e))
raise ProjectSaveException
# Create a project config dict from the current project.
converter = cattrs.Converter(omit_if_default=True)
config = ProjectConfig.new_from_real(project)
config_dict = converter.unstructure(config, ProjectConfig)
# Serialize the config to TOML.
#
# We set the "max" line length to something short so that TOML items are
# always serialized in the expanded format. Without that, e.g. project
# variables might *sometimes* be written in the more compact TOML syntax.
# Either format is fine, but flip-flopping is annoying for diffs, so we
# try to force just one here.
max_len_backup = tomli_w._writer.MAX_LINE_LENGTH
try:
tomli_w._writer.MAX_LINE_LENGTH = 10
config_toml = tomli_w.dumps(config_dict)
finally:
# Restore max line length, for any other users of tomli_w.
tomli_w._writer.MAX_LINE_LENGTH = max_len_backup
# Write the config TOML file.
config_path = root_path.joinpath(PROJECT_DIR, PROJECT_CONFIG)
try:
with config_path.open(mode='wt', encoding='utf-8', newline='\n') as f:
f.write(config_toml)
except PermissionError:
if report:
report({'ERROR'}, rpt_("Cannot write to '{:s}' due to file-system permissions.").format(PROJECT_CONFIG))
raise ProjectSaveException
except Exception as e:
if report:
report({'ERROR'}, str(e))
raise ProjectSaveException
project.is_dirty = False
logger.info("...done.")
def find_and_load_project_for_blend_path(context, blend_path, report=None):
"""
Load the project the blend file is in, or clears the project if none is found.
`blend_path` should be an absolute path.
Throws a ProjectLoadException if a project is found but is invalid
(missing config file, config validation error, etc.).
Optionally takes an `Operator.report` for reporting errors to the user.
"""
from pathlib import Path
if blend_path == "":
# Not an on-disk blend file, so there is no project to load.
bpy.data.project_clear()
return
# Note: `blend_path` (and consequently the resulting `root_path`) are
# assumed/expected to be absolute here.
root_path = find_project_root_from_blend_file_path(Path(blend_path))
if root_path is None:
# No project.
bpy.data.project_clear()
return
if bpy.data.project is not None and os.path.normpath(root_path) == os.path.normpath(bpy.data.project.root_path):
# We already have this project loaded, and we don't want to obliterate
# local unsaved changes if auto-save isn't turned on.
return
bpy.data.project_clear()
# Load project.
config = read_project_toml_config(root_path, report)
bpy.data.project_init(config.name, str(root_path))
config.populate_real(bpy.data.project)
bpy.data.project.is_dirty = False
def find_project_root_from_blend_file_path(blend_path):
"""
Search for a project root in the parent directories of the given path.
Returns the project root if found, or None otherwise.
"""
for parent in blend_path.parents:
if parent.joinpath(PROJECT_DIR).is_dir():
return parent
return None
def read_project_toml_config(root_path, report=None):
"""
Read the project config for the given project root path.
Throws a ProjectLoadException if no config is found, if the config is
not readable due to file-system permissions, or if it's not a valid
project config (e.g. contains invalid TOML or doesn't match the schema).
Optionally takes an `Operator.report` for reporting errors to the user.
Returns the configuration (`ProjectConfig`).
"""
import tomllib
import cattrs
config_path = root_path.joinpath(PROJECT_DIR, PROJECT_CONFIG)
try:
with open(config_path, "rb") as f:
config_dict = tomllib.load(f)
except FileNotFoundError:
if report:
report({'ERROR'}, rpt_("Project has no {:s} file.").format(PROJECT_CONFIG))
raise ProjectLoadException
except PermissionError:
if report:
report({'ERROR'}, rpt_("Cannot access {:s} file due to file-system permissions.").format(PROJECT_CONFIG))
raise ProjectLoadException
except tomllib.TOMLDecodeError as e:
if report:
report({'ERROR'}, rpt_("Project's {:s} file contains invalid TOML: {:s}").format(PROJECT_CONFIG, str(e)))
raise ProjectLoadException
except Exception as e:
if report:
report({'ERROR'}, str(e))
raise ProjectLoadException
# If there's no version schema, that means it's version 1.
if "schema_version" not in config_dict:
config_dict["schema_version"] = 1
# Handle project config versioning.
if config_dict["schema_version"] > PROJECT_SCHEMA_VERSION:
if report:
report({'ERROR'}, rpt_("Project configuration is newer than this version of Blender supports"))
raise ProjectLoadException
elif config_dict["schema_version"] < PROJECT_SCHEMA_VERSION:
# Put versioning upgrade code here when needed.
pass
# Validate schema and convert to ProjectConfig class.
converter = cattrs.Converter()
converter.register_structure_hook(int | float | str, structure_int_float_str)
try:
project_config = converter.structure(config_dict, ProjectConfig)
except (cattrs.BaseValidationError, ValueError) as e:
if report:
report({'ERROR'}, rpt_("Invalid project configuration file: {:s}").format(str(e)))
raise ProjectLoadException
return project_config
def blend_file_is_in_valid_project(blend_file_path):
"""
Return whether the blend file is inside a valid project or not.
True if the blend file is inside a valid project, false if no project is
found or if the project is invalid.
An "invalid project" is one whose TOML config is non-existent or doesn't
validate. See `read_project_toml_config()`.
"""
project_root = find_project_root_from_blend_file_path(blend_file_path)
if project_root is None:
return False
try:
_ = read_project_toml_config(project_root, None)
except ProjectLoadException:
# No valid project found.
return False
return True
# -------------------------------------------------------------
# Operators
class PROJECT_OT_NewProject(Operator):
"""Create a new project"""
bl_idname = "project.new_project"
bl_label = "New Project"
directory: bpy.props.StringProperty(
name="Project Root",
subtype='DIR_PATH',
default="",
)
filter_folder: bpy.props.BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
@classmethod
def poll(cls, context):
return bpy.data.project is None and bpy.data.filepath != ""
def execute(self, context):
from pathlib import Path
if self.directory == "":
self.report({'ERROR'}, "Cannot create a project with an empty directory path.")
return {'CANCELLED'}
if not bpy.path.is_subdir(path=bpy.data.filepath, directory=self.directory):
self.report({'ERROR'}, "New project directory must be a parent of the currently open blend file.")
return {'CANCELLED'}
# Double-check that we're not already in a project directory.
#
# Under normal circumstances this should never happen, because a project
# would already be loaded in that case, and thus `poll()` would fail.
# But if someone manually calls `bpy.data.project_clear()` then this can
# happen.
if blend_file_is_in_valid_project(Path(bpy.data.filepath)):
self.report(
{'ERROR'}, (
"New project directory is already inside of an existing project. "
"Try reloading the current blend file to open the existing project."
)
)
return {'CANCELLED'}
# Get the initial project name based on the folder name.
#
# If the folder name contains no valid unicode (resulting in an empty
# string after processing), we fallback to a default.
project_name = os.path.basename(os.path.normpath(self.directory)).title() \
.encode('utf-8', 'surrogateescape') \
.decode('utf-8', 'ignore') \
or data_(PROJECT_DEFAULT_NAME)
# Create the project.
bpy.data.project_init(project_name, self.directory)
# Immediately save the project.
try:
save_project(bpy.data.project, self.report)
except ProjectSaveException:
# Reporting is handled by `save_project()` call in the `try` block.
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, event):
# Set our initial path as the directory that contains the currently open
# blend file.
dirpath = os.path.dirname(bpy.data.filepath)
if dirpath != "":
self.directory = dirpath
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class PROJECT_OT_SaveProject(Operator):
"""Save the current project to disk"""
bl_idname = "project.save_project"
bl_label = "Save Project"
@classmethod
def poll(cls, context):
return bpy.data.project is not None
def execute(self, context):
try:
save_project(bpy.data.project, self.report)
except ProjectSaveException:
# Reporting is handled by `save_project()` call in the `try` block.
return {'CANCELLED'}
return {'FINISHED'}
class PROJECT_OT_OpenBlendInProject(Operator):
"""Opens a blend file, but only if it's inside of a project."""
bl_idname = "project.open_blend_in_project"
bl_label = "Open File..."
filepath: bpy.props.StringProperty(
name="Blend file path",
subtype='FILE_PATH',
default="",
)
filter_folder: bpy.props.BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_blender: bpy.props.BoolProperty(
name="Filter blend files",
default=True,
options={'HIDDEN'},
)
@classmethod
def poll(cls, context):
return True
def execute(self, context):
from pathlib import Path
if not blend_file_is_in_valid_project(Path(self.filepath)):
self.report(
{'ERROR'},
"Selected blend file is not part of a project.")
return {'CANCELLED'}
bpy.ops.wm.open_mainfile(filepath=self.filepath)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class PROJECT_OT_AddVariable(Operator):
"""Add a new variable to the current project"""
bl_idname = "project.add_variable"
bl_label = "Add Variable"
variable_type: bpy.props.EnumProperty(
default='STRING',
items=[
('STRING', "String Variable", ""),
('FILEPATH', "Filepath Variable", ""),
('INTEGER', "Integer Variable", ""),
('FLOAT', "Float Variable", ""),
],
)
@classmethod
def poll(cls, context):
return bpy.data.project is not None
def execute(self, context):
match self.variable_type:
case 'STRING':
bpy.data.project.variables.new(name="string_variable", type='STRING')
case 'FILEPATH':
var = bpy.data.project.variables.new(name="filepath_variable", type='STRING')
var.subtype = 'FILEPATH'
case 'INTEGER':
bpy.data.project.variables.new(name="integer_variable", type='INTEGER')
case 'FLOAT':
bpy.data.project.variables.new(name="float_variable", type='FLOAT')
case _:
assert (False)
return {'FINISHED'}
class PROJECT_OT_RemoveVariable(Operator):
"""Remove the active variable from the current project"""
bl_idname = "project.remove_variable"
bl_label = "Remove Variable"
@classmethod
def poll(cls, context):
project = bpy.data.project
if project is None:
return False
return project.active_variable_index >= 0 and project.active_variable_index < len(project.variables)
def execute(self, context):
project = bpy.data.project
var = project.variables[project.active_variable_index]
project.variables.remove(var)
return {'FINISHED'}
class PROJECT_OT_MoveVariable(Operator):
"""Move the active variable up or down in the list of variables"""
bl_idname = "project.move_variable"
bl_label = "Move Variable"
direction: bpy.props.EnumProperty(items=[
('UP', "Move Up", ""),
('DOWN', "Move Down", ""),
])
@classmethod
def poll(cls, context):
project = bpy.data.project
if project is None:
return False
return project.active_variable_index >= 0 and project.active_variable_index < len(project.variables)
def execute(self, context):
project = bpy.data.project
index = project.active_variable_index
if self.direction == 'UP' and index > 0:
project.variables.move(index, index - 1)
project.active_variable_index -= 1
elif self.direction == 'DOWN' and (index + 1) < len(project.variables):
project.variables.move(index, index + 1)
project.active_variable_index += 1
return {'FINISHED'}
class PROJECT_OT_AssetLibraryAdd(Operator):
"""
Register a directory to be used by the Asset Browser and other places showing assets,
as source of assets within the current project.
"""
bl_idname = "project.asset_library_add"
bl_label = "Add Asset Library"
bl_options = {'INTERNAL'}
directory: bpy.props.StringProperty(
name="Asset Library Directory",
subtype='DIR_PATH',
default="",
)
filter_folder: bpy.props.BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
@classmethod
def poll(cls, context):
return bpy.data.project is not None
def execute(self, context):
if self.directory == "":
self.report({'ERROR'}, "Cannot create an asset library with an empty directory path.")
return {'CANCELLED'}
# Create an initial name from the folder name
#
# If the folder name contains no valid unicode (resulting in an empty
# string after processing), we fallback to a default.
asset_library_path = os.path.normpath(self.directory)
asset_library_name = os.path.basename(asset_library_path).title() \
.encode('utf-8', 'surrogateescape') \
.decode('utf-8', 'ignore') \
or data_(ASSET_LIBRARY_DEFAULT_NAME)
bpy.data.project.asset_libraries.new(name=asset_library_name, directory=asset_library_path)
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class PROJECT_OT_AssetLibraryRemove(Operator):
"""
Deregister an asset library so that its directory will no longer show up in
Asset Browsers and other places showing assets.
"""
bl_idname = "project.asset_library_remove"
bl_label = "Remove Project Asset Library"
bl_options = {'INTERNAL'}
index: bpy.props.IntProperty(
name="Index",
default=0,
)
@classmethod
def poll(cls, context):
if bpy.data.project is None:
return False
return len(bpy.data.project.asset_libraries) != 0
def execute(self, context):
if self.index >= len(bpy.data.project.asset_libraries):
return {'CANCELLED'}
library = bpy.data.project.asset_libraries[self.index]
bpy.data.project.asset_libraries.remove(library)
return {'FINISHED'}
# -------------------------------------------------------------
# Handler Callbacks
#
# Auto-loading / clearing of projects when loading/saving blend files or
# exiting.
def log_project_save_error():
logger.error(
"Error trying to save project '{:s}' at '{:s}'.".format(
bpy.data.project.name,
bpy.data.project.root_path))
def log_project_load_error(blend_path):
logger.error("Error trying to load project for blend file '{:s}'.".format(blend_path))
@bpy.app.handlers.persistent
def on_blend_load(blend_path):
# Auto-save the current project before loading a different blend file.
if bpy.context.preferences.use_project_auto_save and bpy.data.project is not None and bpy.data.project.is_dirty:
try:
save_project(bpy.data.project)
except ProjectSaveException:
log_project_save_error()
# Load the project (or clear if none) for the blend file we're about to
# load.
try:
find_and_load_project_for_blend_path(bpy.context, blend_path)
except ProjectLoadException:
log_project_load_error(blend_path)
@bpy.app.handlers.persistent
def on_blend_save(blend_path):
# Auto-save project when saving the current blend file.
if bpy.context.preferences.use_project_auto_save and bpy.data.project is not None and bpy.data.project.is_dirty:
try:
save_project(bpy.data.project)
except ProjectSaveException:
log_project_save_error()
# In case we're saving the blend to disk for the first time or to a new
# location, load the project there (if any).
#
# The equality check here is to prevent loading projects from the copy
# location when doing "Save Copy...".
if blend_path == bpy.data.filepath:
try:
find_and_load_project_for_blend_path(bpy.context, blend_path)
except ProjectLoadException:
log_project_load_error(blend_path)
@bpy.app.handlers.persistent
def on_exit(is_user_exit):
if not is_user_exit:
return
if bpy.context.preferences.use_project_auto_save and bpy.data.project is not None and bpy.data.project.is_dirty:
try:
save_project(bpy.data.project)
except ProjectSaveException:
log_project_save_error()
# -----------------------------------------------------------------------------
# Register
classes = (
PROJECT_OT_NewProject,
PROJECT_OT_SaveProject,
PROJECT_OT_OpenBlendInProject,
PROJECT_OT_AddVariable,
PROJECT_OT_RemoveVariable,
PROJECT_OT_MoveVariable,
PROJECT_OT_AssetLibraryAdd,
PROJECT_OT_AssetLibraryRemove,
)
def register():
bpy.app.handlers.load_pre.append(on_blend_load)
bpy.app.handlers.save_post.append(on_blend_save)
bpy.app.handlers.exit_pre.append(on_exit)
def unregister():
bpy.app.handlers.load_pre.remove(on_blend_load)
bpy.app.handlers.save_post.remove(on_blend_save)
bpy.app.handlers.exit_pre.remove(on_exit)