496 lines
17 KiB
Python
496 lines
17 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.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)
|