""" Blender Package manager """ bl_info = { 'name': 'Package Manager', 'author': 'Ellwood Zwovic (gandalf3), Sybren A. Stüvel, Mitchell Stokes (Moguri)', 'version': (0, 1, 0), 'blender': (2, 79, 0), 'location': 'User Preferences > Packages', 'description': 'A tool for installing, updating, and otherwise managing, addons and packages.', 'category': 'System', 'support': 'TESTING', } import logging log = logging.getLogger(__name__) # HACK: # due to lack of fork() on windows, multiprocessing will re-execute this module # in a new process. In such cases we only need subproc, everything else is only # used to spawn the subprocess in the first place. try: import bpy except ImportError: from . import subproc else: if 'bpkg' in locals(): from importlib import reload def recursive_reload(mod): """Reloads the given module and all its submodules""" log.debug("Reloading %s", mod) from types import ModuleType reloaded_mod = reload(mod) for attr in [getattr(mod, attr_name) for attr_name in dir(mod)]: if type(attr) is ModuleType and attr.__name__.startswith(mod.__name__): recursive_reload(attr) return reloaded_mod subproc = recursive_reload(subproc) messages = recursive_reload(messages) utils = recursive_reload(utils) bpkg = recursive_reload(bpkg) Package = bpkg.types.Package from . import subproc from . import messages from . import bpkg from . import utils from .bpkg.types import ( Package, ConsolidatedPackage, ) from pathlib import Path from collections import OrderedDict import multiprocessing mp_context = multiprocessing.get_context() mp_context.set_executable(bpy.app.binary_path_python) # global list of all known packages, indexed by name _packages = OrderedDict() # used for lazy loading _main_has_run = False class SubprocMixin: """Mix-in class for things that need to be run in a subprocess.""" log = logging.getLogger(__name__ + '.SubprocMixin') _state = 'INITIALIZING' _abort_timeout = 0 # time at which we stop waiting for an abort response and just terminate the process # Mapping from message type (see bpkg_manager.subproc) to handler function. # Should be constructed before modal() gets called. msg_handlers = {} def execute(self, context): return self.invoke(context, None) def quit(self): """Signals the state machine to stop this operator from running.""" self._state = 'QUIT' def invoke(self, context, event): self.pipe_blender, self.pipe_subproc = multiprocessing.Pipe() # The subprocess should just be terminated when Blender quits. Without this, # Blender would hang while closing, until the subprocess terminates itself. # TODO: Perhaps it would be better to fork when blender exits? self.process = self.create_subprocess() self.process.daemon = True self.process.start() self._state = 'RUNNING' wm = context.window_manager wm.modal_handler_add(self) self.timer = wm.event_timer_add(0.1, context.window) return {'RUNNING_MODAL'} def modal(self, context, event): import time if event.type == 'ESC': self.log.warning('Escape pressed, sending abort signal to subprocess') self.abort() return {'PASS_THROUGH'} if event.type != 'TIMER': return {'PASS_THROUGH'} if self._state == 'ABORTING' and time.time() > self._abort_timeout: self.log.error('No response from subprocess to abort request, terminating it.') self.report({'ERROR'}, 'No response from subprocess to abort request, terminating it.') self.process.terminate() self._finish(context) return {'CANCELLED'} while self.pipe_blender.poll(): self.handle_received_data() if self._state == 'QUIT': self._finish(context) return {'FINISHED'} if not self.process.is_alive(): self.report_process_died() self._finish(context) return {'CANCELLED'} return {'RUNNING_MODAL'} def abort(self): import time # Allow the subprocess 10 seconds to repsond to our abort message. self._abort_timeout = time.time() + 10 self._state = 'ABORTING' self.pipe_blender.send(messages.Abort()) def _finish(self, context): try: self.cancel(context) except AttributeError: pass global bpkg_operation_running context.window_manager.event_timer_remove(self.timer) bpkg_operation_running = False if self.process and self.process.is_alive(): self.log.debug('Waiting for subprocess to quit') try: self.process.join(timeout=10) except multiprocessing.TimeoutError: self.log.warning('Subprocess is hanging, terminating it forcefully.') self.process.terminate() else: self.log.debug('Subprocess stopped with exit code %i', self.process.exitcode) def handle_received_data(self): recvd = self.pipe_blender.recv() self.log.debug('Received message from subprocess: %s', recvd) try: handler = self.msg_handlers[type(recvd)] except KeyError: self.log.error('Unable to handle received message %s', recvd) # Maybe we shouldn't show this to the user? self.report({'WARNING'}, 'Unable to handle received message %s' % recvd) return handler(recvd) def create_subprocess(self): """Implement this in a subclass. :rtype: multiprocessing.Process """ raise NotImplementedError() def report_process_died(self): """Provides the user with sensible information when the process has died. Implement this in a subclass. """ raise NotImplementedError() class PACKAGE_OT_install(SubprocMixin, bpy.types.Operator): bl_idname = 'package.install' bl_label = 'Install package' bl_description = 'Downloads and installs a Blender add-on package' bl_options = {'REGISTER'} package_name = bpy.props.StringProperty( name='package_name', description='The name of the package to install' ) log = logging.getLogger(__name__ + '.PACKAGE_OT_install') def invoke(self, context, event): if not self.package_name: self.report({'ERROR'}, 'Package name not given') return {'CANCELLED'} return super().invoke(context, event) def create_subprocess(self): """Starts the download process. Also registers the message handlers. :rtype: multiprocessing.Process """ self.msg_handlers = { messages.Progress: self._subproc_progress, messages.DownloadError: self._subproc_download_error, messages.InstallError: self._subproc_install_error, messages.Success: self._subproc_success, messages.Aborted: self._subproc_aborted, } global _packages package = _packages[self.package_name].get_latest_version() import pathlib # TODO: We need other paths besides this one on subprocess end, so it might be better to pass them all at once. # For now, just pass this one. install_path = pathlib.Path(bpy.utils.user_resource('SCRIPTS', 'addons', create=True)) self.log.debug("Using %s as install path", install_path) import addon_utils proc = mp_context.Process(target=subproc.download_and_install_package, args=(self.pipe_subproc, package, install_path)) return proc def _subproc_progress(self, progress: messages.Progress): self.log.info('Task progress at %i%%', progress.progress * 100) def _subproc_download_error(self, error: messages.DownloadError): self.report({'ERROR'}, 'Unable to download package: %s' % error.description) self.quit() def _subproc_install_error(self, error: messages.InstallError): self.report({'ERROR'}, 'Unable to install package: %s' % error.message) self.quit() def _subproc_success(self, success: messages.Success): self.report({'INFO'}, 'Package installed successfully') global _packages _packages = build_packagelist() self.quit() def _subproc_aborted(self, aborted: messages.Aborted): self.report({'ERROR'}, 'Package installation aborted per your request') self.quit() def report_process_died(self): if self.process.exitcode: self.log.error('Process died without telling us! Exit code was %i', self.process.exitcode) self.report({'ERROR'}, 'Error downloading package, exit code %i' % self.process.exitcode) else: self.log.error('Process died without telling us! Exit code was 0 though') self.report({'WARNING'}, 'Error downloading package, but process finished OK. This is weird.') class PACKAGE_OT_uninstall(SubprocMixin, bpy.types.Operator): bl_idname = 'package.uninstall' bl_label = 'Install package' bl_description = "Remove installed package files from filesystem" bl_options = {'REGISTER'} package_name = bpy.props.StringProperty(name='package_name', description='The name of the package to uninstall') log = logging.getLogger(__name__ + '.PACKAGE_OT_uninstall') def invoke(self, context, event): if not self.package_name: self.report({'ERROR'}, 'Package name not given') return {'CANCELLED'} return super().invoke(context, event) def create_subprocess(self): """Starts the uninstall process and registers the message handlers. :rtype: multiprocessing.Process """ self.msg_handlers = { messages.UninstallError: self._subproc_uninstall_error, messages.Success: self._subproc_success, } import pathlib install_path = pathlib.Path(bpy.utils.user_resource('SCRIPTS', 'addons', create=True)) global _packages package = _packages[self.package_name].get_latest_version() proc = mp_context.Process(target=subproc.uninstall_package, args=(self.pipe_subproc, package, install_path)) return proc def _subproc_uninstall_error(self, error: messages.InstallError): self.report({'ERROR'}, error.message) self.quit() def _subproc_success(self, success: messages.Success): self.report({'INFO'}, 'Package uninstalled successfully') bpy.ops.package.refresh_packages() self.quit() def report_process_died(self): if self.process.exitcode: self.log.error('Process died without telling us! Exit code was %i', self.process.exitcode) self.report({'ERROR'}, 'Error downloading package, exit code %i' % self.process.exitcode) else: self.log.error('Process died without telling us! Exit code was 0 though') self.report({'WARNING'}, 'Error downloading package, but process finished OK. This is weird.') def get_installed_packages(refresh=False) -> list: """Get list of packages installed on disk""" import addon_utils installed_pkgs = [] for mod in addon_utils.modules(refresh=refresh): pkg = Package.from_module(mod) pkg.installed = True installed_pkgs.append(pkg) return installed_pkgs def get_repo_storage_path() -> Path: return Path(bpy.utils.user_resource('CONFIG', 'repositories')) def get_repositories() -> list: """ Get list of downloaded repositories and update wm.package_repositories """ log = logging.getLogger(__name__ + ".get_repositories") storage_path = get_repo_storage_path() repos = bpkg.load_repositories(storage_path) log.debug("repos: %s", repos) return repos # class PACKAGE_OT_refresh_packages(bpy.types.Operator): # bl_idname = "package.refresh_packages" # bl_label = "Refresh Packages" # bl_description = "Scan for packages on disk" # # log = logging.getLogger(__name__ + ".PACKAGE_OT_refresh_packages") # # def execute(self, context): # global _packages # installed_packages = get_packages_from_disk(refresh=True) # available_packages = get_packages_from_repo() # _packages = build_composite_packagelist(installed_packages, available_packages) # context.area.tag_redraw() # # return {'FINISHED'} class PACKAGE_OT_refresh(SubprocMixin, bpy.types.Operator): bl_idname = "package.refresh" bl_label = "Refresh" bl_description = 'Check repositories for new and updated packages' bl_options = {'REGISTER'} log = logging.getLogger(__name__ + ".PACKAGE_OT_refresh") _running = False def invoke(self, context, event): wm = context.window_manager self.repositories = wm.package_repositories if len(self.repositories) == 0: self.report({'ERROR'}, "No repositories to refresh") return {'CANCELLED'} PACKAGE_OT_refresh._running = True return super().invoke(context, event) @classmethod def poll(cls, context): return not cls._running def cancel(self, context): PACKAGE_OT_refresh._running = False context.area.tag_redraw() def create_subprocess(self): """Starts the download process. Also registers the message handlers. :rtype: multiprocessing.Process """ #TODO: make sure all possible messages are handled self.msg_handlers = { messages.Progress: self._subproc_progress, messages.SubprocError: self._subproc_error, messages.DownloadError: self._subproc_download_error, messages.Success: self._subproc_success, # messages.RepositoryResult: self._subproc_repository_result, messages.BadRepositoryError: self._subproc_repository_error, messages.Aborted: self._subproc_aborted, } import pathlib storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'repositories', create=True)) repository_urls = [repo.url for repo in self.repositories] self.log.debug("Repository urls %s", repository_urls) proc = mp_context.Process(target=subproc.refresh_repositories, args=(self.pipe_subproc, storage_path, repository_urls)) return proc def _subproc_progress(self, progress: messages.Progress): self.log.info('Task progress at %i%%', progress.progress * 100) def _subproc_error(self, error: messages.SubprocError): self.report({'ERROR'}, 'Unable to refresh package list: %s' % error.message) self.quit() def _subproc_download_error(self, error: messages.DownloadError): self.report({'ERROR'}, 'Unable to download package list: %s' % error.message) self.quit() def _subproc_repository_error(self, error: messages.BadRepositoryError): self.report({'ERROR'}, str(error.message)) self.quit() def _subproc_success(self, success: messages.Success): self.report({'INFO'}, 'Finished refreshing lists') self.quit() # def _subproc_repository_result(self, result: messages.RepositoryResult): # available_packages = result.repository.packages # installed_packages = get_packages_from_disk(refresh=False) # # # TODO: deduplicate creation of view-packages.. # for pkg in available_packages: # pkg.repository = result.repository.name # # global _packages # _packages = build_composite_packagelist(installed_packages, available_packages) def _subproc_aborted(self, aborted: messages.Aborted): self.report({'ERROR'}, 'Package list retrieval aborted per your request') self.quit() def report_process_died(self): if self.process.exitcode: self.log.error('Refresh process died without telling us! Exit code was %i', self.process.exitcode) self.report({'ERROR'}, 'Error refreshing package lists, exit code %i' % self.process.exitcode) else: self.log.error('Refresh process died without telling us! Exit code was 0 though') self.report({'WARNING'}, 'Error refreshing package lists, but process finished OK. This is weird.') class RepositoryProperty(bpy.types.PropertyGroup): name = bpy.props.StringProperty(name="Name") url = bpy.props.StringProperty(name="URL") status = bpy.props.EnumProperty(name="Status", items=[ ("OK", "Okay", "FILE_TICK"), ("NOTFOUND", "Not found", "ERROR"), ("NOCONNECT", "Could not connect", "QUESTION"), ]) enabled = bpy.props.BoolProperty(name="Enabled") class PACKAGE_UL_repositories(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): layout.alignment='LEFT' layout.prop(item, "enabled", text="") if len(item.name) == 0: layout.label(item['url']) else: layout.label(item.name) class PACKAGE_OT_add_repository(bpy.types.Operator): bl_idname = "package.add_repository" bl_label = "Add Repository" url = bpy.props.StringProperty(name="Repository URL") def invoke(self, context, event): wm = context.window_manager return wm.invoke_props_dialog(self) def execute(self, context): wm = context.window_manager if len(self.url) == 0: self.report({'ERROR'}, "Repository URL not specified") return {'CANCELLED'} repo = wm.package_repositories.add() repo.url = utils.sanitize_repository_url(self.url) bpy.ops.package.refresh() context.area.tag_redraw() return {'FINISHED'} class PACKAGE_OT_remove_repository(bpy.types.Operator): bl_idname = "package.remove_repository" bl_label = "Remove Repository" def execute(self, context): wm = context.window_manager try: repo = wm['package_repositories'][wm.package_active_repository] except IndexError: return {'CANCELLED'} filename = bpkg.utils.format_filename(repo.name) + ".json" path = (get_repo_storage_path() / filename) if path.exists(): path.unlink() wm.package_repositories.remove(wm.package_active_repository) return {'FINISHED'} class USERPREF_PT_packages(bpy.types.Panel): bl_label = "Package Management" bl_space_type = 'USER_PREFERENCES' bl_region_type = 'WINDOW' bl_options = {'HIDE_HEADER'} log = logging.getLogger(__name__ + '.USERPREF_PT_packages') displayed_packages = [] expanded_packages = [] preference_package = None redraw = True @classmethod def poll(cls, context): userpref = context.user_preferences return (userpref.active_section == 'PACKAGES') def draw(self, context): layout = self.layout wm = context.window_manager mainrow = layout.row() spl = mainrow.split(.2) sidebar = spl.column(align=True) pkgzone = spl.column() sidebar.label("Repositories") row = sidebar.row() row.template_list("PACKAGE_UL_repositories", "", wm, "package_repositories", wm, "package_active_repository") col = row.column(align=True) col.operator(PACKAGE_OT_add_repository.bl_idname, text="", icon='ZOOMIN') col.operator(PACKAGE_OT_remove_repository.bl_idname, text="", icon='ZOOMOUT') sidebar.separator() sidebar.operator(PACKAGE_OT_refresh.bl_idname, text="Check for updates") sidebar.separator() sidebar.label("Category") sidebar.prop(wm, "addon_filter", text="") sidebar.separator() sidebar.label("Support level") sidebar.prop(wm, "addon_support") top = pkgzone.row() spl = top.split(.6) spl.prop(wm, "package_search", text="", icon='VIEWZOOM') spl_r = spl.row() spl_r.prop(wm, "package_install_filter", expand=True) def filtered_packages(filters: dict, packages: OrderedDict) -> list:# {{{ """Returns filtered and sorted list of names of packages which match filters""" #TODO: using lower() for case-insensitive comparison doesn't work in some languages def match_contains(blinfo) -> bool: if blinfo['name'].lower().__contains__(filters['search'].lower()): return True return False def match_startswith(blinfo) -> bool: if blinfo['name'].lower().startswith(filters['search'].lower()): return True return False def match_support(blinfo) -> bool: if 'support' in blinfo: if set((blinfo['support'],)).issubset(filters['support']): return True else: if {'COMMUNITY'}.issubset(filters['support']): return True return False def match_installstate(metapkg: ConsolidatedPackage) -> bool: if filters['installstate'] == 'AVAILABLE': return True if filters['installstate'] == 'INSTALLED': if metapkg.installed: return True if filters['installstate'] == 'UPDATES': if metapkg.installed: if metapkg.get_latest_installed_version().version < metapkg.get_latest_version().version: return True return False def match_repositories(metapkg) -> bool: pkg = metapkg.get_display_version() for repo in pkg.repositories: if repo.name in filters['repository']: return True return False def match_category(blinfo) -> bool: if filters['category'].lower() == 'all': return True if 'category' not in blinfo: return False if blinfo['category'].lower() == filters['category'].lower(): return True return False # use two lists as a simple way of putting "matches from the beginning" on top contains = [] startswith = [] for pkgname, metapkg in packages.items(): blinfo = metapkg.versions[0].bl_info if match_repositories(metapkg)\ and match_category(blinfo)\ and match_support(blinfo)\ and match_installstate(metapkg): if len(filters['search']) == 0: startswith.append(pkgname) continue if match_startswith(blinfo): startswith.append(pkgname) continue if match_contains(blinfo): contains.append(pkgname) continue return startswith + contains# }}} def draw_package(metapkg: ConsolidatedPackage, layout: bpy.types.UILayout): #{{{ """Draws the given package""" pkg = metapkg.get_display_version() def draw_operators(metapkg, layout): # {{{ """ Draws install, uninstall, update, enable, disable, and preferences buttons as applicable for the given package """ pkg = metapkg.get_display_version() if metapkg.installed: if metapkg.updateable: layout.operator( PACKAGE_OT_install.bl_idname, text="Update to {}".format(utils.fmt_version(metapkg.get_latest_version().version)), ).package_name=metapkg.name layout.separator() #TODO: only show preferences button if addon has preferences to show if pkg.enabled: layout.operator( WM_OT_package_toggle_preferences.bl_idname, text="Preferences", ).package_name=metapkg.name layout.operator( PACKAGE_OT_uninstall.bl_idname, text="Uninstall", ).package_name=metapkg.name else: layout.operator( PACKAGE_OT_install.bl_idname, text="Install", ).package_name=metapkg.name # }}} def draw_preferences(pkg: Package, layout: bpy.types.UILayout): """Draw the package's preferences in the given layout""" addon_preferences = context.user_preferences.addons[pkg.module_name].preferences if addon_preferences is not None: draw = getattr(addon_preferences, "draw", None) if draw is not None: addon_preferences_class = type(addon_preferences) box_prefs = layout.box() box_prefs.label("Preferences:") addon_preferences_class.layout = box_prefs try: draw(context) except: import traceback traceback.print_exc() box_prefs.label(text="Error (see console)", icon='ERROR') del addon_preferences_class.layout def collapsed(metapkg, layout):# {{{ """Draw collapsed version of package layout""" pkg = metapkg.get_display_version() # Only 'install' button is shown when package isn't installed, # so allow more space for title/description. spl = layout.split(.5 if pkg.installed else .8) metacol = spl.column(align=True) buttonrow = spl.row(align=True) buttonrow.alignment = 'RIGHT' l1 = metacol.row() l2 = metacol.row() draw_operators(metapkg, buttonrow) if pkg.installed: metacol.active = pkg.enabled l1.operator(PACKAGE_OT_toggle_enabled.bl_idname, icon='CHECKBOX_HLT' if pkg.enabled else 'CHECKBOX_DEHLT', text="", emboss=False, ).package_name = metapkg.name if pkg.name: l1.label(text=pkg.name) if pkg.description: l2.label(text=pkg.description) l2.enabled = False #Give name more visual weight # }}} def expanded(metapkg, layout, layoutbox):# {{{ """Draw expanded version of package layout""" pkg = metapkg.get_display_version() metacol = layoutbox.column(align=True) row1 = layout.row(align=True) row1.operator(PACKAGE_OT_toggle_enabled.bl_idname, icon='CHECKBOX_HLT' if pkg.enabled else 'CHECKBOX_DEHLT', text="", emboss=False, ).package_name = metapkg.name row1.label(pkg.name) if metapkg.installed: metacol.active = pkg.enabled row1.active = pkg.enabled if pkg.description: row = metacol.row() row.label(pkg.description) def draw_metadatum(label: str, value: str, layout: bpy.types.UILayout): """Draw the given key value pair in a new row in given layout container""" row = layout.row() row.scale_y = .8 spl = row.split(.15) spl.label("{}:".format(label)) spl.label(value) # don't compare against None here; we don't want to display empty arrays/strings either if pkg.location: draw_metadatum("Location", pkg.location, metacol) if pkg.version: draw_metadatum("Version", utils.fmt_version(pkg.version), metacol) if pkg.blender: draw_metadatum("Blender version", utils.fmt_version(pkg.blender), metacol) if pkg.category: draw_metadatum("Category", pkg.category, metacol) if pkg.author: draw_metadatum("Author", pkg.author, metacol) if pkg.support: draw_metadatum("Support level", pkg.support.title(), metacol) if pkg.warning: draw_metadatum("Warning", pkg.warning, metacol) metacol.separator() spl = layoutbox.row().split(.35) urlrow = spl.row() buttonrow = spl.row(align=True) urlrow.alignment = 'LEFT' if pkg.wiki_url: urlrow.operator("wm.url_open", text="Documentation", icon='HELP').url=pkg.wiki_url if pkg.tracker_url: urlrow.operator("wm.url_open", text="Report a Bug", icon='URL').url=pkg.tracker_url buttonrow.alignment = 'RIGHT' draw_operators(metapkg, buttonrow) def draw_version(layout: bpy.types.UILayout, pkg: Package): """Draw version of package""" spl = layout.split(.9) left = spl.column() right = spl.column() right.alignment = 'RIGHT' left.label(text=utils.fmt_version(pkg.version)) for repo in pkg.repositories: draw_metadatum("Repository", repo.name, left) if pkg.installed: right.label(text="Installed") draw_metadatum("Installed to", str(pkg.installed_location), left) if len(metapkg.versions) > 1: row = pkgbox.row() row.label(text="There are multiple versions of this package:") for version in metapkg.versions: subvbox = pkgbox.box() draw_version(subvbox, version) # }}} is_expanded = (metapkg.name in self.expanded_packages) pkgbox = layout.box() row = pkgbox.row(align=True) row.operator( WM_OT_package_toggle_expand.bl_idname, icon='TRIA_DOWN' if is_expanded else 'TRIA_RIGHT', emboss=False, ).package_name=metapkg.name if is_expanded: expanded(metapkg, row, pkgbox) else: collapsed(metapkg, row)# }}} if pkg.installed and pkg.enabled and pkg.name == USERPREF_PT_packages.preference_package: draw_preferences(pkg, pkgbox) def center_message(layout, msg: str): """draw a label in the center of an extra-tall row""" row = layout.row() row.label(text=msg) row.alignment='CENTER' row.scale_y = 10 global _main_has_run if not _main_has_run: # TODO: read repository and installed packages synchronously for now; # can't run an operator from draw code to do async monitoring main() global _packages if len(_packages) == 0: center_message(pkgzone, "No packages found") return wm = bpy.context.window_manager filters = { 'category': wm.addon_filter, 'search': wm.package_search, 'support': wm.addon_support, 'repository': set([repo.name for repo in wm.package_repositories if repo.enabled]), 'installstate': wm.package_install_filter, } USERPREF_PT_packages.displayed_packages = filtered_packages(filters, _packages) for pkgname in USERPREF_PT_packages.displayed_packages: row = pkgzone.row() draw_package(_packages[pkgname], row) class WM_OT_package_toggle_expand(bpy.types.Operator):# {{{ bl_idname = "wm.package_toggle_expand" bl_label = "" bl_description = "Toggle display of extended information for given package (hold shift to collapse all other packages)" bl_options = {'INTERNAL'} log = logging.getLogger(__name__ + ".WM_OT_package_toggle_expand") package_name = bpy.props.StringProperty( name="Package Name", description="Name of package to expand/collapse", ) def invoke(self, context, event): if event.shift: USERPREF_PT_packages.expanded_packages = [] if self.package_name in USERPREF_PT_packages.expanded_packages: USERPREF_PT_packages.expanded_packages.remove(self.package_name) else: USERPREF_PT_packages.expanded_packages.append(self.package_name) return {'FINISHED'}# }}} class WM_OT_package_toggle_preferences(bpy.types.Operator):# {{{ bl_idname = "wm.package_toggle_preferences" bl_label = "" bl_description = "Toggle display of package preferences" bl_options = {'INTERNAL'} package_name = bpy.props.StringProperty( name="Package Name", description="Name of package whos preferences to display", ) def invoke(self, context, event): if USERPREF_PT_packages.preference_package == self.package_name: USERPREF_PT_packages.preference_package = None else: USERPREF_PT_packages.preference_package = self.package_name return {'FINISHED'}# }}} class PACKAGE_OT_toggle_enabled(bpy.types.Operator):# {{{ bl_idname = "package.toggle_enabled" bl_label = "" bl_description = "Enable given package if it's disabled, and vice versa if it's enabled" log = logging.getLogger(__name__ + ".PACKAGE_OT_toggle_enabled") package_name = bpy.props.StringProperty( name="Package Name", description="Name of package to enable", ) def execute(self, context): import addon_utils global _packages metapkg = _packages[self.package_name] if not metapkg.installed: self.report({'ERROR'}, "Can't enable package which isn't installed") return {'CANCELLED'} # enable/disable all installed versions, just in case there are more than one for pkg in metapkg.versions: if not pkg.installed: continue if not pkg.module_name: self.log.warning("Can't enable package `%s` without a module name", pkg.name) continue if pkg.enabled: addon_utils.disable(pkg.module_name, default_set=True) pkg.enabled = False self.log.debug("Disabling") else: self.log.debug("Enabling") addon_utils.enable(pkg.module_name, default_set=True) pkg.enabled = True return {'FINISHED'}# }}} class PACKAGE_OT_disable(bpy.types.Operator):# {{{ bl_idname = "package.disable" bl_label = "" bl_description = "Disable given package" log = logging.getLogger(__name__ + ".PACKAGE_OT_disable") package_name = bpy.props.StringProperty( name="Package Name", description="Name of package to disable", ) def execute(self, context): global _packages package = _packages[self.package_name].get_display_version() if not package.module_name: self.log.error("Can't disable package without a module name") return {'CANCELLED'} ret = bpy.ops.wm.addon_disable(package.module_name) if ret == {'FINISHED'}: _packages[self.package_name].enabled = False return ret# }}} # class PackageManagerPreferences(bpy.types.AddonPreferences): # bl_idname = __package__ # # repositories = bpy.props.CollectionProperty( # type=RepositoryProperty, # name="Repositories", # ) # active_repository = bpy.props.IntProperty() def build_packagelist() -> OrderedDict:# {{{ """Make an OrderedDict of ConsolidatedPackages from known repositories + installed packages, keyed by package name""" log = logging.getLogger(__name__ + ".build_composite_packagelist") masterlist = {} installed_packages = get_installed_packages() known_repositories = get_repositories() for repo in known_repositories: for pkg in repo.packages: pkg.repositories.add(repo) if pkg.name is None: return OrderedDict() if pkg.name in masterlist: masterlist[pkg.name].add_version(pkg) else: masterlist[pkg.name] = ConsolidatedPackage(pkg) for pkg in installed_packages: if pkg.name in masterlist: masterlist[pkg.name].add_version(pkg) else: masterlist[pkg.name] = ConsolidatedPackage(pkg) # log.debug(masterlist[None].__dict__) return OrderedDict(sorted(masterlist.items()))# }}} def main(): """Entry point; performs initial loading of repositories and installed packages""" global _packages global _main_has_run _packages = build_packagelist() # load repositories from disk repos = get_repositories() wm = bpy.context.window_manager wm.package_repositories.clear() #TODO: store repository props in .blend so enabled/disabled state can be remembered for repo in repos: repo_prop = wm.package_repositories.add() repo_prop.name = repo.name repo_prop.enabled = True repo_prop.url = repo.url # needed for lazy loading _main_has_run = True def register(): bpy.utils.register_class(PACKAGE_OT_install) bpy.utils.register_class(PACKAGE_OT_uninstall) bpy.utils.register_class(PACKAGE_OT_toggle_enabled) # bpy.utils.register_class(PACKAGE_OT_disable) # bpy.utils.register_class(PACKAGE_OT_refresh_repositories) # bpy.utils.register_class(PACKAGE_OT_refresh_packages) bpy.utils.register_class(PACKAGE_OT_refresh) bpy.utils.register_class(USERPREF_PT_packages) bpy.utils.register_class(WM_OT_package_toggle_expand) bpy.utils.register_class(WM_OT_package_toggle_preferences) bpy.types.WindowManager.package_search = bpy.props.StringProperty( name="Search", description="Filter packages by name", options={'TEXTEDIT_UPDATE'} ) bpy.types.WindowManager.package_install_filter = bpy.props.EnumProperty( items=[('AVAILABLE', "Available", "All packages in selected repositories"), ('INSTALLED', "Installed", "All installed packages"), ('UPDATES', "Updates", "All installed packages for which there is a newer version availabe") ], name="Install filter", default='AVAILABLE', ) bpy.utils.register_class(RepositoryProperty) bpy.types.WindowManager.package_repositories = bpy.props.CollectionProperty( type=RepositoryProperty, name="Repositories", ) bpy.types.WindowManager.package_active_repository = bpy.props.IntProperty() bpy.utils.register_class(PACKAGE_OT_add_repository) bpy.utils.register_class(PACKAGE_OT_remove_repository) bpy.utils.register_class(PACKAGE_UL_repositories) # bpy.utils.register_class(PackageManagerPreferences) def unregister(): bpy.utils.unregister_class(PACKAGE_OT_install) bpy.utils.unregister_class(PACKAGE_OT_uninstall) bpy.utils.unregister_class(PACKAGE_OT_toggle_enabled) # bpy.utils.unregister_class(PACKAGE_OT_disable) # bpy.utils.unregister_class(PACKAGE_OT_refresh_repositories) # bpy.utils.unregister_class(PACKAGE_OT_refresh_packages) bpy.utils.unregister_class(PACKAGE_OT_refresh) bpy.utils.unregister_class(USERPREF_PT_packages) bpy.utils.unregister_class(WM_OT_package_toggle_expand) bpy.utils.unregister_class(WM_OT_package_toggle_preferences) del bpy.types.WindowManager.package_search del bpy.types.WindowManager.package_install_filter bpy.utils.unregister_class(RepositoryProperty) del bpy.types.WindowManager.package_repositories del bpy.types.WindowManager.package_active_repository bpy.utils.unregister_class(PACKAGE_OT_add_repository) bpy.utils.unregister_class(PACKAGE_OT_remove_repository) bpy.utils.unregister_class(PACKAGE_UL_repositories) # bpy.utils.unregister_class(PackageManagerPreferences)