Extensions: refactor CommandBatch.exec_non_blocking return value
[blender-addons-contrib.git] / bl_pkg / bl_extension_ops.py
blob601e5a0d27ecb6b64e09e0c8513dc1ab440ebeb8
1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
5 """
6 Blender, thin wrapper around ``blender_extension_utils``.
7 Where the operator shows progress, any errors and supports canceling operations.
8 """
10 __all__ = (
11 "extension_repos_read",
14 import os
16 from functools import partial
18 from typing import (
19 NamedTuple,
22 import bpy
24 from bpy.types import (
25 Operator,
27 from bpy.props import (
28 BoolProperty,
29 CollectionProperty,
30 EnumProperty,
31 StringProperty,
32 IntProperty,
34 from bpy.app.translations import (
35 pgettext_iface as iface_,
38 # Localize imports.
39 from . import (
40 bl_extension_utils,
41 ) # noqa: E402
43 from . import (
44 repo_status_text,
45 cookie_from_session,
48 from .bl_extension_utils import (
49 RepoLock,
50 RepoLockContext,
53 rna_prop_url = StringProperty(name="URL", subtype='FILE_PATH', options={'HIDDEN'})
54 rna_prop_directory = StringProperty(name="Repo Directory", subtype='FILE_PATH')
55 rna_prop_repo_index = IntProperty(name="Repo Index", default=-1)
56 rna_prop_repo_url = StringProperty(name="Repo URL", subtype='FILE_PATH')
57 rna_prop_pkg_id = StringProperty(name="Package ID")
59 rna_prop_enable_on_install = BoolProperty(
60 name="Enable on Install",
61 description="Enable after installing",
62 default=True,
64 rna_prop_enable_on_install_type_map = {
65 "add-on": "Enable Add-on",
66 "theme": "Set Current Theme",
70 def rna_prop_repo_enum_local_only_itemf(_self, context):
71 if context is None:
72 result = []
73 else:
74 result = [
76 repo_item.module,
77 repo_item.name if repo_item.enabled else (repo_item.name + " (disabled)"),
78 "",
80 for repo_item in repo_iter_valid_local_only(context)
82 # Prevent the strings from being freed.
83 rna_prop_repo_enum_local_only_itemf._result = result
84 return result
87 is_background = bpy.app.background
89 # Execute tasks concurrently.
90 is_concurrent = True
92 # Selected check-boxes.
93 blender_extension_mark = set()
94 blender_extension_show = set()
97 # Map the enum value to the value in the manifest.
98 blender_filter_by_type_map = {
99 "ALL": "",
100 "ADDON": "add-on",
101 "KEYMAP": "keymap",
102 "THEME": "theme",
106 # -----------------------------------------------------------------------------
107 # Signal Context Manager (Catch Control-C)
111 class CheckSIGINT_Context:
112 __slots__ = (
113 "has_interrupt",
114 "_old_fn",
117 def _signal_handler_sigint(self, _, __):
118 self.has_interrupt = True
119 print("INTERRUPT")
121 def __init__(self):
122 self.has_interrupt = False
123 self._old_fn = None
125 def __enter__(self):
126 import signal
127 self._old_fn = signal.signal(signal.SIGINT, self._signal_handler_sigint)
128 return self
130 def __exit__(self, _ty, _value, _traceback):
131 import signal
132 signal.signal(signal.SIGINT, self._old_fn or signal.SIG_DFL)
135 # -----------------------------------------------------------------------------
136 # Internal Utilities
139 def extension_url_find_repo_index_and_pkg_id(url):
140 from .bl_extension_utils import (
141 pkg_manifest_archive_url_abs_from_repo_url,
143 from .bl_extension_ops import (
144 extension_repos_read,
146 # return repo_index, pkg_id
147 from . import repo_cache_store
149 # NOTE: we might want to use `urllib.parse.urlsplit` so it's possible to include variables in the URL.
150 url_basename = url.rpartition("/")[2]
152 repos_all = extension_repos_read()
154 for repo_index, (
155 pkg_manifest_remote,
156 pkg_manifest_local,
157 ) in enumerate(zip(
158 repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=print),
159 repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print),
161 # It's possible the remote repo could not be connected to when syncing.
162 # Allow it to be None without raising an exception.
163 if pkg_manifest_remote is None:
164 continue
166 repo = repos_all[repo_index]
167 repo_url = repo.repo_url
168 if not repo_url:
169 continue
170 for pkg_id, item_remote in pkg_manifest_remote.items():
171 archive_url = item_remote["archive_url"]
172 archive_url_basename = archive_url.rpartition("/")[2]
173 # First compare the filenames, if this matches, check the full URL.
174 if url_basename != archive_url_basename:
175 continue
177 # Calculate the absolute URL.
178 archive_url_abs = pkg_manifest_archive_url_abs_from_repo_url(repo_url, archive_url)
179 if archive_url_abs == url:
180 return repo_index, repo.name, pkg_id, item_remote, pkg_manifest_local.get(pkg_id)
182 return -1, "", "", None, None
185 def online_user_agent_from_blender():
186 # NOTE: keep this brief and avoid `platform.platform()` which could identify individual users.
187 # Produces something like this: `Blender/4.2.0 (Linux x86_64; cycle=alpha)` or similar.
188 import platform
189 return "Blender/{:d}.{:d}.{:d} ({:s} {:s}; cycle={:s})".format(
190 *bpy.app.version,
191 platform.system(),
192 platform.machine(),
193 bpy.app.version_cycle,
197 def lock_result_any_failed_with_report(op, lock_result, report_type='ERROR'):
199 Convert any locking errors from ``bl_extension_utils.RepoLock.acquire`` into reports.
201 Note that we might want to allow some repositories not to lock and still proceed (in the future).
203 any_errors = False
204 for directory, lock_result_for_repo in lock_result.items():
205 if lock_result_for_repo is None:
206 continue
207 print("Error \"{:s}\" locking \"{:s}\"".format(lock_result_for_repo, repr(directory)))
208 op.report({report_type}, lock_result_for_repo)
209 any_errors = True
210 return any_errors
213 def pkg_info_check_exclude_filter_ex(name, tagline, search_lower):
214 return (
215 (search_lower in name.lower() or search_lower in iface_(name).lower()) or
216 (search_lower in tagline.lower() or search_lower in iface_(tagline).lower())
220 def pkg_info_check_exclude_filter(item, search_lower):
221 return pkg_info_check_exclude_filter_ex(item["name"], item["tagline"], search_lower)
224 def extension_theme_enable_filepath(filepath):
225 bpy.ops.script.execute_preset(
226 filepath=filepath,
227 menu_idname="USERPREF_MT_interface_theme_presets",
231 def extension_theme_enable(repo_directory, pkg_idname):
232 from .bl_extension_utils import (
233 pkg_theme_file_list,
235 # Enable the theme.
236 theme_dir, theme_files = pkg_theme_file_list(repo_directory, pkg_idname)
238 # NOTE: a theme package can contain multiple themes, in this case just use the first
239 # as the list is sorted and picking any theme is arbitrary if there are multiple.
240 if not theme_files:
241 return
243 extension_theme_enable_filepath(os.path.join(theme_dir, theme_files[0]))
246 def repo_iter_valid_local_only(context):
247 from . import repo_paths_or_none
248 extension_repos = context.preferences.filepaths.extension_repos
249 for repo_item in extension_repos:
250 if not repo_item.enabled:
251 continue
252 # Ignore repositories that have invalid settings.
253 directory, remote_path = repo_paths_or_none(repo_item)
254 if directory is None:
255 continue
256 if remote_path:
257 continue
258 yield repo_item
261 class RepoItem(NamedTuple):
262 name: str
263 directory: str
264 repo_url: str
265 module: str
266 use_cache: bool
269 def repo_cache_store_refresh_from_prefs(include_disabled=False):
270 from . import repo_cache_store
271 from . import repo_paths_or_none
272 extension_repos = bpy.context.preferences.filepaths.extension_repos
273 repos = []
274 for repo_item in extension_repos:
275 if not include_disabled:
276 if not repo_item.enabled:
277 continue
278 directory, remote_path = repo_paths_or_none(repo_item)
279 if directory is None:
280 continue
281 repos.append((directory, remote_path))
283 repo_cache_store.refresh_from_repos(repos=repos)
286 def _preferences_ensure_disabled(*, repo_item, pkg_id_sequence, default_set):
287 import sys
288 import addon_utils
290 result = {}
291 errors = []
293 def handle_error(ex):
294 print("Error:", ex)
295 errors.append(str(ex))
297 modules_clear = []
299 module_base_elem = ("bl_ext", repo_item.module)
301 repo_module = sys.modules.get(".".join(module_base_elem))
302 if repo_module is None:
303 print("Repo module \"{:s}\" not in \"sys.modules\", unexpected!".format(".".join(module_base_elem)))
305 for pkg_id in pkg_id_sequence:
306 addon_module_elem = (*module_base_elem, pkg_id)
307 addon_module_name = ".".join(addon_module_elem)
308 loaded_default, loaded_state = addon_utils.check(addon_module_name)
310 result[addon_module_name] = loaded_default, loaded_state
312 # Not loaded or default, skip.
313 if not (loaded_default or loaded_state):
314 continue
316 # This report isn't needed, it just shows a warning in the case of irregularities
317 # which may be useful when debugging issues.
318 if repo_module is not None:
319 if not hasattr(repo_module, pkg_id):
320 print("Repo module \"{:s}.{:s}\" not a sub-module!".format(".".join(module_base_elem), pkg_id))
322 addon_utils.disable(addon_module_name, default_set=default_set, handle_error=handle_error)
324 modules_clear.append(pkg_id)
326 # Clear modules.
328 # Extensions, repository & final `.` to ensure the module is part of the repository.
329 prefix_base = ".".join(module_base_elem) + "."
330 # Needed for `startswith` check.
331 prefix_addon_modules = {prefix_base + pkg_id for pkg_id in modules_clear}
332 # Needed for `startswith` check (sub-modules).
333 prefix_addon_modules_base = tuple([module + "." for module in prefix_addon_modules])
335 # NOTE(@ideasman42): clearing the modules is not great practice,
336 # however we need to ensure this is fully un-loaded then reloaded.
337 for key in list(sys.modules.keys()):
338 if not key.startswith(prefix_base):
339 continue
340 if not (
341 # This module is the add-on.
342 key in prefix_addon_modules or
343 # This module is a sub-module of the add-on.
344 key.startswith(prefix_addon_modules_base)
346 continue
348 # Use pop instead of del because there is a (very) small chance
349 # that classes defined in a removed module define a `__del__` method manipulates modules.
350 sys.modules.pop(key, None)
352 # Now remove from the module from it's parent (when found).
353 # Although in most cases this isn't needed because disabling the add-on typically deletes the module,
354 # don't report a warning if this is the case.
355 if repo_module is not None:
356 for pkg_id in pkg_id_sequence:
357 if not hasattr(repo_module, pkg_id):
358 continue
359 delattr(repo_module, pkg_id)
361 return result, errors
364 def _preferences_ensure_enabled(*, repo_item, pkg_id_sequence, result, handle_error):
365 import addon_utils
366 for addon_module_name, (loaded_default, loaded_state) in result.items():
367 # The module was not loaded, so no need to restore it.
368 if not loaded_state:
369 continue
371 addon_utils.enable(addon_module_name, default_set=loaded_default, handle_error=handle_error)
374 def _preferences_ensure_enabled_all(*, addon_restore, handle_error):
375 for repo_item, pkg_id_sequence, result in addon_restore:
376 _preferences_ensure_enabled(
377 repo_item=repo_item,
378 pkg_id_sequence=pkg_id_sequence,
379 result=result,
380 handle_error=handle_error,
384 def _preferences_install_post_enable_on_install(
386 directory,
387 pkg_manifest_local,
388 pkg_id_sequence,
389 # There were already installed and an attempt to enable it will have already been made.
390 pkg_id_sequence_upgrade,
391 handle_error,
393 import addon_utils
395 # It only ever makes sense to enable one theme.
396 has_theme = False
398 repo_item = _extensions_repo_from_directory(directory)
399 for pkg_id in pkg_id_sequence:
400 item_local = pkg_manifest_local.get(pkg_id)
401 if item_local is None:
402 # Unlikely but possible, do nothing in this case.
403 print("Package should have been installed but not found:", pkg_id)
404 return
406 if item_local["type"] == "add-on":
407 # Check if the add-on will have been enabled from re-installing.
408 if pkg_id in pkg_id_sequence_upgrade:
409 continue
411 addon_module_name = "bl_ext.{:s}.{:s}".format(repo_item.module, pkg_id)
412 addon_utils.enable(addon_module_name, default_set=True, handle_error=handle_error)
413 elif item_local["type"] == "theme":
414 if has_theme:
415 continue
416 extension_theme_enable(directory, pkg_id)
417 has_theme = True
420 def _preferences_ui_redraw():
421 for win in bpy.context.window_manager.windows:
422 for area in win.screen.areas:
423 if area.type != 'PREFERENCES':
424 continue
425 area.tag_redraw()
428 def _preferences_ui_refresh_addons():
429 import addon_utils
430 # TODO: make a public method.
431 addon_utils.modules._is_first = True
434 def _preferences_ensure_sync():
435 # TODO: define when/where exactly sync should be ensured.
436 # This is a general issue:
437 from . import repo_cache_store
438 sync_required = False
439 for repo_index, (
440 pkg_manifest_remote,
441 pkg_manifest_local,
442 ) in enumerate(zip(
443 repo_cache_store.pkg_manifest_from_remote_ensure(error_fn=print),
444 repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print),
446 if pkg_manifest_remote is None:
447 sync_required = True
448 break
449 if pkg_manifest_local is None:
450 sync_required = True
451 break
453 if sync_required:
454 for wm in bpy.data.window_managers:
455 for win in wm.windows:
456 win.cursor_set('WAIT')
457 try:
458 bpy.ops.bl_pkg.repo_sync_all()
459 except BaseException as ex:
460 print("Sync failed:", ex)
462 for wm in bpy.data.window_managers:
463 for win in wm.windows:
464 win.cursor_set('DEFAULT')
467 def extension_repos_read_index(index, *, include_disabled=False):
468 from . import repo_paths_or_none
469 extension_repos = bpy.context.preferences.filepaths.extension_repos
470 index_test = 0
471 for repo_item in extension_repos:
472 if not include_disabled:
473 if not repo_item.enabled:
474 continue
475 directory, remote_path = repo_paths_or_none(repo_item)
476 if directory is None:
477 continue
479 if index == index_test:
480 return RepoItem(
481 name=repo_item.name,
482 directory=directory,
483 repo_url=remote_path,
484 module=repo_item.module,
485 use_cache=repo_item.use_cache,
487 index_test += 1
488 return None
491 def extension_repos_read(*, include_disabled=False, use_active_only=False):
492 from . import repo_paths_or_none
493 paths = bpy.context.preferences.filepaths
494 extension_repos = paths.extension_repos
495 result = []
497 if use_active_only:
498 try:
499 extension_active = extension_repos[paths.active_extension_repo]
500 except IndexError:
501 return result
503 extension_repos = [extension_active]
504 del extension_active
506 for repo_item in extension_repos:
507 if not include_disabled:
508 if not repo_item.enabled:
509 continue
511 # Ignore repositories that have invalid settings.
512 directory, remote_path = repo_paths_or_none(repo_item)
513 if directory is None:
514 continue
516 result.append(RepoItem(
517 name=repo_item.name,
518 directory=directory,
519 repo_url=remote_path,
520 module=repo_item.module,
521 use_cache=repo_item.use_cache,
523 return result
526 def _extension_repos_index_from_directory(directory):
527 directory = os.path.normpath(directory)
528 repos_all = extension_repos_read()
529 for i, repo_item in enumerate(repos_all):
530 if os.path.normpath(repo_item.directory) == directory:
531 return i
532 if os.path.exists(directory):
533 for i, repo_item in enumerate(repos_all):
534 if os.path.normpath(repo_item.directory) == directory:
535 return i
536 return -1
539 def _extensions_repo_from_directory(directory):
540 repos_all = extension_repos_read()
541 repo_index = _extension_repos_index_from_directory(directory)
542 if repo_index == -1:
543 return None
544 return repos_all[repo_index]
547 def _extensions_repo_from_directory_and_report(directory, report_fn):
548 if not directory:
549 report_fn({'ERROR', "Directory not set"})
550 return None
552 repo_item = _extensions_repo_from_directory(directory)
553 if repo_item is None:
554 report_fn({'ERROR'}, "Directory has no repo entry: {:s}".format(directory))
555 return None
556 return repo_item
559 def _pkg_marked_by_repo(pkg_manifest_all):
560 # NOTE: pkg_manifest_all can be from local or remote source.
561 wm = bpy.context.window_manager
562 search_lower = wm.extension_search.lower()
563 filter_by_type = blender_filter_by_type_map[wm.extension_type]
565 repo_pkg_map = {}
566 for pkg_id, repo_index in blender_extension_mark:
567 # While this should be prevented, any marked packages out of the range will cause problems, skip them.
568 if repo_index >= len(pkg_manifest_all):
569 continue
571 pkg_manifest = pkg_manifest_all[repo_index]
572 item = pkg_manifest.get(pkg_id)
573 if item is None:
574 continue
575 if filter_by_type and (filter_by_type != item["type"]):
576 continue
577 if search_lower and not pkg_info_check_exclude_filter(item, search_lower):
578 continue
580 pkg_list = repo_pkg_map.get(repo_index)
581 if pkg_list is None:
582 pkg_list = repo_pkg_map[repo_index] = []
583 pkg_list.append(pkg_id)
584 return repo_pkg_map
587 # -----------------------------------------------------------------------------
588 # Wheel Handling
591 def _extensions_wheel_filter_for_platform(wheels):
593 # Copied from `wheel.bwheel_dist.get_platform(..)` which isn't part of Python.
594 # This misses some additional checks which aren't supported by official Blender builds,
595 # it's highly doubtful users ever run into this but we could add extend this if it's really needed.
596 # (e.g. `linux-i686` on 64 bit systems & `linux-armv7l`).
597 import sysconfig
598 platform_tag_current = sysconfig.get_platform().replace("-", "_")
600 # https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention
601 # This also defines the name spec:
602 # `{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl`
604 wheels_compatible = []
605 for wheel in wheels:
606 wheel_filename = wheel.rsplit("/", 1)[-1]
608 # Handled by validation (paranoid).
609 if not wheel_filename.lower().endswith(".whl"):
610 print("Error: wheel doesn't end with \".whl\", skipping!")
611 continue
613 wheel_filename_split = wheel_filename[:-4].split("-")
614 # Skipping, should never happen as validation will fail,
615 # keep paranoid check although this might be removed in the future.
616 if not (5 <= len(wheel_filename_split) <= 6):
617 print("Error: wheel doesn't follow naming spec \"{:s}\"".format(wheel_filename))
618 continue
619 # TODO: Match Python & ABI tags.
620 _python_tag, _abi_tag, platform_tag = wheel_filename_split[-3:]
622 if platform_tag in {"any", platform_tag_current}:
623 pass
624 elif platform_tag_current.startswith("macosx_") and (
625 # FIXME: `macosx_11.00` should be `macosx_11_0`.
626 platform_tag.startswith("macosx_") and
627 # Ignore the MACOSX version, ensure `arm64` suffix.
628 platform_tag.endswith("_" + platform_tag_current.rpartition("_")[2])
630 pass
631 elif platform_tag_current.startswith("linux_") and (
632 # May be `manylinux1` or `manylinux2010`.
633 platform_tag.startswith("manylinux") and
634 # Match against the architecture: `linux_x86_64` -> `_x86_64` (ensure the same suffix).
635 # The GLIBC version is ignored because it will often be older.
636 # Although we will probably want to detect incompatible GLIBC versions eventually.
637 platform_tag.endswith("_" + platform_tag_current.partition("_")[2])
639 pass
640 else:
641 # Useful to know, can quiet print in the future.
642 print(
643 "Skipping wheel for other system",
644 "({:s} != {:s}):".format(platform_tag, platform_tag_current),
645 wheel_filename,
647 continue
649 wheels_compatible.append(wheel)
650 return wheels_compatible
653 def _extensions_repo_sync_wheels(repo_cache_store):
655 This function collects all wheels from all packages and ensures the packages are either extracted or removed
656 when they are no longer used.
658 from .bl_extension_local import sync
660 repos_all = extension_repos_read()
662 wheel_list = []
663 for repo_index, pkg_manifest_local in enumerate(repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print)):
664 repo = repos_all[repo_index]
665 repo_module = repo.module
666 repo_directory = repo.directory
667 for pkg_id, item_local in pkg_manifest_local.items():
668 pkg_dirpath = os.path.join(repo_directory, pkg_id)
669 wheels_rel = item_local.get("wheels", None)
670 if wheels_rel is None:
671 continue
672 if not isinstance(wheels_rel, list):
673 continue
675 # Filter only the wheels for this platform.
676 wheels_rel = _extensions_wheel_filter_for_platform(wheels_rel)
677 if not wheels_rel:
678 continue
680 wheels_abs = []
681 for filepath_rel in wheels_rel:
682 filepath_abs = os.path.join(pkg_dirpath, filepath_rel)
683 if not os.path.exists(filepath_abs):
684 continue
685 wheels_abs.append(filepath_abs)
687 if not wheels_abs:
688 continue
690 unique_pkg_id = "{:s}.{:s}".format(repo_module, pkg_id)
691 wheel_list.append((unique_pkg_id, wheels_abs))
693 extensions = bpy.utils.user_resource('EXTENSIONS')
694 local_dir = os.path.join(extensions, ".local")
696 sync(
697 local_dir=local_dir,
698 wheel_list=wheel_list,
702 # -----------------------------------------------------------------------------
703 # Theme Handling
706 def _preferences_theme_state_create():
707 from .bl_extension_utils import (
708 file_mtime_or_none,
709 scandir_with_demoted_errors,
711 filepath = bpy.context.preferences.themes[0].filepath
712 if not filepath:
713 return None, None
715 if (result := file_mtime_or_none(filepath)) is not None:
716 return result, filepath
718 # It's possible the XML was renamed after upgrading, detect another.
719 dirpath = os.path.dirname(filepath)
721 # Not essential, just avoids a demoted error from `scandir` which seems like it may be a bug.
722 if not os.path.exists(dirpath):
723 return None, None
725 filepath = ""
726 for entry in scandir_with_demoted_errors(dirpath):
727 if entry.is_dir():
728 continue
729 # There must only ever be one.
730 if entry.name.lower().endswith(".xml"):
731 if (result := file_mtime_or_none(entry.path)) is not None:
732 return result, filepath
733 return None, None
736 def _preferences_theme_state_restore(state):
737 state_update = _preferences_theme_state_create()
738 # Unchanged, return.
739 if state == state_update:
740 return
742 # Uninstall:
743 # The current theme was an extension that was uninstalled.
744 if state[0] is not None and state_update[0] is None:
745 bpy.ops.preferences.reset_default_theme()
746 return
748 # Update:
749 if state_update[0] is not None:
750 extension_theme_enable_filepath(state_update[1])
753 # -----------------------------------------------------------------------------
754 # Internal Implementation
757 def _is_modal(op):
758 if is_background:
759 return False
760 if not op.options.is_invoke:
761 return False
762 return True
765 class CommandHandle:
766 __slots__ = (
767 "modal_timer",
768 "cmd_batch",
769 "wm",
770 "request_exit",
773 def __init__(self):
774 self.modal_timer = None
775 self.cmd_batch = None
776 self.wm = None
777 self.request_exit = None
779 @staticmethod
780 def op_exec_from_iter(op, context, cmd_batch, is_modal):
781 if not is_modal:
782 with CheckSIGINT_Context() as sigint_ctx:
783 has_request_exit = cmd_batch.exec_blocking(
784 report_fn=_report,
785 request_exit_fn=lambda: sigint_ctx.has_interrupt,
786 concurrent=is_concurrent,
788 if has_request_exit:
789 op.report({'WARNING'}, "Command interrupted")
790 return {'FINISHED'}
792 return {'FINISHED'}
794 handle = CommandHandle()
795 handle.cmd_batch = cmd_batch
796 handle.modal_timer = context.window_manager.event_timer_add(0.01, window=context.window)
797 handle.wm = context.window_manager
799 handle.wm.modal_handler_add(op)
800 op._runtime_handle = handle
801 return {'RUNNING_MODAL'}
803 def op_modal_step(self, op, context):
804 command_result = self.cmd_batch.exec_non_blocking(
805 request_exit=self.request_exit,
808 # Forward new messages to reports.
809 msg_list_per_command = self.cmd_batch.calc_status_log_since_last_request_or_none()
810 if msg_list_per_command is not None:
811 for i, msg_list in enumerate(msg_list_per_command, 1):
812 for (ty, msg) in msg_list:
813 if len(msg_list_per_command) > 1:
814 # These reports are flattened, note the process number that fails so
815 # whoever is reading the reports can make sense of the messages.
816 msg = "{:s} (process {:d} of {:d})".format(msg, i, len(msg_list_per_command))
817 if ty == 'STATUS':
818 op.report({'INFO'}, msg)
819 else:
820 op.report({'WARNING'}, msg)
821 del msg_list_per_command
823 # Avoid high CPU usage by only redrawing when there has been a change.
824 msg_list = self.cmd_batch.calc_status_log_or_none()
825 if msg_list is not None:
826 context.workspace.status_text_set(
827 " | ".join(
828 ["{:s}: {:s}".format(ty, str(msg)) for (ty, msg) in msg_list]
832 # Setting every time is a bit odd. but OK.
833 repo_status_text.title = self.cmd_batch.title
834 repo_status_text.log = msg_list
835 repo_status_text.running = True
836 _preferences_ui_redraw()
838 if command_result.all_complete:
839 self.wm.event_timer_remove(self.modal_timer)
840 del op._runtime_handle
841 context.workspace.status_text_set(None)
842 repo_status_text.running = False
843 return {'FINISHED'}
845 return {'RUNNING_MODAL'}
847 def op_modal_impl(self, op, context, event):
848 refresh = False
849 if event.type == 'TIMER':
850 refresh = True
851 elif event.type == 'ESC':
852 if not self.request_exit:
853 print("Request exit!")
854 self.request_exit = True
855 refresh = True
857 if refresh:
858 return self.op_modal_step(op, context)
859 return {'RUNNING_MODAL'}
862 def _report(ty, msg):
863 if ty == 'DONE':
864 assert msg == ""
865 return
867 if is_background:
868 print(ty, msg)
869 return
872 def _repo_dir_and_index_get(repo_index, directory, report_fn):
873 if repo_index != -1:
874 repo_item = extension_repos_read_index(repo_index)
875 directory = repo_item.directory if (repo_item is not None) else ""
876 if not directory:
877 report_fn({'ERROR'}, "Repository not set")
878 return directory
881 # -----------------------------------------------------------------------------
882 # Public Repository Actions
885 class _BlPkgCmdMixIn:
887 Utility to execute mix-in.
889 Sub-class must define.
890 - bl_idname
891 - bl_label
892 - exec_command_iter
893 - exec_command_finish
895 cls_slots = (
896 "_runtime_handle",
899 @classmethod
900 def __init_subclass__(cls) -> None:
901 for attr in ("exec_command_iter", "exec_command_finish"):
902 if getattr(cls, attr) is getattr(_BlPkgCmdMixIn, attr):
903 raise Exception("Subclass did not define 'exec_command_iter'!")
905 def exec_command_iter(self, is_modal):
906 raise Exception("Subclass must define!")
908 def exec_command_finish(self):
909 raise Exception("Subclass must define!")
911 def error_fn_from_exception(self, ex):
912 # A bit silly setting every time, but it's needed to ensure there is a title.
913 repo_status_text.log.append(("ERROR", str(ex)))
915 def execute(self, context):
916 is_modal = _is_modal(self)
917 cmd_batch = self.exec_command_iter(is_modal)
918 # It's possible the action could not be started.
919 # In this case `exec_command_iter` should report an error.
920 if cmd_batch is None:
921 return {'CANCELLED'}
923 # Needed in cast there are no commands within `cmd_batch`,
924 # the title should still be set.
925 repo_status_text.title = cmd_batch.title
927 result = CommandHandle.op_exec_from_iter(self, context, cmd_batch, is_modal)
928 if 'FINISHED' in result:
929 self.exec_command_finish()
930 return result
932 def modal(self, context, event):
933 result = self._runtime_handle.op_modal_impl(self, context, event)
934 if 'FINISHED' in result:
935 self.exec_command_finish()
936 return result
939 class BlPkgDummyProgress(Operator, _BlPkgCmdMixIn):
940 bl_idname = "bl_pkg.dummy_progress"
941 bl_label = "Ext Demo"
942 __slots__ = _BlPkgCmdMixIn.cls_slots
944 def exec_command_iter(self, is_modal):
945 return bl_extension_utils.CommandBatch(
946 title="Dummy Progress",
947 batch=[
948 partial(
949 bl_extension_utils.dummy_progress,
950 use_idle=is_modal,
955 def exec_command_finish(self):
956 _preferences_ui_redraw()
959 class BlPkgRepoSync(Operator, _BlPkgCmdMixIn):
960 bl_idname = "bl_pkg.repo_sync"
961 bl_label = "Ext Repo Sync"
962 __slots__ = _BlPkgCmdMixIn.cls_slots
964 repo_directory: rna_prop_directory
965 repo_index: rna_prop_repo_index
967 def exec_command_iter(self, is_modal):
968 directory = _repo_dir_and_index_get(self.repo_index, self.repo_directory, self.report)
969 if not directory:
970 return None
972 if (repo_item := _extensions_repo_from_directory_and_report(directory, self.report)) is None:
973 return None
975 if not os.path.exists(directory):
976 try:
977 os.makedirs(directory)
978 except BaseException as ex:
979 self.report({'ERROR'}, str(ex))
980 return {'CANCELLED'}
982 # Needed to refresh.
983 self.repo_directory = directory
985 # Lock repositories.
986 self.repo_lock = RepoLock(repo_directories=[directory], cookie=cookie_from_session())
987 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
988 return None
990 cmd_batch = []
991 if repo_item.repo_url:
992 cmd_batch.append(
993 partial(
994 bl_extension_utils.repo_sync,
995 directory=directory,
996 repo_url=repo_item.repo_url,
997 online_user_agent=online_user_agent_from_blender(),
998 use_idle=is_modal,
1002 return bl_extension_utils.CommandBatch(
1003 title="Sync",
1004 batch=cmd_batch,
1007 def exec_command_finish(self):
1008 from . import repo_cache_store
1010 repo_cache_store_refresh_from_prefs()
1011 repo_cache_store.refresh_remote_from_directory(
1012 directory=self.repo_directory,
1013 error_fn=self.error_fn_from_exception,
1014 force=True,
1017 # Unlock repositories.
1018 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1019 del self.repo_lock
1021 _preferences_ui_redraw()
1024 class BlPkgRepoSyncAll(Operator, _BlPkgCmdMixIn):
1025 bl_idname = "bl_pkg.repo_sync_all"
1026 bl_label = "Ext Repo Sync All"
1027 __slots__ = _BlPkgCmdMixIn.cls_slots
1029 use_active_only: BoolProperty(
1030 name="Active Only",
1031 description="Only sync the active repository",
1034 def exec_command_iter(self, is_modal):
1035 use_active_only = self.use_active_only
1036 repos_all = extension_repos_read(use_active_only=use_active_only)
1038 if not repos_all:
1039 self.report({'INFO'}, "No repositories to sync")
1040 return None
1042 for repo_item in repos_all:
1043 if not os.path.exists(repo_item.directory):
1044 try:
1045 os.makedirs(repo_item.directory)
1046 except BaseException as ex:
1047 self.report({'WARNING'}, str(ex))
1048 return None
1050 cmd_batch = []
1051 for repo_item in repos_all:
1052 # Local only repositories should still refresh, but not run the sync.
1053 if repo_item.repo_url:
1054 cmd_batch.append(partial(
1055 bl_extension_utils.repo_sync,
1056 directory=repo_item.directory,
1057 repo_url=repo_item.repo_url,
1058 online_user_agent=online_user_agent_from_blender(),
1059 use_idle=is_modal,
1062 repos_lock = [repo_item.directory for repo_item in repos_all]
1064 # Lock repositories.
1065 self.repo_lock = RepoLock(repo_directories=repos_lock, cookie=cookie_from_session())
1066 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1067 return None
1069 return bl_extension_utils.CommandBatch(
1070 title="Sync \"{:s}\"".format(repos_all[0].name) if use_active_only else "Sync All",
1071 batch=cmd_batch,
1074 def exec_command_finish(self):
1075 from . import repo_cache_store
1077 repo_cache_store_refresh_from_prefs()
1079 for repo_item in extension_repos_read():
1080 repo_cache_store.refresh_remote_from_directory(
1081 directory=repo_item.directory,
1082 error_fn=self.error_fn_from_exception,
1083 force=True,
1086 # Unlock repositories.
1087 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1088 del self.repo_lock
1090 _preferences_ui_redraw()
1093 class BlPkgPkgUpgradeAll(Operator, _BlPkgCmdMixIn):
1094 bl_idname = "bl_pkg.pkg_upgrade_all"
1095 bl_label = "Ext Package Upgrade All"
1096 __slots__ = _BlPkgCmdMixIn.cls_slots + (
1097 "_repo_directories",
1100 use_active_only: BoolProperty(
1101 name="Active Only",
1102 description="Only sync the active repository",
1105 def exec_command_iter(self, is_modal):
1106 from . import repo_cache_store
1107 self._repo_directories = set()
1108 self._addon_restore = []
1109 self._theme_restore = _preferences_theme_state_create()
1111 use_active_only = self.use_active_only
1112 repos_all = extension_repos_read(use_active_only=use_active_only)
1113 repo_directory_supset = [repo_entry.directory for repo_entry in repos_all] if use_active_only else None
1115 if not repos_all:
1116 self.report({'INFO'}, "No repositories to upgrade")
1117 return None
1119 # NOTE: Unless we have a "clear-cache" operator - there isn't a great place to apply cache-clearing.
1120 # So when cache is disabled simply clear all cache before performing an update.
1121 # Further, individual install & remove operation will manage the cache
1122 # for the individual packages being installed or removed.
1123 for repo_item in repos_all:
1124 if repo_item.use_cache:
1125 continue
1126 bl_extension_utils.pkg_repo_cache_clear(repo_item.directory)
1128 # Track add-ons to disable before uninstalling.
1129 handle_addons_info = []
1131 packages_to_upgrade = [[] for _ in range(len(repos_all))]
1132 package_count = 0
1134 pkg_manifest_local_all = list(repo_cache_store.pkg_manifest_from_local_ensure(
1135 error_fn=self.error_fn_from_exception,
1136 directory_subset=repo_directory_supset,
1138 for repo_index, pkg_manifest_remote in enumerate(repo_cache_store.pkg_manifest_from_remote_ensure(
1139 error_fn=self.error_fn_from_exception,
1140 directory_subset=repo_directory_supset,
1142 if pkg_manifest_remote is None:
1143 continue
1145 pkg_manifest_local = pkg_manifest_local_all[repo_index]
1146 if pkg_manifest_local is None:
1147 continue
1149 for pkg_id, item_remote in pkg_manifest_remote.items():
1150 item_local = pkg_manifest_local.get(pkg_id)
1151 if item_local is None:
1152 # Not installed.
1153 continue
1155 if item_remote["version"] != item_local["version"]:
1156 packages_to_upgrade[repo_index].append(pkg_id)
1157 package_count += 1
1159 if packages_to_upgrade[repo_index]:
1160 handle_addons_info.append((repos_all[repo_index], list(packages_to_upgrade[repo_index])))
1162 cmd_batch = []
1163 for repo_index, pkg_id_sequence in enumerate(packages_to_upgrade):
1164 if not pkg_id_sequence:
1165 continue
1166 repo_item = repos_all[repo_index]
1167 cmd_batch.append(partial(
1168 bl_extension_utils.pkg_install,
1169 directory=repo_item.directory,
1170 repo_url=repo_item.repo_url,
1171 pkg_id_sequence=pkg_id_sequence,
1172 online_user_agent=online_user_agent_from_blender(),
1173 use_cache=repo_item.use_cache,
1174 use_idle=is_modal,
1176 self._repo_directories.add(repo_item.directory)
1178 if not cmd_batch:
1179 self.report({'INFO'}, "No installed packages to update")
1180 return None
1182 # Lock repositories.
1183 self.repo_lock = RepoLock(repo_directories=list(self._repo_directories), cookie=cookie_from_session())
1184 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1185 return None
1187 for repo_item, pkg_id_sequence in handle_addons_info:
1188 result, errors = _preferences_ensure_disabled(
1189 repo_item=repo_item,
1190 pkg_id_sequence=pkg_id_sequence,
1191 default_set=False,
1193 self._addon_restore.append((repo_item, pkg_id_sequence, result))
1195 return bl_extension_utils.CommandBatch(
1196 title=(
1197 "Update {:d} Package(s) from \"{:s}\"".format(package_count, repos_all[0].name) if use_active_only else
1198 "Update {:d} Package(s)".format(package_count)
1200 batch=cmd_batch,
1203 def exec_command_finish(self):
1205 # Unlock repositories.
1206 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1207 del self.repo_lock
1209 # Refresh installed packages for repositories that were operated on.
1210 from . import repo_cache_store
1211 for directory in self._repo_directories:
1212 repo_cache_store.refresh_local_from_directory(
1213 directory=directory,
1214 error_fn=self.error_fn_from_exception,
1217 # TODO: it would be nice to include this message in the banner.
1218 def handle_error(ex):
1219 self.report({'ERROR'}, str(ex))
1221 _preferences_ensure_enabled_all(
1222 addon_restore=self._addon_restore,
1223 handle_error=handle_error,
1225 _preferences_theme_state_restore(self._theme_restore)
1227 _preferences_ui_redraw()
1228 _preferences_ui_refresh_addons()
1231 class BlPkgPkgInstallMarked(Operator, _BlPkgCmdMixIn):
1232 bl_idname = "bl_pkg.pkg_install_marked"
1233 bl_label = "Ext Package Install_marked"
1234 __slots__ = _BlPkgCmdMixIn.cls_slots + (
1235 "_repo_directories",
1236 "_repo_map_packages_addon_only",
1239 enable_on_install: rna_prop_enable_on_install
1241 def exec_command_iter(self, is_modal):
1242 from . import repo_cache_store
1243 repos_all = extension_repos_read()
1244 pkg_manifest_remote_all = list(repo_cache_store.pkg_manifest_from_remote_ensure(
1245 error_fn=self.error_fn_from_exception,
1247 repo_pkg_map = _pkg_marked_by_repo(pkg_manifest_remote_all)
1248 self._repo_directories = set()
1249 self._repo_map_packages_addon_only = []
1250 package_count = 0
1252 cmd_batch = []
1253 for repo_index, pkg_id_sequence in sorted(repo_pkg_map.items()):
1254 repo_item = repos_all[repo_index]
1255 # Filter out already installed.
1256 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1257 directory=repo_item.directory,
1258 error_fn=self.error_fn_from_exception,
1260 if pkg_manifest_local is None:
1261 continue
1262 pkg_id_sequence = [pkg_id for pkg_id in pkg_id_sequence if pkg_id not in pkg_manifest_local]
1263 if not pkg_id_sequence:
1264 continue
1266 cmd_batch.append(partial(
1267 bl_extension_utils.pkg_install,
1268 directory=repo_item.directory,
1269 repo_url=repo_item.repo_url,
1270 pkg_id_sequence=pkg_id_sequence,
1271 online_user_agent=online_user_agent_from_blender(),
1272 use_cache=repo_item.use_cache,
1273 use_idle=is_modal,
1275 self._repo_directories.add(repo_item.directory)
1276 package_count += len(pkg_id_sequence)
1278 # Filter out non add-on extensions.
1279 pkg_manifest_remote = pkg_manifest_remote_all[repo_index]
1281 pkg_id_sequence_addon_only = [
1282 pkg_id for pkg_id in pkg_id_sequence if pkg_manifest_remote[pkg_id]["type"] == "add-on"]
1283 if pkg_id_sequence_addon_only:
1284 self._repo_map_packages_addon_only.append((repo_item.directory, pkg_id_sequence_addon_only))
1286 if not cmd_batch:
1287 self.report({'ERROR'}, "No un-installed packages marked")
1288 return None
1290 # Lock repositories.
1291 self.repo_lock = RepoLock(repo_directories=list(self._repo_directories), cookie=cookie_from_session())
1292 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1293 return None
1295 return bl_extension_utils.CommandBatch(
1296 title="Install {:d} Marked Package(s)".format(package_count),
1297 batch=cmd_batch,
1300 def exec_command_finish(self):
1302 # Unlock repositories.
1303 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1304 del self.repo_lock
1306 # Refresh installed packages for repositories that were operated on.
1307 from . import repo_cache_store
1308 for directory in self._repo_directories:
1309 repo_cache_store.refresh_local_from_directory(
1310 directory=directory,
1311 error_fn=self.error_fn_from_exception,
1314 _extensions_repo_sync_wheels(repo_cache_store)
1316 # TODO: it would be nice to include this message in the banner.
1317 def handle_error(ex):
1318 self.report({'ERROR'}, str(ex))
1320 for directory, pkg_id_sequence in self._repo_map_packages_addon_only:
1322 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1323 directory=directory,
1324 error_fn=self.error_fn_from_exception,
1327 if self.enable_on_install:
1328 _preferences_install_post_enable_on_install(
1329 directory=directory,
1330 pkg_manifest_local=pkg_manifest_local,
1331 pkg_id_sequence=pkg_id_sequence,
1332 # Installed packages are always excluded.
1333 pkg_id_sequence_upgrade=[],
1334 handle_error=handle_error,
1337 _preferences_ui_redraw()
1338 _preferences_ui_refresh_addons()
1341 class BlPkgPkgUninstallMarked(Operator, _BlPkgCmdMixIn):
1342 bl_idname = "bl_pkg.pkg_uninstall_marked"
1343 bl_label = "Ext Package Uninstall_marked"
1344 __slots__ = _BlPkgCmdMixIn.cls_slots + (
1345 "_repo_directories",
1348 def exec_command_iter(self, is_modal):
1349 from . import repo_cache_store
1350 # TODO: check if the packages are already installed (notify the user).
1351 # Perhaps re-install?
1352 repos_all = extension_repos_read()
1353 pkg_manifest_local_all = list(repo_cache_store.pkg_manifest_from_local_ensure(
1354 error_fn=self.error_fn_from_exception,
1356 repo_pkg_map = _pkg_marked_by_repo(pkg_manifest_local_all)
1357 package_count = 0
1359 self._repo_directories = set()
1360 self._theme_restore = _preferences_theme_state_create()
1362 # Track add-ons to disable before uninstalling.
1363 handle_addons_info = []
1365 cmd_batch = []
1366 for repo_index, pkg_id_sequence in sorted(repo_pkg_map.items()):
1367 repo_item = repos_all[repo_index]
1369 # Filter out not installed.
1370 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1371 directory=repo_item.directory,
1372 error_fn=self.error_fn_from_exception,
1374 if pkg_manifest_local is None:
1375 continue
1376 pkg_id_sequence = [pkg_id for pkg_id in pkg_id_sequence if pkg_id in pkg_manifest_local]
1377 if not pkg_id_sequence:
1378 continue
1380 cmd_batch.append(
1381 partial(
1382 bl_extension_utils.pkg_uninstall,
1383 directory=repo_item.directory,
1384 pkg_id_sequence=pkg_id_sequence,
1385 use_idle=is_modal,
1387 self._repo_directories.add(repo_item.directory)
1388 package_count += len(pkg_id_sequence)
1390 handle_addons_info.append((repo_item, pkg_id_sequence))
1392 if not cmd_batch:
1393 self.report({'ERROR'}, "No installed packages marked")
1394 return None
1396 # Lock repositories.
1397 self.repo_lock = RepoLock(repo_directories=list(self._repo_directories), cookie=cookie_from_session())
1398 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1399 return None
1401 for repo_item, pkg_id_sequence in handle_addons_info:
1402 # No need to store the result (`_`) because the add-ons aren't going to be enabled again.
1403 _, errors = _preferences_ensure_disabled(
1404 repo_item=repo_item,
1405 pkg_id_sequence=pkg_id_sequence,
1406 default_set=True,
1409 return bl_extension_utils.CommandBatch(
1410 title="Uninstall {:d} Marked Package(s)".format(package_count),
1411 batch=cmd_batch,
1414 def exec_command_finish(self):
1416 # Unlock repositories.
1417 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1418 del self.repo_lock
1420 # Refresh installed packages for repositories that were operated on.
1421 from . import repo_cache_store
1422 for directory in self._repo_directories:
1423 repo_cache_store.refresh_local_from_directory(
1424 directory=directory,
1425 error_fn=self.error_fn_from_exception,
1428 _extensions_repo_sync_wheels(repo_cache_store)
1430 _preferences_theme_state_restore(self._theme_restore)
1432 _preferences_ui_redraw()
1433 _preferences_ui_refresh_addons()
1436 class BlPkgPkgInstallFiles(Operator, _BlPkgCmdMixIn):
1437 """Install an extension from a file into a locally managed repository"""
1438 bl_idname = "bl_pkg.pkg_install_files"
1439 bl_label = "Install from Disk"
1440 __slots__ = _BlPkgCmdMixIn.cls_slots + (
1441 "repo_directory",
1442 "pkg_id_sequence"
1444 _drop_variables = None
1446 filter_glob: StringProperty(default="*.zip", options={'HIDDEN'})
1448 directory: StringProperty(
1449 name="Directory",
1450 subtype='DIR_PATH',
1451 default="",
1453 files: CollectionProperty(
1454 type=bpy.types.OperatorFileListElement,
1455 options={'HIDDEN', 'SKIP_SAVE'}
1458 # Use for for scripts.
1459 filepath: StringProperty(
1460 subtype='FILE_PATH',
1463 repo: EnumProperty(
1464 name="Local Repository",
1465 items=rna_prop_repo_enum_local_only_itemf,
1466 description="The local repository to install extensions into",
1469 enable_on_install: rna_prop_enable_on_install
1471 # Only used for code-path for dropping an extension.
1472 url: rna_prop_url
1474 def exec_command_iter(self, is_modal):
1475 from .bl_extension_utils import (
1476 pkg_manifest_dict_from_file_or_error,
1479 self._addon_restore = []
1480 self._theme_restore = _preferences_theme_state_create()
1482 # Happens when run from scripts and this argument isn't passed in.
1483 if not self.properties.is_property_set("repo"):
1484 self.report({'ERROR'}, "Repository not set")
1485 return None
1487 # Repository accessed.
1488 repo_module_name = self.repo
1489 repo_item = next(
1490 (repo_item for repo_item in extension_repos_read() if repo_item.module == repo_module_name),
1491 None,
1493 # This should really never happen as poll means this shouldn't be possible.
1494 assert repo_item is not None
1495 del repo_module_name
1496 # Done with the repository.
1498 source_files = [os.path.join(file.name) for file in self.files]
1499 source_directory = self.directory
1500 # Support a single `filepath`, more convenient when calling from scripts.
1501 if not (source_directory and source_files):
1502 source_directory, source_file = os.path.split(self.filepath)
1503 if not (source_directory and source_file):
1504 self.report({'ERROR'}, "No source paths set")
1505 return None
1506 source_files = [source_file]
1507 del source_file
1508 assert len(source_files) > 0
1510 # Make absolute paths.
1511 source_files = [os.path.join(source_directory, filename) for filename in source_files]
1513 # Extract meta-data from package files.
1514 # Note that errors are ignored here, let the underlying install operation do this.
1515 pkg_id_sequence = []
1516 for source_filepath in source_files:
1517 result = pkg_manifest_dict_from_file_or_error(source_filepath)
1518 if isinstance(result, str):
1519 continue
1520 pkg_id = result["id"]
1521 if pkg_id in pkg_id_sequence:
1522 continue
1523 pkg_id_sequence.append(pkg_id)
1525 directory = repo_item.directory
1526 assert directory != ""
1528 # Collect package ID's.
1529 self.repo_directory = directory
1530 self.pkg_id_sequence = pkg_id_sequence
1532 # Detect upgrade.
1533 if pkg_id_sequence:
1534 from . import repo_cache_store
1535 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1536 directory=self.repo_directory,
1537 error_fn=self.error_fn_from_exception,
1539 if pkg_manifest_local is not None:
1540 pkg_id_sequence_upgrade = [pkg_id for pkg_id in pkg_id_sequence if pkg_id in pkg_manifest_local]
1541 if pkg_id_sequence_upgrade:
1542 result, errors = _preferences_ensure_disabled(
1543 repo_item=repo_item,
1544 pkg_id_sequence=pkg_id_sequence_upgrade,
1545 default_set=False,
1547 self._addon_restore.append((repo_item, pkg_id_sequence_upgrade, result))
1548 del repo_cache_store, pkg_manifest_local
1550 # Lock repositories.
1551 self.repo_lock = RepoLock(repo_directories=[repo_item.directory], cookie=cookie_from_session())
1552 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1553 return None
1555 return bl_extension_utils.CommandBatch(
1556 title="Install Package Files",
1557 batch=[
1558 partial(
1559 bl_extension_utils.pkg_install_files,
1560 directory=directory,
1561 files=source_files,
1562 use_idle=is_modal,
1567 def exec_command_finish(self):
1569 # Refresh installed packages for repositories that were operated on.
1570 from . import repo_cache_store
1572 # Re-generate JSON meta-data from TOML files (needed for offline repository).
1573 repo_cache_store.refresh_remote_from_directory(
1574 directory=self.repo_directory,
1575 error_fn=self.error_fn_from_exception,
1576 force=True,
1579 # Unlock repositories.
1580 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1581 del self.repo_lock
1583 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1584 directory=self.repo_directory,
1585 error_fn=self.error_fn_from_exception,
1588 _extensions_repo_sync_wheels(repo_cache_store)
1590 # TODO: it would be nice to include this message in the banner.
1592 def handle_error(ex):
1593 self.report({'ERROR'}, str(ex))
1595 _preferences_ensure_enabled_all(
1596 addon_restore=self._addon_restore,
1597 handle_error=handle_error,
1599 _preferences_theme_state_restore(self._theme_restore)
1601 if self._addon_restore:
1602 pkg_id_sequence_upgrade = self._addon_restore[0][1]
1603 else:
1604 pkg_id_sequence_upgrade = []
1606 if self.enable_on_install:
1607 _preferences_install_post_enable_on_install(
1608 directory=self.repo_directory,
1609 pkg_manifest_local=pkg_manifest_local,
1610 pkg_id_sequence=self.pkg_id_sequence,
1611 pkg_id_sequence_upgrade=pkg_id_sequence_upgrade,
1612 handle_error=handle_error,
1615 _preferences_ui_redraw()
1616 _preferences_ui_refresh_addons()
1618 @classmethod
1619 def poll(cls, context):
1620 if next(repo_iter_valid_local_only(context), None) is None:
1621 cls.poll_message_set("There must be at least one \"Local\" repository set to install extensions into")
1622 return False
1623 return True
1625 def invoke(self, context, event):
1626 if self.properties.is_property_set("url"):
1627 return self._invoke_for_drop(context, event)
1629 # Ensure the value is marked as set (else an error is reported).
1630 self.repo = self.repo
1632 context.window_manager.fileselect_add(self)
1633 return {'RUNNING_MODAL'}
1635 def draw(self, context):
1636 if self._drop_variables is not None:
1637 return self._draw_for_drop(context)
1639 # Override draw because the repository names may be over-long and not fit well in the UI.
1640 # Show the text & repository names in two separate rows.
1641 layout = self.layout
1642 col = layout.column()
1643 col.label(text="Local Repository:")
1644 col.prop(self, "repo", text="")
1646 layout.prop(self, "enable_on_install")
1648 def _invoke_for_drop(self, context, event):
1649 self._drop_variables = True
1650 # Drop logic.
1651 url = self.url
1652 print("DROP FILE:", url)
1654 from .bl_extension_ops import repo_iter_valid_local_only
1655 from .bl_extension_utils import pkg_manifest_dict_from_file_or_error
1657 if not list(repo_iter_valid_local_only(bpy.context)):
1658 self.report({'ERROR'}, "No Local Repositories")
1659 return {'CANCELLED'}
1661 if isinstance(result := pkg_manifest_dict_from_file_or_error(url), str):
1662 self.report({'ERROR'}, "Error in manifest {:s}".format(result))
1663 return {'CANCELLED'}
1665 pkg_id = result["id"]
1666 pkg_type = result["type"]
1667 del result
1669 self._drop_variables = pkg_id, pkg_type
1671 # Set to it's self to the property is considered "set".
1672 self.repo = self.repo
1673 self.filepath = url
1675 wm = context.window_manager
1676 wm.invoke_props_dialog(self)
1678 return {'RUNNING_MODAL'}
1680 def _draw_for_drop(self, context):
1682 layout = self.layout
1683 layout.operator_context = 'EXEC_DEFAULT'
1685 pkg_id, pkg_type = self._drop_variables
1687 layout.label(text="Local Repository")
1688 layout.prop(self, "repo", text="")
1690 layout.prop(self, "enable_on_install", text=rna_prop_enable_on_install_type_map[pkg_type])
1693 class BlPkgPkgInstall(Operator, _BlPkgCmdMixIn):
1694 bl_idname = "bl_pkg.pkg_install"
1695 bl_label = "Install Extension"
1696 __slots__ = _BlPkgCmdMixIn.cls_slots
1698 _drop_variables = None
1700 repo_directory: rna_prop_directory
1701 repo_index: rna_prop_repo_index
1703 pkg_id: rna_prop_pkg_id
1705 enable_on_install: rna_prop_enable_on_install
1707 # Only used for code-path for dropping an extension.
1708 url: rna_prop_url
1710 def exec_command_iter(self, is_modal):
1711 self._addon_restore = []
1712 self._theme_restore = _preferences_theme_state_create()
1714 directory = _repo_dir_and_index_get(self.repo_index, self.repo_directory, self.report)
1715 if not directory:
1716 return None
1717 self.repo_directory = directory
1719 if (repo_item := _extensions_repo_from_directory_and_report(directory, self.report)) is None:
1720 return None
1722 if not (pkg_id := self.pkg_id):
1723 self.report({'ERROR'}, "Package ID not set")
1724 return None
1726 # Detect upgrade.
1727 from . import repo_cache_store
1728 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1729 directory=self.repo_directory,
1730 error_fn=self.error_fn_from_exception,
1732 is_installed = pkg_manifest_local is not None and (pkg_id in pkg_manifest_local)
1733 del repo_cache_store, pkg_manifest_local
1735 if is_installed:
1736 pkg_id_sequence = (pkg_id,)
1737 result, errors = _preferences_ensure_disabled(
1738 repo_item=repo_item,
1739 pkg_id_sequence=pkg_id_sequence,
1740 default_set=False,
1742 self._addon_restore.append((repo_item, pkg_id_sequence, result))
1743 del pkg_id_sequence
1745 # Lock repositories.
1746 self.repo_lock = RepoLock(repo_directories=[repo_item.directory], cookie=cookie_from_session())
1747 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1748 return None
1750 return bl_extension_utils.CommandBatch(
1751 title="Install Package",
1752 batch=[
1753 partial(
1754 bl_extension_utils.pkg_install,
1755 directory=directory,
1756 repo_url=repo_item.repo_url,
1757 pkg_id_sequence=(pkg_id,),
1758 online_user_agent=online_user_agent_from_blender(),
1759 use_cache=repo_item.use_cache,
1760 use_idle=is_modal,
1765 def exec_command_finish(self):
1767 # Unlock repositories.
1768 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1769 del self.repo_lock
1771 # Refresh installed packages for repositories that were operated on.
1772 from . import repo_cache_store
1773 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
1774 directory=self.repo_directory,
1775 error_fn=self.error_fn_from_exception,
1778 _extensions_repo_sync_wheels(repo_cache_store)
1780 # TODO: it would be nice to include this message in the banner.
1781 def handle_error(ex):
1782 self.report({'ERROR'}, str(ex))
1784 _preferences_ensure_enabled_all(
1785 addon_restore=self._addon_restore,
1786 handle_error=handle_error,
1788 _preferences_theme_state_restore(self._theme_restore)
1790 if self._addon_restore:
1791 pkg_id_sequence_upgrade = self._addon_restore[0][1]
1792 else:
1793 pkg_id_sequence_upgrade = []
1795 if self.enable_on_install:
1796 _preferences_install_post_enable_on_install(
1797 directory=self.repo_directory,
1798 pkg_manifest_local=pkg_manifest_local,
1799 pkg_id_sequence=(self.pkg_id,),
1800 pkg_id_sequence_upgrade=pkg_id_sequence_upgrade,
1801 handle_error=handle_error,
1804 _preferences_ui_redraw()
1805 _preferences_ui_refresh_addons()
1807 def invoke(self, context, event):
1808 # Only for drop logic!
1809 if self.properties.is_property_set("url"):
1810 return self._invoke_for_drop(context, event)
1812 return self.execute(context)
1814 def _invoke_for_drop(self, context, event):
1815 url = self.url
1816 print("DROP URL:", url)
1818 _preferences_ensure_sync()
1820 repo_index, repo_name, pkg_id, item_remote, item_local = extension_url_find_repo_index_and_pkg_id(url)
1822 if repo_index == -1:
1823 self.report({'ERROR'}, "Extension: URL not found in remote repositories!\n{:s}".format(url))
1824 return {'CANCELLED'}
1826 if item_local is not None:
1827 self.report({'ERROR'}, "Extension: \"{:s}\" Already installed!".format(pkg_id))
1828 return {'CANCELLED'}
1830 self._drop_variables = repo_index, repo_name, pkg_id, item_remote
1832 self.repo_index = repo_index
1833 self.pkg_id = pkg_id
1835 wm = context.window_manager
1836 wm.invoke_props_dialog(self)
1837 return {'RUNNING_MODAL'}
1839 def draw(self, context):
1840 if self._drop_variables is not None:
1841 return self._draw_for_drop(context)
1843 def _draw_for_drop(self, context):
1844 from .bl_extension_ui import (
1845 size_as_fmt_string,
1847 layout = self.layout
1849 repo_index, repo_name, pkg_id, item_remote = self._drop_variables
1851 layout.label(text="Do you want to install the following {:s}?".format(item_remote["type"]))
1853 col = layout.column(align=True)
1854 col.label(text="Name: {:s}".format(item_remote["name"]))
1855 col.label(text="Repository: {:s}".format(repo_name))
1856 col.label(text="Size: {:s}".format(size_as_fmt_string(item_remote["archive_size"], precision=0)))
1857 del col
1859 layout.separator()
1861 layout.prop(self, "enable_on_install", text=rna_prop_enable_on_install_type_map[item_remote["type"]])
1864 class BlPkgPkgUninstall(Operator, _BlPkgCmdMixIn):
1865 bl_idname = "bl_pkg.pkg_uninstall"
1866 bl_label = "Ext Package Uninstall"
1867 __slots__ = _BlPkgCmdMixIn.cls_slots
1869 repo_directory: rna_prop_directory
1870 repo_index: rna_prop_repo_index
1872 pkg_id: rna_prop_pkg_id
1874 def exec_command_iter(self, is_modal):
1876 self._theme_restore = _preferences_theme_state_create()
1878 directory = _repo_dir_and_index_get(self.repo_index, self.repo_directory, self.report)
1879 if not directory:
1880 return None
1881 self.repo_directory = directory
1883 if (repo_item := _extensions_repo_from_directory_and_report(directory, self.report)) is None:
1884 return None
1886 if not (pkg_id := self.pkg_id):
1887 self.report({'ERROR'}, "Package ID not set")
1888 return None
1890 _, errors = _preferences_ensure_disabled(
1891 repo_item=repo_item,
1892 pkg_id_sequence=(pkg_id,),
1893 default_set=True,
1896 # Lock repositories.
1897 self.repo_lock = RepoLock(repo_directories=[repo_item.directory], cookie=cookie_from_session())
1898 if lock_result_any_failed_with_report(self, self.repo_lock.acquire()):
1899 return None
1901 return bl_extension_utils.CommandBatch(
1902 title="Uninstall Package",
1903 batch=[
1904 partial(
1905 bl_extension_utils.pkg_uninstall,
1906 directory=directory,
1907 pkg_id_sequence=(pkg_id, ),
1908 use_idle=is_modal,
1913 def exec_command_finish(self):
1915 # Refresh installed packages for repositories that were operated on.
1916 from . import repo_cache_store
1918 repo_item = _extensions_repo_from_directory(self.repo_directory)
1919 if repo_item.repo_url == "":
1920 # Re-generate JSON meta-data from TOML files (needed for offline repository).
1921 # NOTE: This could be slow with many local extensions,
1922 # we could simply remove the package that was uninstalled.
1923 repo_cache_store.refresh_remote_from_directory(
1924 directory=self.repo_directory,
1925 error_fn=self.error_fn_from_exception,
1926 force=True,
1928 del repo_item
1930 # Unlock repositories.
1931 lock_result_any_failed_with_report(self, self.repo_lock.release(), report_type='WARNING')
1932 del self.repo_lock
1934 repo_cache_store.refresh_local_from_directory(
1935 directory=self.repo_directory,
1936 error_fn=self.error_fn_from_exception,
1939 _extensions_repo_sync_wheels(repo_cache_store)
1941 _preferences_theme_state_restore(self._theme_restore)
1943 _preferences_ui_redraw()
1944 _preferences_ui_refresh_addons()
1947 class BlPkgPkgDisable_TODO(Operator):
1948 """Turn off this extension"""
1949 bl_idname = "bl_pkg.extension_disable"
1950 bl_label = "Disable extension"
1952 def execute(self, _context):
1953 self.report({'WARNING'}, "Disabling themes is not yet supported")
1954 return {'CANCELLED'}
1957 class BlPkgPkgThemeEnable(Operator):
1958 """Turn off this theme"""
1959 bl_idname = "bl_pkg.extension_theme_enable"
1960 bl_label = "Enable theme extension"
1962 pkg_id: rna_prop_pkg_id
1963 repo_index: rna_prop_repo_index
1965 def execute(self, context):
1966 self.repo_index
1967 repo_item = extension_repos_read_index(self.repo_index)
1968 extension_theme_enable(repo_item.directory, self.pkg_id)
1969 print(repo_item.directory, self.pkg_id)
1970 return {'FINISHED'}
1973 class BlPkgPkgThemeDisable(Operator):
1974 """Turn off this theme"""
1975 bl_idname = "bl_pkg.extension_theme_disable"
1976 bl_label = "Disable theme extension"
1978 pkg_id: rna_prop_pkg_id
1979 repo_index: rna_prop_repo_index
1981 def execute(self, context):
1982 import os
1983 repo_item = extension_repos_read_index(self.repo_index)
1984 dirpath = os.path.join(repo_item.directory, self.pkg_id)
1985 if os.path.samefile(dirpath, os.path.dirname(context.preferences.themes[0].filepath)):
1986 bpy.ops.preferences.reset_default_theme()
1987 return {'FINISHED'}
1990 # -----------------------------------------------------------------------------
1991 # Non Wrapped Actions
1993 # These actions don't wrap command line access.
1995 # NOTE: create/destroy might not be best names.
1998 class BlPkgDisplayErrorsClear(Operator):
1999 bl_idname = "bl_pkg.pkg_display_errors_clear"
2000 bl_label = "Clear Status"
2002 def execute(self, _context):
2003 from .bl_extension_ui import display_errors
2004 display_errors.clear()
2005 _preferences_ui_redraw()
2006 return {'FINISHED'}
2009 class BlPkgStatusClear(Operator):
2010 bl_idname = "bl_pkg.pkg_status_clear"
2011 bl_label = "Clear Status"
2013 def execute(self, _context):
2014 repo_status_text.running = False
2015 repo_status_text.log.clear()
2016 _preferences_ui_redraw()
2017 return {'FINISHED'}
2020 class BlPkgPkgMarkSet(Operator):
2021 bl_idname = "bl_pkg.pkg_mark_set"
2022 bl_label = "Mark Package"
2024 pkg_id: rna_prop_pkg_id
2025 repo_index: rna_prop_repo_index
2027 def execute(self, _context):
2028 key = (self.pkg_id, self.repo_index)
2029 blender_extension_mark.add(key)
2030 _preferences_ui_redraw()
2031 return {'FINISHED'}
2034 class BlPkgPkgMarkClear(Operator):
2035 bl_idname = "bl_pkg.pkg_mark_clear"
2036 bl_label = "Mark Package"
2038 pkg_id: rna_prop_pkg_id
2039 repo_index: rna_prop_repo_index
2041 def execute(self, _context):
2042 key = (self.pkg_id, self.repo_index)
2043 blender_extension_mark.discard(key)
2044 _preferences_ui_redraw()
2045 return {'FINISHED'}
2048 class BlPkgPkgShowSet(Operator):
2049 bl_idname = "bl_pkg.pkg_show_set"
2050 bl_label = "Show Package Set"
2052 pkg_id: rna_prop_pkg_id
2053 repo_index: rna_prop_repo_index
2055 def execute(self, _context):
2056 key = (self.pkg_id, self.repo_index)
2057 blender_extension_show.add(key)
2058 _preferences_ui_redraw()
2059 return {'FINISHED'}
2062 class BlPkgPkgShowClear(Operator):
2063 bl_idname = "bl_pkg.pkg_show_clear"
2064 bl_label = "Show Package Clear"
2066 pkg_id: rna_prop_pkg_id
2067 repo_index: rna_prop_repo_index
2069 def execute(self, _context):
2070 key = (self.pkg_id, self.repo_index)
2071 blender_extension_show.discard(key)
2072 _preferences_ui_redraw()
2073 return {'FINISHED'}
2076 class BlPkgPkgShowSettings(Operator):
2077 bl_idname = "bl_pkg.pkg_show_settings"
2078 bl_label = "Show Settings"
2080 pkg_id: rna_prop_pkg_id
2081 repo_index: rna_prop_repo_index
2083 def execute(self, _context):
2084 repo_item = extension_repos_read_index(self.repo_index)
2085 bpy.ops.preferences.addon_show(module="bl_ext.{:s}.{:s}".format(repo_item.module, self.pkg_id))
2086 return {'FINISHED'}
2089 # -----------------------------------------------------------------------------
2090 # Testing Operators
2094 class BlPkgObsoleteMarked(Operator):
2095 """Zeroes package versions, useful for development - to test upgrading"""
2096 bl_idname = "bl_pkg.obsolete_marked"
2097 bl_label = "Obsolete Marked"
2099 def execute(self, _context):
2100 from . import (
2101 repo_cache_store,
2104 repos_all = extension_repos_read()
2105 pkg_manifest_local_all = list(repo_cache_store.pkg_manifest_from_local_ensure(error_fn=print))
2106 repo_pkg_map = _pkg_marked_by_repo(pkg_manifest_local_all)
2107 found = False
2109 repos_lock = [repos_all[repo_index].directory for repo_index in sorted(repo_pkg_map.keys())]
2111 with RepoLockContext(repo_directories=repos_lock, cookie=cookie_from_session()) as lock_result:
2112 if lock_result_any_failed_with_report(self, lock_result):
2113 return {'CANCELLED'}
2115 directories_update = set()
2117 for repo_index, pkg_id_sequence in sorted(repo_pkg_map.items()):
2118 repo_item = repos_all[repo_index]
2119 pkg_manifest_local = repo_cache_store.refresh_local_from_directory(
2120 repo_item.directory,
2121 error_fn=print,
2123 found_for_repo = False
2124 for pkg_id in pkg_id_sequence:
2125 is_installed = pkg_id in pkg_manifest_local
2126 if not is_installed:
2127 continue
2129 bl_extension_utils.pkg_make_obsolete_for_testing(repo_item.directory, pkg_id)
2130 found = True
2131 found_for_repo = True
2133 if found_for_repo:
2134 directories_update.add(repo_item.directory)
2136 if not found:
2137 self.report({'ERROR'}, "No installed packages marked")
2138 return {'CANCELLED'}
2140 for directory in directories_update:
2141 repo_cache_store.refresh_remote_from_directory(
2142 directory=directory,
2143 error_fn=print,
2144 force=True,
2146 repo_cache_store.refresh_local_from_directory(
2147 directory=directory,
2148 error_fn=print,
2150 _preferences_ui_redraw()
2152 return {'FINISHED'}
2155 class BlPkgRepoLock(Operator):
2156 """Lock repositories - to test locking"""
2157 bl_idname = "bl_pkg.repo_lock"
2158 bl_label = "Lock Repository (Testing)"
2160 lock = None
2162 def execute(self, _context):
2163 repos_all = extension_repos_read()
2164 repos_lock = [repo_item.directory for repo_item in repos_all]
2166 lock_handle = RepoLock(repo_directories=repos_lock, cookie=cookie_from_session())
2167 lock_result = lock_handle.acquire()
2168 if lock_result_any_failed_with_report(self, lock_result):
2169 # At least one lock failed, unlock all and return.
2170 lock_handle.release()
2171 return {'CANCELLED'}
2173 self.report({'INFO'}, "Locked {:d} repos(s)".format(len(lock_result)))
2174 BlPkgRepoLock.lock = lock_handle
2175 return {'FINISHED'}
2178 class BlPkgRepoUnlock(Operator):
2179 """Unlock repositories - to test unlocking"""
2180 bl_idname = "bl_pkg.repo_unlock"
2181 bl_label = "Unlock Repository (Testing)"
2183 def execute(self, _context):
2184 lock_handle = BlPkgRepoLock.lock
2185 if lock_handle is None:
2186 self.report({'ERROR'}, "Lock not held!")
2187 return {'CANCELLED'}
2189 lock_result = lock_handle.release()
2191 BlPkgRepoLock.lock = None
2193 if lock_result_any_failed_with_report(self, lock_result):
2194 # This isn't canceled, but there were issues unlocking.
2195 return {'FINISHED'}
2197 self.report({'INFO'}, "Unlocked {:d} repos(s)".format(len(lock_result)))
2198 return {'FINISHED'}
2201 # NOTE: this is a modified version of `PREFERENCES_OT_addon_show`.
2202 # It would make most sense to extend this operator to support showing extensions to upgrade (eventually).
2203 class BlPkgShowUpgrade(Operator):
2204 """Show add-on preferences"""
2205 bl_idname = "bl_pkg.extensions_show_for_update"
2206 bl_label = ""
2207 bl_options = {'INTERNAL'}
2209 def execute(self, context):
2211 # TODO: support filtering only extensions to upgrade.
2212 context.preferences.active_section = 'ADDONS'
2213 context.preferences.view.show_addons_enabled_only = False
2214 bpy.ops.screen.userpref_show('INVOKE_DEFAULT')
2216 return {'FINISHED'}
2219 class BlPkgEnableNotInstalled(Operator):
2220 """Turn on this extension"""
2221 bl_idname = "bl_pkg.extensions_enable_not_installed"
2222 bl_label = "Enable Extension"
2224 @classmethod
2225 def poll(cls, context):
2226 cls.poll_message_set("Extension needs to be installed before it can be enabled")
2227 return False
2229 def execute(self, context):
2230 # This operator only exists to be able to show disabled check-boxes for extensions
2231 # while giving users a reasonable explanation on why is that.
2232 return {'CANCELLED'}
2235 # -----------------------------------------------------------------------------
2236 # Register
2238 classes = (
2239 BlPkgRepoSync,
2240 BlPkgRepoSyncAll,
2242 BlPkgPkgInstallFiles,
2243 BlPkgPkgInstall,
2244 BlPkgPkgUninstall,
2245 BlPkgPkgDisable_TODO,
2247 BlPkgPkgThemeEnable,
2248 BlPkgPkgThemeDisable,
2250 BlPkgPkgUpgradeAll,
2251 BlPkgPkgInstallMarked,
2252 BlPkgPkgUninstallMarked,
2254 # UI only operator (to select a package).
2255 BlPkgDisplayErrorsClear,
2256 BlPkgStatusClear,
2257 BlPkgPkgShowSet,
2258 BlPkgPkgShowClear,
2259 BlPkgPkgMarkSet,
2260 BlPkgPkgMarkClear,
2261 BlPkgPkgShowSettings,
2263 BlPkgObsoleteMarked,
2264 BlPkgRepoLock,
2265 BlPkgRepoUnlock,
2267 BlPkgShowUpgrade,
2269 # Dummy, just shows a message.
2270 BlPkgEnableNotInstalled,
2272 # Dummy commands (for testing).
2273 BlPkgDummyProgress,
2277 def register():
2278 for cls in classes:
2279 bpy.utils.register_class(cls)
2282 def unregister():
2283 for cls in classes:
2284 bpy.utils.unregister_class(cls)
2287 if __name__ == "__main__":
2288 register()