# 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, ) 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 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, )