# -*- coding: utf-8 -*-
#
# Copyright (c) 2016-2018 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.
import os
from boltons.cacheutils import cachedproperty
from boltons.strutils import indent, 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
try:
from shutil import which
except ImportError:
from backports.shutil_which import which
# Rendering format of CLI in JSON fields.
CLI_FORMATS = frozenset(['plain', 'fragments', 'bitbar'])
[docs]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. """
margin = ' ' * 2
return indent((
"\nReturn code: {}\n"
"Output:\n{}\n"
"Error:\n{}").format(
self.code,
indent(str(self.output), margin),
indent(str(self.error), margin)), margin)
[docs]class PackageManager(object):
""" Base class from which all package manager definitions should inherits.
"""
# 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 __init__(self):
# Tell the manager either to raise or continue on errors.
self.raise_on_cli_error = False
# Some managers have the ability to report or ignore packages
# possessing their own auto-update mecanism.
self.ignore_auto_updates = True
# Log of all encountered CLI errors.
self.cli_errors = []
@cachedproperty
def cli_name(self):
""" Package manager's CLI name.
Is derived by default from the manager's ID.
"""
return self.id
@cachedproperty
def cli_path(self):
""" Fully qualified path to the package manager CLI.
Automaticaly search the location of the CLI in the system.
Returns `None` if CLI is not found or is not a file.
"""
cli_path = which(self.cli_name, mode=os.F_OK)
logger.debug(
"CLI found at {}".format(cli_path) if cli_path
else "{} CLI not found.".format(self.cli_name))
return cli_path
[docs] 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:
version = self.get_version()
if version:
return version.strip()
@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 executable(self):
""" Is the package manager CLI can be executed by the current user? """
if not self.cli_path:
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 - was found on the system,
3 - is executable, and
4 - match the version requirement.
"""
return bool(
self.supported and
self.cli_path and
self.executable and
self.fresh)
[docs] 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:
exception = CLIError(code, output, error)
if self.raise_on_cli_error:
raise exception
else:
logger.error(error)
self.cli_errors.append(exception)
logger.debug(output)
return output
@property
def sync(self):
""" Refresh local manager metadata from remote repository. """
logger.info('Sync {} package info...'.format(self.id))
@property
def installed(self):
""" List packages currently installed on the system.
Returns a dict indexed by package IDs. Each item is a dict with
package ID, name and version.
"""
raise NotImplementedError
[docs] @staticmethod
def exact_match(query, result):
""" Compare search query and matching result.
Returns `True` if the matching result exactly match the search query.
Still pplies a light normalization and tokenization of strings before
comparison to make the "exactiness" in the human sense instead of
strictly machine sense.
"""
# TODO: tokenize.
return query.lower() == result.lower()
[docs] def search(self, query):
""" Search packages whose ID contain exact or partial query.
Returns a dict indexed by package IDs. Each item is a dict with
package ID, name, version and a boolean indicating if the match is
exact or partial.
"""
raise NotImplementedError
@property
def outdated(self):
""" List currently installed packages having a new version available.
Returns a dict indexed by package IDs. Each item is a dict with
package ID, name, current installed version and latest upgradeable
version.
"""
raise NotImplementedError
[docs] def upgrade_cli(self, package_id=None):
""" Return a bash-compatible full-CLI to upgrade a package. """
raise NotImplementedError
[docs] 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)
[docs] def upgrade_all_cli(self):
""" Return a bash-compatible full-CLI to upgrade all packages. """
raise NotImplementedError
[docs] 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)
[docs] @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
[docs] @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