From d568abea628bdcf3796985199da073fad928e285 Mon Sep 17 00:00:00 2001 From: Brecht Van Lommel Date: Fri, 31 May 2024 16:22:08 +0200 Subject: [PATCH] Extensions: Install From Disk operator handling both legacy and new When a user has downloaded an add-on as a zip file, it's not clear in advance if this is a legacy add-on or a new extension. So they would have to use trial and error, or inspect the zip file contents. This uses a simple heuristic to check if the file is a legacy add-on, and if so automatically calls the legacy operator instead. The operator now show both extension and legacy add-on properties, with the latter in a default collapsed subpanel. Pull Request: https://projects.blender.org/blender/blender/pulls/121926 --- .../addons_core/bl_pkg/bl_extension_ops.py | 109 +++++++++++++++--- scripts/addons_core/bl_pkg/bl_extension_ui.py | 3 +- .../addons_core/bl_pkg/bl_extension_utils.py | 5 + scripts/addons_core/bl_pkg/cli/blender_ext.py | 32 +++++ scripts/modules/rna_manual_reference.py | 1 + scripts/startup/bl_operators/userpref.py | 9 ++ 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/scripts/addons_core/bl_pkg/bl_extension_ops.py b/scripts/addons_core/bl_pkg/bl_extension_ops.py index b5b509bd7d0..9008eca22c8 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_ops.py +++ b/scripts/addons_core/bl_pkg/bl_extension_ops.py @@ -1544,8 +1544,9 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): "pkg_id_sequence" ) _drop_variables = None + _legacy_drop = None - filter_glob: StringProperty(default="*.zip", options={'HIDDEN'}) + filter_glob: StringProperty(default="*.zip;*.py", options={'HIDDEN'}) directory: StringProperty( name="Directory", @@ -1570,12 +1571,26 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): enable_on_install: rna_prop_enable_on_install + # Properties matching the legacy operator, not used by extension packages. + target: EnumProperty( + name="Legacy Target Path", + items=bpy.types.PREFERENCES_OT_addon_install._target_path_items, + description="Path to install legacy add-on packages to", + ) + + overwrite: BoolProperty( + name="Legacy Overwrite", + description="Remove existing add-ons with the same ID", + default=True, + ) + # Only used for code-path for dropping an extension. url: rna_prop_url def exec_command_iter(self, is_modal): from .bl_extension_utils import ( pkg_manifest_dict_from_file_or_error, + pkg_is_legacy_addon, ) self._addon_restore = [] @@ -1623,7 +1638,14 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): # Extract meta-data from package files. # Note that errors are ignored here, let the underlying install operation do this. pkg_id_sequence = [] + pkg_files = [] + pkg_legacy_files = [] for source_filepath in source_files: + if pkg_is_legacy_addon(source_filepath): + pkg_legacy_files.append(source_filepath) + continue + pkg_files.append(source_filepath) + result = pkg_manifest_dict_from_file_or_error(source_filepath) if isinstance(result, str): continue @@ -1635,6 +1657,13 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): directory = repo_item.directory assert directory != "" + # Install legacy add-ons + for source_filepath in pkg_legacy_files: + self.exec_legacy(source_filepath) + + if not pkg_files: + return None + # Collect package ID's. self.repo_directory = directory self.pkg_id_sequence = pkg_id_sequence @@ -1668,7 +1697,7 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): partial( bl_extension_utils.pkg_install_files, directory=directory, - files=source_files, + files=pkg_files, use_idle=is_modal, ) ], @@ -1726,6 +1755,12 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): _preferences_ui_redraw() _preferences_ui_refresh_addons() + def exec_legacy(self, filepath): + backup_filepath = self.filepath + self.filepath = filepath + bpy.types.PREFERENCES_OT_addon_install.execute(self, bpy.context) + self.filepath = backup_filepath + @classmethod def poll(cls, context): if next(repo_iter_valid_local_only(context), None) is None: @@ -1746,18 +1781,34 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): def draw(self, context): if self._drop_variables is not None: return self._draw_for_drop(context) + elif self._legacy_drop is not None: + return self._draw_for_legacy_drop(context) # Override draw because the repository names may be over-long and not fit well in the UI. # Show the text & repository names in two separate rows. layout = self.layout - col = layout.column() - col.label(text="Local Repository:") - col.prop(self, "repo", text="") - + layout.use_property_split = True + layout.use_property_decorate = False layout.prop(self, "enable_on_install") + header, body = layout.panel("extensions") + header.label(text="Extensions") + if body: + body.prop(self, "repo", text="Repository") + + header, body = layout.panel("legacy", default_closed=True) + header.label(text="Legacy Add-ons") + + row = header.row() + row.alignment = 'RIGHT' + row.emboss = 'NONE' + row.operator("wm.doc_view_manual", icon='URL', text="").doc_id = "preferences.addon_install" + + if body: + body.prop(self, "target", text="Target Path") + body.prop(self, "overwrite", text="Overwrite") + def _invoke_for_drop(self, context, event): - self._drop_variables = True # Drop logic. print("DROP FILE:", self.url) @@ -1769,23 +1820,33 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): # These are not supported for dropping. Since at the time of dropping it's not known that the # path is referenced from a "local" repository or a "remote" that uses a `file://` URL. filepath = self.url + print(filepath) - from .bl_extension_ops import repo_iter_valid_local_only - from .bl_extension_utils import pkg_manifest_dict_from_file_or_error + from .bl_extension_utils import pkg_is_legacy_addon - if not list(repo_iter_valid_local_only(bpy.context)): - self.report({'ERROR'}, "No Local Repositories") - return {'CANCELLED'} + if not pkg_is_legacy_addon(filepath): + self._drop_variables = True + self._legacy_drop = None - if isinstance(result := pkg_manifest_dict_from_file_or_error(filepath), str): - self.report({'ERROR'}, "Error in manifest {:s}".format(result)) - return {'CANCELLED'} + from .bl_extension_ops import repo_iter_valid_local_only + from .bl_extension_utils import pkg_manifest_dict_from_file_or_error - pkg_id = result["id"] - pkg_type = result["type"] - del result + if not list(repo_iter_valid_local_only(bpy.context)): + self.report({'ERROR'}, "No Local Repositories") + return {'CANCELLED'} - self._drop_variables = pkg_id, pkg_type + if isinstance(result := pkg_manifest_dict_from_file_or_error(filepath), str): + self.report({'ERROR'}, "Error in manifest {:s}".format(result)) + return {'CANCELLED'} + + pkg_id = result["id"] + pkg_type = result["type"] + del result + + self._drop_variables = pkg_id, pkg_type + else: + self._drop_variables = None + self._legacy_drop = True # Set to it's self to the property is considered "set". self.repo = self.repo @@ -1808,6 +1869,16 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn): layout.prop(self, "enable_on_install", text=rna_prop_enable_on_install_type_map[pkg_type]) + def _draw_for_legacy_drop(self, context): + + layout = self.layout + layout.operator_context = 'EXEC_DEFAULT' + + layout.label(text="Legacy Add-on") + layout.prop(self, "target", text="Target") + layout.prop(self, "overwrite", text="Overwrite") + layout.prop(self, "enable_on_install") + class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn): """Download and install the extension""" diff --git a/scripts/addons_core/bl_pkg/bl_extension_ui.py b/scripts/addons_core/bl_pkg/bl_extension_ui.py index 16bfb581cc9..e07efebc66d 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_ui.py +++ b/scripts/addons_core/bl_pkg/bl_extension_ui.py @@ -783,8 +783,7 @@ class USERPREF_MT_extensions_settings(Menu): layout.separator() layout.operator("extensions.package_upgrade_all", text="Install Available Updates", icon='IMPORT') - layout.operator("extensions.package_install_files", text="Install from Disk") - layout.operator("preferences.addon_install", text="Install Legacy Add-on") + layout.operator("extensions.package_install_files", text="Install from Disk...") if prefs.experimental.use_extension_utils: layout.separator() diff --git a/scripts/addons_core/bl_pkg/bl_extension_utils.py b/scripts/addons_core/bl_pkg/bl_extension_utils.py index 13df298a379..9404e3ea304 100644 --- a/scripts/addons_core/bl_pkg/bl_extension_utils.py +++ b/scripts/addons_core/bl_pkg/bl_extension_utils.py @@ -633,6 +633,11 @@ def pkg_manifest_archive_url_abs_from_remote_url(remote_url: str, archive_url: s return archive_url +def pkg_is_legacy_addon(filepath: str) -> bool: + from .cli.blender_ext import pkg_is_legacy_addon + return pkg_is_legacy_addon(filepath) + + def pkg_repo_cache_clear(local_dir: str) -> None: local_cache_dir = os.path.join(local_dir, ".blender_ext", "cache") if not os.path.isdir(local_cache_dir): diff --git a/scripts/addons_core/bl_pkg/cli/blender_ext.py b/scripts/addons_core/bl_pkg/cli/blender_ext.py index 98c2181ddcb..01aab5830da 100755 --- a/scripts/addons_core/bl_pkg/cli/blender_ext.py +++ b/scripts/addons_core/bl_pkg/cli/blender_ext.py @@ -660,6 +660,38 @@ def pkg_manifest_from_archive_and_validate( return pkg_manifest_from_zipfile_and_validate(zip_fh, archive_subdir, strict=strict) +def pkg_is_legacy_addon(filepath: str) -> bool: + # Python file is legacy. + if os.path.splitext(filepath)[1].lower() == ".py": + return True + + try: + zip_fh_context = zipfile.ZipFile(filepath, mode="r") + except BaseException as ex: + return False + + with contextlib.closing(zip_fh_context) as zip_fh: + # If manifest not legacy. + if pkg_zipfile_detect_subdir_or_none(zip_fh) is not None: + return False + + # If any python file contains bl_info it's legacy. + base_dir = None + for filename in zip_fh_context.NameToInfo.keys(): + if filename.startswith("."): + continue + if not filename.lower().endswith(".py"): + continue + try: + file_content = zip_fh.read(filename) + except: + file_content = None + if file_content and file_content.find(b"bl_info"): + return True + + return False + + def remote_url_has_filename_suffix(url: str) -> bool: # When the URL ends with `.json` it's assumed to be a URL that is inside a directory. # In these cases the file is stripped before constricting relative paths. diff --git a/scripts/modules/rna_manual_reference.py b/scripts/modules/rna_manual_reference.py index f6b2a475b1f..fa8999df652 100644 --- a/scripts/modules/rna_manual_reference.py +++ b/scripts/modules/rna_manual_reference.py @@ -3214,6 +3214,7 @@ url_manual_mapping = ( ("bpy.ops.object.shade_flat*", "scene_layout/object/editing/shading.html#bpy-ops-object-shade-flat"), ("bpy.ops.paintcurve.select*", "sculpt_paint/brush/stroke.html#bpy-ops-paintcurve-select"), ("bpy.ops.pose.paths_update*", "animation/motion_paths.html#bpy-ops-pose-paths-update"), + ("bpy.ops.preferences.addon_install*", "advanced/extensions/addons.html#bpy-ops-preferences-addon-install"), ("bpy.ops.preferences.addon*", "editors/preferences/addons.html#bpy-ops-preferences-addon"), ("bpy.ops.scene.light_cache*", "render/eevee/render_settings/indirect_lighting.html#bpy-ops-scene-light-cache"), ("bpy.ops.screen.actionzone*", "interface/window_system/areas.html#bpy-ops-screen-actionzone"), diff --git a/scripts/startup/bl_operators/userpref.py b/scripts/startup/bl_operators/userpref.py index 189d756cfb2..a97e0dab50f 100644 --- a/scripts/startup/bl_operators/userpref.py +++ b/scripts/startup/bl_operators/userpref.py @@ -597,6 +597,12 @@ class PREFERENCES_OT_addon_install(Operator): 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: @@ -740,6 +746,9 @@ class PREFERENCES_OT_addon_install(Operator): if mod.__name__ in addons_new: bl_info = addon_utils.module_bl_info(mod) + if self.enable_on_install: + bpy.ops.preferences.addon_enable(module=mod.__name__) + # show the newly installed addon. context.preferences.view.show_addons_enabled_only = False context.window_manager.addon_filter = 'All'