Cleanup: rename package manager addon from bpkg -> package_manager
As I see it, the *package manager* is called "bpkg", while the *package manager addon* is called "package_manager". The *package manager* contains the actual package management code, and the *package manager addon* provides the interface to use it.
This commit is contained in:
897
package_manager/__init__.py
Normal file
897
package_manager/__init__.py
Normal file
@@ -0,0 +1,897 @@
|
||||
"""
|
||||
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)
|
||||
Package = subproc.Package
|
||||
else:
|
||||
from . import subproc
|
||||
from .subproc import Package
|
||||
|
||||
import bpy
|
||||
from collections import OrderedDict
|
||||
|
||||
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')
|
||||
getattr(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 = USERPREF_PT_packages.all_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'}, 'Unable to install package: %s' % error.message)
|
||||
self.quit()
|
||||
|
||||
def _subproc_success(self, success: subproc.Success):
|
||||
self.report({'INFO'}, 'Package uninstalled successfully')
|
||||
getattr(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 = subproc._load_repo(storage_path)
|
||||
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()
|
||||
USERPREF_PT_packages.all_packages = build_composite_packagelist(installed_packages, available_packages)
|
||||
|
||||
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
|
||||
|
||||
USERPREF_PT_packages.all_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):
|
||||
getattr(bpy.ops, __package__).refresh_repositories()
|
||||
# getattr(bpy.ops, __package__).refresh_packages()
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class PACKAGE_OT_hang(SubprocMixin, bpy.types.Operator):
|
||||
bl_idname = 'package.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__ + '.PACKAGE_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 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')
|
||||
|
||||
all_packages = OrderedDict()
|
||||
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(pkg: ConsolidatedPackage, layout: bpy.types.UILayout):# {{{
|
||||
"""Draws the given package"""
|
||||
pkgbox = layout.box()
|
||||
spl = pkgbox.split(.8)
|
||||
left = spl.row(align=True)
|
||||
blinfo = pkg.versions[0].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.expanded else 'TRIA_RIGHT',
|
||||
emboss=False,
|
||||
).package_name=blinfo['name']
|
||||
|
||||
# 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
|
||||
|
||||
latest_pkg = pkg.get_latest_version()
|
||||
if latest_pkg.installed:
|
||||
if latest_pkg.url:
|
||||
right.operator(PACKAGE_OT_uninstall.bl_idname,
|
||||
text="Uninstall").package_name=latest_pkg.name
|
||||
else:
|
||||
right.label("Installed")
|
||||
else:
|
||||
if latest_pkg.url:
|
||||
right.operator(PACKAGE_OT_install.bl_idname,
|
||||
text="Install").package_url=pkg.versions[0].url
|
||||
else:
|
||||
right.label("Not installed, but no URL?")
|
||||
|
||||
def expanded():
|
||||
row1 = leftcol.row()
|
||||
row1.label(blinfo.get('name'), "")
|
||||
|
||||
def string_version(version_number) -> str:
|
||||
"""Take version number as an iterable and format it as a string"""
|
||||
vstr = str(version_number[0])
|
||||
for component in version_number[1:]:
|
||||
vstr += "." + str(component)
|
||||
return vstr
|
||||
|
||||
if blinfo.get('description'):
|
||||
row2 = leftcol.row()
|
||||
row2.label(blinfo['description'])
|
||||
# row2.scale_y = 1.2
|
||||
|
||||
if blinfo.get('version'):
|
||||
spl = leftcol.row().split(.15)
|
||||
spl.label("Version:")
|
||||
spl.label(string_version(blinfo['version']))
|
||||
|
||||
def draw_metadatum(key: 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(key))
|
||||
spl.label(value)
|
||||
|
||||
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]))
|
||||
|
||||
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=string_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(pkg.versions) > 1:
|
||||
row = pkgbox.row()
|
||||
row.label(text="There are multiple providers of this package:")
|
||||
for version in pkg.versions:
|
||||
# row = pkgbox.row()
|
||||
subvbox = pkgbox.box()
|
||||
draw_version(subvbox, version)
|
||||
|
||||
|
||||
if pkg.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
|
||||
|
||||
if len(USERPREF_PT_packages.all_packages) == 0:
|
||||
center_message(pkgzone, "No packages found.")
|
||||
|
||||
# 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()
|
||||
try:
|
||||
available_packages = get_packages_from_repo()
|
||||
except FileNotFoundError:
|
||||
center_message(pkgzone, "No repositories found")
|
||||
return
|
||||
|
||||
all_packages = build_composite_packagelist(installed_packages, available_packages)
|
||||
if len(all_packages) == 0:
|
||||
center_message(pkgzone, "No packages found")
|
||||
return
|
||||
|
||||
USERPREF_PT_packages.all_packages = all_packages
|
||||
|
||||
|
||||
filters = {
|
||||
'category': bpy.context.window_manager.addon_filter,
|
||||
'search': bpy.context.window_manager.package_search,
|
||||
}
|
||||
USERPREF_PT_packages.displayed_packages = filtered_packages(filters, USERPREF_PT_packages.all_packages)
|
||||
|
||||
for pkgname in USERPREF_PT_packages.displayed_packages:
|
||||
row = pkgzone.row()
|
||||
draw_package(USERPREF_PT_packages.all_packages[pkgname], row)
|
||||
|
||||
class ConsolidatedPackage:
|
||||
"""
|
||||
Stores a grouping of different versions of the same packages,
|
||||
and view-specific data used for drawing
|
||||
"""
|
||||
def __init__(self, pkg=None):
|
||||
self.versions = []
|
||||
self.expanded = False
|
||||
self.installed = False
|
||||
|
||||
if pkg is not None:
|
||||
self.add_version(pkg)
|
||||
|
||||
def get_latest_version(self) -> 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 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):
|
||||
try:
|
||||
pkg = USERPREF_PT_packages.all_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:
|
||||
USERPREF_PT_packages.all_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(PACKAGE_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(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(PACKAGE_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)
|
Reference in New Issue
Block a user