Extensions: fixed & refactor internals for extension visibility

Add utility class to check extension visibility to remove
incomplete logic that was duplicated into operator code.
Also minor refactoring to reduce the number of arguments passed
to internal function.s
This commit is contained in:
Campbell Barton
2024-06-27 14:33:47 +10:00
parent 4e4d8476c4
commit 989de85cf6
3 changed files with 214 additions and 158 deletions

View File

@@ -719,27 +719,31 @@ def _extensions_repo_from_directory_and_report(directory, report_fn):
return repo_item
def _pkg_marked_by_repo(pkg_manifest_all):
def _pkg_marked_by_repo(repo_cache_store, pkg_manifest_all):
# NOTE: pkg_manifest_all can be from local or remote source.
wm = bpy.context.window_manager
search_casefold = wm.extension_search.casefold()
filter_by_type = blender_filter_by_type_map[wm.extension_type]
from .bl_extension_ui import ExtensionUI_Visibility
ui_visibility = None if is_background else ExtensionUI_Visibility(bpy.context, repo_cache_store)
repo_pkg_map = {}
for pkg_id, repo_index in blender_extension_mark:
# While this should be prevented, any marked packages out of the range will cause problems, skip them.
if repo_index >= len(pkg_manifest_all):
continue
if (pkg_manifest := pkg_manifest_all[repo_index]) is None:
continue
if ui_visibility is not None:
if not ui_visibility.test((pkg_id, repo_index)):
continue
else:
# Background mode, just to a simple range check.
# While this should be prevented, any marked packages out of the range will cause problems, skip them.
if repo_index >= len(pkg_manifest_all):
continue
if (pkg_manifest := pkg_manifest_all[repo_index]) is None:
continue
item = pkg_manifest.get(pkg_id)
if item is None:
continue
if filter_by_type and (filter_by_type != item.type):
continue
if search_casefold and not pkg_info_check_exclude_filter(item, search_casefold):
continue
pkg_list = repo_pkg_map.get(repo_index)
if pkg_list is None:
@@ -1668,7 +1672,7 @@ class EXTENSIONS_OT_package_install_marked(Operator, _ExtCmdMixIn):
pkg_manifest_remote_all = list(repo_cache_store.pkg_manifest_from_remote_ensure(
error_fn=self.error_fn_from_exception,
))
repo_pkg_map = _pkg_marked_by_repo(pkg_manifest_remote_all)
repo_pkg_map = _pkg_marked_by_repo(repo_cache_store, pkg_manifest_remote_all)
self._repo_directories = set()
self._repo_map_packages_addon_only = []
package_count = 0
@@ -1796,7 +1800,7 @@ class EXTENSIONS_OT_package_uninstall_marked(Operator, _ExtCmdMixIn):
pkg_manifest_local_all = list(repo_cache_store.pkg_manifest_from_local_ensure(
error_fn=self.error_fn_from_exception,
))
repo_pkg_map = _pkg_marked_by_repo(pkg_manifest_local_all)
repo_pkg_map = _pkg_marked_by_repo(repo_cache_store, pkg_manifest_local_all)
package_count = 0
self._repo_directories = set()
@@ -2577,7 +2581,7 @@ class EXTENSIONS_OT_package_install(Operator, _ExtCmdMixIn):
def _draw_override_after_sync(
self,
*,
context, # `bpy.types.Context`
_context, # `bpy.types.Context`
remote_url, # `Optional[str]`
repo_from_url_name, # `str`
url, # `str`
@@ -2912,8 +2916,13 @@ class EXTENSIONS_OT_package_mark_set_all(Operator):
bl_idname = "extensions.package_mark_set_all"
bl_label = "Mark All Packages"
def execute(self, _context):
def execute(self, context):
from .bl_extension_ui import ExtensionUI_Visibility
repo_cache_store = repo_cache_store_ensure()
ui_visibility = None if is_background else ExtensionUI_Visibility(context, repo_cache_store)
for repo_index, (
pkg_manifest_remote,
pkg_manifest_local,
@@ -2923,10 +2932,18 @@ class EXTENSIONS_OT_package_mark_set_all(Operator):
)):
if pkg_manifest_remote is not None:
for pkg_id in pkg_manifest_remote.keys():
blender_extension_mark.add((pkg_id, repo_index))
key = pkg_id, repo_index
if ui_visibility is not None:
if not ui_visibility.test(key):
continue
blender_extension_mark.add(key)
if pkg_manifest_local is not None:
for pkg_id in pkg_manifest_local.keys():
blender_extension_mark.add((pkg_id, repo_index))
key = pkg_id, repo_index
if ui_visibility is not None:
if not ui_visibility.test(key):
continue
blender_extension_mark.add(key)
_preferences_ui_redraw()
return {'FINISHED'}
@@ -2937,6 +2954,7 @@ class EXTENSIONS_OT_package_mark_clear_all(Operator):
def execute(self, _context):
blender_extension_mark.clear()
return {'FINISHED'}
class EXTENSIONS_OT_package_show_set(Operator):
@@ -2996,7 +3014,7 @@ class EXTENSIONS_OT_package_obselete_marked(Operator):
repo_cache_store = repo_cache_store_ensure()
pkg_manifest_local_all = list(repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print))
repo_pkg_map = _pkg_marked_by_repo(pkg_manifest_local_all)
repo_pkg_map = _pkg_marked_by_repo(repo_cache_store, pkg_manifest_local_all)
found = False
repos_lock = [repos_all[repo_index].directory for repo_index in sorted(repo_pkg_map.keys())]

View File

@@ -11,6 +11,8 @@ __all__ = (
"display_errors",
"register",
"unregister",
"ExtensionUI_Visibility",
)
import bpy
@@ -711,6 +713,7 @@ class ExtensionUI_FilterParams:
"filter_by_type",
"addons_enabled",
"active_theme_info",
"repos_all",
# From the window manager.
"show_installed_enabled",
@@ -731,6 +734,7 @@ class ExtensionUI_FilterParams:
filter_by_type,
addons_enabled,
active_theme_info,
repos_all,
show_installed_enabled,
show_installed_disabled,
show_available,
@@ -740,6 +744,7 @@ class ExtensionUI_FilterParams:
self.filter_by_type = filter_by_type
self.addons_enabled = addons_enabled
self.active_theme_info = active_theme_info
self.repos_all = repos_all
self.show_installed_enabled = show_installed_enabled
self.show_installed_disabled = show_installed_disabled
self.show_available = show_available
@@ -748,88 +753,166 @@ class ExtensionUI_FilterParams:
self.has_installed_disabled = False
self.has_available = False
@staticmethod
def default_from_context(context):
from .bl_extension_ops import (
blender_filter_by_type_map,
extension_repos_read,
)
# The main function that iterates over remote data and decides what is "visible" based on "params".
def extension_ui_filtered(
pkg_manifest_local, # `Dict[str, PkgManifest_Normalized]`
pkg_manifest_remote, # `Dict[str, PkgManifest_Normalized]`
repo_index, # `int`
repo_item, # `RepoItem`
params, # `ExtensionUI_FilterParams`
):
from .bl_extension_ops import (
pkg_info_check_exclude_filter,
wm = context.window_manager
prefs = context.preferences
repos_all = extension_repos_read()
filter_by_type = blender_filter_by_type_map[wm.extension_type]
show_addons = filter_by_type in {"", "add-on"}
show_themes = filter_by_type in {"", "theme"}
if show_addons:
addons_enabled = {addon.module for addon in prefs.addons} if show_addons else None
else:
addons_enabled = None # Unused.
if show_themes:
active_theme_info = pkg_repo_and_id_from_theme_path(repos_all, prefs.themes[0].filepath)
else:
active_theme_info = None # Unused.
# Create a set of tags marked False to simplify exclusion & avoid it altogether when all tags are enabled.
extension_tags_exclude = {k for (k, v) in wm.get("extension_tags", {}).items() if v is False}
return ExtensionUI_FilterParams(
search_casefold=wm.extension_search.casefold(),
tags_exclude=extension_tags_exclude,
filter_by_type=filter_by_type,
addons_enabled=addons_enabled,
active_theme_info=active_theme_info,
repos_all=repos_all,
# Extensions don't different between these (add-ons do).
show_installed_enabled=wm.extension_show_panel_installed,
show_installed_disabled=wm.extension_show_panel_installed,
show_available=wm.extension_show_panel_available,
)
# The main function that iterates over remote data and decides what is "visible".
def extension_ui_visible(
self,
repo_index, # `int`
pkg_manifest_local, # `Dict[str, PkgManifest_Normalized]`
pkg_manifest_remote, # `Dict[str, PkgManifest_Normalized]`
):
from .bl_extension_ops import (
pkg_info_check_exclude_filter,
)
show_addons = self.filter_by_type in {"", "add-on"}
if show_addons:
repo_module_prefix = pkg_repo_module_prefix(self.repos_all[repo_index])
for pkg_id, (item_local, item_remote) in pkg_manifest_zip_all_items(pkg_manifest_local, pkg_manifest_remote):
is_installed = item_local is not None
item = item_local or item_remote
if self.filter_by_type and (self.filter_by_type != item.type):
continue
if self.search_casefold and (not pkg_info_check_exclude_filter(item, self.search_casefold)):
continue
if self.tags_exclude:
if tags_exclude_match(item.tags, self.tags_exclude):
continue
is_addon = False
is_theme = False
match item.type:
case "add-on":
is_addon = True
case "theme":
is_theme = True
if is_addon:
if is_installed:
# Currently we only need to know the module name once installed.
addon_module_name = repo_module_prefix + pkg_id
# pylint: disable-next=possibly-used-before-assignment
is_enabled = addon_module_name in self.addons_enabled
else:
is_enabled = False
addon_module_name = None
elif is_theme:
# pylint: disable-next=possibly-used-before-assignment
is_enabled = (repo_index, pkg_id) == self.active_theme_info
addon_module_name = None
else:
# TODO: ability to disable.
is_enabled = is_installed
addon_module_name = None
item_version = item.version
if item_local is None or item_remote is None:
item_remote_version = None
is_outdated = False
else:
item_remote_version = item_remote.version
is_outdated = item_remote_version != item_version
if is_installed:
if is_enabled:
self.has_installed_enabled = True
if not self.show_installed_enabled:
continue
else:
self.has_installed_disabled = True
if not self.show_installed_disabled:
continue
else:
self.has_available = True
if not self.show_available:
continue
yield ExtensionUI(repo_index, pkg_id, item_local, item_remote, is_enabled, is_outdated)
# The purpose of this class is to allow operators to check if an extension is visible without operator
# logic depending on UI internals such as `ExtensionUI_FilterParams` & `ExtensionUI`,
# the state of panels and so on. As this is used by operators it's intended for a one-off usage
# (operating on visible extensions), so a performance trade-off to keep the API simple is acceptable.
# It could also be optimized in the future to avoid calculating all data up-front - if that's ever needed.
class ExtensionUI_Visibility:
__slots__ = (
"_visible",
)
show_addons = params.filter_by_type in {"", "add-on"}
def __init__(self, context, repo_cache_store):
visible = set()
if show_addons:
repo_module_prefix = pkg_repo_module_prefix(repo_item)
params = ExtensionUI_FilterParams.default_from_context(context)
for pkg_id, (item_local, item_remote) in pkg_manifest_zip_all_items(pkg_manifest_local, pkg_manifest_remote):
for repo_index, (
pkg_manifest_local,
pkg_manifest_remote,
) in enumerate(zip(
repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print),
repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=print),
strict=True,
)):
for ext_ui in params.extension_ui_visible(
repo_index,
pkg_manifest_local,
pkg_manifest_remote,
):
visible.add((ext_ui.pkg_id, repo_index))
is_installed = item_local is not None
self._visible = visible
item = item_local or item_remote
if params.filter_by_type and (params.filter_by_type != item.type):
continue
if params.search_casefold and (not pkg_info_check_exclude_filter(item, params.search_casefold)):
continue
if params.tags_exclude:
if tags_exclude_match(item.tags, params.tags_exclude):
continue
is_addon = False
is_theme = False
match item.type:
case "add-on":
is_addon = True
case "theme":
is_theme = True
if is_addon:
if is_installed:
# Currently we only need to know the module name once installed.
addon_module_name = repo_module_prefix + pkg_id
# pylint: disable-next=possibly-used-before-assignment
is_enabled = addon_module_name in params.addons_enabled
else:
is_enabled = False
addon_module_name = None
elif is_theme:
# pylint: disable-next=possibly-used-before-assignment
is_enabled = (repo_index, pkg_id) == params.active_theme_info
addon_module_name = None
else:
# TODO: ability to disable.
is_enabled = is_installed
addon_module_name = None
item_version = item.version
if item_local is None or item_remote is None:
item_remote_version = None
is_outdated = False
else:
item_remote_version = item_remote.version
is_outdated = item_remote_version != item_version
if is_installed:
if is_enabled:
params.has_installed_enabled = True
if not params.show_installed_enabled:
continue
else:
params.has_installed_disabled = True
if not params.show_installed_disabled:
continue
else:
params.has_available = True
if not params.show_available:
continue
yield ExtensionUI(repo_index, pkg_id, item_local, item_remote, is_enabled, is_outdated)
def test(self, key):
return key in self._visible
# -----------------------------------------------------------------------------
@@ -1182,9 +1265,7 @@ def extension_draw_item(
def extensions_panel_draw_impl(
self,
context, # `bpy.types.Context`
search_casefold, # `str`
filter_by_type, # `str`
extension_tags_exclude, # `Set[str]`
params, # `ExtensionUI_FilterParams`
operation_in_progress, # `bool`
show_development, # `bool`
):
@@ -1197,12 +1278,13 @@ def extensions_panel_draw_impl(
from .bl_extension_ops import (
blender_extension_mark,
blender_extension_show,
extension_repos_read,
repo_cache_store_refresh_from_prefs,
)
from . import repo_cache_store_ensure
prefs = context.preferences
repo_cache_store = repo_cache_store_ensure()
# This isn't elegant, but the preferences aren't available on registration.
@@ -1211,16 +1293,12 @@ def extensions_panel_draw_impl(
layout = self.layout
prefs = context.preferences
# Define a top-most column to place warnings (if-any).
# Needed so the warnings aren't mixed in with other content.
layout_topmost = layout.column()
repos_all = extension_repos_read()
if bpy.app.online_access:
if notify_info.update_ensure(repos_all):
if notify_info.update_ensure(params.repos_all):
# TODO: should be part of the status bar.
from .bl_extension_notify import update_ui_text
text, icon = update_ui_text()
@@ -1228,19 +1306,6 @@ def extensions_panel_draw_impl(
layout_topmost.box().label(text=text, icon=icon)
del text, icon
# To access enabled add-ons.
show_addons = filter_by_type in {"", "add-on"}
show_themes = filter_by_type in {"", "theme"}
if show_addons:
addons_enabled = {addon.module for addon in prefs.addons}
else:
addons_enabled = None # Unused.
if show_themes:
active_theme_info = pkg_repo_and_id_from_theme_path(repos_all, prefs.themes[0].filepath)
else:
active_theme_info = None
# Collect exceptions accessing repositories, and optionally show them.
errors_on_draw = []
@@ -1294,21 +1359,6 @@ def extensions_panel_draw_impl(
#
# TODO(@ideasman42): handle permissions on upgrade.
wm = context.window_manager
params = ExtensionUI_FilterParams(
search_casefold=search_casefold,
tags_exclude=extension_tags_exclude,
filter_by_type=filter_by_type,
addons_enabled=addons_enabled,
active_theme_info=active_theme_info,
# Extensions don't different between these (add-ons do).
show_installed_enabled=wm.extension_show_panel_installed,
show_installed_disabled=wm.extension_show_panel_installed,
show_available=wm.extension_show_panel_available,
)
section_list = (
# Installed (upgrade, enabled).
ExtensionUI_Section(panel_header=(iface_("Installed"), "extension_show_panel_installed"), do_sort=True),
@@ -1350,7 +1400,7 @@ def extensions_panel_draw_impl(
# IO errors in general and it is better to show a warning than to ignore the error entirely
# or cause a trace-back which breaks the UI.
if (remote_ex is not None) or (local_ex is not None):
repo = repos_all[repo_index]
repo = params.repos_all[repo_index]
# NOTE: `FileNotFoundError` occurs when a repository has been added but has not update with its remote.
# We may want a way for users to know a repository is missing from the view and they need to run update
# to access its extensions.
@@ -1369,7 +1419,7 @@ def extensions_panel_draw_impl(
local_ex = None
continue
has_remote = repos_all[repo_index].remote_url != ""
has_remote = params.repos_all[repo_index].remote_url != ""
if pkg_manifest_remote is None:
if has_remote:
# NOTE: it would be nice to detect when the repository ran sync and it failed.
@@ -1381,7 +1431,7 @@ def extensions_panel_draw_impl(
"Repository: \"{:s}\" remote data unavailable, "
"sync with the remote repository."
).format(
repos_all[repo_index].name,
params.repos_all[repo_index].name,
)
)
elif prefs.extensions.use_online_access_handled is False:
@@ -1401,17 +1451,15 @@ def extensions_panel_draw_impl(
"Repository: \"{:s}\" remote data unavailable, "
"either allow \"Online Access\" or disable the repository to suppress this message"
).format(
repos_all[repo_index].name,
params.repos_all[repo_index].name,
)
)
continue
for ext_ui in extension_ui_filtered(
for ext_ui in params.extension_ui_visible(
repo_index,
pkg_manifest_local,
pkg_manifest_remote,
repo_index,
repos_all[repo_index],
params,
):
section = (
section_available if ext_ui.item_local is None else
@@ -1432,12 +1480,14 @@ def extensions_panel_draw_impl(
if section.panel_header:
label, prop_id = section.panel_header
layout_header, layout_panel = layout.panel_prop(wm, prop_id)
layout_header, layout_panel = layout.panel_prop(context.window_manager, prop_id)
layout_header.label(text=label, translate=False)
del label, prop_id, layout_header
if (layout_panel is None) or (not section.extension_ui_list):
continue
if layout_panel is None:
continue
if not section.extension_ui_list:
continue
if section.do_sort:
section.sort_by_name()
@@ -1455,7 +1505,7 @@ def extensions_panel_draw_impl(
# General vars.
repo_index=ext_ui.repo_index,
repo_item=repos_all[ext_ui.repo_index],
repo_item=params.repos_all[ext_ui.repo_index],
operation_in_progress=operation_in_progress,
)
@@ -1707,9 +1757,6 @@ def extensions_panel_draw(panel, context):
)
from bpy.app.translations import pgettext_iface as iface_
from .bl_extension_ops import (
blender_filter_by_type_map,
)
wm = context.window_manager
prefs = context.preferences
@@ -1821,15 +1868,10 @@ def extensions_panel_draw(panel, context):
):
extensions_panel_draw_online_extensions_request_impl(panel, context)
# Create a set of tags marked False to simplify exclusion & avoid it altogether when all tags are enabled.
extension_tags_exclude = {k for (k, v) in wm.get("extension_tags", {}).items() if v is False}
extensions_panel_draw_impl(
panel,
context,
wm.extension_search.casefold(),
blender_filter_by_type_map[wm.extension_type],
extension_tags_exclude,
ExtensionUI_FilterParams.default_from_context(context),
operation_in_progress,
show_development,
)
@@ -1916,6 +1958,7 @@ def tags_current(wm, tags_attr):
filter_by_type=filter_by_type,
addons_enabled=addons_enabled,
active_theme_info=active_theme_info,
repos_all=repos_all,
show_installed_enabled=show_installed_enabled,
show_installed_disabled=show_installed_disabled,
@@ -1935,12 +1978,10 @@ def tags_current(wm, tags_attr):
((None,) * len(repos_all)),
strict=True,
)):
for ext_ui in extension_ui_filtered(
for ext_ui in params.extension_ui_visible(
repo_index,
pkg_manifest_local,
pkg_manifest_remote,
repo_index,
repos_all[repo_index],
params,
):
if pkg_tags := (ext_ui.item_local or ext_ui.item_remote).tags:
tags.update(pkg_tags)

View File

@@ -2648,9 +2648,6 @@ class subcmd_server:
capwords,
)
import urllib
import urllib.parse
filepath_repo_html = os.path.join(repo_dir, "index.html")
fh = io.StringIO()