Last active
February 1, 2022 15:28
-
-
Save pombredanne/d3585617882f91d9316be5ce5eddf190 to your computer and use it in GitHub Desktop.
Eclectic package manager
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- coding: utf-8 -*- | |
# | |
# Copyright (c) the purl authors | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be included in all | |
# copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
# SOFTWARE. | |
import shlex | |
import subprocess | |
import sys | |
""" | |
The eclectic package manager (epm) installs many OS or application software | |
packages from a variety of sources provided as Package URLs. | |
You will need sudo to install system packages. Application packages are | |
installed in the local directory. | |
Run with: | |
python epm.py | |
For instance: | |
python epm.py pkg:npm/left-pad pkg:pypi/boolean.py@3.7 pkg:deb/nano pkg:deb/zlib1g-dev pkg:gem/bundler | |
Note that as a convenience the packageurl-python is vendored in this script. | |
""" | |
def install(purls): | |
""" | |
Install many packages from a `purls` list of Package URL strings. | |
""" | |
for package_url in purls: | |
purl = PackageURL.from_string(package_url) | |
cmd_builder = installers_by_type[purl.type] | |
cmd = cmd_builder(purl) | |
args = shlex.split(cmd) | |
print('Installing:', package_url, 'with:', repr(cmd)) | |
subprocess.check_call(args) | |
def deb(purl): | |
cmd = 'sudo apt-get install -y {name}' | |
if purl.version: | |
cmd += '={version}' | |
return cmd.format(**purl.to_dict()) | |
def rpm(purl): | |
if 'fedora' in purl.namespace: | |
tool = 'dnf' | |
elif 'suse' in purl.namespace: | |
tool = 'zypper' | |
else: | |
tool = 'yum' | |
cmd = 'sudo {} install -y {name}'.format(tool) | |
if purl.version: | |
cmd += '={version}' | |
return cmd.format(**purl.to_dict()) | |
def brew(purl): | |
return 'sudo brew install {name}'.format(**purl.to_dict()) | |
def pypi(purl, target='.'): | |
cmd = 'pip install --target {target} {name}' | |
if purl.version: | |
cmd += '=={version}' | |
data = purl.to_dict() | |
data['target'] = target | |
return cmd.format(**data) | |
def gem(purl, install_dir='.'): | |
cmd = 'gem install --install-dir {install_dir} {name}' | |
if purl.version: | |
cmd += '--version {version}' | |
data = purl.to_dict() | |
data['install_dir'] = install_dir | |
return cmd.format(**data) | |
def npm(purl): | |
if purl.namespace: | |
cmd = 'npm install {namespace}/{name}' | |
else: | |
cmd = 'npm install {name}' | |
if purl.version: | |
cmd += '@{version}' | |
return cmd.format(**purl.to_dict()) | |
# map a package type to a function that accepts a PackageURL and returns a command string to run | |
installers_by_type = { | |
'deb': deb, | |
'rpm': rpm, | |
'brew': brew, | |
'pypi': pypi, | |
'gem': gem, | |
'npm': npm, | |
} | |
################################################################################ | |
# vendored packageurl module for convenience such that this script is self-contained | |
# https://raw.githubusercontent.com/package-url/packageurl-python/v0.8.7/src/packageurl/__init__.py | |
################################################################################ | |
# -*- coding: utf-8 -*- | |
# | |
# Copyright (c) the purl authors | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be included in all | |
# copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
# SOFTWARE. | |
# Visit https://github.com/package-url/packageurl-python for support and | |
# download. | |
from collections import namedtuple | |
from collections import OrderedDict | |
import string | |
# Python 2 and 3 support | |
try: | |
# Python 2 | |
from urlparse import urlsplit as _urlsplit | |
from urllib import quote as _percent_quote | |
from urllib import unquote as _percent_unquote | |
except ImportError: | |
# Python 3 | |
from urllib.parse import urlsplit as _urlsplit | |
from urllib.parse import quote as _percent_quote | |
from urllib.parse import unquote as _percent_unquote | |
# Python 2 and 3 support | |
try: | |
# Python 2 | |
unicode | |
basestring = basestring # NOQA | |
bytes = str # NOQA | |
str = unicode # NOQA | |
except NameError: | |
# Python 3 | |
unicode = str # NOQA | |
basestring = (bytes, str,) # NOQA | |
""" | |
A purl (aka. Package URL) implementation as specified at: | |
https://github.com/package-url/purl-spec | |
""" | |
def quote(s): | |
""" | |
Return a percent-encoded unicode string, except for colon :, given an `s` | |
byte or unicode string. | |
""" | |
if isinstance(s, unicode): | |
s = s.encode('utf-8') | |
quoted = _percent_quote(s) | |
if not isinstance(quoted, unicode): | |
quoted = quoted.decode('utf-8') | |
quoted = quoted.replace('%3A', ':') | |
return quoted | |
def unquote(s): | |
""" | |
Return a percent-decoded unicode string, given an `s` byte or unicode | |
string. | |
""" | |
unquoted = _percent_unquote(s) | |
if not isinstance(unquoted, unicode): | |
unquoted = unquoted .decode('utf-8') | |
return unquoted | |
def get_quoter(encode=True): | |
""" | |
Return quoting callable given an `encode` tri-boolean (True, False or None) | |
""" | |
if encode is True: | |
return quote | |
elif encode is False: | |
return unquote | |
elif encode is None: | |
return lambda x: x | |
def normalize_type(type, encode=True): # NOQA | |
if not type: | |
return | |
if not isinstance(type, unicode): | |
type = type.decode('utf-8') # NOQA | |
quoter = get_quoter(encode) | |
type = quoter(type) # NOQA | |
return type.strip().lower() or None | |
def normalize_namespace(namespace, ptype, encode=True): # NOQA | |
if not namespace: | |
return | |
if not isinstance(namespace, unicode): | |
namespace = namespace.decode('utf-8') | |
namespace = namespace.strip().strip('/') | |
if ptype in ('bitbucket', 'github', 'pypi'): | |
namespace = namespace.lower() | |
segments = [seg for seg in namespace.split('/') if seg.strip()] | |
segments = map(get_quoter(encode), segments) | |
return '/'.join(segments) or None | |
def normalize_name(name, ptype, encode=True): # NOQA | |
if not name: | |
return | |
if not isinstance(name, unicode): | |
name = name.decode('utf-8') | |
quoter = get_quoter(encode) | |
name = quoter(name) | |
name = name.strip().strip('/') | |
if ptype in ('bitbucket', 'github', 'pypi',): | |
name = name.lower() | |
if ptype in ('pypi',): | |
name = name.replace('_', '-') | |
return name or None | |
def normalize_version(version, encode=True): # NOQA | |
if not version: | |
return | |
if not isinstance(version, unicode): | |
version = version.decode('utf-8') | |
quoter = get_quoter(encode) | |
version = quoter(version.strip()) | |
return version or None | |
def normalize_qualifiers(qualifiers, encode=True): # NOQA | |
""" | |
Return normalized `qualifiers` as a mapping (or as a string if `encode` is | |
True). The `qualifiers` arg is either a mapping or a string. | |
Always return a mapping if decode is True (and never None). | |
Raise ValueError on errors. | |
""" | |
if not qualifiers: | |
return None if encode else OrderedDict() | |
if isinstance(qualifiers, basestring): | |
if not isinstance(qualifiers, unicode): | |
qualifiers = qualifiers.decode('utf-8') | |
# decode string to list of tuples | |
qualifiers = qualifiers.split('&') | |
if not all('=' in kv for kv in qualifiers): | |
raise ValueError( | |
'Invalid qualifier. ' | |
'Must be a string of key=value pairs:{}'.format(repr(qualifiers))) | |
qualifiers = [kv.partition('=') for kv in qualifiers] | |
qualifiers = [(k, v) for k, _, v in qualifiers] | |
elif isinstance(qualifiers, dict): | |
qualifiers = qualifiers.items() | |
else: | |
raise ValueError( | |
'Invalid qualifier. ' | |
'Must be a string or dict:{}'.format(repr(qualifiers))) | |
quoter = get_quoter(encode) | |
qualifiers = {k.strip().lower(): quoter(v) | |
for k, v in qualifiers if k and k.strip() and v and v.strip()} | |
valid_chars = string.ascii_letters + string.digits + '.-_' | |
for key in qualifiers: | |
if not key: | |
raise ValueError('A qualifier key cannot be empty') | |
if '%' in key: | |
raise ValueError( | |
"A qualifier key cannot be percent encoded: {}".format(repr(key))) | |
if ' ' in key: | |
raise ValueError( | |
"A qualifier key cannot contain spaces: {}".format(repr(key))) | |
if not all(c in valid_chars for c in key): | |
raise ValueError( | |
"A qualifier key must be composed only of ASCII letters and numbers" | |
"period, dash and underscore: {}".format(repr(key))) | |
if key[0] in string.digits: | |
raise ValueError( | |
"A qualifier key cannot start with a number: {}".format(repr(key))) | |
qualifiers = sorted(qualifiers.items()) | |
qualifiers = OrderedDict(qualifiers) | |
if encode: | |
qualifiers = ['{}={}'.format(k, v) for k, v in qualifiers.items()] | |
qualifiers = '&'.join(qualifiers) | |
return qualifiers or None | |
else: | |
return qualifiers or {} | |
def normalize_subpath(subpath, encode=True): # NOQA | |
if not subpath: | |
return None | |
if not isinstance(subpath, unicode): | |
subpath = subpath.decode('utf-8') | |
quoter = get_quoter(encode) | |
segments = subpath.split('/') | |
segments = [quoter(s) for s in segments if s.strip() and s not in ('.', '..')] | |
subpath = '/'.join(segments) | |
return subpath or None | |
def normalize(type, namespace, name, version, qualifiers, subpath, encode=True): # NOQA | |
""" | |
Return normalized purl components | |
""" | |
type = normalize_type(type, encode) # NOQA | |
namespace = normalize_namespace(namespace, type, encode) | |
name = normalize_name(name, type, encode) | |
version = normalize_version(version, encode) | |
qualifiers = normalize_qualifiers(qualifiers, encode) | |
subpath = normalize_subpath(subpath, encode) | |
return type, namespace, name, version, qualifiers, subpath | |
_components = ['type', 'namespace', 'name', 'version', 'qualifiers', 'subpath'] | |
class PackageURL(namedtuple('PackageURL', _components)): | |
""" | |
A purl is a package URL as defined at | |
https://github.com/package-url/purl-spec | |
""" | |
def __new__(self, type=None, namespace=None, name=None, # NOQA | |
version=None, qualifiers=None, subpath=None): | |
required = dict(type=type, name=name) | |
for key, value in required.items(): | |
if value: | |
continue | |
raise ValueError('Invalid purl: {} is a required argument.' | |
.format(key)) | |
strings = dict(type=type, namespace=namespace, name=name, | |
version=version, subpath=subpath) | |
for key, value in strings.items(): | |
if value and isinstance(value, basestring) or not value: | |
continue | |
raise ValueError('Invalid purl: {} argument must be a string: {}.' | |
.format(key, repr(value))) | |
if qualifiers and not isinstance(qualifiers, (basestring, dict,)): | |
raise ValueError('Invalid purl: {} argument must be a dict or a string: {}.' | |
.format('qualifiers', repr(qualifiers))) | |
type, namespace, name, version, qualifiers, subpath = normalize(# NOQA | |
type, namespace, name, version, qualifiers, subpath, encode=None) | |
return super(PackageURL, self).__new__(PackageURL, type=type, | |
namespace=namespace, name=name, version=version, | |
qualifiers=qualifiers, subpath=subpath) | |
def __str__(self, *args, **kwargs): | |
return self.to_string() | |
def to_dict(self, encode=False): | |
""" | |
Return an ordered dict of purl components as {key: value}. If `encode` | |
is True, then "qualifiers" are encoded as a normalized string. | |
Otherwise, qualifiers is a mapping. | |
""" | |
data = self._asdict() | |
if encode: | |
data['qualifiers'] = normalize_qualifiers(self.qualifiers, | |
encode=encode) | |
return data | |
def to_string(self): | |
""" | |
Return a purl string built from components. | |
""" | |
type, namespace, name, version, qualifiers, subpath = normalize(# NOQA | |
self.type, self.namespace, self.name, self.version, | |
self.qualifiers, self.subpath, | |
encode=True | |
) | |
purl = ['pkg:', type, '/'] | |
if namespace: | |
purl.append(namespace) | |
purl.append('/') | |
purl.append(name) | |
if version: | |
purl.append('@') | |
purl.append(version) | |
if qualifiers: | |
purl.append('?') | |
purl.append(qualifiers) | |
if subpath: | |
purl.append('#') | |
purl.append(subpath) | |
return ''.join(purl) | |
@classmethod | |
def from_string(cls, purl): | |
""" | |
Return a PackageURL object parsed from a string. | |
Raise ValueError on errors. | |
""" | |
if (not purl or not isinstance(purl, basestring) | |
or not purl.strip()): | |
raise ValueError('A purl string argument is required.') | |
scheme, sep, remainder = purl.partition(':') | |
if not sep or scheme != 'pkg': | |
raise ValueError( | |
'purl is missing the required ' | |
'"pkg" scheme component: {}.'.format(repr(purl))) | |
# this strip '/, // and /// as possible in :// or :/// | |
remainder = remainder.strip().lstrip('/') | |
type, sep, remainder = remainder.partition('/') # NOQA | |
if not type or not sep: | |
raise ValueError( | |
'purl is missing the required ' | |
'type component: {}.'.format(repr(purl))) | |
scheme, authority, path, qualifiers, subpath = _urlsplit( | |
url=remainder, scheme='', allow_fragments=True) | |
if scheme or authority: | |
msg = ('Invalid purl {} cannot contain a "user:pass@host:port" ' | |
'URL Authority component: {}.') | |
raise ValueError(msg.format( | |
repr(purl), repr(authority) | |
)) | |
path = path.lstrip('/') | |
remainder, sep, version = path.rpartition('@') | |
if not sep: | |
remainder = version | |
version = None | |
ns_name = remainder.strip().strip('/') | |
ns_name = ns_name.split('/') | |
ns_name = [seg for seg in ns_name if seg and seg.strip()] | |
namespace = '' | |
name = '' | |
if len(ns_name) > 1: | |
name = ns_name[-1] | |
ns = ns_name[0:-1] | |
namespace = '/'.join(ns) | |
elif len(ns_name) == 1: | |
name = ns_name[0] | |
if not name: | |
raise ValueError( | |
'purl is missing the required ' | |
'name component: {}'.format(repr(purl))) | |
type, namespace, name, version, qualifiers, subpath = normalize(# NOQA | |
type, namespace, name, version, qualifiers, subpath, | |
encode=False | |
) | |
return PackageURL(type, namespace, name, version, qualifiers, subpath) | |
################################################################################ | |
# end vendoring | |
################################################################################ | |
_help = '''The eclectic package manager (epm) installs many OS and application | |
software packages at once from a variety of sources provided as Package URLs. | |
See https://github.com/package-url/purl-spec for details | |
Usage: epm <package-url>... | |
Install system and application packages identified by one or more <package-url>... | |
The supported package types are: {}. | |
Application packages will be installed in the current directory. | |
The package manager needs to be installed first for now. | |
System packages will be installed system-wide and require sudo. | |
Example: | |
python epm.py pkg:npm/left-pad pkg:pypi/boolean.py@3.7 pkg:deb/zlib1g-dev pkg:gem/bundler | |
This will install a npm JavaScript package, a Python package, a Debian package | |
and one Rubygems at once. | |
'''.format(', '.join(installers_by_type)) | |
if __name__ == '__main__': | |
args = sys.argv[1:] | |
if not args or args[0] in ('-h', '--help'): | |
print(_help) | |
sys.exit(1) | |
install(args) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment