diff --git a/bpkg_manager/__init__.py b/bpkg_manager/__init__.py index 0920ec5..95e1042 100644 --- a/bpkg_manager/__init__.py +++ b/bpkg_manager/__init__.py @@ -182,12 +182,21 @@ class BPKG_OT_install(SubprocMixin, bpy.types.Operator): 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) + proc = multiprocessing.Process(target=subproc.download_and_install, - args=(self.pipe_subproc, self.package_url,)) + args=(self.pipe_subproc, self.package_url, install_path)) return proc def _subproc_progress(self, progress: subproc.Progress): @@ -197,12 +206,20 @@ class BPKG_OT_install(SubprocMixin, bpy.types.Operator): 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 downloaded successfully') + self.report({'INFO'}, 'Package installed successfully') self.quit() def _subproc_aborted(self, aborted: subproc.Aborted): - self.report({'ERROR'}, 'Package download aborted per your request') + self.report({'ERROR'}, 'Package installation aborted per your request') self.quit() def report_process_died(self): diff --git a/bpkg_manager/subproc.py b/bpkg_manager/subproc.py index 6dc1a0b..3eae60e 100644 --- a/bpkg_manager/subproc.py +++ b/bpkg_manager/subproc.py @@ -31,6 +31,27 @@ class Progress(SubprocMessage): def __init__(self, progress: float): self.progress = progress +class SubprocError(SubprocMessage): + """Superclass for all fatal error messages sent from the subprocess.""" + + def __init__(self, message: str): + self.message = message + +class SubprocWarning(SubprocMessage): + """Superclass for all non-fatal warning messages sent from the subprocess.""" + + def __init__(self, message: str): + self.message = message + +class InstallError(SubprocError): + """Sent when there was an error installing something.""" + +class FileConflictError(InstallError): + """Sent when installation would overwrite existing files.""" + + def __init__(self, message: str, conflicts: list): + self.message = message + self.conflicts = conflicts class DownloadError(SubprocMessage): """Sent when there was an error downloading something.""" @@ -48,6 +69,9 @@ class Aborted(SubprocMessage): """Sent as response to Abort message.""" +class InstallException(Exception): + """Raised when there is an error during installation""" + def _download(pipe_to_blender, package_url: str, download_dir: pathlib.Path) -> pathlib.Path: """Downloads the given package @@ -77,6 +101,8 @@ def _download(pipe_to_blender, package_url: str, download_dir: pathlib.Path) -> log.warning('Server did not send content length, cannot report progress.') content_length = float('inf') + # TODO: check if there's enough disk space. + # TODO: get filename from Content-Disposition header, if available. # TODO: use urllib.parse to parse the URL. local_filename = package_url.split('/')[-1] or 'download.tmp' @@ -105,11 +131,74 @@ def _download(pipe_to_blender, package_url: str, download_dir: pathlib.Path) -> # leave 30% "progress" for installation of the package. pipe_to_blender.send(Progress(downloaded_length / content_length)) - pipe_to_blender.send(Success()) return local_fpath +def _install(pipe_to_blender, pkgpath: pathlib.Path, dest: pathlib.Path): + """Extracts/moves package at `pkgpath` to `dest`""" + import zipfile -def download_and_install(pipe_to_blender, package_url: str): + pipe_to_blender.send(Progress(0.0)) + + if not pkgpath.is_file(): + log.error("File to install isn't a file: %s", pkgpath) + pipe_to_blender.send(InstallError("Package is not a file")) + raise InstallException + + if not dest.is_dir(): + log.error("Destination for install isn't a directory: %s", dest) + pipe_to_blender.send(InstallError("Destination is not a directory")) + raise InstallException + + # TODO: check to make sure addon/package isn't already installed elsewhere + + # The following is adapted from `addon_install` in bl_operators/wm.py + + # check to see if the file is in compressed format (.zip) + if zipfile.is_zipfile(pkgpath): + try: + file_to_extract = zipfile.ZipFile(str(pkgpath), 'r') + except Exception as err: + pipe_to_blender.send(InstallError("Failed to read zip file: %s" % err)) + raise InstallException from err + + conflicts = [f for f in file_to_extract.namelist() if (dest / f).exists()] + if len(conflicts) > 0: + # TODO: handle this better than just dumping a list of all files + pipe_to_blender.send(FileConflictError("Installation would overwrite: %s" % conflicts, conflicts)) + raise InstallException + + try: + file_to_extract.extractall(str(dest)) + except Exception as err: + pipe_to_blender.send(InstallError("Failed to extract zip file to '%s': %s" % (dest, err))) + raise InstallException from err + + else: + dest_file = (dest / pkgpath.name) + + if dest_file.exists(): + pipe_to_blender.send(FileConflictError("Installation would overwrite %s" % dest_file, [dest_file])) + raise InstallException + + import shutil + + try: + shutil.copyfile(str(pkgpath), str(dest_file)) + except Exception as err: + pipe_to_blender.send(InstallError("Failed to copy file to '%s': %s" % (dest, err))) + raise InstallException from err + + try: + pkgpath.unlink() + except Exception as err: + pipe_to_blender.send(SubprocWarning("Failed to remove package from cache: %s" % err)) + raise InstallException from err + + pipe_to_blender.send(Progress(1.0)) + return + + +def download_and_install(pipe_to_blender, package_url: str, install_dir: pathlib.Path): """Downloads and installs the given package.""" from . import cache @@ -123,8 +212,13 @@ def download_and_install(pipe_to_blender, package_url: str): log.debug('Download failed/aborted, not going to install anything.') return - # TODO: actually install the package - log.warning('Installing is not actually implemented, install %s yourself.', downloaded) + # Only send success if _install doesn't throw an exception + # Maybe not the best way to do this? (will also catch exceptions which occur during message sending) + try: + _install(pipe_to_blender, downloaded, install_dir) + pipe_to_blender.send(Success()) + except: + raise def debug_hang():