1 # SPDX-FileCopyrightText: 2023 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
6 Blender, thin wrapper around ``blender_extension_utils``.
7 Where the operator shows progress, any errors and supports canceling operations.
11 "extension_repos_read",
16 from functools
import partial
24 from bpy
.types
import (
27 from bpy
.props
import (
34 from bpy
.app
.translations
import (
35 pgettext_iface
as iface_
,
48 from .bl_extension_utils
import (
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",
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
):
77 repo_item
.name
if repo_item
.enabled
else (repo_item
.name
+ " (disabled)"),
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
87 is_background
= bpy
.app
.background
89 # Execute tasks concurrently.
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
= {
106 # -----------------------------------------------------------------------------
107 # Signal Context Manager (Catch Control-C)
111 class CheckSIGINT_Context
:
117 def _signal_handler_sigint(self
, _
, __
):
118 self
.has_interrupt
= True
122 self
.has_interrupt
= False
127 self
._old
_fn
= signal
.signal(signal
.SIGINT
, self
._signal
_handler
_sigint
)
130 def __exit__(self
, _ty
, _value
, _traceback
):
132 signal
.signal(signal
.SIGINT
, self
._old
_fn
or signal
.SIG_DFL
)
135 # -----------------------------------------------------------------------------
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()
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:
166 repo
= repos_all
[repo_index
]
167 repo_url
= repo
.repo_url
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
:
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.
189 return "Blender/{:d}.{:d}.{:d} ({:s} {:s}; cycle={:s})".format(
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).
204 for directory
, lock_result_for_repo
in lock_result
.items():
205 if lock_result_for_repo
is None:
207 print("Error \"{:s}\" locking \"{:s}\"".format(lock_result_for_repo
, repr(directory
)))
208 op
.report({report_type}
, lock_result_for_repo
)
213 def pkg_info_check_exclude_filter_ex(name
, tagline
, search_lower
):
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(
227 menu_idname
="USERPREF_MT_interface_theme_presets",
231 def extension_theme_enable(repo_directory
, pkg_idname
):
232 from .bl_extension_utils
import (
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.
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
:
252 # Ignore repositories that have invalid settings.
253 directory
, remote_path
= repo_paths_or_none(repo_item
)
254 if directory
is None:
261 class RepoItem(NamedTuple
):
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
274 for repo_item
in extension_repos
:
275 if not include_disabled
:
276 if not repo_item
.enabled
:
278 directory
, remote_path
= repo_paths_or_none(repo_item
)
279 if directory
is None:
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
):
293 def handle_error(ex
):
295 errors
.append(str(ex
))
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
):
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
)
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
):
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
)
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
):
359 delattr(repo_module
, pkg_id
)
361 return result
, errors
364 def _preferences_ensure_enabled(*, repo_item
, pkg_id_sequence
, result
, handle_error
):
366 for addon_module_name
, (loaded_default
, loaded_state
) in result
.items():
367 # The module was not loaded, so no need to restore it.
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(
378 pkg_id_sequence
=pkg_id_sequence
,
380 handle_error
=handle_error
,
384 def _preferences_install_post_enable_on_install(
389 # There were already installed and an attempt to enable it will have already been made.
390 pkg_id_sequence_upgrade
,
395 # It only ever makes sense to enable one theme.
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
)
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
:
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":
416 extension_theme_enable(directory
, pkg_id
)
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':
428 def _preferences_ui_refresh_addons():
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
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:
449 if pkg_manifest_local
is None:
454 for wm
in bpy
.data
.window_managers
:
455 for win
in wm
.windows
:
456 win
.cursor_set('WAIT')
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
471 for repo_item
in extension_repos
:
472 if not include_disabled
:
473 if not repo_item
.enabled
:
475 directory
, remote_path
= repo_paths_or_none(repo_item
)
476 if directory
is None:
479 if index
== index_test
:
483 repo_url
=remote_path
,
484 module
=repo_item
.module
,
485 use_cache
=repo_item
.use_cache
,
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
499 extension_active
= extension_repos
[paths
.active_extension_repo
]
503 extension_repos
= [extension_active
]
506 for repo_item
in extension_repos
:
507 if not include_disabled
:
508 if not repo_item
.enabled
:
511 # Ignore repositories that have invalid settings.
512 directory
, remote_path
= repo_paths_or_none(repo_item
)
513 if directory
is None:
516 result
.append(RepoItem(
519 repo_url
=remote_path
,
520 module
=repo_item
.module
,
521 use_cache
=repo_item
.use_cache
,
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
:
532 if os
.path
.exists(directory
):
533 for i
, repo_item
in enumerate(repos_all
):
534 if os
.path
.normpath(repo_item
.directory
) == directory
:
539 def _extensions_repo_from_directory(directory
):
540 repos_all
= extension_repos_read()
541 repo_index
= _extension_repos_index_from_directory(directory
)
544 return repos_all
[repo_index
]
547 def _extensions_repo_from_directory_and_report(directory
, report_fn
):
549 report_fn({'ERROR', "Directory not set"})
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
))
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
]
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
):
571 pkg_manifest
= pkg_manifest_all
[repo_index
]
572 item
= pkg_manifest
.get(pkg_id
)
575 if filter_by_type
and (filter_by_type
!= item
["type"]):
577 if search_lower
and not pkg_info_check_exclude_filter(item
, search_lower
):
580 pkg_list
= repo_pkg_map
.get(repo_index
)
582 pkg_list
= repo_pkg_map
[repo_index
] = []
583 pkg_list
.append(pkg_id
)
587 # -----------------------------------------------------------------------------
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`).
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
= []
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!")
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
))
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
}:
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])
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])
641 # Useful to know, can quiet print in the future.
643 "Skipping wheel for other system",
644 "({:s} != {:s}):".format(platform_tag
, platform_tag_current
),
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()
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:
672 if not isinstance(wheels_rel
, list):
675 # Filter only the wheels for this platform.
676 wheels_rel
= _extensions_wheel_filter_for_platform(wheels_rel
)
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
):
685 wheels_abs
.append(filepath_abs
)
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")
698 wheel_list
=wheel_list
,
702 # -----------------------------------------------------------------------------
706 def _preferences_theme_state_create():
707 from .bl_extension_utils
import (
709 scandir_with_demoted_errors
,
711 filepath
= bpy
.context
.preferences
.themes
[0].filepath
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
):
726 for entry
in scandir_with_demoted_errors(dirpath
):
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
736 def _preferences_theme_state_restore(state
):
737 state_update
= _preferences_theme_state_create()
739 if state
== state_update
:
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()
749 if state_update
[0] is not None:
750 extension_theme_enable_filepath(state_update
[1])
753 # -----------------------------------------------------------------------------
754 # Internal Implementation
760 if not op
.options
.is_invoke
:
774 self
.modal_timer
= None
775 self
.cmd_batch
= None
777 self
.request_exit
= None
780 def op_exec_from_iter(op
, context
, cmd_batch
, is_modal
):
782 with
CheckSIGINT_Context() as sigint_ctx
:
783 has_request_exit
= cmd_batch
.exec_blocking(
785 request_exit_fn
=lambda: sigint_ctx
.has_interrupt
,
786 concurrent
=is_concurrent
,
789 op
.report({'WARNING'}, "Command interrupted")
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
))
818 op
.report({'INFO'}, msg
)
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(
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
845 return {'RUNNING_MODAL'}
847 def op_modal_impl(self
, op
, context
, event
):
849 if event
.type == 'TIMER':
851 elif event
.type == 'ESC':
852 if not self
.request_exit
:
853 print("Request exit!")
854 self
.request_exit
= True
858 return self
.op_modal_step(op
, context
)
859 return {'RUNNING_MODAL'}
862 def _report(ty
, msg
):
872 def _repo_dir_and_index_get(repo_index
, directory
, report_fn
):
874 repo_item
= extension_repos_read_index(repo_index
)
875 directory
= repo_item
.directory
if (repo_item
is not None) else ""
877 report_fn({'ERROR'}, "Repository not set")
881 # -----------------------------------------------------------------------------
882 # Public Repository Actions
885 class _BlPkgCmdMixIn
:
887 Utility to execute mix-in.
889 Sub-class must define.
893 - exec_command_finish
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:
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()
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()
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",
949 bl_extension_utils
.dummy_progress
,
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
)
972 if (repo_item
:= _extensions_repo_from_directory_and_report(directory
, self
.report
)) is None:
975 if not os
.path
.exists(directory
):
977 os
.makedirs(directory
)
978 except BaseException
as ex
:
979 self
.report({'ERROR'}, str(ex
))
983 self
.repo_directory
= directory
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()):
991 if repo_item
.repo_url
:
994 bl_extension_utils
.repo_sync
,
996 repo_url
=repo_item
.repo_url
,
997 online_user_agent
=online_user_agent_from_blender(),
1002 return bl_extension_utils
.CommandBatch(
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
,
1017 # Unlock repositories.
1018 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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(
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
)
1039 self
.report({'INFO'}, "No repositories to sync")
1042 for repo_item
in repos_all
:
1043 if not os
.path
.exists(repo_item
.directory
):
1045 os
.makedirs(repo_item
.directory
)
1046 except BaseException
as ex
:
1047 self
.report({'WARNING'}, str(ex
))
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(),
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()):
1069 return bl_extension_utils
.CommandBatch(
1070 title
="Sync \"{:s}\"".format(repos_all
[0].name
) if use_active_only
else "Sync All",
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
,
1086 # Unlock repositories.
1087 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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(
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
1116 self
.report({'INFO'}, "No repositories to upgrade")
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
:
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
))]
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:
1145 pkg_manifest_local
= pkg_manifest_local_all
[repo_index
]
1146 if pkg_manifest_local
is None:
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:
1155 if item_remote
["version"] != item_local
["version"]:
1156 packages_to_upgrade
[repo_index
].append(pkg_id
)
1159 if packages_to_upgrade
[repo_index
]:
1160 handle_addons_info
.append((repos_all
[repo_index
], list(packages_to_upgrade
[repo_index
])))
1163 for repo_index
, pkg_id_sequence
in enumerate(packages_to_upgrade
):
1164 if not pkg_id_sequence
:
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
,
1176 self
._repo
_directories
.add(repo_item
.directory
)
1179 self
.report({'INFO'}, "No installed packages to update")
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()):
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
,
1193 self
._addon
_restore
.append((repo_item
, pkg_id_sequence
, result
))
1195 return bl_extension_utils
.CommandBatch(
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
)
1203 def exec_command_finish(self
):
1205 # Unlock repositories.
1206 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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
= []
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:
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
:
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
,
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
))
1287 self
.report({'ERROR'}, "No un-installed packages marked")
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()):
1295 return bl_extension_utils
.CommandBatch(
1296 title
="Install {:d} Marked Package(s)".format(package_count
),
1300 def exec_command_finish(self
):
1302 # Unlock repositories.
1303 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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
)
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
= []
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:
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
:
1382 bl_extension_utils
.pkg_uninstall
,
1383 directory
=repo_item
.directory
,
1384 pkg_id_sequence
=pkg_id_sequence
,
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
))
1393 self
.report({'ERROR'}, "No installed packages marked")
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()):
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
,
1409 return bl_extension_utils
.CommandBatch(
1410 title
="Uninstall {:d} Marked Package(s)".format(package_count
),
1414 def exec_command_finish(self
):
1416 # Unlock repositories.
1417 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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
+ (
1444 _drop_variables
= None
1446 filter_glob
: StringProperty(default
="*.zip", options
={'HIDDEN'})
1448 directory
: StringProperty(
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',
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.
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")
1487 # Repository accessed.
1488 repo_module_name
= self
.repo
1490 (repo_item
for repo_item
in extension_repos_read() if repo_item
.module
== repo_module_name
),
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")
1506 source_files
= [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):
1520 pkg_id
= result
["id"]
1521 if pkg_id
in pkg_id_sequence
:
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
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
,
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()):
1555 return bl_extension_utils
.CommandBatch(
1556 title
="Install Package Files",
1559 bl_extension_utils
.pkg_install_files
,
1560 directory
=directory
,
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
,
1579 # Unlock repositories.
1580 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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]
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()
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")
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
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"]
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
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.
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
)
1717 self
.repo_directory
= directory
1719 if (repo_item
:= _extensions_repo_from_directory_and_report(directory
, self
.report
)) is None:
1722 if not (pkg_id
:= self
.pkg_id
):
1723 self
.report({'ERROR'}, "Package ID not set")
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
1736 pkg_id_sequence
= (pkg_id
,)
1737 result
, errors
= _preferences_ensure_disabled(
1738 repo_item
=repo_item
,
1739 pkg_id_sequence
=pkg_id_sequence
,
1742 self
._addon
_restore
.append((repo_item
, pkg_id_sequence
, result
))
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()):
1750 return bl_extension_utils
.CommandBatch(
1751 title
="Install Package",
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
,
1765 def exec_command_finish(self
):
1767 # Unlock repositories.
1768 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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]
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
):
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 (
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)))
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
)
1881 self
.repo_directory
= directory
1883 if (repo_item
:= _extensions_repo_from_directory_and_report(directory
, self
.report
)) is None:
1886 if not (pkg_id
:= self
.pkg_id
):
1887 self
.report({'ERROR'}, "Package ID not set")
1890 _
, errors
= _preferences_ensure_disabled(
1891 repo_item
=repo_item
,
1892 pkg_id_sequence
=(pkg_id
,),
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()):
1901 return bl_extension_utils
.CommandBatch(
1902 title
="Uninstall Package",
1905 bl_extension_utils
.pkg_uninstall
,
1906 directory
=directory
,
1907 pkg_id_sequence
=(pkg_id
, ),
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
,
1930 # Unlock repositories.
1931 lock_result_any_failed_with_report(self
, self
.repo_lock
.release(), report_type
='WARNING')
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
):
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
)
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
):
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()
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()
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()
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()
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()
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()
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()
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
))
2089 # -----------------------------------------------------------------------------
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
):
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
)
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
,
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
:
2129 bl_extension_utils
.pkg_make_obsolete_for_testing(repo_item
.directory
, pkg_id
)
2131 found_for_repo
= True
2134 directories_update
.add(repo_item
.directory
)
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
,
2146 repo_cache_store
.refresh_local_from_directory(
2147 directory
=directory
,
2150 _preferences_ui_redraw()
2155 class BlPkgRepoLock(Operator
):
2156 """Lock repositories - to test locking"""
2157 bl_idname
= "bl_pkg.repo_lock"
2158 bl_label
= "Lock Repository (Testing)"
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
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.
2197 self
.report({'INFO'}, "Unlocked {:d} repos(s)".format(len(lock_result
)))
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"
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')
2219 class BlPkgEnableNotInstalled(Operator
):
2220 """Turn on this extension"""
2221 bl_idname
= "bl_pkg.extensions_enable_not_installed"
2222 bl_label
= "Enable Extension"
2225 def poll(cls
, context
):
2226 cls
.poll_message_set("Extension needs to be installed before it can be enabled")
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 # -----------------------------------------------------------------------------
2242 BlPkgPkgInstallFiles
,
2245 BlPkgPkgDisable_TODO
,
2247 BlPkgPkgThemeEnable
,
2248 BlPkgPkgThemeDisable
,
2251 BlPkgPkgInstallMarked
,
2252 BlPkgPkgUninstallMarked
,
2254 # UI only operator (to select a package).
2255 BlPkgDisplayErrorsClear
,
2261 BlPkgPkgShowSettings
,
2263 BlPkgObsoleteMarked
,
2269 # Dummy, just shows a message.
2270 BlPkgEnableNotInstalled
,
2272 # Dummy commands (for testing).
2279 bpy
.utils
.register_class(cls
)
2284 bpy
.utils
.unregister_class(cls
)
2287 if __name__
== "__main__":