
We store it outside blender in JSON anyway, storing it two places at once could be confusing. We can move it back into the .blend later if it seems that would be preferred.
655 lines
23 KiB
Python
655 lines
23 KiB
Python
"""
|
|
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.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 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', 'packages', 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):
|
|
bpy.context.window_manager['package_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 BPKG_OT_load_repositories(SubprocMixin, bpy.types.Operator):
|
|
bl_idname = 'bpkg.load_repositories'
|
|
bl_label = 'Load Repositories'
|
|
bl_description = 'Load repositories from disk'
|
|
bl_options = {'REGISTER'}
|
|
|
|
log = logging.getLogger(__name__ + '.BPKG_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')
|
|
|
|
@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(filters: dict, packages: list) -> list:
|
|
"""Returns filtered and sorted list of packages which match filters defined in dict"""
|
|
|
|
#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 pkg in packages:
|
|
blinfo = pkg['bl_info']
|
|
if match_category(blinfo):
|
|
if len(filters['search']) == 0:
|
|
startswith.append(pkg)
|
|
continue
|
|
if match_startswith(blinfo):
|
|
startswith.append(pkg)
|
|
continue
|
|
if match_contains(blinfo):
|
|
contains.append(pkg)
|
|
continue
|
|
|
|
return startswith + contains
|
|
|
|
def draw_package(pkg, layout):# {{{
|
|
"""Draws the given package"""
|
|
pkgbox = layout.box()
|
|
spl = pkgbox.split(.8)
|
|
left = spl.row(align=True)
|
|
blinfo = pkg['bl_info']
|
|
|
|
# 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 pkg.get('expand') else 'TRIA_RIGHT',
|
|
emboss=False,
|
|
).package_id=pkg['id']
|
|
|
|
# for metadata
|
|
leftcol = left.column(align=True)
|
|
|
|
def collapsed():
|
|
lr1 = leftcol.row()
|
|
lr2 = leftcol.row()
|
|
|
|
lr1.label(text=blinfo.get('name', "MISSING NAME"))
|
|
lr2.label(text=blinfo.get('description', "MISSING DESCRIPTION"))
|
|
lr2.enabled = False #Give name more visual weight
|
|
|
|
right.operator(BPKG_OT_install.bl_idname,
|
|
text="Install").package_url=pkg.get('url', "")
|
|
|
|
def expanded():
|
|
row1 = leftcol.row()
|
|
row1.label(blinfo.get('name'), "MISSING NAME")
|
|
|
|
if blinfo.get('description'):
|
|
row2 = leftcol.row()
|
|
row2.label(blinfo['description'])
|
|
# row2.scale_y = 1.2
|
|
|
|
if blinfo.get('version'):
|
|
vstr = str(blinfo['version'][0])
|
|
for component in blinfo['version'][1:]:
|
|
vstr += "." + str(component)
|
|
spl = leftcol.row().split(.15)
|
|
spl.label("Version:")
|
|
spl.label(vstr)
|
|
|
|
for prop in (
|
|
# "description",
|
|
"author",
|
|
"category",
|
|
# "version",
|
|
# "blender",
|
|
"location",
|
|
"warning",
|
|
"support",
|
|
# "wiki_url",
|
|
# "tracker_url",
|
|
):
|
|
if blinfo.get(prop):
|
|
row = leftcol.row()
|
|
row.scale_y = .8
|
|
spl = row.split(.15)
|
|
spl.label("{}:".format(prop.title()))
|
|
spl.label(str(blinfo[prop]))
|
|
|
|
if pkg.get('expand'):
|
|
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
|
|
|
|
try:
|
|
repo = wm['package_repo']
|
|
except KeyError:
|
|
center_message(pkgzone, "Loading Repositories...")
|
|
|
|
import pathlib
|
|
# TODO: read repository synchronously for now; can't run an operator to do async monitoring from draw code
|
|
storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'packages', create=True))
|
|
try:
|
|
res = subproc._load_repo(storage_path)
|
|
wm['package_repo'] = res.to_dict(sort=True, ids=True)
|
|
except FileNotFoundError:
|
|
wm['package_repo'] = None
|
|
return
|
|
|
|
if repo is None:
|
|
center_message(pkgzone, "No repositories found.")
|
|
return
|
|
|
|
filters = {
|
|
'category': bpy.context.window_manager.addon_filter,
|
|
'search': bpy.context.window_manager.package_search,
|
|
}
|
|
filtered_packages = filtered(filters, repo['packages'])
|
|
|
|
for pkg in filtered_packages:
|
|
row = pkgzone.row()
|
|
draw_package(pkg, row)
|
|
|
|
|
|
class WM_OT_package_toggle_expand(bpy.types.Operator):
|
|
bl_idname = "wm.package_toggle_expand"
|
|
bl_label = ""
|
|
bl_description = "Toggle display of all information for given package"
|
|
bl_options = {'INTERNAL'}
|
|
|
|
log = logging.getLogger(__name__ + ".WM_OT_package_toggle_expand")
|
|
|
|
package_id = bpy.props.StringProperty(
|
|
name="Package ID",
|
|
description="ID of package to expand/shrink",
|
|
)
|
|
|
|
def execute(self, context):
|
|
repo = context.window_manager.get('package_repo')
|
|
|
|
if not repo:
|
|
return {'CANCELLED'}
|
|
|
|
for pkg in repo['packages']:
|
|
if pkg.get('id') == self.package_id:
|
|
# if pkg['expand'] is unset, it's not expanded
|
|
pkg['expand'] = not pkg.get('expand', 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(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_load_repositories)
|
|
bpy.utils.register_class(BPKG_OT_hang)
|
|
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(BPKG_OT_install)
|
|
bpy.utils.unregister_class(BPKG_OT_refresh)
|
|
bpy.utils.unregister_class(BPKG_OT_load_repositories)
|
|
bpy.utils.unregister_class(BPKG_OT_hang)
|
|
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)
|