Files
test/scripts/startup/bl_operators/userpref.py
YimingWu 9384ae387c Fix #118035: Don't import keymap with the same name as built-in ones
If importing keymap with the same name as the built-in ones (like
"Blender", "Industrial Compatible"), those entries will become stale and
can't be removed from the UI because when removing preset it checks the
name against builtin ones. This patch fixes the problem from the
importing side and disallow names that are the same with builtin ones
from being imported (They will be renamed upon importing).

Pull Request: https://projects.blender.org/blender/blender/pulls/142697
2025-07-22 11:55:42 +02:00

1325 lines
41 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, filenames):
# Remove all Python modules defined by `filenames` in `base_path`.
# The `filenames` argument is expected to be a result from `_zipfile_root_namelist`
# but could be any iterable of file-names.
import os
import shutil
module_names = {
filename_only for filename in filenames
# Excludes non module names including hidden (dot-files).
if (filename_only := os.path.splitext(filename)[0]).isidentifier()
}
paths_stale = []
for f in os.listdir(path_base):
f_base = os.path.splitext(f)[0]
if f_base in module_names:
f_full = os.path.join(path_base, f)
if os.path.isdir(f_full) and (not os.path.islink(f_full)):
shutil.rmtree(f_full, ignore_errors=True)
else:
try:
os.remove(f_full)
except Exception:
pass
if os.path.exists(f_full):
paths_stale.append(f_full)
if paths_stale:
import addon_utils
addon_utils.stale_pending_stage_paths(path_base, paths_stale)
def _wm_wait_cursor(value):
for wm in bpy.data.window_managers:
for window in wm.windows:
if value:
window.cursor_modal_set('WAIT')
else:
window.cursor_modal_restore()
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,
)
# When importing keymap files with the same name with built-in presets (like
# "Blender" or "Industry Compatible"), we need to rename the imported ones
# so those entries can be properly removed (built-in ones can't be removed).
# See #118035.
@classmethod
def _preset_prevent_name_collision(cls, config_name):
import os
from bpy.utils import is_path_builtin
path = bpy.utils.user_resource(
'SCRIPTS',
path=os.path.join("presets", "keyconfig"),
create=True,
)
config_name_final = config_name
config_name_noext, config_name_ext = os.path.splitext(config_name)
preset_path = bpy.utils.preset_find(config_name_noext, "keyconfig", ext=".py")
if preset_path is not None and is_path_builtin(preset_path):
config_name_final = "{:s} (User){:s}".format(config_name_noext, config_name_ext)
return os.path.join(path, config_name_final)
def execute(self, _context):
import shutil
from os.path import basename
if not self.filepath:
self.report({'ERROR'}, "Filepath not set")
return {'CANCELLED'}
config_name = basename(self.filepath)
path = self._preset_prevent_name_collision(config_name)
try:
if self.keep_original:
shutil.copy(self.filepath, path)
else:
shutil.move(self.filepath, path)
except Exception 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)
context.preferences.is_dirty = True
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 add-on"""
bl_idname = "preferences.addon_enable"
bl_label = "Enable Add-on"
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)
# Refreshing wheels can be slow, use the wait cursor.
cursor_set = self.options.is_invoke
if cursor_set:
_wm_wait_cursor(True)
# Ensure any wheels are setup before enabling.
module_name = self.module
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 for Blender "
"version {:d}.{:d}.{:d} and might not "
"function (correctly), "
"though it is enabled"
).format(*info_ver)
)
result = {'FINISHED'}
else:
if err_str:
self.report({'ERROR'}, err_str)
result = {'CANCELLED'}
if cursor_set:
_wm_wait_cursor(False)
return result
class PREFERENCES_OT_addon_disable(Operator):
"""Turn off this add-on"""
bl_idname = "preferences.addon_disable"
bl_label = "Disable Add-on"
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)
# Refreshing wheels can be slow, use the wait cursor.
cursor_set = self.options.is_invoke
if cursor_set:
_wm_wait_cursor(True)
module_name = self.module
addon_utils.disable(module_name, default_set=True, handle_error=err_cb)
if err_str:
self.report({'ERROR'}, err_str)
if cursor_set:
_wm_wait_cursor(False)
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 Exception:
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 Exception:
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 Exception:
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:
_module_filesystem_remove(path_addons, file_to_extract_root)
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 Exception:
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 Exception:
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, ignore_errors=True)
else:
try:
os.remove(path)
except Exception:
pass
if os.path.exists(path):
addon_utils.stale_pending_stage_paths(os.path.dirname(path), [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"]
# No need to show the editor if it is already visible in the main window.
if 'PREFERENCES' not in (area.type for area in context.screen.areas):
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 Exception:
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 Exception:
traceback.print_exc()
return {'CANCELLED'}
file_to_extract_root = _zipfile_root_namelist(file_to_extract)
if self.overwrite:
_module_filesystem_remove(path_app_templates, file_to_extract_root)
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 Exception:
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",
subtype='FILE_NAME',
)
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,
)