This repository has been archived on 2023-02-07. You can view files and clone it, but cannot push or open issues or pull requests.
Files
blender-package-manager-addon/bpkg/__init__.py

656 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', ""))
lr2.label(text=blinfo.get('description', ""))
lr2.enabled = False #Give name more visual weight
if pkg.get('url'):
right.operator(BPKG_OT_install.bl_idname,
text="Install").package_url=pkg.get('url')
def expanded():
row1 = leftcol.row()
row1.label(blinfo.get('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)