meta_package_manager.base — Meta Package Manager 2.4.1 documentation

# Source code for meta_package_manager.base

    # -*- coding: utf-8 -*-
    #
    # Copyright (c) 2016-2017 Kevin Deldycke <kevin@deldycke.com>
    #                         and contributors.
    # All Rights Reserved.
    #
    # This program is Free Software; you can redistribute it and/or
    # modify it under the terms of the GNU General Public License
    # as published by the Free Software Foundation; either version 2
    # of the License, or (at your option) any later version.
    #
    # This program is distributed in the hope that it will be useful,
    # but WITHOUT ANY WARRANTY; without even the implied warranty of
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    # GNU General Public License for more details.
    #
    # You should have received a copy of the GNU General Public License
    # along with this program; if not, write to the Free Software
    # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

    from __future__ import (
        absolute_import,
        division,
        print_function,
        unicode_literals
    )

    import os

    from boltons.cacheutils import cachedproperty
    from boltons.strutils import strip_ansi
    from packaging.specifiers import SpecifierSet
    from packaging.version import parse as parse_version

    from . import logger
    from .bitbar import run
    from .platform import current_os

    # Rendering format of CLI in JSON fields.
    CLI_FORMATS = frozenset(['plain', 'fragments', 'bitbar'])

    class CLIError(Exception):

        """ An error occured when running package manager CLI. """

        def __init__(self, code, output, error):
            """ The exception internally keeps the result of CLI execution. """
            super(CLIError, self).__init__()
            self.code = code
            self.output = output
            self.error = error

        def __str__(self):
            """ Human-readable error. """
            return (
                "Return code: {}\n"
                "Output:\n{}\n"
                "Error:\n{}\n").format(self.code, self.output, self.error)

    class PackageManager(object):

        """ Base class from which all package manager definitions should inherits.
        """

        # Fully qualified path to the package manager CLI.
        cli_path = None

        # Systematic options passed to package manager CLI. Might be of use to
        # force silencing or high verbosity for instance.
        cli_args = []

        # List of platforms supported by the manager.
        platforms = frozenset()

        # Version requirement specifier.
        requirement = None

        def get_version(self):
            """ Invoke the manager and extract its own reported version. """
            raise NotImplementedError

        @cachedproperty
        def version_string(self):
            """ Raw but cleaned string of the package manager version.

            Returns `None` if the manager had an issue extracting its version.
            """
            if self.executable:
                try:
                    return self.get_version().strip()
                except CLIError as expt:
                    logger.warning(expt.error)

        @cachedproperty
        def version(self):
            """ Parsed and normalized package manager's own version.

            Returns an instance of ``packaging.Version`` or None.
            """
            if self.version_string:
                return parse_version(self.version_string)

        @cachedproperty
        def id(self):
            """ Return package manager's ID. Defaults based on class name.

            This ID must be unique among all package manager definitions and
            lower-case as they're used as feature flags for the :command:`mpm` CLI.
            """
            return self.__class__.__name__.lower()

        @cachedproperty
        def name(self):
            """ Return package manager's common name. Defaults based on class name.
            """
            return self.__class__.__name__

        @cachedproperty
        def supported(self):
            """ Is the package manager supported on that platform? """
            return current_os()[0] in self.platforms

        @cachedproperty
        def exists(self):
            """ Is the package manager CLI exist on the system? """
            if not os.path.isfile(self.cli_path):
                logger.debug("{} not found.".format(self.cli_path))
                return False
            return True

        @cachedproperty
        def executable(self):
            """ Is the package manager CLI can be executed by the current user? """
            if not self.exists:
                return False
            if not os.access(self.cli_path, os.X_OK):
                logger.debug("{} not executable.".format(self.cli_path))
                return False
            return True

        @cachedproperty
        def fresh(self):
            """ Does the package manager match the version requirement? """
            # Version is mandatory.
            if not self.version:
                return False
            if self.requirement:
                if self.version not in SpecifierSet(self.requirement):
                    logger.debug(
                        "{} {} doesn't fit the '{}' version requirement.".format(
                            self.id, self.version, self.requirement))
                    return False
            return True

        @cachedproperty
        def available(self):
            """ Is the package manager available and ready-to-use on the system?

            Returns True only if the main CLI:
                1 - is supported on the current platform,
                2 - exists on the system,
                3 - is executable, and
                4 - match the version requirement.
            """
            return (
                self.supported and self.exists and self.executable and self.fresh)

        def run(self, args, dry_run=False):
            """ Run a shell command, return the output and keep error message.

            Removes ANSI escape codes, and returns ready-to-use strings.
            """
            assert isinstance(args, list)
            logger.debug("Running `{}`...".format(' '.join(args)))

            code = 0
            output = None
            error = None

            if not dry_run:
                code, output, error = run(*args)
            else:
                logger.warning("Dry-run mode active: skip execution of command.")

            # Normalize messages.
            if error:
                error = strip_ansi(error)
                error = error if error else None
            if output:
                output = strip_ansi(output)
                output = output if output else None

            if code and error:
                raise CLIError(code, output, error)

            logger.debug(output)

            return output

        def sync(self):
            """ Fetch latest versions of installed packages. """
            logger.info('Sync {} package info...'.format(self.id))

        @property
        def installed(self):
            """ List packages currently installed on the system.

            Returns a list of dict with package ID, name and version.
            """
            raise NotImplementedError

        @property
        def outdated(self):
            """ List currently installed packages having a new version available.

            Returns a list of dict with package ID, name, current installed version
            and latest upgradeable version.
            """
            raise NotImplementedError

        def upgrade_cli(self, package_id=None):
            """ Return a bash-compatible full-CLI to upgrade a package. """
            raise NotImplementedError

        def upgrade(self, package_id=None, dry_run=False):
            """ Perform the upgrade of the provided package to latest version. """
            return self.run(self.upgrade_cli(package_id), dry_run=dry_run)

        def upgrade_all_cli(self):
            """ Return a bash-compatible full-CLI to upgrade all packages. """
            raise NotImplementedError

        def upgrade_all(self, dry_run=False):
            """ Perform a full upgrade of all outdated packages to latest versions.

            If the manager doesn't implements a full upgrade one-liner, then
            fall-back to calling single-package upgrade one by one.
            """
            try:
                return self.run(self.upgrade_all_cli(), dry_run=dry_run)
            except NotImplementedError:
                logger.warning(
                    "{} doesn't seems to implement a full upgrade subcommand. "
                    "Call single-package upgrade CLI one by one.".format(self.id))
                log = []
                for package_id in self.outdated:
                    output = self.upgrade(package_id, dry_run=dry_run)
                    if output:
                        log.append(output)
                if log:
                    return '\n'.join(log)

        @staticmethod
        def render_cli(cmd, cli_format='plain'):
            """ Return a formatted CLI in the provided format. """
            assert isinstance(cmd, list)
            assert cli_format in CLI_FORMATS
            if cli_format != 'fragments':
                cmd = ' '.join(cmd)
                if cli_format == 'bitbar':
                    cmd = PackageManager.render_bitbar_cli(cmd)
            return cmd

        @staticmethod
        def render_bitbar_cli(full_cli):
            """ Format a bash-runnable full-CLI with parameters into bitbar schema.
            """
            cmd, params = full_cli.strip().split(' ', 1)
            bitbar_cli = "bash={}".format(cmd)
            for index, param in enumerate(params.split(' ')):
                bitbar_cli += " param{}={}".format(index + 1, param)
            return bitbar_cli