diff --git a/bpkg/__init__.py b/bpkg/__init__.py index c5761b6..b403d49 100644 --- a/bpkg/__init__.py +++ b/bpkg/__init__.py @@ -173,7 +173,7 @@ class BPKG_OT_install(SubprocMixin, bpy.types.Operator): def create_subprocess(self): """Starts the download process. - +tuple to Also registers the message handlers. :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) 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 @@ -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.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: """Get list of packages installed on disk""" 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)""" import pathlib 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): 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") def execute(self, context): - installed_packages = get_packages_from_disk() - USERPREF_PT_packages.all_packages = combine_packagelists(installed_packages, USERPREF_PT_packages.available_packages) + 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'} @@ -321,7 +383,15 @@ class BPKG_OT_refresh_repositories(SubprocMixin, bpy.types.Operator): self.quit() 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') def _subproc_aborted(self, aborted: subproc.Aborted): @@ -346,7 +416,7 @@ class BPKG_OT_refresh(bpy.types.Operator): def execute(self, context): getattr(bpy.ops, __package__).refresh_repositories() - getattr(bpy.ops, __package__).refresh_packages() + # getattr(bpy.ops, __package__).refresh_packages() return {'FINISHED'} @@ -545,26 +615,48 @@ class USERPREF_PT_packages(bpy.types.Panel): lr2.label(text=blinfo.get('description', "")) lr2.enabled = False #Give name more visual weight - if pkg.versions[0].url: - right.operator(BPKG_OT_install.bl_idname, - text="Install").package_url=pkg.versions[0].url + latest_pkg = pkg.get_latest_version() + if latest_pkg.installed: + 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(): 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'): - 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) + 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", @@ -585,6 +677,32 @@ class USERPREF_PT_packages(bpy.types.Panel): 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: @@ -609,7 +727,7 @@ class USERPREF_PT_packages(bpy.types.Panel): center_message(pkgzone, "No repositories found") return - all_packages = combine_packagelists(installed_packages, available_packages) + all_packages = build_composite_packagelist(installed_packages, available_packages) if len(all_packages) == 0: center_message(pkgzone, "No packages found") return @@ -640,8 +758,13 @@ class ViewPackage: 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) @@ -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""" 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""" - log = logging.getLogger(__name__ + ".combine_packagelists") + 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""" - blinfo1 = pkg1.bl_info - blinfo2 = pkg2.bl_info - return blinfo1['version'] == blinfo2['version']\ - and blinfo1['files'] == blinfo2['files'] + return pkg1.version == pkg2.version\ + and pkg1.files == pkg2.files for pkg in available: pkgname = pkg.bl_info['name'] @@ -720,22 +841,24 @@ def combine_packagelists(installed: list, available: list) -> OrderedDict: masterlist[pkgname] = ViewPackage(pkg) for pkg in installed: - pkgname = pkg.bl_info['name'] - if pkgname in masterlist: - for masterpkg in masterlist[pkgname]: + 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: - pkg.installed = True - masterlist[pkgname].add_version(pkg) + masterlist[pkg.name].add_version(pkg) else: - masterlist[pkgname] = ViewPackage(pkg) + masterlist[pkg.name] = ViewPackage(pkg) return OrderedDict(sorted(masterlist.items())) def register(): 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_packages) bpy.utils.register_class(BPKG_OT_refresh) @@ -761,6 +884,7 @@ def register(): def unregister(): 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_packages) bpy.utils.unregister_class(BPKG_OT_refresh) diff --git a/bpkg/subproc.py b/bpkg/subproc.py index 4f83e10..8b880c8 100644 --- a/bpkg/subproc.py +++ b/bpkg/subproc.py @@ -48,6 +48,9 @@ class SubprocWarning(SubprocMessage): class InstallError(SubprocError): """Sent when there was an error installing something.""" +class UninstallError(SubprocError): + """Sent when there was an error uninstalling something.""" + class FileConflictError(InstallError): """Sent when installation would overwrite existing files.""" @@ -149,6 +152,9 @@ class Package: self.files = [] self.set_from_dict(package_dict) + self.installed = False + self.repository = None + def to_dict(self) -> dict: """ Return a dict representation of the package @@ -171,14 +177,21 @@ class Package: setattr(self, attr, package_dict[attr]) #bl_info convenience getters - def get_name() -> str: + @property + def name(self) -> str: """Get name from bl_info""" return self.bl_info['name'] - def get_description() -> str: + @property + def description(self) -> str: """Get description from bl_info""" return self.bl_info['description'] + @property + def version(self) -> tuple: + """Get version from bl_info""" + return tuple(self.bl_info['version']) + # @classmethod # def from_dict(cls, package_dict: dict): # """ @@ -205,7 +218,8 @@ class Package: filepath = filepath.parent pkg = cls() - pkg.files = [filepath] + pkg.files = [filepath.name] + pkg.installed_location = str(filepath) try: pkg.bl_info = module.bl_info 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)) 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: resp.raise_for_status() @@ -522,7 +540,7 @@ def _install(pipe_to_blender, pkgpath: pathlib.Path, dest: pathlib.Path, searchp 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.""" 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__) 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: 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: _install(pipe_to_blender, downloaded, install_path, search_paths) - _add_to_installed(repo_path, Package.from_dict(package)) pipe_to_blender.send(Success()) except InstallException as err: log.exception("Failed to install package: %s", 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: """Reads the stored repositories""" @@ -571,7 +612,7 @@ def refresh(pipe_to_blender, storage_path: pathlib.Path, repository_url: str): return 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()) def load(pipe_to_blender, storage_path: pathlib.Path):