Add uninstall function and operator

For now the install/uninstall operators only operate on the newest
available version of a given package.
This commit is contained in:
Ellwood Zwovic
2017-07-20 19:22:45 -07:00
parent e32c920368
commit 9740d3fce7
2 changed files with 200 additions and 35 deletions

View File

@@ -173,7 +173,7 @@ class BPKG_OT_install(SubprocMixin, bpy.types.Operator):
def create_subprocess(self): def create_subprocess(self):
"""Starts the download process. """Starts the download process.
tuple to
Also registers the message handlers. Also registers the message handlers.
:rtype: multiprocessing.Process :rtype: multiprocessing.Process
@@ -198,7 +198,6 @@ class BPKG_OT_install(SubprocMixin, bpy.types.Operator):
self.log.debug("Using %s as install path", install_path) self.log.debug("Using %s as install path", install_path)
import addon_utils import addon_utils
proc = multiprocessing.Process(target=subproc.download_and_install, proc = multiprocessing.Process(target=subproc.download_and_install,
args=(self.pipe_subproc, self.package_url, install_path, addon_utils.paths())) args=(self.pipe_subproc, self.package_url, install_path, addon_utils.paths()))
return proc return proc
@@ -235,6 +234,65 @@ class BPKG_OT_install(SubprocMixin, bpy.types.Operator):
self.log.error('Process died without telling us! Exit code was 0 though') 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.') self.report({'WARNING'}, 'Error downloading package, but process finished OK. This is weird.')
class BPKG_OT_uninstall(SubprocMixin, bpy.types.Operator):
bl_idname = 'bpkg.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__ + '.BPKG_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: def get_packages_from_disk(refresh=False) -> list:
"""Get list of packages installed on disk""" """Get list of packages installed on disk"""
import addon_utils import addon_utils
@@ -244,7 +302,10 @@ def get_packages_from_repo() -> list:
"""Get list of packages from cached repository lists (does not refresh them from server)""" """Get list of packages from cached repository lists (does not refresh them from server)"""
import pathlib import pathlib
storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'packages', create=True)) storage_path = pathlib.Path(bpy.utils.user_resource('CONFIG', 'packages', create=True))
return subproc._load_repo(storage_path).packages repo = subproc._load_repo(storage_path)
for pkg in repo.packages:
pkg.repository = repo.name
return repo.packages
class BPKG_OT_refresh_packages(bpy.types.Operator): class BPKG_OT_refresh_packages(bpy.types.Operator):
bl_idname = "bpkg.refresh_packages" bl_idname = "bpkg.refresh_packages"
@@ -254,8 +315,9 @@ class BPKG_OT_refresh_packages(bpy.types.Operator):
log = logging.getLogger(__name__ + ".BPKG_OT_refresh_packages") log = logging.getLogger(__name__ + ".BPKG_OT_refresh_packages")
def execute(self, context): def execute(self, context):
installed_packages = get_packages_from_disk() installed_packages = get_packages_from_disk(refresh=True)
USERPREF_PT_packages.all_packages = combine_packagelists(installed_packages, USERPREF_PT_packages.available_packages) available_packages = get_packages_from_repo()
USERPREF_PT_packages.all_packages = build_composite_packagelist(installed_packages, available_packages)
return {'FINISHED'} return {'FINISHED'}
@@ -321,7 +383,15 @@ class BPKG_OT_refresh_repositories(SubprocMixin, bpy.types.Operator):
self.quit() self.quit()
def _subproc_repository_result(self, result: subproc.RepositoryResult): def _subproc_repository_result(self, result: subproc.RepositoryResult):
USERPREF_PT_packages.available_packages = result.repository['packages'] 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') self.report({'INFO'}, 'Package list retrieved successfully')
def _subproc_aborted(self, aborted: subproc.Aborted): def _subproc_aborted(self, aborted: subproc.Aborted):
@@ -346,7 +416,7 @@ class BPKG_OT_refresh(bpy.types.Operator):
def execute(self, context): def execute(self, context):
getattr(bpy.ops, __package__).refresh_repositories() getattr(bpy.ops, __package__).refresh_repositories()
getattr(bpy.ops, __package__).refresh_packages() # getattr(bpy.ops, __package__).refresh_packages()
return {'FINISHED'} return {'FINISHED'}
@@ -545,26 +615,48 @@ class USERPREF_PT_packages(bpy.types.Panel):
lr2.label(text=blinfo.get('description', "")) lr2.label(text=blinfo.get('description', ""))
lr2.enabled = False #Give name more visual weight lr2.enabled = False #Give name more visual weight
if pkg.versions[0].url: latest_pkg = pkg.get_latest_version()
right.operator(BPKG_OT_install.bl_idname, if latest_pkg.installed:
text="Install").package_url=pkg.versions[0].url if latest_pkg.url:
right.operator(BPKG_OT_uninstall.bl_idname,
text="Uninstall").package_name=latest_pkg.name
else:
right.label("Installed")
else:
if latest_pkg.url:
right.operator(BPKG_OT_install.bl_idname,
text="Install").package_url=pkg.versions[0].url
else:
right.label("Not installed, but no URL?")
def expanded(): def expanded():
row1 = leftcol.row() row1 = leftcol.row()
row1.label(blinfo.get('name'), "") 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'): if blinfo.get('description'):
row2 = leftcol.row() row2 = leftcol.row()
row2.label(blinfo['description']) row2.label(blinfo['description'])
# row2.scale_y = 1.2 # row2.scale_y = 1.2
if blinfo.get('version'): if blinfo.get('version'):
vstr = str(blinfo['version'][0])
for component in blinfo['version'][1:]:
vstr += "." + str(component)
spl = leftcol.row().split(.15) spl = leftcol.row().split(.15)
spl.label("Version:") spl.label("Version:")
spl.label(vstr) 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 ( for prop in (
# "description", # "description",
@@ -585,6 +677,32 @@ class USERPREF_PT_packages(bpy.types.Panel):
spl.label("{}:".format(prop.title())) spl.label("{}:".format(prop.title()))
spl.label(str(blinfo[prop])) 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: if pkg.expanded:
expanded() expanded()
else: else:
@@ -609,7 +727,7 @@ class USERPREF_PT_packages(bpy.types.Panel):
center_message(pkgzone, "No repositories found") center_message(pkgzone, "No repositories found")
return return
all_packages = combine_packagelists(installed_packages, available_packages) all_packages = build_composite_packagelist(installed_packages, available_packages)
if len(all_packages) == 0: if len(all_packages) == 0:
center_message(pkgzone, "No packages found") center_message(pkgzone, "No packages found")
return return
@@ -640,8 +758,13 @@ class ViewPackage:
if pkg is not None: if pkg is not None:
self.add_version(pkg) 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): def add_version(self, pkg: Package):
self.versions.append(pkg) self.versions.append(pkg)
self.versions.sort(key=lambda v: v.version, reverse=True)
def __iter__(self): def __iter__(self):
return (pkg for pkg in self.versions) return (pkg for pkg in self.versions)
@@ -698,19 +821,17 @@ def validate_packagelist(pkglist: list) -> list:
"""Ensures all packages have required fields; strips out bad packages and returns them in a list""" """Ensures all packages have required fields; strips out bad packages and returns them in a list"""
pass pass
def combine_packagelists(installed: list, available: list) -> OrderedDict: def build_composite_packagelist(installed: list, available: list) -> OrderedDict:
"""Merge list of installed and available packages into one dict, keyed by package name""" """Merge list of installed and available packages into one dict, keyed by package name"""
log = logging.getLogger(__name__ + ".combine_packagelists") log = logging.getLogger(__name__ + ".build_composite_packagelist")
masterlist = {} masterlist = {}
def packages_are_equivilent(pkg1: Package, pkg2: Package) -> bool: def packages_are_equivilent(pkg1: Package, pkg2: Package) -> bool:
"""Check that packages are the same version and provide the same files""" """Check that packages are the same version and provide the same files"""
blinfo1 = pkg1.bl_info return pkg1.version == pkg2.version\
blinfo2 = pkg2.bl_info and pkg1.files == pkg2.files
return blinfo1['version'] == blinfo2['version']\
and blinfo1['files'] == blinfo2['files']
for pkg in available: for pkg in available:
pkgname = pkg.bl_info['name'] pkgname = pkg.bl_info['name']
@@ -720,22 +841,24 @@ def combine_packagelists(installed: list, available: list) -> OrderedDict:
masterlist[pkgname] = ViewPackage(pkg) masterlist[pkgname] = ViewPackage(pkg)
for pkg in installed: for pkg in installed:
pkgname = pkg.bl_info['name'] pkg.installed = True
if pkgname in masterlist: if pkg.name in masterlist:
for masterpkg in masterlist[pkgname]: 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): if packages_are_equivilent(pkg, masterpkg):
masterpkg.installed = True masterpkg.installed = True
masterpkg.installed_location = pkg.installed_location
break break
else: else:
pkg.installed = True masterlist[pkg.name].add_version(pkg)
masterlist[pkgname].add_version(pkg)
else: else:
masterlist[pkgname] = ViewPackage(pkg) masterlist[pkg.name] = ViewPackage(pkg)
return OrderedDict(sorted(masterlist.items())) return OrderedDict(sorted(masterlist.items()))
def register(): def register():
bpy.utils.register_class(BPKG_OT_install) bpy.utils.register_class(BPKG_OT_install)
bpy.utils.register_class(BPKG_OT_uninstall)
bpy.utils.register_class(BPKG_OT_refresh_repositories) bpy.utils.register_class(BPKG_OT_refresh_repositories)
bpy.utils.register_class(BPKG_OT_refresh_packages) bpy.utils.register_class(BPKG_OT_refresh_packages)
bpy.utils.register_class(BPKG_OT_refresh) bpy.utils.register_class(BPKG_OT_refresh)
@@ -761,6 +884,7 @@ def register():
def unregister(): def unregister():
bpy.utils.unregister_class(BPKG_OT_install) bpy.utils.unregister_class(BPKG_OT_install)
bpy.utils.unregister_class(BPKG_OT_uninstall)
bpy.utils.unregister_class(BPKG_OT_refresh_repositories) bpy.utils.unregister_class(BPKG_OT_refresh_repositories)
bpy.utils.unregister_class(BPKG_OT_refresh_packages) bpy.utils.unregister_class(BPKG_OT_refresh_packages)
bpy.utils.unregister_class(BPKG_OT_refresh) bpy.utils.unregister_class(BPKG_OT_refresh)

View File

@@ -48,6 +48,9 @@ class SubprocWarning(SubprocMessage):
class InstallError(SubprocError): class InstallError(SubprocError):
"""Sent when there was an error installing something.""" """Sent when there was an error installing something."""
class UninstallError(SubprocError):
"""Sent when there was an error uninstalling something."""
class FileConflictError(InstallError): class FileConflictError(InstallError):
"""Sent when installation would overwrite existing files.""" """Sent when installation would overwrite existing files."""
@@ -149,6 +152,9 @@ class Package:
self.files = [] self.files = []
self.set_from_dict(package_dict) self.set_from_dict(package_dict)
self.installed = False
self.repository = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
""" """
Return a dict representation of the package Return a dict representation of the package
@@ -171,14 +177,21 @@ class Package:
setattr(self, attr, package_dict[attr]) setattr(self, attr, package_dict[attr])
#bl_info convenience getters #bl_info convenience getters
def get_name() -> str: @property
def name(self) -> str:
"""Get name from bl_info""" """Get name from bl_info"""
return self.bl_info['name'] return self.bl_info['name']
def get_description() -> str: @property
def description(self) -> str:
"""Get description from bl_info""" """Get description from bl_info"""
return self.bl_info['description'] return self.bl_info['description']
@property
def version(self) -> tuple:
"""Get version from bl_info"""
return tuple(self.bl_info['version'])
# @classmethod # @classmethod
# def from_dict(cls, package_dict: dict): # def from_dict(cls, package_dict: dict):
# """ # """
@@ -205,7 +218,8 @@ class Package:
filepath = filepath.parent filepath = filepath.parent
pkg = cls() pkg = cls()
pkg.files = [filepath] pkg.files = [filepath.name]
pkg.installed_location = str(filepath)
try: try:
pkg.bl_info = module.bl_info pkg.bl_info = module.bl_info
except AttributeError as err: except AttributeError as err:
@@ -381,7 +395,11 @@ def _download(pipe_to_blender, package_url: str, download_dir: pathlib.Path) ->
pipe_to_blender.send(Progress(0.0)) pipe_to_blender.send(Progress(0.0))
log.info('Downloading %s', package_url) log.info('Downloading %s', package_url)
resp = requests.get(package_url, stream=True, verify=True) try:
resp = requests.get(package_url, stream=True, verify=True)
except requests.exceptions.RequestException as err:
pipe_to_blender.send(DownloadError(1, err))
raise
try: try:
resp.raise_for_status() resp.raise_for_status()
@@ -522,7 +540,7 @@ def _install(pipe_to_blender, pkgpath: pathlib.Path, dest: pathlib.Path, searchp
return return
def download_and_install(pipe_to_blender, package: dict, install_path: pathlib.Path, repo_path: pathlib.Path, search_paths: list): def download_and_install(pipe_to_blender, package_url: str, install_path: pathlib.Path, search_paths: list):
"""Downloads and installs the given package.""" """Downloads and installs the given package."""
from . import cache from . import cache
@@ -530,7 +548,7 @@ def download_and_install(pipe_to_blender, package: dict, install_path: pathlib.P
log = logging.getLogger('%s.download_and_install' % __name__) log = logging.getLogger('%s.download_and_install' % __name__)
cache_dir = cache.cache_directory('downloads') cache_dir = cache.cache_directory('downloads')
downloaded = _download(pipe_to_blender, package.url, cache_dir) downloaded = _download(pipe_to_blender, package_url, cache_dir)
if not downloaded: if not downloaded:
log.debug('Download failed/aborted, not going to install anything.') log.debug('Download failed/aborted, not going to install anything.')
@@ -538,12 +556,35 @@ def download_and_install(pipe_to_blender, package: dict, install_path: pathlib.P
try: try:
_install(pipe_to_blender, downloaded, install_path, search_paths) _install(pipe_to_blender, downloaded, install_path, search_paths)
_add_to_installed(repo_path, Package.from_dict(package))
pipe_to_blender.send(Success()) pipe_to_blender.send(Success())
except InstallException as err: except InstallException as err:
log.exception("Failed to install package: %s", err) log.exception("Failed to install package: %s", err)
pipe_to_blender.send(InstallError(err)) pipe_to_blender.send(InstallError(err))
def uninstall(pipe_to_blender, package: Package, install_path: pathlib.Path):
"""Deletes the given package's files from the install directory"""
#TODO: move package to cache and present an "undo" button to user, to give nicer UX on misclicks
#TODO: move this to a shared utility function
# Duplicated code with InplaceBackup class
def _rm(path: pathlib.Path):
"""Just delete whatever is specified by `path`"""
if path.is_dir():
shutil.rmtree(str(path))
else:
path.unlink()
for pkgfile in [install_path / pathlib.Path(p) for p in package.files]:
if not pkgfile.exists():
pipe_to_blender.send(UninstallError("Could not find file owned by package: '%s'. Refusing to uninstall." % pkgfile))
return None
for pkgfile in [install_path / pathlib.Path(p) for p in package.files]:
_rm(pkgfile)
pipe_to_blender.send(Success())
def _load_repo(storage_path: pathlib.Path) -> Repository: def _load_repo(storage_path: pathlib.Path) -> Repository:
"""Reads the stored repositories""" """Reads the stored repositories"""
@@ -571,7 +612,7 @@ def refresh(pipe_to_blender, storage_path: pathlib.Path, repository_url: str):
return return
repo.to_file(repo_path) # TODO: this always writes even if repo wasn't changed repo.to_file(repo_path) # TODO: this always writes even if repo wasn't changed
pipe_to_blender.send(RepositoryResult(repo.to_dict(sort=True, ids=True))) pipe_to_blender.send(RepositoryResult(repo))
pipe_to_blender.send(Success()) pipe_to_blender.send(Success())
def load(pipe_to_blender, storage_path: pathlib.Path): def load(pipe_to_blender, storage_path: pathlib.Path):