blender/scripts/startup/bl_operators/userpref.py
Campbell Barton 67ddb0e1a5 Extensions: detect external changes on startup & loading preferences
Changes to an extensions manifest weren't accounted for.
This was particularly a problem for "System" extensions which aren't
intended to be managed inside Blender however the problem existed for
any changes made outside of Blender.

Now enabled extensions are checked on startup to ensure:

- They are compatible with Blender.
- The Python wheels are synchronized.

Resolves #123645.

Details:

- Any extension incompatibilities prevent the add-on being enabled
  with a message printing the reason for it being disabled.

- Incompatible add-ons are kept enabled in the preferences to avoid
  loosing their own preferences and allow for an upgrade to restore
  compatibility.

- To avoid slowing down Blender's startup:

  - Checks are skipped when no extensions are enabled
    (as is the case for `--factory-startup` & running tests).
  - Compatibility data is cached so in common case,
    the cache is loaded and all enabled extensions `stat` their
    manifests to detect changes without having to parse them.
  - The cache is re-generated if any extensions change or the
    Blender/Python version changes.

- Compatibility data is updated:

  - On startup (when needed).
  - On an explicit "Refresh Local"
    (mainly for developers who may edit the manifest).
  - When refreshing extensions after install/uninstall etc.
    since an incompatible extensions may become compatible
    after an update.
  - When reloading preferences.

- Additional info is shown when the `--debug-python` is enabled,
  if there are ever issues with the extension compatibility cache
  generation not working as expected.

- The behavior for Python wheels has changed so they are only setup
  when the extension is enabled. This was done to simplify startup
  checks and has the benefit that an installed but disabled extension
  never runs code - as the ability to install wheels means it could
  have been imported from other scripts. It also means users can disable
  an extension to avoid wheel version conflicts.

  This does add the complication however that enabling add-on which is
  an extension must first ensure it's wheels are setup.
  See `addon_utils.extensions_refresh(..)`.

See code-comments for further details.
2024-07-01 15:08:14 +10:00

1266 lines
39 KiB
Python

# SPDX-FileCopyrightText: 2019-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import (
Operator,
OperatorFileListElement,
)
from bpy.props import (
BoolProperty,
EnumProperty,
IntProperty,
StringProperty,
CollectionProperty,
)
from bpy.app.translations import (
pgettext_iface as iface_,
pgettext_tip as rpt_,
)
def _zipfile_root_namelist(file_to_extract):
# Return a list of root paths from zipfile.ZipFile.namelist.
import os
root_paths = []
for f in file_to_extract.namelist():
# Python's `zipfile` API always adds a separate at the end of directories.
# use `os.path.normpath` instead of `f.removesuffix(os.sep)`
# since paths could be stored as `./paths/./`.
#
# Note that `..` prefixed paths can exist in ZIP files but they don't write to parent directory when extracting.
# Nor do they pass the `os.sep not in f` test, this is important,
# otherwise `shutil.rmtree` below could made to remove directories outside the installation directory.
f = os.path.normpath(f)
if os.sep not in f:
root_paths.append(f)
return root_paths
def _module_filesystem_remove(path_base, module_name):
# Remove all Python modules with `module_name` in `base_path`.
# The `module_name` is expected to be a result from `_zipfile_root_namelist`.
import os
import shutil
module_name = os.path.splitext(module_name)[0]
for f in os.listdir(path_base):
f_base = os.path.splitext(f)[0]
if f_base == module_name:
f_full = os.path.join(path_base, f)
if os.path.isdir(f_full):
shutil.rmtree(f_full)
else:
os.remove(f_full)
class PREFERENCES_OT_keyconfig_activate(Operator):
bl_idname = "preferences.keyconfig_activate"
bl_label = "Activate Keyconfig"
filepath: StringProperty(
subtype='FILE_PATH',
)
def execute(self, _context):
if bpy.utils.keyconfig_set(self.filepath, report=self.report):
return {'FINISHED'}
else:
return {'CANCELLED'}
class PREFERENCES_OT_copy_prev(Operator):
"""Copy settings from previous version"""
bl_idname = "preferences.copy_prev"
bl_label = "Copy Previous Settings"
@classmethod
def _old_version_path(cls, version):
return bpy.utils.resource_path('USER', major=version[0], minor=version[1])
@classmethod
def previous_version(cls):
import os
# Find config folder from previous version.
#
# Always allow to load startup data from any release from current major release cycle, and the previous one.
# NOTE: This value may need to be updated when the release cycle system is modified.
# Here could be `6` in theory (Blender 3.6 LTS), just give it a bit of extra room, such that it does not have to
# be updated if there ever exist a 3.7 release e.g.
MAX_MINOR_VERSION_FOR_PREVIOUS_MAJOR_LOOKUP = 10
version_new = bpy.app.version[:2]
version_old = [version_new[0], version_new[1] - 1]
while True:
while version_old[1] >= 0:
if os.path.isdir(cls._old_version_path(version_old)):
return tuple(version_old)
version_old[1] -= 1
if version_new[0] == version_old[0]:
# Retry with older major version.
version_old[0] -= 1
version_old[1] = MAX_MINOR_VERSION_FOR_PREVIOUS_MAJOR_LOOKUP
else:
break
return None
@classmethod
def _old_path(cls):
version_old = cls.previous_version()
return cls._old_version_path(version_old) if version_old else None
@classmethod
def _new_path(cls):
return bpy.utils.resource_path('USER')
@classmethod
def poll(cls, _context):
import os
old = cls._old_path()
new = cls._new_path()
if not old:
return False
# Disable operator in case config path is overridden with environment
# variable. That case has no automatic per-version configuration.
userconfig_path = os.path.normpath(bpy.utils.user_resource('CONFIG'))
new_userconfig_path = os.path.normpath(os.path.join(new, "config"))
if userconfig_path != new_userconfig_path:
return False
# Enable operator if new config path does not exist yet.
if os.path.isdir(old) and not os.path.isdir(new):
return True
# Enable operator also if there are no new user preference yet.
old_userpref = os.path.join(old, "config", "userpref.blend")
new_userpref = os.path.join(new, "config", "userpref.blend")
return os.path.isfile(old_userpref) and not os.path.isfile(new_userpref)
def execute(self, _context):
import shutil
shutil.copytree(self._old_path(), self._new_path(), dirs_exist_ok=True, symlinks=True)
# Reload preferences and `recent-files.txt`.
bpy.ops.wm.read_userpref()
bpy.ops.wm.read_history()
# Fix operator presets that have unwanted filepath properties
bpy.ops.wm.operator_presets_cleanup()
# don't loose users work if they open the splash later.
if bpy.data.is_saved is bpy.data.is_dirty is False:
bpy.ops.wm.read_homefile()
else:
self.report({'INFO'}, "Reload Start-Up file to restore settings")
return {'FINISHED'}
class PREFERENCES_OT_keyconfig_test(Operator):
"""Test key configuration for conflicts"""
bl_idname = "preferences.keyconfig_test"
bl_label = "Test Key Configuration for Conflicts"
def execute(self, context):
from bpy_extras import keyconfig_utils
wm = context.window_manager
kc = wm.keyconfigs.default
if keyconfig_utils.keyconfig_test(kc):
print("CONFLICT")
return {'FINISHED'}
class PREFERENCES_OT_keyconfig_import(Operator):
"""Import key configuration from a Python script"""
bl_idname = "preferences.keyconfig_import"
bl_label = "Import Key Configuration..."
filepath: StringProperty(
subtype='FILE_PATH',
default="keymap.py",
)
filter_folder: BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_text: BoolProperty(
name="Filter text",
default=True,
options={'HIDDEN'},
)
filter_python: BoolProperty(
name="Filter Python",
default=True,
options={'HIDDEN'},
)
keep_original: BoolProperty(
name="Keep Original",
description="Keep original file after copying to configuration folder",
default=True,
)
def execute(self, _context):
import os
from os.path import basename
import shutil
if not self.filepath:
self.report({'ERROR'}, "Filepath not set")
return {'CANCELLED'}
config_name = basename(self.filepath)
path = bpy.utils.user_resource(
'SCRIPTS',
path=os.path.join("presets", "keyconfig"),
create=True,
)
path = os.path.join(path, config_name)
try:
if self.keep_original:
shutil.copy(self.filepath, path)
else:
shutil.move(self.filepath, path)
except BaseException as ex:
self.report({'ERROR'}, rpt_("Installing keymap failed: {:s}").format(str(ex)))
return {'CANCELLED'}
# sneaky way to check we're actually running the code.
if bpy.utils.keyconfig_set(path, report=self.report):
return {'FINISHED'}
else:
return {'CANCELLED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
# This operator is also used by interaction presets saving - AddPresetBase
class PREFERENCES_OT_keyconfig_export(Operator):
"""Export key configuration to a Python script"""
bl_idname = "preferences.keyconfig_export"
bl_label = "Export Key Configuration..."
all: BoolProperty(
name="All Keymaps",
default=False,
description="Write all keymaps (not just user modified)",
)
filepath: StringProperty(
subtype='FILE_PATH',
default="",
)
filter_folder: BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_text: BoolProperty(
name="Filter text",
default=True,
options={'HIDDEN'},
)
filter_python: BoolProperty(
name="Filter Python",
default=True,
options={'HIDDEN'},
)
def execute(self, context):
from bl_keymap_utils.io import keyconfig_export_as_data
if not self.filepath:
raise Exception("Filepath not set")
if not self.filepath.endswith(".py"):
self.filepath += ".py"
wm = context.window_manager
keyconfig_export_as_data(
wm,
wm.keyconfigs.active,
self.filepath,
all_keymaps=self.all,
)
return {'FINISHED'}
def invoke(self, context, _event):
import os
wm = context.window_manager
if not self.filepath:
self.filepath = os.path.join(
os.path.expanduser("~"),
bpy.path.display_name_to_filepath(wm.keyconfigs.active.name) + ".py",
)
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class PREFERENCES_OT_keymap_restore(Operator):
"""Restore key map(s)"""
bl_idname = "preferences.keymap_restore"
bl_label = "Restore Key Map(s)"
all: BoolProperty(
name="All Keymaps",
description="Restore all keymaps to default",
)
def execute(self, context):
wm = context.window_manager
if self.all:
for km in wm.keyconfigs.user.keymaps:
km.restore_to_default()
else:
km = context.keymap
km.restore_to_default()
context.preferences.is_dirty = True
return {'FINISHED'}
class PREFERENCES_OT_keyitem_restore(Operator):
"""Restore key map item"""
bl_idname = "preferences.keyitem_restore"
bl_label = "Restore Key Map Item"
item_id: IntProperty(
name="Item Identifier",
description="Identifier of the item to restore",
)
@classmethod
def poll(cls, context):
keymap = getattr(context, "keymap", None)
return keymap
def execute(self, context):
km = context.keymap
kmi = km.keymap_items.from_id(self.item_id)
if (not kmi.is_user_defined) and kmi.is_user_modified:
km.restore_item_to_default(kmi)
return {'FINISHED'}
class PREFERENCES_OT_keyitem_add(Operator):
"""Add key map item"""
bl_idname = "preferences.keyitem_add"
bl_label = "Add Key Map Item"
def execute(self, context):
km = context.keymap
if km.is_modal:
km.keymap_items.new_modal("", 'A', 'PRESS')
else:
km.keymap_items.new("none", 'A', 'PRESS')
# clear filter and expand keymap so we can see the newly added item
if context.space_data.filter_text != "":
context.space_data.filter_text = ""
km.show_expanded_items = True
km.show_expanded_children = True
context.preferences.is_dirty = True
return {'FINISHED'}
class PREFERENCES_OT_keyitem_remove(Operator):
"""Remove key map item"""
bl_idname = "preferences.keyitem_remove"
bl_label = "Remove Key Map Item"
item_id: IntProperty(
name="Item Identifier",
description="Identifier of the item to remove",
)
@classmethod
def poll(cls, context):
return hasattr(context, "keymap")
def execute(self, context):
km = context.keymap
kmi = km.keymap_items.from_id(self.item_id)
km.keymap_items.remove(kmi)
context.preferences.is_dirty = True
return {'FINISHED'}
class PREFERENCES_OT_keyconfig_remove(Operator):
"""Remove key config"""
bl_idname = "preferences.keyconfig_remove"
bl_label = "Remove Key Config"
@classmethod
def poll(cls, context):
wm = context.window_manager
keyconf = wm.keyconfigs.active
return keyconf and keyconf.is_user_defined
def execute(self, context):
wm = context.window_manager
keyconfig = wm.keyconfigs.active
wm.keyconfigs.remove(keyconfig)
return {'FINISHED'}
# -----------------------------------------------------------------------------
# Add-on Operators
class PREFERENCES_OT_addon_enable(Operator):
"""Turn on this extension"""
bl_idname = "preferences.addon_enable"
bl_label = "Enable Extension"
module: StringProperty(
name="Module",
description="Module name of the add-on to enable",
)
def execute(self, _context):
import addon_utils
err_str = ""
def err_cb(ex):
import traceback
traceback.print_exc()
# The full trace-back in the UI is unwieldy and associated with unhandled exceptions.
# Only show a single exception instead of the full trace-back,
# developers can debug using information printed in the console.
nonlocal err_str
err_str = str(ex)
module_name = self.module
# Ensure any wheels are setup before enabling.
is_extension = addon_utils.check_extension(module_name)
if is_extension:
addon_utils.extensions_refresh(ensure_wheels=True, addon_modules_pending=[module_name])
mod = addon_utils.enable(module_name, default_set=True, handle_error=err_cb)
if mod:
bl_info = addon_utils.module_bl_info(mod)
info_ver = bl_info.get("blender", (0, 0, 0))
if info_ver > bpy.app.version:
self.report(
{'WARNING'},
rpt_(
"This script was written Blender "
"version {:d}.{:d}.{:d} and might not "
"function (correctly), "
"though it is enabled"
).format(info_ver)
)
return {'FINISHED'}
else:
if err_str:
self.report({'ERROR'}, err_str)
if is_extension:
# Since the add-on didn't work, remove any wheels it may have installed.
addon_utils.extensions_refresh(ensure_wheels=True)
return {'CANCELLED'}
class PREFERENCES_OT_addon_disable(Operator):
"""Turn off this extension"""
bl_idname = "preferences.addon_disable"
bl_label = "Disable Extension"
module: StringProperty(
name="Module",
description="Module name of the add-on to disable",
)
def execute(self, _context):
import addon_utils
err_str = ""
def err_cb(ex):
import traceback
nonlocal err_str
err_str = traceback.format_exc()
print(err_str)
module_name = self.module
is_extension = addon_utils.check_extension(module_name)
addon_utils.disable(module_name, default_set=True, handle_error=err_cb)
if is_extension:
addon_utils.extensions_refresh(ensure_wheels=True)
if err_str:
self.report({'ERROR'}, err_str)
return {'FINISHED'}
class PREFERENCES_OT_theme_install(Operator):
"""Load and apply a Blender XML theme file"""
bl_idname = "preferences.theme_install"
bl_label = "Install Theme..."
overwrite: BoolProperty(
name="Overwrite",
description="Remove existing theme file if exists",
default=True,
)
filepath: StringProperty(
subtype='FILE_PATH',
)
filter_folder: BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_glob: StringProperty(
default="*.xml",
options={'HIDDEN'},
)
def execute(self, _context):
import os
import shutil
import traceback
xmlfile = self.filepath
path_themes = bpy.utils.user_resource(
'SCRIPTS',
path=os.path.join("presets", "interface_theme"),
create=True,
)
if not path_themes:
self.report({'ERROR'}, "Failed to get themes path")
return {'CANCELLED'}
path_dest = os.path.join(path_themes, os.path.basename(xmlfile))
if not self.overwrite:
if os.path.exists(path_dest):
self.report({'WARNING'}, rpt_("File already installed to {!r}").format(path_dest))
return {'CANCELLED'}
try:
shutil.copyfile(xmlfile, path_dest)
bpy.ops.script.execute_preset(
filepath=path_dest,
menu_idname="USERPREF_MT_interface_theme_presets",
)
except BaseException:
traceback.print_exc()
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class PREFERENCES_OT_addon_refresh(Operator):
"""Scan add-on directories for new modules"""
bl_idname = "preferences.addon_refresh"
bl_label = "Refresh"
def execute(self, _context):
import addon_utils
addon_utils.modules_refresh()
return {'FINISHED'}
# Note: shares some logic with PREFERENCES_OT_app_template_install
# but not enough to de-duplicate. Fixed here may apply to both.
class PREFERENCES_OT_addon_install(Operator):
"""Install an add-on"""
bl_idname = "preferences.addon_install"
bl_label = "Install Add-on"
overwrite: BoolProperty(
name="Overwrite",
description="Remove existing add-ons with the same ID",
default=True,
)
enable_on_install: BoolProperty(
name="Enable on Install",
description="Enable after installing",
default=False,
)
def _target_path_items(_self, context):
default_item = ('DEFAULT', "Default", "")
if context is None:
return (
default_item,
)
paths = context.preferences.filepaths
script_directories_items = [
(item.name, item.name, "") for index, item in enumerate(paths.script_directories)
if item.directory
]
return (
(default_item, None, *script_directories_items) if script_directories_items else
(default_item,)
)
target: EnumProperty(
name="Target Path",
items=_target_path_items,
)
filepath: StringProperty(
subtype='FILE_PATH',
)
filter_folder: BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_python: BoolProperty(
name="Filter Python",
default=True,
options={'HIDDEN'},
)
filter_glob: StringProperty(
default="*.py;*.zip",
options={'HIDDEN'},
)
def execute(self, context):
import addon_utils
import traceback
import zipfile
import shutil
import os
pyfile = self.filepath
if self.target == 'DEFAULT':
# Don't use `bpy.utils.script_paths(path="addons")` because we may not be able to write to it.
path_addons = bpy.utils.user_resource('SCRIPTS', path="addons", create=True)
else:
paths = context.preferences.filepaths
for script_directory in paths.script_directories:
if script_directory.name == self.target:
path_addons = os.path.join(script_directory.directory, "addons")
break
if not path_addons:
self.report({'ERROR'}, "Failed to get add-ons path")
return {'CANCELLED'}
if not os.path.isdir(path_addons):
try:
os.makedirs(path_addons, exist_ok=True)
except BaseException:
traceback.print_exc()
# Check if we are installing from a target path,
# doing so causes 2+ addons of same name or when the same from/to
# location is used, removal of the file!
addon_path = ""
pyfile_dir = os.path.dirname(pyfile)
for addon_path in addon_utils.paths():
if os.path.samefile(pyfile_dir, addon_path):
self.report({'ERROR'}, rpt_("Source file is in the add-on search path: {!r}").format(addon_path))
return {'CANCELLED'}
del addon_path
del pyfile_dir
# done checking for exceptional case
addons_old = {mod.__name__ for mod in addon_utils.modules()}
# check to see if the file is in compressed format (.zip)
if zipfile.is_zipfile(pyfile):
try:
file_to_extract = zipfile.ZipFile(pyfile, "r")
except BaseException:
traceback.print_exc()
return {'CANCELLED'}
file_to_extract_root = _zipfile_root_namelist(file_to_extract)
if "__init__.py" in file_to_extract_root:
self.report({'ERROR'}, rpt_(
"ZIP packaged incorrectly; __init__.py should be in a directory, not at top-level"
))
return {'CANCELLED'}
if self.overwrite:
for f in file_to_extract_root:
_module_filesystem_remove(path_addons, f)
else:
for f in file_to_extract_root:
path_dest = os.path.join(path_addons, os.path.basename(f))
if os.path.exists(path_dest):
self.report({'WARNING'}, rpt_("File already installed to {!r}").format(path_dest))
return {'CANCELLED'}
try: # extract the file to "addons"
file_to_extract.extractall(path_addons)
except BaseException:
traceback.print_exc()
return {'CANCELLED'}
else:
path_dest = os.path.join(path_addons, os.path.basename(pyfile))
if self.overwrite:
_module_filesystem_remove(path_addons, os.path.basename(pyfile))
elif os.path.exists(path_dest):
self.report({'WARNING'}, rpt_("File already installed to {!r}").format(path_dest))
return {'CANCELLED'}
# if not compressed file just copy into the addon path
try:
shutil.copyfile(pyfile, path_dest)
except BaseException:
traceback.print_exc()
return {'CANCELLED'}
addons_new = {mod.__name__ for mod in addon_utils.modules()} - addons_old
addons_new.discard("modules")
# disable any addons we may have enabled previously and removed.
# this is unlikely but do just in case. bug #23978.
for new_addon in addons_new:
addon_utils.disable(new_addon, default_set=True)
# possible the zip contains multiple addons, we could disallow this
# but for now just use the first
for mod in addon_utils.modules(refresh=False):
if mod.__name__ in addons_new:
bl_info = addon_utils.module_bl_info(mod)
# show the newly installed addon.
context.preferences.view.show_addons_enabled_only = False
context.window_manager.addon_filter = 'All'
context.window_manager.addon_search = bl_info["name"]
break
# in case a new module path was created to install this addon.
bpy.utils.refresh_script_paths()
# Auto enable if needed.
if self.enable_on_install:
for mod in addon_utils.modules(refresh=False):
if mod.__name__ in addons_new:
bpy.ops.preferences.addon_enable(module=mod.__name__)
# print message
msg = rpt_("Modules Installed ({:s}) from {!r} into {!r}").format(
", ".join(sorted(addons_new)), pyfile, path_addons,
)
print(msg)
self.report({'INFO'}, msg)
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class PREFERENCES_OT_addon_remove(Operator):
"""Delete the add-on from the file system"""
bl_idname = "preferences.addon_remove"
bl_label = "Remove Add-on"
module: StringProperty(
name="Module",
description="Module name of the add-on to remove",
)
@staticmethod
def path_from_addon(module):
import os
import addon_utils
for mod in addon_utils.modules():
if mod.__name__ == module:
filepath = mod.__file__
if os.path.exists(filepath):
if os.path.splitext(os.path.basename(filepath))[0] == "__init__":
return os.path.dirname(filepath), True
else:
return filepath, False
return None, False
def execute(self, context):
import addon_utils
import os
path, isdir = PREFERENCES_OT_addon_remove.path_from_addon(self.module)
if path is None:
self.report({'WARNING'}, rpt_("Add-on path {!r} could not be found").format(path))
return {'CANCELLED'}
# in case its enabled
addon_utils.disable(self.module, default_set=True)
import shutil
if isdir and (not os.path.islink(path)):
shutil.rmtree(path)
else:
os.remove(path)
addon_utils.modules_refresh()
context.area.tag_redraw()
return {'FINISHED'}
# lame confirmation check
def draw(self, _context):
self.layout.label(text=iface_("Remove Add-on: {!r}?").format(self.module), translate=False)
path, _isdir = PREFERENCES_OT_addon_remove.path_from_addon(self.module)
self.layout.label(text=iface_("Path: {!r}").format(path), translate=False)
def invoke(self, context, _event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=600)
class PREFERENCES_OT_addon_expand(Operator):
"""Display information and preferences for this add-on"""
bl_idname = "preferences.addon_expand"
bl_label = ""
bl_options = {'INTERNAL'}
module: StringProperty(
name="Module",
description="Module name of the add-on to expand",
)
def execute(self, _context):
import addon_utils
addon_module_name = self.module
# Ensure `addons_fake_modules` is set.
_modules = addon_utils.modules(refresh=False)
mod = addon_utils.addons_fake_modules.get(addon_module_name)
if mod is not None:
bl_info = addon_utils.module_bl_info(mod)
bl_info["show_expanded"] = not bl_info["show_expanded"]
return {'FINISHED'}
class PREFERENCES_OT_addon_show(Operator):
"""Show add-on preferences"""
bl_idname = "preferences.addon_show"
bl_label = ""
bl_options = {'INTERNAL'}
module: StringProperty(
name="Module",
description="Module name of the add-on to expand",
)
def execute(self, context):
import addon_utils
addon_module_name = self.module
# Ensure `addons_fake_modules` is set.
_modules = addon_utils.modules(refresh=False)
mod = addon_utils.addons_fake_modules.get(addon_module_name)
if mod is not None:
bl_info = addon_utils.module_bl_info(mod)
bl_info["show_expanded"] = True
context.preferences.active_section = 'ADDONS'
context.preferences.view.show_addons_enabled_only = False
context.window_manager.addon_filter = 'All'
context.window_manager.addon_search = bl_info["name"]
bpy.ops.screen.userpref_show('INVOKE_DEFAULT')
return {'FINISHED'}
# Note: shares some logic with PREFERENCES_OT_addon_install
# but not enough to de-duplicate. Fixes here may apply to both.
class PREFERENCES_OT_app_template_install(Operator):
"""Install an application template"""
bl_idname = "preferences.app_template_install"
bl_label = "Install Template from File..."
overwrite: BoolProperty(
name="Overwrite",
description="Remove existing template with the same ID",
default=True,
)
filepath: StringProperty(
subtype='FILE_PATH',
)
filter_folder: BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_glob: StringProperty(
default="*.zip",
options={'HIDDEN'},
)
def execute(self, _context):
import traceback
import zipfile
import os
filepath = self.filepath
path_app_templates = bpy.utils.user_resource(
'SCRIPTS',
path=os.path.join("startup", "bl_app_templates_user"),
create=True,
)
if not path_app_templates:
self.report({'ERROR'}, "Failed to get add-ons path")
return {'CANCELLED'}
if not os.path.isdir(path_app_templates):
try:
os.makedirs(path_app_templates, exist_ok=True)
except BaseException:
traceback.print_exc()
app_templates_old = set(os.listdir(path_app_templates))
# check to see if the file is in compressed format (.zip)
if zipfile.is_zipfile(filepath):
try:
file_to_extract = zipfile.ZipFile(filepath, "r")
except BaseException:
traceback.print_exc()
return {'CANCELLED'}
file_to_extract_root = _zipfile_root_namelist(file_to_extract)
if self.overwrite:
for f in file_to_extract_root:
_module_filesystem_remove(path_app_templates, f)
else:
for f in file_to_extract_root:
path_dest = os.path.join(path_app_templates, os.path.basename(f))
if os.path.exists(path_dest):
self.report({'WARNING'}, rpt_("File already installed to {!r}").format(path_dest))
return {'CANCELLED'}
try: # extract the file to "bl_app_templates_user"
file_to_extract.extractall(path_app_templates)
except BaseException:
traceback.print_exc()
return {'CANCELLED'}
else:
# Only support installing zip-files.
self.report({'WARNING'}, rpt_("Expected a zip-file {!r}").format(filepath))
return {'CANCELLED'}
app_templates_new = set(os.listdir(path_app_templates)) - app_templates_old
# in case a new module path was created to install this addon.
bpy.utils.refresh_script_paths()
# print message
msg = rpt_("Template Installed ({:s}) from {!r} into {!r}").format(
", ".join(sorted(app_templates_new)),
filepath,
path_app_templates,
)
print(msg)
self.report({'INFO'}, msg)
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
# -----------------------------------------------------------------------------
# Studio Light Operations
class PREFERENCES_OT_studiolight_install(Operator):
"""Install a user defined light"""
bl_idname = "preferences.studiolight_install"
bl_label = "Install Light"
files: CollectionProperty(
name="File Path",
type=OperatorFileListElement,
)
directory: StringProperty(
subtype='DIR_PATH',
)
filter_folder: BoolProperty(
name="Filter Folders",
default=True,
options={'HIDDEN'},
)
filter_glob: StringProperty(
default="*.png;*.jpg;*.hdr;*.exr",
options={'HIDDEN'},
)
type: EnumProperty(
name="Type",
items=(
('MATCAP', "MatCap", "Install custom MatCaps"),
('WORLD', "World", "Install custom HDRIs"),
('STUDIO', "Studio", "Install custom Studio Lights"),
),
)
def execute(self, context):
import os
import shutil
prefs = context.preferences
path_studiolights = os.path.join("studiolights", self.type.lower())
path_studiolights = bpy.utils.user_resource('DATAFILES', path=path_studiolights, create=True)
if not path_studiolights:
self.report({'ERROR'}, "Failed to create Studio Light path")
return {'CANCELLED'}
for e in self.files:
shutil.copy(os.path.join(self.directory, e.name), path_studiolights)
prefs.studio_lights.load(os.path.join(path_studiolights, e.name), self.type)
# print message
msg = rpt_("StudioLight Installed {!r} into {!r}").format(
", ".join(e.name for e in self.files),
path_studiolights,
)
print(msg)
self.report({'INFO'}, msg)
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
if self.type == 'STUDIO':
self.filter_glob = "*.sl"
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class PREFERENCES_OT_studiolight_new(Operator):
"""Save custom studio light from the studio light editor settings"""
bl_idname = "preferences.studiolight_new"
bl_label = "Save Custom Studio Light"
filename: StringProperty(
name="Name",
default="StudioLight",
)
ask_override = False
def execute(self, context):
import os
prefs = context.preferences
wm = context.window_manager
filename = bpy.path.ensure_ext(self.filename, ".sl")
path_studiolights = bpy.utils.user_resource(
'DATAFILES',
path=os.path.join("studiolights", "studio"),
create=True,
)
if not path_studiolights:
self.report({'ERROR'}, "Failed to get Studio Light path")
return {'CANCELLED'}
filepath_final = os.path.join(path_studiolights, filename)
if os.path.isfile(filepath_final):
if not self.ask_override:
self.ask_override = True
return wm.invoke_props_dialog(self, width=320)
else:
for studio_light in prefs.studio_lights:
if studio_light.name == filename:
bpy.ops.preferences.studiolight_uninstall(index=studio_light.index)
prefs.studio_lights.new(path=filepath_final)
# print message
msg = rpt_("StudioLight Installed {!r} into {!r}").format(self.filename, str(path_studiolights))
print(msg)
self.report({'INFO'}, msg)
return {'FINISHED'}
def draw(self, _context):
layout = self.layout
if self.ask_override:
layout.label(text="Warning, file already exists. Overwrite existing file?")
else:
layout.prop(self, "filename")
def invoke(self, context, _event):
wm = context.window_manager
return wm.invoke_props_dialog(self, width=320)
class PREFERENCES_OT_studiolight_uninstall(Operator):
"""Delete Studio Light"""
bl_idname = "preferences.studiolight_uninstall"
bl_label = "Uninstall Studio Light"
index: IntProperty()
def execute(self, context):
import os
prefs = context.preferences
for studio_light in prefs.studio_lights:
if studio_light.index == self.index:
filepath = studio_light.path
if filepath and os.path.exists(filepath):
os.unlink(filepath)
prefs.studio_lights.remove(studio_light)
return {'FINISHED'}
return {'CANCELLED'}
class PREFERENCES_OT_studiolight_copy_settings(Operator):
"""Copy Studio Light settings to the Studio Light editor"""
bl_idname = "preferences.studiolight_copy_settings"
bl_label = "Copy Studio Light Settings"
index: IntProperty()
def execute(self, context):
prefs = context.preferences
system = prefs.system
for studio_light in prefs.studio_lights:
if studio_light.index == self.index:
system.light_ambient = studio_light.light_ambient
for sys_light, light in zip(system.solid_lights, studio_light.solid_lights):
sys_light.use = light.use
sys_light.diffuse_color = light.diffuse_color
sys_light.specular_color = light.specular_color
sys_light.smooth = light.smooth
sys_light.direction = light.direction
return {'FINISHED'}
return {'CANCELLED'}
class PREFERENCES_OT_script_directory_new(Operator):
bl_idname = "preferences.script_directory_add"
bl_label = "Add Python Script Directory"
directory: StringProperty(
subtype='DIR_PATH',
)
filter_folder: BoolProperty(
name="Filter Folders",
default=True,
options={'HIDDEN'},
)
def execute(self, context):
import os
script_directories = context.preferences.filepaths.script_directories
new_dir = script_directories.new()
# Assign path selected via file browser.
new_dir.directory = self.directory
new_dir.name = os.path.basename(self.directory.rstrip(os.sep))
assert context.preferences.is_dirty is True
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class PREFERENCES_OT_script_directory_remove(Operator):
bl_idname = "preferences.script_directory_remove"
bl_label = "Remove Python Script Directory"
index: IntProperty(
name="Index",
description="Index of the script directory to remove",
)
def execute(self, context):
script_directories = context.preferences.filepaths.script_directories
for search_index, script_directory in enumerate(script_directories):
if search_index == self.index:
script_directories.remove(script_directory)
break
assert context.preferences.is_dirty is True
return {'FINISHED'}
classes = (
PREFERENCES_OT_addon_disable,
PREFERENCES_OT_addon_enable,
PREFERENCES_OT_addon_expand,
PREFERENCES_OT_addon_install,
PREFERENCES_OT_addon_refresh,
PREFERENCES_OT_addon_remove,
PREFERENCES_OT_addon_show,
PREFERENCES_OT_app_template_install,
PREFERENCES_OT_copy_prev,
PREFERENCES_OT_keyconfig_activate,
PREFERENCES_OT_keyconfig_export,
PREFERENCES_OT_keyconfig_import,
PREFERENCES_OT_keyconfig_remove,
PREFERENCES_OT_keyconfig_test,
PREFERENCES_OT_keyitem_add,
PREFERENCES_OT_keyitem_remove,
PREFERENCES_OT_keyitem_restore,
PREFERENCES_OT_keymap_restore,
PREFERENCES_OT_theme_install,
PREFERENCES_OT_studiolight_install,
PREFERENCES_OT_studiolight_new,
PREFERENCES_OT_studiolight_uninstall,
PREFERENCES_OT_studiolight_copy_settings,
PREFERENCES_OT_script_directory_new,
PREFERENCES_OT_script_directory_remove,
)