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.
This commit is contained in:
parent
24d8694fe3
commit
67ddb0e1a5
@ -90,6 +90,50 @@ def cookie_from_session():
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared Low Level Utilities
|
||||
|
||||
# NOTE(@ideasman42): this is used externally from `addon_utils` which is something we try to avoid but done in
|
||||
# the case of generating compatibility cache, avoiding this "bad-level call" would be good but impractical when
|
||||
# the command line tool is treated as a stand-alone program (which I prefer to keep).
|
||||
def manifest_compatible_with_wheel_data_or_error(
|
||||
pkg_manifest_filepath, # `str`
|
||||
repo_module, # `str`
|
||||
pkg_id, # `str`
|
||||
repo_directory, # `str`
|
||||
wheel_list, # `List[Tuple[str, List[str]]]`
|
||||
): # `Optional[str]`
|
||||
from bl_pkg.bl_extension_utils import (
|
||||
pkg_manifest_dict_is_valid_or_error,
|
||||
toml_from_filepath,
|
||||
)
|
||||
from bl_pkg.bl_extension_ops import (
|
||||
pkg_manifest_params_compatible_or_error_for_this_system,
|
||||
)
|
||||
|
||||
try:
|
||||
manifest_dict = toml_from_filepath(pkg_manifest_filepath)
|
||||
except Exception as ex:
|
||||
return "Error reading TOML data {:s}".format(str(ex))
|
||||
|
||||
if (error := pkg_manifest_dict_is_valid_or_error(manifest_dict, from_repo=False, strict=False)):
|
||||
return error
|
||||
|
||||
if isinstance(error := pkg_manifest_params_compatible_or_error_for_this_system(
|
||||
blender_version_min=manifest_dict.get("blender_version_min", ""),
|
||||
blender_version_max=manifest_dict.get("blender_version_max", ""),
|
||||
platforms=manifest_dict.get("platforms", ""),
|
||||
), str):
|
||||
return error
|
||||
|
||||
# NOTE: the caller may need to collect wheels when refreshing.
|
||||
# While this isn't so clean it happens to be efficient.
|
||||
# It could be refactored to work differently in the future if that is ever needed.
|
||||
if wheels_rel := manifest_dict.get("wheels"):
|
||||
from .bl_extension_ops import pkg_wheel_filter
|
||||
if (wheel_abs := pkg_wheel_filter(repo_module, pkg_id, repo_directory, wheels_rel)) is not None:
|
||||
wheel_list.append(wheel_abs)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def repo_paths_or_none(repo_item):
|
||||
if (directory := repo_item.directory) == "":
|
||||
return None, None
|
||||
|
@ -9,6 +9,7 @@ Where the operator shows progress, any errors and supports canceling operations.
|
||||
|
||||
__all__ = (
|
||||
"extension_repos_read",
|
||||
"pkg_wheel_filter",
|
||||
)
|
||||
|
||||
import os
|
||||
@ -60,6 +61,9 @@ rna_prop_enable_on_install_type_map = {
|
||||
"theme": "Set Current Theme",
|
||||
}
|
||||
|
||||
_ext_base_pkg_idname = "bl_ext"
|
||||
_ext_base_pkg_idname_with_dot = _ext_base_pkg_idname + "."
|
||||
|
||||
|
||||
def url_append_defaults(url):
|
||||
from .bl_extension_utils import url_append_query_for_blender
|
||||
@ -488,7 +492,7 @@ def _preferences_ensure_disabled(*, repo_item, pkg_id_sequence, default_set):
|
||||
|
||||
modules_clear = []
|
||||
|
||||
module_base_elem = ("bl_ext", repo_item.module)
|
||||
module_base_elem = (_ext_base_pkg_idname, repo_item.module)
|
||||
|
||||
repo_module = sys.modules.get(".".join(module_base_elem))
|
||||
if repo_module is None:
|
||||
@ -600,7 +604,7 @@ def _preferences_install_post_enable_on_install(
|
||||
if pkg_id in pkg_id_sequence_upgrade:
|
||||
continue
|
||||
|
||||
addon_module_name = "bl_ext.{:s}.{:s}".format(repo_item.module, pkg_id)
|
||||
addon_module_name = "{:s}.{:s}.{:s}".format(_ext_base_pkg_idname, repo_item.module, pkg_id)
|
||||
addon_utils.enable(addon_module_name, default_set=True, handle_error=handle_error)
|
||||
elif item_local.type == "theme":
|
||||
if has_theme:
|
||||
@ -819,7 +823,61 @@ def _extensions_wheel_filter_for_platform(wheels):
|
||||
return wheels_compatible
|
||||
|
||||
|
||||
def _extensions_repo_sync_wheels(repo_cache_store):
|
||||
def pkg_wheel_filter(
|
||||
repo_module, # `str`
|
||||
pkg_id, # `str`
|
||||
repo_directory, # `str`
|
||||
wheels_rel, # `List[str]`
|
||||
): # `-> Tuple[str, List[str]]`
|
||||
# Filter only the wheels for this platform.
|
||||
wheels_rel = _extensions_wheel_filter_for_platform(wheels_rel)
|
||||
if not wheels_rel:
|
||||
return None
|
||||
|
||||
pkg_dirpath = os.path.join(repo_directory, pkg_id)
|
||||
|
||||
wheels_abs = []
|
||||
for filepath_rel in wheels_rel:
|
||||
filepath_abs = os.path.join(pkg_dirpath, filepath_rel)
|
||||
if not os.path.exists(filepath_abs):
|
||||
continue
|
||||
wheels_abs.append(filepath_abs)
|
||||
|
||||
if not wheels_abs:
|
||||
return None
|
||||
|
||||
unique_pkg_id = "{:s}.{:s}".format(repo_module, pkg_id)
|
||||
return (unique_pkg_id, wheels_abs)
|
||||
|
||||
|
||||
def _extension_repos_directory_to_module_map():
|
||||
return {repo.directory: repo.module for repo in bpy.context.preferences.extensions.repos if repo.enabled}
|
||||
|
||||
|
||||
def _extensions_enabled():
|
||||
from addon_utils import check_extension
|
||||
extensions_enabled = set()
|
||||
extensions_prefix_len = len(_ext_base_pkg_idname_with_dot)
|
||||
for addon in bpy.context.preferences.addons:
|
||||
module_name = addon.module
|
||||
if check_extension(module_name):
|
||||
extensions_enabled.add(module_name[extensions_prefix_len:].partition(".")[0::2])
|
||||
return extensions_enabled
|
||||
|
||||
|
||||
def _extensions_enabled_from_repo_directory_and_pkg_id_sequence(repo_directory_and_pkg_id_sequence):
|
||||
# Use to calculate extensions which will be enabled,
|
||||
# needed so the wheels for the extensions can be enabled before the add-on is enabled that uses them.
|
||||
extensions_enabled_pending = set()
|
||||
repo_directory_to_module_map = _extension_repos_directory_to_module_map()
|
||||
for repo_directory, pkg_id_sequence in repo_directory_and_pkg_id_sequence:
|
||||
repo_module = repo_directory_to_module_map[repo_directory]
|
||||
for pkg_id in pkg_id_sequence:
|
||||
extensions_enabled_pending.add((repo_module, pkg_id))
|
||||
return extensions_enabled_pending
|
||||
|
||||
|
||||
def _extensions_repo_sync_wheels(repo_cache_store, extensions_enabled):
|
||||
"""
|
||||
This function collects all wheels from all packages and ensures the packages are either extracted or removed
|
||||
when they are no longer used.
|
||||
@ -838,28 +896,18 @@ def _extensions_repo_sync_wheels(repo_cache_store):
|
||||
repo_module = repo.module
|
||||
repo_directory = repo.directory
|
||||
for pkg_id, item_local in pkg_manifest_local.items():
|
||||
pkg_dirpath = os.path.join(repo_directory, pkg_id)
|
||||
|
||||
# Check it's enabled before initializing its wheels.
|
||||
# NOTE: no need for compatibility checks here as only compatible items will be included.
|
||||
if (repo_module, pkg_id) not in extensions_enabled:
|
||||
continue
|
||||
|
||||
wheels_rel = item_local.wheels
|
||||
if not wheels_rel:
|
||||
continue
|
||||
|
||||
# Filter only the wheels for this platform.
|
||||
wheels_rel = _extensions_wheel_filter_for_platform(wheels_rel)
|
||||
if not wheels_rel:
|
||||
continue
|
||||
|
||||
wheels_abs = []
|
||||
for filepath_rel in wheels_rel:
|
||||
filepath_abs = os.path.join(pkg_dirpath, filepath_rel)
|
||||
if not os.path.exists(filepath_abs):
|
||||
continue
|
||||
wheels_abs.append(filepath_abs)
|
||||
|
||||
if not wheels_abs:
|
||||
continue
|
||||
|
||||
unique_pkg_id = "{:s}.{:s}".format(repo_module, pkg_id)
|
||||
wheel_list.append((unique_pkg_id, wheels_abs))
|
||||
if (wheel_abs := pkg_wheel_filter(repo_module, pkg_id, repo_directory, wheels_rel)) is not None:
|
||||
wheel_list.append(wheel_abs)
|
||||
|
||||
extensions = bpy.utils.user_resource('EXTENSIONS')
|
||||
local_dir = os.path.join(extensions, ".local")
|
||||
@ -871,6 +919,26 @@ def _extensions_repo_sync_wheels(repo_cache_store):
|
||||
)
|
||||
|
||||
|
||||
def _extensions_repo_refresh_on_change(repo_cache_store, *, extensions_enabled, compat_calc, stats_calc):
|
||||
import addon_utils
|
||||
if extensions_enabled is not None:
|
||||
_extensions_repo_sync_wheels(repo_cache_store, extensions_enabled)
|
||||
# Wheel sync handled above.
|
||||
|
||||
if compat_calc:
|
||||
# NOTE: `extensions_enabled` may contain add-ons which are not yet enabled (these are pending).
|
||||
# These will *not* have their compatibility information refreshed here.
|
||||
# This is acceptable because:
|
||||
# - Installing & enabling an extension relies on the extension being compatible,
|
||||
# so it can be assumed to already be the compatible.
|
||||
# - If the add-on existed and was incompatible it *will* have it's compatibility recalculated.
|
||||
# - Any missing cache entries will cause cache to be re-generated on next start or from an explicit refresh.
|
||||
addon_utils.extensions_refresh(ensure_wheels=False)
|
||||
|
||||
if stats_calc:
|
||||
repo_stats_calc()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Theme Handling
|
||||
#
|
||||
@ -1417,6 +1485,8 @@ class EXTENSIONS_OT_repo_refresh_all(Operator):
|
||||
|
||||
# In-line `bpy.ops.preferences.addon_refresh`.
|
||||
addon_utils.modules_refresh()
|
||||
# Ensure compatibility info and wheels is up to date.
|
||||
addon_utils.extensions_refresh(ensure_wheels=True)
|
||||
|
||||
_preferences_ui_redraw()
|
||||
_preferences_ui_refresh_addons()
|
||||
@ -1755,8 +1825,21 @@ class EXTENSIONS_OT_package_install_marked(Operator, _ExtCmdMixIn):
|
||||
error_fn=self.error_fn_from_exception,
|
||||
)
|
||||
|
||||
_extensions_repo_sync_wheels(repo_cache_store)
|
||||
repo_stats_calc()
|
||||
extensions_enabled = None
|
||||
if self.enable_on_install:
|
||||
extensions_enabled = _extensions_enabled()
|
||||
extensions_enabled.update(
|
||||
_extensions_enabled_from_repo_directory_and_pkg_id_sequence(
|
||||
self._repo_map_packages_addon_only,
|
||||
)
|
||||
)
|
||||
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=extensions_enabled,
|
||||
compat_calc=True,
|
||||
stats_calc=True,
|
||||
)
|
||||
|
||||
# TODO: it would be nice to include this message in the banner.
|
||||
def handle_error(ex):
|
||||
@ -1779,6 +1862,17 @@ class EXTENSIONS_OT_package_install_marked(Operator, _ExtCmdMixIn):
|
||||
handle_error=handle_error,
|
||||
)
|
||||
|
||||
if self.enable_on_install:
|
||||
if (extensions_enabled_test := _extensions_enabled()) != extensions_enabled:
|
||||
# Some extensions could not be enabled, re-calculate wheels which may have been setup
|
||||
# in anticipation for the add-on working.
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=extensions_enabled_test,
|
||||
compat_calc=False,
|
||||
stats_calc=False,
|
||||
)
|
||||
|
||||
_preferences_ui_redraw()
|
||||
_preferences_ui_refresh_addons()
|
||||
|
||||
@ -1878,8 +1972,12 @@ class EXTENSIONS_OT_package_uninstall_marked(Operator, _ExtCmdMixIn):
|
||||
error_fn=self.error_fn_from_exception,
|
||||
)
|
||||
|
||||
_extensions_repo_sync_wheels(repo_cache_store)
|
||||
repo_stats_calc()
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=_extensions_enabled(),
|
||||
compat_calc=True,
|
||||
stats_calc=True,
|
||||
)
|
||||
|
||||
_preferences_theme_state_restore(self._theme_restore)
|
||||
|
||||
@ -2082,8 +2180,22 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn):
|
||||
error_fn=self.error_fn_from_exception,
|
||||
)
|
||||
|
||||
_extensions_repo_sync_wheels(repo_cache_store)
|
||||
repo_stats_calc()
|
||||
extensions_enabled = None
|
||||
if self.enable_on_install:
|
||||
extensions_enabled = _extensions_enabled()
|
||||
# We may want to support multiple.
|
||||
extensions_enabled.update(
|
||||
_extensions_enabled_from_repo_directory_and_pkg_id_sequence(
|
||||
[(self.repo_directory, self.pkg_id_sequence)]
|
||||
)
|
||||
)
|
||||
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=extensions_enabled,
|
||||
compat_calc=True,
|
||||
stats_calc=True,
|
||||
)
|
||||
|
||||
# TODO: it would be nice to include this message in the banner.
|
||||
|
||||
@ -2110,6 +2222,17 @@ class EXTENSIONS_OT_package_install_files(Operator, _ExtCmdMixIn):
|
||||
handle_error=handle_error,
|
||||
)
|
||||
|
||||
if self.enable_on_install:
|
||||
if (extensions_enabled_test := _extensions_enabled()) != extensions_enabled:
|
||||
# Some extensions could not be enabled, re-calculate wheels which may have been setup
|
||||
# in anticipation for the add-on working.
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=extensions_enabled_test,
|
||||
compat_calc=False,
|
||||
stats_calc=False,
|
||||
)
|
||||
|
||||
_preferences_ui_redraw()
|
||||
_preferences_ui_refresh_addons()
|
||||
|
||||
@ -2385,8 +2508,21 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn):
|
||||
error_fn=self.error_fn_from_exception,
|
||||
)
|
||||
|
||||
_extensions_repo_sync_wheels(repo_cache_store)
|
||||
repo_stats_calc()
|
||||
extensions_enabled = None
|
||||
if self.enable_on_install:
|
||||
extensions_enabled = _extensions_enabled()
|
||||
extensions_enabled.update(
|
||||
_extensions_enabled_from_repo_directory_and_pkg_id_sequence(
|
||||
[(self.repo_directory, (self.pkg_id,))]
|
||||
)
|
||||
)
|
||||
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=extensions_enabled,
|
||||
compat_calc=True,
|
||||
stats_calc=True,
|
||||
)
|
||||
|
||||
# TODO: it would be nice to include this message in the banner.
|
||||
def handle_error(ex):
|
||||
@ -2412,6 +2548,17 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn):
|
||||
handle_error=handle_error,
|
||||
)
|
||||
|
||||
if self.enable_on_install:
|
||||
if (extensions_enabled_test := _extensions_enabled()) != extensions_enabled:
|
||||
# Some extensions could not be enabled, re-calculate wheels which may have been setup
|
||||
# in anticipation for the add-on working.
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=extensions_enabled_test,
|
||||
compat_calc=False,
|
||||
stats_calc=False,
|
||||
)
|
||||
|
||||
_preferences_ui_redraw()
|
||||
_preferences_ui_refresh_addons()
|
||||
|
||||
@ -2784,8 +2931,12 @@ class EXTENSIONS_OT_package_uninstall(Operator, _ExtCmdMixIn):
|
||||
error_fn=self.error_fn_from_exception,
|
||||
)
|
||||
|
||||
_extensions_repo_sync_wheels(repo_cache_store)
|
||||
repo_stats_calc()
|
||||
_extensions_repo_refresh_on_change(
|
||||
repo_cache_store,
|
||||
extensions_enabled=None,
|
||||
compat_calc=True,
|
||||
stats_calc=True,
|
||||
)
|
||||
|
||||
_preferences_theme_state_restore(self._theme_restore)
|
||||
|
||||
@ -2995,7 +3146,9 @@ class EXTENSIONS_OT_package_show_settings(Operator):
|
||||
|
||||
def execute(self, _context):
|
||||
repo_item = extension_repos_read_index(self.repo_index)
|
||||
bpy.ops.preferences.addon_show(module="bl_ext.{:s}.{:s}".format(repo_item.module, self.pkg_id))
|
||||
bpy.ops.preferences.addon_show(
|
||||
module="{:s}.{:s}.{:s}".format(_ext_base_pkg_idname, repo_item.module, self.pkg_id),
|
||||
)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
@ -77,7 +77,7 @@ class UI_OT_i18n_updatetranslation_work_repo(Operator):
|
||||
self.settings.to_json(),
|
||||
)
|
||||
# Not working (UI is not refreshed...).
|
||||
#self.report({'INFO'}, "Extracting messages, this will take some time...")
|
||||
# self.report({'INFO'}, "Extracting messages, this will take some time...")
|
||||
context.window_manager.progress_update(1)
|
||||
ret = subprocess.run(cmmd, env=env)
|
||||
if ret.returncode != 0:
|
||||
|
3
scripts/modules/_bpy_internal/addons/__init__.py
Normal file
3
scripts/modules/_bpy_internal/addons/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2024 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
42
scripts/modules/_bpy_internal/addons/cli.py
Normal file
42
scripts/modules/_bpy_internal/addons/cli.py
Normal file
@ -0,0 +1,42 @@
|
||||
# SPDX-FileCopyrightText: 2017-2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
Implementation of blender's command line ``--addons`` argument,
|
||||
e.g. ``--addons a,b,c`` to enable add-ons.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"set_from_cli",
|
||||
)
|
||||
|
||||
|
||||
def set_from_cli(addons_as_string):
|
||||
from addon_utils import (
|
||||
check,
|
||||
check_extension,
|
||||
enable,
|
||||
extensions_refresh,
|
||||
)
|
||||
addon_modules = addons_as_string.split(",")
|
||||
addon_modules_extensions = [m for m in addon_modules if check_extension(m)]
|
||||
addon_modules_extensions_has_failure = False
|
||||
|
||||
if addon_modules_extensions:
|
||||
extensions_refresh(
|
||||
ensure_wheels=True,
|
||||
addon_modules_pending=addon_modules_extensions,
|
||||
)
|
||||
|
||||
for m in addon_modules:
|
||||
if check(m)[1] is False:
|
||||
if enable(m, persistent=True) is None:
|
||||
if check_extension(m):
|
||||
addon_modules_extensions_has_failure = True
|
||||
|
||||
# Re-calculate wheels if any extensions failed to be enabled.
|
||||
if addon_modules_extensions_has_failure:
|
||||
extensions_refresh(
|
||||
ensure_wheels=True,
|
||||
)
|
@ -12,6 +12,7 @@ __all__ = (
|
||||
"disable_all",
|
||||
"reset_all",
|
||||
"module_bl_info",
|
||||
"extensions_refresh",
|
||||
)
|
||||
|
||||
import bpy as _bpy
|
||||
@ -22,6 +23,10 @@ error_encoding = False
|
||||
error_duplicates = []
|
||||
addons_fake_modules = {}
|
||||
|
||||
# Global cached extensions, set before loading extensions on startup.
|
||||
# `{addon_module_name: "Reason for incompatibility", ...}`
|
||||
_extensions_incompatible = {}
|
||||
|
||||
|
||||
# called only once at startup, avoids calling 'reset_all', correct but slower.
|
||||
def _initialize_once():
|
||||
@ -309,8 +314,6 @@ def enable(module_name, *, default_set=False, persistent=False, handle_error=Non
|
||||
import importlib
|
||||
from bpy_restrict_state import RestrictBlend
|
||||
|
||||
is_extension = module_name.startswith(_ext_base_pkg_idname_with_dot)
|
||||
|
||||
if handle_error is None:
|
||||
def handle_error(ex):
|
||||
if isinstance(ex, ImportError):
|
||||
@ -323,6 +326,18 @@ def enable(module_name, *, default_set=False, persistent=False, handle_error=Non
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if (is_extension := module_name.startswith(_ext_base_pkg_idname_with_dot)):
|
||||
# Ensure the extensions are compatible.
|
||||
if _extensions_incompatible:
|
||||
if (error := _extensions_incompatible.get(
|
||||
module_name[len(_ext_base_pkg_idname_with_dot):].partition(".")[0::2],
|
||||
)):
|
||||
try:
|
||||
raise RuntimeError("Extension {:s} is incompatible ({:s})".format(module_name, error))
|
||||
except RuntimeError as ex:
|
||||
handle_error(ex)
|
||||
return None
|
||||
|
||||
# reload if the mtime changes
|
||||
mod = sys.modules.get(module_name)
|
||||
# chances of the file _not_ existing are low, but it could be removed
|
||||
@ -540,6 +555,10 @@ def reset_all(*, reload_scripts=False):
|
||||
modules._is_first = True
|
||||
addons_fake_modules.clear()
|
||||
|
||||
# Update extensions compatibility (after reloading preferences).
|
||||
# Potentially refreshing wheels too.
|
||||
_initialize_extensions_compat_data(_bpy.utils.user_resource('EXTENSIONS'), True, None)
|
||||
|
||||
for path, pkg_id in _paths_with_extension_repos():
|
||||
if not pkg_id:
|
||||
_bpy.utils._sys_path_ensure_append(path)
|
||||
@ -654,6 +673,336 @@ def module_bl_info(mod, *, info_basis=None):
|
||||
return addon_info
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Extension Pre-Flight Compatibility Check
|
||||
#
|
||||
# Check extension compatibility on startup so any extensions which are incompatible with Blender are marked as
|
||||
# incompatible and wont be loaded. This cache avoids having to scan all extensions on startup on *every* startup.
|
||||
#
|
||||
# Implementation:
|
||||
#
|
||||
# The emphasis for this cache is to have minimum overhead for the common case where:
|
||||
# - The simple case where there are no extensions enabled (running tests, background tasks etc).
|
||||
# - The more involved case where extensions are enabled and have not changed since last time Blender started.
|
||||
# In this case do as little as possible since it runs on every startup, the following steps are unavoidable.
|
||||
# - When reading compatibility cache, then run the following tests, regenerating when changes are detected.
|
||||
# - Compare with previous blender version/platform.
|
||||
# - Stat the manifests of all enabled extensions, testing that their modification-time and size are unchanged.
|
||||
# - When any changes are detected,
|
||||
# regenerate compatibility information which does more expensive operations
|
||||
# (loading manifests, check version ranges etc).
|
||||
#
|
||||
# Other notes:
|
||||
#
|
||||
# - This internal format may change at any point, regenerating the cache should be reasonably fast
|
||||
# but may introduce a small but noticeable pause on startup for user configurations that contain many extensions.
|
||||
# - Failure to load will simply ignore the file and regenerate the file as needed.
|
||||
#
|
||||
# Format:
|
||||
#
|
||||
# - The cache is ZLIB compressed pickled Python dictionary.
|
||||
# - The dictionary keys are as follows:
|
||||
# `"blender": (bpy.app.version, platform.system(), platform.machine(), python_version, magic_number)`
|
||||
# `"filesystem": [(repo_module, pkg_id, manifest_time, manifest_size), ...]`
|
||||
# `"incompatible": {(repo_module, pkg_id): "Reason for being incompatible", ...}`
|
||||
#
|
||||
|
||||
|
||||
def _pickle_zlib_file_read(filepath):
|
||||
import pickle
|
||||
import gzip
|
||||
|
||||
with gzip.GzipFile(filepath, "rb") as fh:
|
||||
data = pickle.load(fh)
|
||||
return data
|
||||
|
||||
|
||||
def _pickle_zlib_file_write(filepath, data) -> None:
|
||||
import pickle
|
||||
import gzip
|
||||
|
||||
with gzip.GzipFile(filepath, "wb", compresslevel=9) as fh:
|
||||
pickle.dump(data, fh)
|
||||
|
||||
|
||||
def _extension_repos_module_to_directory_map():
|
||||
return {repo.module: repo.directory for repo in _preferences.extensions.repos if repo.enabled}
|
||||
|
||||
|
||||
def _extension_compat_cache_update_needed(
|
||||
cache_data, # `Dict[str, Any]`
|
||||
blender_id, # `Tuple[Any, ...]`
|
||||
extensions_enabled, # `Set[Tuple[str, str]]`
|
||||
print_debug, # `Optional[Callable[[Any], None]]`
|
||||
): # `-> bool`
|
||||
|
||||
# Detect when Blender itself changes.
|
||||
if cache_data.get("blender") != blender_id:
|
||||
if print_debug is not None:
|
||||
print_debug("blender changed")
|
||||
return True
|
||||
|
||||
# Detect when any of the extensions paths change.
|
||||
cache_filesystem = cache_data.get("filesystem", [])
|
||||
|
||||
# Avoid touching the file-system if at all possible.
|
||||
# When the length is the same and all cached ID's are in this set, we can be sure they are a 1:1 patch.
|
||||
if len(cache_filesystem) != len(extensions_enabled):
|
||||
if print_debug is not None:
|
||||
print_debug("length changes ({:d} -> {:d}).".format(len(cache_filesystem), len(extensions_enabled)))
|
||||
return True
|
||||
|
||||
from os import stat
|
||||
from os.path import join
|
||||
repos_module_to_directory_map = _extension_repos_module_to_directory_map()
|
||||
|
||||
for repo_module, pkg_id, cache_stat_time, cache_stat_size in cache_filesystem:
|
||||
if (repo_module, pkg_id) not in extensions_enabled:
|
||||
if print_debug is not None:
|
||||
print_debug("\"{:s}.{:s}\" no longer enabled.".format(repo_module, pkg_id))
|
||||
return True
|
||||
|
||||
if repo_directory := repos_module_to_directory_map.get(repo_module, ""):
|
||||
pkg_manifest_filepath = join(repo_directory, pkg_id, _ext_manifest_filename_toml)
|
||||
else:
|
||||
pkg_manifest_filepath = ""
|
||||
|
||||
# It's possible an extension has been set as an add-on but cannot find the repository it came from.
|
||||
# In this case behave as if the file can't be found (because it can't) instead of ignoring it.
|
||||
# This is done because it's important to match.
|
||||
if pkg_manifest_filepath:
|
||||
try:
|
||||
statinfo = stat(pkg_manifest_filepath)
|
||||
except Exception:
|
||||
statinfo = None
|
||||
else:
|
||||
statinfo = None
|
||||
|
||||
if statinfo is None:
|
||||
test_time = 0
|
||||
test_size = 0
|
||||
else:
|
||||
test_time = statinfo.st_mtime
|
||||
test_size = statinfo.st_size
|
||||
|
||||
# Detect changes to any files manifest.
|
||||
if cache_stat_time != test_time:
|
||||
if print_debug is not None:
|
||||
print_debug("\"{:s}.{:s}\" time changed ({:g} -> {:g}).".format(
|
||||
repo_module, pkg_id, cache_stat_time, test_time,
|
||||
))
|
||||
return True
|
||||
if cache_stat_size != test_size:
|
||||
if print_debug is not None:
|
||||
print_debug("\"{:s}.{:s}\" size changed ({:d} -> {:d}).".format(
|
||||
repo_module, pkg_id, cache_stat_size, test_size,
|
||||
))
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# This function should not run every startup, so it can afford to be slower,
|
||||
# although users should not have to wait for it either.
|
||||
def _extension_compat_cache_create(
|
||||
blender_id, # `Tuple[Any, ...]`
|
||||
extensions_enabled, # `Set[Tuple[str, str]]`
|
||||
wheel_list, # `List[Tuple[str, List[str]]]`
|
||||
print_debug, # `Optional[Callable[[Any], None]]`
|
||||
): # `-> Dict[str, Any]`
|
||||
import os
|
||||
from os.path import join
|
||||
|
||||
filesystem = []
|
||||
incompatible = {}
|
||||
|
||||
cache_data = {
|
||||
"blender": blender_id,
|
||||
"filesystem": filesystem,
|
||||
"incompatible": incompatible,
|
||||
}
|
||||
|
||||
repos_module_to_directory_map = _extension_repos_module_to_directory_map()
|
||||
|
||||
for repo_module, pkg_id in extensions_enabled:
|
||||
if repo_directory := repos_module_to_directory_map.get(repo_module, ""):
|
||||
pkg_manifest_filepath = join(repo_directory, pkg_id, _ext_manifest_filename_toml)
|
||||
else:
|
||||
pkg_manifest_filepath = ""
|
||||
if print_debug is not None:
|
||||
print_debug("directory for module \"{:s}\" not found!".format(repo_module))
|
||||
|
||||
if pkg_manifest_filepath:
|
||||
try:
|
||||
statinfo = os.stat(pkg_manifest_filepath)
|
||||
except Exception:
|
||||
statinfo = None
|
||||
if print_debug is not None:
|
||||
print_debug("unable to find \"{:s}\"".format(pkg_manifest_filepath))
|
||||
else:
|
||||
statinfo = None
|
||||
|
||||
if statinfo is None:
|
||||
test_time = 0.0
|
||||
test_size = 0.0
|
||||
else:
|
||||
test_time = statinfo.st_mtime
|
||||
test_size = statinfo.st_size
|
||||
# Store the reason for failure, to print when attempting to load.
|
||||
from bl_pkg import manifest_compatible_with_wheel_data_or_error
|
||||
if (error := manifest_compatible_with_wheel_data_or_error(
|
||||
pkg_manifest_filepath,
|
||||
repo_module,
|
||||
pkg_id,
|
||||
repo_directory,
|
||||
wheel_list,
|
||||
)) is not None:
|
||||
incompatible[(repo_module, pkg_id)] = error
|
||||
|
||||
filesystem.append((repo_module, pkg_id, test_time, test_size))
|
||||
|
||||
return cache_data
|
||||
|
||||
|
||||
def _initialize_extensions_compat_ensure_up_to_date(extensions_directory, extensions_enabled, print_debug):
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
global _extensions_incompatible
|
||||
|
||||
updated = False
|
||||
wheel_list = []
|
||||
|
||||
# Number to bump to change this format and force re-generation.
|
||||
magic_number = 0
|
||||
|
||||
blender_id = (_bpy.app.version, platform.system(), platform.machine(), sys.version_info[0:2], magic_number)
|
||||
|
||||
filepath_compat = os.path.join(extensions_directory, ".cache", "compat.dat")
|
||||
|
||||
# Cache data contains a dict of:
|
||||
# {
|
||||
# "blender": (...)
|
||||
# "paths": [path data to detect changes]
|
||||
# "incompatible": {set of incompatible extensions}
|
||||
# }
|
||||
if os.path.exists(filepath_compat):
|
||||
try:
|
||||
cache_data = _pickle_zlib_file_read(filepath_compat)
|
||||
except Exception as ex:
|
||||
cache_data = None
|
||||
# While this should not happen continuously (that would point to writing invalid cache),
|
||||
# it is not a problem if there is some corruption with the cache and it needs to be re-generated.
|
||||
# Show a message since this should be a rare occurrence - if it happens often it's likely to be a bug.
|
||||
print("Extensions: reading cache failed ({:s}), creating...".format(str(ex)))
|
||||
else:
|
||||
cache_data = None
|
||||
if print_debug is not None:
|
||||
print_debug("doesn't exist, creating...")
|
||||
|
||||
if cache_data is not None:
|
||||
# NOTE: the exception handling here is fairly paranoid and accounts for invalid values in the loaded cache.
|
||||
# An example would be values expected to be lists/dictionaries being other types (None or strings for e.g.).
|
||||
# While this should not happen, some bad value should not prevent Blender from loading properly,
|
||||
# so report the error and regenerate cache.
|
||||
try:
|
||||
if _extension_compat_cache_update_needed(cache_data, blender_id, extensions_enabled, print_debug):
|
||||
cache_data = None
|
||||
except Exception as ex:
|
||||
print("Extension: unexpected error reading cache, this is is a bug! (regenerating)")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
cache_data = None
|
||||
|
||||
if cache_data is None:
|
||||
cache_data = _extension_compat_cache_create(blender_id, extensions_enabled, wheel_list, print_debug)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(filepath_compat), exist_ok=True)
|
||||
_pickle_zlib_file_write(filepath_compat, cache_data)
|
||||
if print_debug is not None:
|
||||
print_debug("update written to disk.")
|
||||
except Exception as ex:
|
||||
# Should be rare but should not cause this function to fail.
|
||||
print("Extensions: writing cache failed ({:s}).".format(str(ex)))
|
||||
|
||||
# Set to true even when not written to disk as the run-time data *has* been updated,
|
||||
# cache will attempt to be generated next time this is called.
|
||||
updated = True
|
||||
else:
|
||||
if print_debug is not None:
|
||||
print_debug("up to date.")
|
||||
|
||||
_extensions_incompatible = cache_data["incompatible"]
|
||||
|
||||
return updated, wheel_list
|
||||
|
||||
|
||||
def _initialize_extensions_compat_ensure_up_to_date_wheels(extensions_directory, wheel_list):
|
||||
import os
|
||||
_extension_sync_wheels(
|
||||
local_dir=os.path.join(extensions_directory, ".local"),
|
||||
wheel_list=wheel_list,
|
||||
)
|
||||
|
||||
|
||||
def _initialize_extensions_compat_data(extensions_directory, ensure_wheels, addon_modules_pending):
|
||||
# WARNING: this function must *never* raise an exception because it would interfere with low level initialization.
|
||||
# As the function deals with file IO, use what are typically over zealous exception checks so as to rule out
|
||||
# interfering with Blender loading properly in unexpected cases such as disk-full, read-only file-system
|
||||
# or any other rare but possible scenarios.
|
||||
|
||||
_extensions_incompatible.clear()
|
||||
|
||||
# Create a set of all extension ID's.
|
||||
extensions_enabled = set()
|
||||
extensions_prefix_len = len(_ext_base_pkg_idname_with_dot)
|
||||
for addon in _preferences.addons:
|
||||
module_name = addon.module
|
||||
if check_extension(module_name):
|
||||
extensions_enabled.add(module_name[extensions_prefix_len:].partition(".")[0::2])
|
||||
|
||||
if addon_modules_pending is not None:
|
||||
for module_name in addon_modules_pending:
|
||||
if check_extension(module_name):
|
||||
extensions_enabled.add(module_name[extensions_prefix_len:].partition(".")[0::2])
|
||||
|
||||
print_debug = (
|
||||
(lambda *args, **kwargs: print("Extension version cache:", *args, **kwargs)) if _bpy.app.debug_python else
|
||||
None
|
||||
)
|
||||
|
||||
# Early exit, use for automated tests.
|
||||
# Avoid (relatively) expensive file-system scanning if at all possible.
|
||||
if not extensions_enabled:
|
||||
if print_debug is not None:
|
||||
print_debug("no extensions, skipping cache data.")
|
||||
return
|
||||
|
||||
# While this isn't expected to fail, any failure here is a bug
|
||||
# but it should not cause Blender's startup to fail.
|
||||
try:
|
||||
updated, wheel_list = _initialize_extensions_compat_ensure_up_to_date(
|
||||
extensions_directory,
|
||||
extensions_enabled,
|
||||
print_debug,
|
||||
)
|
||||
except Exception as ex:
|
||||
print("Extension: unexpected error detecting cache, this is is a bug!")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
updated = False
|
||||
|
||||
if ensure_wheels:
|
||||
if updated:
|
||||
try:
|
||||
_initialize_extensions_compat_ensure_up_to_date_wheels(extensions_directory, wheel_list)
|
||||
except Exception as ex:
|
||||
print("Extension: unexpected error updating wheels, this is is a bug!")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Extension Utilities
|
||||
|
||||
@ -1149,7 +1498,7 @@ def _initialize_extension_repos_post(*_, is_first=False):
|
||||
modules._is_first = True
|
||||
|
||||
|
||||
def _initialize_extensions_site_packages(*, create=False):
|
||||
def _initialize_extensions_site_packages(*, extensions_directory, create=False):
|
||||
# Add extension site-packages to `sys.path` (if it exists).
|
||||
# Use for wheels.
|
||||
import os
|
||||
@ -1161,7 +1510,7 @@ def _initialize_extensions_site_packages(*, create=False):
|
||||
# so this can't simply be treated as a module directory unless those files would be excluded
|
||||
# which may interfere with the wheels functionality.
|
||||
site_packages = os.path.join(
|
||||
_bpy.utils.user_resource('EXTENSIONS'),
|
||||
extensions_directory,
|
||||
".local",
|
||||
"lib",
|
||||
"python{:d}.{:d}".format(sys.version_info.major, sys.version_info.minor),
|
||||
@ -1205,8 +1554,13 @@ def _initialize_extensions_repos_once():
|
||||
module_handle.register_module()
|
||||
_ext_global.module_handle = module_handle
|
||||
|
||||
extensions_directory = _bpy.utils.user_resource('EXTENSIONS')
|
||||
|
||||
# Ensure extensions wheels can be loaded (when found).
|
||||
_initialize_extensions_site_packages()
|
||||
_initialize_extensions_site_packages(extensions_directory=extensions_directory)
|
||||
|
||||
# Ensure extension compatibility data has been loaded and matches the manifests.
|
||||
_initialize_extensions_compat_data(extensions_directory, True, None)
|
||||
|
||||
# Setup repositories for the first time.
|
||||
# Intentionally don't call `_initialize_extension_repos_pre` as this is the first time,
|
||||
@ -1216,3 +1570,16 @@ def _initialize_extensions_repos_once():
|
||||
# Internal handlers intended for Blender's own handling of repositories.
|
||||
_bpy.app.handlers._extension_repos_update_pre.append(_initialize_extension_repos_pre)
|
||||
_bpy.app.handlers._extension_repos_update_post.append(_initialize_extension_repos_post)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Extension Public API
|
||||
|
||||
def extensions_refresh(ensure_wheels=True, addon_modules_pending=None):
|
||||
|
||||
# Ensure any changes to extensions refresh `_extensions_incompatible`.
|
||||
_initialize_extensions_compat_data(
|
||||
_bpy.utils.user_resource('EXTENSIONS'),
|
||||
ensure_wheels=ensure_wheels,
|
||||
addon_modules_pending=addon_modules_pending,
|
||||
)
|
||||
|
@ -451,7 +451,14 @@ class PREFERENCES_OT_addon_enable(Operator):
|
||||
nonlocal err_str
|
||||
err_str = str(ex)
|
||||
|
||||
mod = addon_utils.enable(self.module, default_set=True, handle_error=err_cb)
|
||||
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)
|
||||
@ -474,6 +481,10 @@ class PREFERENCES_OT_addon_enable(Operator):
|
||||
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'}
|
||||
|
||||
|
||||
@ -498,7 +509,11 @@ class PREFERENCES_OT_addon_disable(Operator):
|
||||
err_str = traceback.format_exc()
|
||||
print(err_str)
|
||||
|
||||
addon_utils.disable(self.module, default_set=True, handle_error=err_cb)
|
||||
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)
|
||||
|
@ -2385,10 +2385,8 @@ static int arg_handle_addons_set(int argc, const char **argv, void *data)
|
||||
if (argc > 1) {
|
||||
# ifdef WITH_PYTHON
|
||||
const char script_str[] =
|
||||
"from addon_utils import check, enable\n"
|
||||
"for m in '%s'.split(','):\n"
|
||||
" if check(m)[1] is False:\n"
|
||||
" enable(m, persistent=True)";
|
||||
"from _bpy_internal.addons.cli import set_from_cli\n"
|
||||
"set_from_cli('%s')";
|
||||
const int slen = strlen(argv[1]) + (sizeof(script_str) - 2);
|
||||
char *str = static_cast<char *>(malloc(slen));
|
||||
bContext *C = static_cast<bContext *>(data);
|
||||
|
Loading…
Reference in New Issue
Block a user