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
This commit is contained in:
parent
62bfb3623d
commit
d568abea62
@ -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"""
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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"),
|
||||
|
@ -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'
|
||||
|
Loading…
x
Reference in New Issue
Block a user