""" Blender Package manager """ bl_info = { 'name': 'Package Manager', 'author': 'Sybren A. Stüvel', 'version': (0, 1, 0), 'blender': (2, 79, 0), 'location': 'Addon Preferences panel', 'description': 'Add-on package manager.', 'category': 'System', } import logging if 'bpy' in locals(): import importlib subproc = importlib.reload(subproc) else: from . import subproc import bpy 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): import multiprocessing self.log.info('Starting') 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. 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(subproc.Abort()) def _finish(self, context): import multiprocessing 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.info('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 BPKG_OT_install(SubprocMixin, bpy.types.Operator): bl_idname = 'bpkg.install' bl_label = 'Install package' bl_description = 'Downloads and installs a Blender add-on package' bl_options = {'REGISTER'} package_url = bpy.props.StringProperty(name='package_url', description='The URL of the file to download') log = logging.getLogger(__name__ + '.BPKG_OT_install') def invoke(self, context, event): if not self.package_url: self.report({'ERROR'}, 'Package URL 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 """ import multiprocessing self.msg_handlers = { subproc.Progress: self._subproc_progress, subproc.DownloadError: self._subproc_download_error, subproc.InstallError: self._subproc_install_error, subproc.FileConflictError: self._subproc_conflict_error, subproc.Success: self._subproc_success, subproc.Aborted: self._subproc_aborted, } 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 = multiprocessing.Process(target=subproc.download_and_install, args=(self.pipe_subproc, self.package_url, install_path, addon_utils.paths())) return proc def _subproc_progress(self, progress: subproc.Progress): self.log.info('Task progress at %i%%', progress.progress * 100) def _subproc_download_error(self, error: subproc.DownloadError): self.report({'ERROR'}, 'Unable to download package: %s' % error.description) self.quit() def _subproc_install_error(self, error: subproc.InstallError): self.report({'ERROR'}, 'Unable to install package: %s' % error.message) self.quit() def _subproc_conflict_error(self, error: subproc.FileConflictError): self.report({'ERROR'}, 'Unable to install package: %s' % error.message) self.quit() def _subproc_success(self, success: subproc.Success): self.report({'INFO'}, 'Package installed successfully') self.quit() def _subproc_aborted(self, aborted: subproc.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 BPKG_OT_refresh(SubprocMixin, bpy.types.Operator): bl_idname = "bpkg.refresh" bl_label = "Refresh Packages" bl_description = 'Check for new and updated packages' bl_options = {'REGISTER'} log = logging.getLogger(__name__ + ".BPKG_OT_refresh") def create_subprocess(self): """Starts the download process. Also registers the message handlers. :rtype: multiprocessing.Process """ import multiprocessing self.msg_handlers = { subproc.Progress: self._subproc_progress, subproc.SubprocError: self._subproc_error, subproc.DownloadError: self._subproc_download_error, subproc.Success: self._subproc_success, subproc.RepositoryResult: self._subproc_repository_result, subproc.Aborted: self._subproc_aborted, } import pathlib storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'addons', create=True)) repository_url = bpy.context.user_preferences.addons[__package__].preferences.repository_url proc = multiprocessing.Process(target=subproc.refresh, args=(self.pipe_subproc, storage_path, repository_url)) return proc def _subproc_progress(self, progress: subproc.Progress): self.log.info('Task progress at %i%%', progress.progress * 100) def _subproc_error(self, error: subproc.SubprocError): self.report({'ERROR'}, 'Unable to refresh package list: %s' % error.message) self.quit() def _subproc_download_error(self, error: subproc.DownloadError): self.report({'ERROR'}, 'Unable to download package list: %s' % error.description) self.quit() def _subproc_success(self, success: subproc.Success): self.report({'INFO'}, 'Package list retrieved successfully') self.quit() def _subproc_repository_result(self, result: subproc.RepositoryResult): prefs = bpy.context.user_preferences.addons[__package__].preferences prefs['repo'] = result.repository self.report({'INFO'}, 'Package list retrieved successfully') self.quit() def _subproc_aborted(self, aborted: subproc.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('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('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 BPKG_OT_hang(SubprocMixin, bpy.types.Operator): bl_idname = 'bpkg.hang' bl_label = 'Hang (debug)' bl_description = 'Starts a process that hangs for an hour, for debugging purposes' bl_options = {'REGISTER'} log = logging.getLogger(__name__ + '.BPKG_OT_install') def create_subprocess(self): """Starts the download process. Also registers the message handlers. :rtype: multiprocessing.Process """ import multiprocessing proc = multiprocessing.Process(target=subproc.debug_hang) return proc def report_process_died(self): self.report({'ERROR'}, 'Process died, exit code %s' % self.process.exitcode) 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') @classmethod def poll(cls, context): userpref = context.user_preferences return (userpref.active_section == 'PACKAGES') def draw(self, context): try: repo = context.user_preferences.addons[__package__].preferences['repo'] except KeyError: # HACK: # If no repositories are initialized, we should try to refresh them. If that doesn't work, display a message repo = {'packages': []} layout = self.layout main = layout.row() spl = main.split(.12) sidebar = spl.column(align=True) pkgzone = spl.column() sidebar.label(text="Category") sidebar.prop(context.window_manager, "addon_filter", text="") top = pkgzone.row() spl = top.split(.6) spl.prop(context.window_manager, "package_search", text="", icon='VIEWZOOM') spl_r = spl.row() spl_r.prop(context.window_manager, "package_install_filter", expand=True) #TODO: more advanced filter/sorting; sort matches which match the filter string from the start higher #Also some caching of this would be nice, this only needs to be re-run when any of the filters change. def filter_package(package): """Returns true if the given package matches all filters""" filterstr = bpy.context.window_manager.package_search category = bpy.context.window_manager.addon_filter blinfo = package['bl_info'] def match_search() -> bool: if len(filterstr) == 0: return True if blinfo['name'].lower().__contains__(filterstr.lower()): return True return False def match_category() -> bool: if category.upper() == 'ALL': return True if 'category' not in blinfo: return True if blinfo['category'].upper() == category.upper(): return True return False if match_search() and match_category(): return True return False def draw_package(package, layout): """Draws the given package""" pkgbox = layout.box() spl = pkgbox.split(.8) left = spl.column(align=True) # for install/uninstall buttons right = spl.row() right.alignment = 'RIGHT' right.scale_y = 2 # for title & description lr1 = left.row() lr2 = left.row() lr2.enabled = False #Give name more visual weight lr1.label(text=pkg['bl_info'].get('name', "MISSING NAME")) lr2.label(text=pkg['bl_info'].get('description', "MISSING DESCRIPTION")) right.operator(BPKG_OT_install.bl_idname, text="Install").package_url=pkg.get('url', "") for pkg in repo['packages']: if filter_package(pkg): row = pkgzone.row() draw_package(pkg, row) # class WM_OT_package_expand(Operator): # bl_idname = "wm.package_expand" # bl_label = "" # bl_description = "Display information and preferences for this package" # bl_options = {'INTERNAL'} # # module = StringProperty( # name="Module", # description="Module name of the add-on to expand", # ) # # def execute(self, context): # import addon_utils # # module_name = self.module # # mod = addon_utils.addons_fake_modules.get(module_name) # if mod is not None: # info = addon_utils.module_bl_info(mod) # info["show_expanded"] = not info["show_expanded"] # # return {'FINISHED'} # class PackageManagerPreferences(bpy.types.AddonPreferences): bl_idname = __package__ package_url = bpy.props.StringProperty( name='Package URL', description='Just a temporary place to store the URL of a package to download') repository_url = bpy.props.StringProperty( name='Repository URL', description='Temporary repository URL') def draw(self, context): layout = self.layout temp_box = layout.box() temp_box.label(text="Temporary stuff while we're developing") temp_box.prop(self, 'repository_url') temp_box.operator(BPKG_OT_refresh.bl_idname) def register(): bpy.utils.register_class(BPKG_OT_install) bpy.utils.register_class(BPKG_OT_refresh) bpy.utils.register_class(BPKG_OT_hang) bpy.utils.register_class(USERPREF_PT_packages) 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(PackageManagerPreferences) def unregister(): bpy.utils.unregister_class(BPKG_OT_install) bpy.utils.unregister_class(BPKG_OT_refresh) bpy.utils.unregister_class(BPKG_OT_hang) bpy.utils.unregister_class(USERPREF_PT_packages) del bpy.types.WindowManager.package_search del bpy.types.WindowManager.package_install_filter bpy.utils.unregister_class(PackageManagerPreferences)