""" 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__) if 'bpy' in locals(): import importlib log.debug("Reloading") subproc = importlib.reload(subproc) bpkg = importlib.reload(bpkg) Package = bpkg.Package else: from . import subproc from . import bpkg from .bpkg import Package import bpy from collections import OrderedDict # global list of all known packages, indexed by name _packages = OrderedDict() class ConsolidatedPackage: """ Stores a grouping of different versions of the same package """ log = logging.getLogger(__name__ + ".ConsolidatedPackage") def __init__(self, pkg=None): self.versions = [] self.expanded = False if pkg is not None: self.add_version(pkg) def get_latest_version(self) -> bpkg.Package: """Get package with highest version number""" return self.versions[0] # this is always sorted with the highest on top def add_version(self, pkg: Package): self.versions.append(pkg) self.versions.sort(key=lambda v: v.version, reverse=True) def __iter__(self): return (pkg for pkg in self.versions) 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.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_url = bpy.props.StringProperty(name='package_url', description='The URL of the file to download') log = logging.getLogger(__name__ + '.PACKAGE_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') bpy.ops.package.refresh_packages() 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 PACKAGE_OT_uninstall(SubprocMixin, bpy.types.Operator): bl_idname = 'package.uninstall' 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 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 """ import multiprocessing self.msg_handlers = { subproc.UninstallError: self._subproc_uninstall_error, subproc.Success: self._subproc_success, } import pathlib install_path = pathlib.Path(bpy.utils.user_resource('SCRIPTS', 'addons', create=True)) # TODO: only drawing-related package data should be stored on the panel. Maybe move this to a global..? package = _packages[self.package_name].get_latest_version() proc = multiprocessing.Process(target=subproc.uninstall, args=(self.pipe_subproc, package, install_path)) return proc def _subproc_uninstall_error(self, error: subproc.InstallError): self.report({'ERROR'}, error.message) self.quit() def _subproc_success(self, success: subproc.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_packages_from_disk(refresh=False) -> list: """Get list of packages installed on disk""" import addon_utils return [Package.from_module(mod) for mod in addon_utils.modules(refresh=refresh)] def get_packages_from_repo() -> list: """Get list of packages from cached repository lists (does not refresh them from server)""" import pathlib storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'packages', create=True)) / 'repo.json' try: repo = bpkg.Repository.from_file(storage_path) except FileNotFoundError: return [] for pkg in repo.packages: pkg.repository = repo.name return repo.packages 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): 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_repositories(SubprocMixin, bpy.types.Operator): bl_idname = "package.refresh_repositories" bl_label = "Refresh Repositories" bl_description = 'Check repositories for new and updated packages' bl_options = {'REGISTER'} log = logging.getLogger(__name__ + ".PACKAGE_OT_refresh") def create_subprocess(self): """Starts the download process. Also registers the message handlers. :rtype: multiprocessing.Process """ import multiprocessing #TODO: make sure all possible messages are handled 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', 'packages', create=True)) repository_url = bpy.context.user_preferences.addons[__package__].preferences.repository_url from urllib.parse import urlsplit, urlunsplit parsed_url = urlsplit(repository_url) if not parsed_url.path.endswith("repo.json"): if parsed_url.path.endswith('/'): new_path = parsed_url.path + "repo.json" else: new_path = parsed_url.path + "/repo.json" repository_url = urlunsplit((parsed_url.scheme, parsed_url.netloc, new_path, parsed_url.query, parsed_url.fragment)) 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.quit() def _subproc_repository_result(self, result: subproc.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 _packages = build_composite_packagelist(installed_packages, available_packages) USERPREF_PT_packages.available_packages = available_packages self.report({'INFO'}, 'Package list retrieved successfully') 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.') #TODO: # monkey patch refresh_repositories and add refresh_packages in the success callback # this way refresh_packages is always called after repositories have been refreshed class PACKAGE_OT_refresh(bpy.types.Operator): bl_idname = "package.refresh" bl_label = "Refresh" bl_description = "Check for new and updated packages" def execute(self, context): bpy.ops.package.refresh_repositories() # getattr(bpy.ops, __package__).refresh_packages() return {'FINISHED'} class PACKAGE_OT_load_repositories(SubprocMixin, bpy.types.Operator): bl_idname = 'package.load_repositories' bl_label = 'Load Repositories' bl_description = 'Load repositories from disk' bl_options = {'REGISTER'} log = logging.getLogger(__name__ + '.PACKAGE_OT_load_repositories') def create_subprocess(self): """ Start the load process and register message handlers """ import multiprocessing 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. storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'packages', create=True)) self.log.debug("Using %s as install path", install_path) import addon_utils proc = multiprocessing.Process( target=subproc.load_repositories, args=(self.pipe_subproc, self.storage_path) ) return proc self.msg_handlers = { subproc.SubprocError: self._subproc_error, subproc.RepositoryResult: self._subproc_repository_result, subproc.Success: self._subproc_success, subproc.Aborted: self._subproc_aborted, } def _subproc_error(self, error: subproc.SubprocError): self.report({'ERROR'}, 'Failed to load repositories: %s' % error.message) self.quit() def _subproc_repository_result(self, result: subproc.RepositoryResult): bpy.context.user_preferences.addons[__package__].preferences['repo'] = result.repository self.log.info("Loaded repository %s", result.repository.name) def _subproc_success(self, success: subproc.Success): self.log.info("Successfully loaded repositories") 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 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') available_packages = [] installed_packages = [] displayed_packages = [] @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 main = layout.row() spl = main.split(.12) sidebar = spl.column(align=True) pkgzone = spl.column() sidebar.label(text="Category") sidebar.prop(wm, "addon_filter", text="") 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_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, pkg in packages.items(): blinfo = pkg.versions[0].bl_info if match_category(blinfo): 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""" def collapsed(): """Draw collapsed version of package layout""" lr1 = leftcol.row() lr2 = leftcol.row() if pkg.name: lr1.label(text=pkg.name) if pkg.description: lr2.label(text=pkg.description) lr2.enabled = False #Give name more visual weight def expanded(): """Draw expanded version of package layout""" def fmt_version(version_number: tuple) -> str: """Take version number as a tuple and format it as a string""" vstr = str(version_number[0]) for component in version_number[1:]: vstr += "." + str(component) return vstr row1 = leftcol.row() row1.label(pkg.name) if pkg.description: row2 = leftcol.row() row2.label(pkg.description) # row2.scale_y = 1.2 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, leftcol) if pkg.version: draw_metadatum("Version", fmt_version(pkg.version), leftcol) # if pkg.blender: # draw_metadatum("Compatible blender version", fmt_version(pkg.blender), leftcol) if pkg.category: draw_metadatum("Category", pkg.category, leftcol) if pkg.author: draw_metadatum("Author", pkg.author, leftcol) if pkg.support: draw_metadatum("Support level", pkg.support.title(), leftcol) if pkg.warning: draw_metadatum("Warning", pkg.warning, leftcol) if pkg.wiki_url or pkg.tracker_url: padrow = leftcol.row() padrow.label() padrow.scale_y = .5 urlrow = leftcol.row() 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 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=fmt_version(pkg.version)) if pkg.repository is not None: draw_metadatum("Repository", pkg.repository, 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 providers of this package:") for version in metapkg.versions: # row = pkgbox.row() subvbox = pkgbox.box() draw_version(subvbox, version) pkg = metapkg.get_latest_version() pkgbox = layout.box() spl = pkgbox.split(.8) left = spl.row(align=True) # for install/uninstall buttons right = spl.row() right.alignment = 'RIGHT' right.scale_y = 1.5 # for collapse/expand button left.operator( WM_OT_package_toggle_expand.bl_idname, icon='TRIA_DOWN' if metapkg.expanded else 'TRIA_RIGHT', emboss=False, ).package_name=pkg.name # for metadata leftcol = left.column(align=True) if pkg.installed: if pkg.url: right.operator(PACKAGE_OT_uninstall.bl_idname, text="Uninstall").package_name=pkg.name else: right.label("Installed") else: if pkg.url: right.operator(PACKAGE_OT_install.bl_idname, text="Install").package_url=pkg.url else: right.label("Not installed, but no URL?") if metapkg.expanded: expanded() else: collapsed()# }}} 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 _packages if len(_packages) == 0: # TODO: read repository and installed packages synchronously for now; # can't run an operator from draw code to do async monitoring installed_packages = get_packages_from_disk() available_packages = get_packages_from_repo() _packages = build_composite_packagelist(installed_packages, available_packages) if len(_packages) == 0: center_message(pkgzone, "No packages found") return filters = { 'category': bpy.context.window_manager.addon_filter, 'search': bpy.context.window_manager.package_search, } 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): global _packages try: pkg = _packages[self.package_name] except KeyError: log.error("Couldn't find package '%s'", self.package_name) return {'CANCELLED'} pkg.expanded = not pkg.expanded if event.shift: for pkgname in USERPREF_PT_packages.displayed_packages: if not pkgname == self.package_name: _packages[pkgname].expanded = False 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(PACKAGE_OT_refresh.bl_idname) def validate_packagelist(pkglist: list) -> list: """Ensures all packages have required fields; strips out bad packages and returns them in a list""" pass def build_composite_packagelist(installed: list, available: list) -> OrderedDict: """Merge list of installed and available packages into one dict, keyed by package name""" log = logging.getLogger(__name__ + ".build_composite_packagelist") masterlist = {} def packages_are_equivilent(pkg1: Package, pkg2: Package) -> bool: """Check that packages are the same version and provide the same files""" return pkg1.version == pkg2.version\ and pkg1.files == pkg2.files for pkg in available: pkgname = pkg.bl_info['name'] if pkgname in masterlist: masterlist[pkgname].add_version(pkg) else: masterlist[pkgname] = ConsolidatedPackage(pkg) for pkg in installed: pkg.installed = True if pkg.name in masterlist: for masterpkg in masterlist[pkg.name]: # log.debug("{} and {} equivilent? {}".format((pkg.name, pkg.version), (masterpkg.name, masterpkg.version), packages_are_equivilent(pkg, masterpkg))) if packages_are_equivilent(pkg, masterpkg): masterpkg.installed = True masterpkg.installed_location = pkg.installed_location break else: masterlist[pkg.name].add_version(pkg) else: masterlist[pkg.name] = ConsolidatedPackage(pkg) return OrderedDict(sorted(masterlist.items())) def register(): bpy.utils.register_class(PACKAGE_OT_install) bpy.utils.register_class(PACKAGE_OT_uninstall) 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(PACKAGE_OT_load_repositories) bpy.utils.register_class(USERPREF_PT_packages) bpy.utils.register_class(WM_OT_package_toggle_expand) 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(PACKAGE_OT_install) bpy.utils.unregister_class(PACKAGE_OT_uninstall) 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(PACKAGE_OT_load_repositories) bpy.utils.unregister_class(USERPREF_PT_packages) bpy.utils.unregister_class(WM_OT_package_toggle_expand) del bpy.types.WindowManager.package_search del bpy.types.WindowManager.package_install_filter bpy.utils.unregister_class(PackageManagerPreferences)