This commit is contained in:
jpic 2020-02-20 13:18:53 +01:00
parent 2db971234a
commit ea061db51f
23 changed files with 356 additions and 263 deletions

View File

@ -5,7 +5,7 @@ setup(
name='shlax',
versioning='dev',
setup_requires='setupmeta',
install_requires=['cli2>=1.1.6'],
install_requires=['cli2'],
extras_require=dict(
full=[
'pyyaml',

View File

@ -1,7 +0,0 @@
from .actions import *
from .image import Image
from .strategies import *
from .output import Output
from .proc import Proc
from .targets import *
from .shlaxfile import Shlaxfile

View File

@ -1,7 +0,0 @@
from .copy import Copy
from .packages import Packages # noqa
from .base import Action # noqa
from .htpasswd import Htpasswd
from .run import Run # noqa
from .pip import Pip
from .service import Service

View File

@ -6,9 +6,28 @@ import sys
from ..output import Output
from ..exceptions import WrongResult
from ..result import Result
class class_or_instance_method:
def __init__(self, f):
self.f = f
def __get__(self, instance, owner):
def newfunc(*args, **kwargs):
return self.f(
instance if instance is not None else owner,
*args,
**kwargs
)
return newfunc
class Action:
display_variables = []
hide_variables = ['output']
default_steps = ['apply']
parent = None
contextualize = []
regexps = {
@ -26,26 +45,12 @@ class Action:
),
)
def __init__(self, *args, doc=None, **kwargs):
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
self.call_args = []
self.call_kwargs = {}
self._doc = doc
self.menu = {
name: value
for name, value in kwargs.items()
if isinstance(value, Action)
}
@property
def context(self):
if not self.parent:
if '_context' not in self.__dict__:
self._context = dict()
return self._context
else:
return self.parent.context
for key, value in kwargs.items():
setattr(self, key, value)
if isinstance(value, Action):
getattr(self, key).shlaxstep = True
def actions_filter(self, results, f=None, **filters):
if f:
@ -100,77 +105,72 @@ class Action:
add(self)
return self.actions_filter(children, f, **filters)
def __getattr__(self, name):
for a in self.parents() + self.sibblings() + self.children():
if name in a.contextualize:
return getattr(a, name)
raise AttributeError(f'{type(self).__name__} has no {name}')
async def __call__(self, *targets, **options):
if not targets:
from ..targets.localhost import Localhost
targets = [Localhost()]
async def call(self, *args, **kwargs):
print(f'{self}.call(*args, **kwargs) not implemented')
sys.exit(1)
output = Output(regexp=self.regexps, debug=True)
results = []
for target in targets:
target.output = output
if len(targets) > 1:
output.prefix = target
from copy import deepcopy
action = deepcopy(self)
action.target = target
result = Result(action, target)
results.append(result)
action.result = result
action.output = output
for step in options.get('steps', None) or self.default_steps:
if step not in action.steps():
print(f'Failed to find {type(action).__name__}.{step}')
continue
action.step = step
output.start(action)
try:
await getattr(action, step)()
except Exception as e:
output.fail(action, e)
action.result.status = 'fail'
proc = getattr(e, 'proc', None)
if proc:
result = proc.rc
else:
raise
else:
output.success(action)
result.status = 'success'
finally:
clean = getattr(action, 'clean', None)
if clean:
output.clean(action)
await clean(target)
def output_factory(self, *args, **kwargs):
kwargs.setdefault('regexps', self.regexps)
return Output(**kwargs)
async def __call__(self, *args, **kwargs):
self.call_args = list(self.call_args) + list(args)
self.call_kwargs.update(kwargs)
self.output = self.output_factory(*args, **kwargs)
self.output_start()
self.status = 'running'
try:
result = await self.call(*args, **kwargs)
except Exception as e:
self.output_fail(e)
self.status = 'fail'
proc = getattr(e, 'proc', None)
if proc:
result = proc.rc
else:
raise
else:
self.output_success()
if self.status == 'running':
self.status = 'success'
finally:
clean = getattr(self, 'clean', None)
if clean:
self.output.clean(self)
await clean(*args, **kwargs)
return result
def output_start(self):
if self.kwargs.get('quiet', False):
return
self.output.start(self)
def output_fail(self, exception=None):
if self.kwargs.get('quiet', False):
return
self.output.fail(self, exception)
def output_success(self):
if self.kwargs.get('quiet', False):
return
self.output.success(self)
return results
def __repr__(self):
return ' '.join([type(self).__name__] + list(self.args) + [
return ' '.join([type(self).__name__] + [
f'{k}={v}'
for k, v in self.kwargs.items()
for k, v in self.__dict__.items()
if (k in self.display_variables or not self.display_variables)
and (k not in self.hide_variables)
])
def colorized(self):
def colorized(self, colors):
return ' '.join([
self.output.colors['pink1']
colors['pink1']
+ type(self).__name__
+ self.output.colors['yellow']
] + list(self.args) + [
f'{self.output.colors["blue"]}{k}{self.output.colors["gray"]}={self.output.colors["green2"]}{v}'
for k, v in self.kwargs_output().items()
] + [self.output.colors['reset']])
+ '.'
+ self.step
+ colors['yellow']
] + [
f'{colors["blue"]}{k}{colors["gray"]}={colors["green2"]}{v}'
for k, v in self.__dict__.items()
if (k in self.display_variables or not self.display_variables)
and (k not in self.hide_variables)
] + [colors['reset']])
def callable(self):
from ..targets import Localhost
@ -199,6 +199,7 @@ class Action:
def action(self, action, *args, **kwargs):
if isinstance(action, str):
# import dotted module path string to action
import cli2
a = cli2.Callable.factory(action).target
if not a:
@ -209,17 +210,26 @@ class Action:
action = a
p = action(*args, **kwargs)
p.parent = self
for parent in self.parents():
if hasattr(parent, 'actions'):
p.parent = parent
break
p.parent = parent
if 'actions' not in self.__dict__:
# "mutate" to Strategy
from ..strategies.script import Actions
self.actions = Actions(self, [p])
return p
def bind(self, *args):
clone = deepcopy(self)
clone.call_args = args
return clone
@class_or_instance_method
def steps(self):
return {
key: getattr(self, key)
for key in dir(self)
if key != 'steps' # avoid recursion
and (
key in self.default_steps
or getattr(getattr(self, key), 'shlaxstep', False)
)
}

View File

@ -2,5 +2,6 @@ from .base import Action
class Copy(Action):
"""Copy files or directories to target."""
async def call(self, *args, **kwargs):
await self.copy(*self.args)

View File

@ -6,14 +6,22 @@ from .base import Action
class Htpasswd(Action):
def __init__(self, path, user, *args, **kwargs):
self.path = path
self.user = user
super().__init__(*args, **kwargs)
"""Ensure a user is present in an htpasswd file."""
display_variables = ('user', 'path')
regexps = {
r'(.*)': '{red}\\1{gray}:${blue}\\2${blue}',
r'([^:]*):\\$([^$]*)\\$(.*)$': '{red}\\1{gray}:${blue}\\2${blue}\\3',
}
async def call(self, *args, **kwargs):
def __init__(self, user, path, **kwargs):
self.user = user
self.path = path
super().__init__(**kwargs)
async def apply(self):
found = False
htpasswd = await self.exec('cat', self.path, raises=False)
htpasswd = await self.target.exec(
'cat', self.path, raises=False)
if htpasswd.rc == 0:
for line in htpasswd.out.split('\n'):
if line.startswith(self.user + ':'):
@ -26,4 +34,4 @@ class Htpasswd(Action):
) for i in range(20))
hashed = hashlib.sha1(self.password.encode('utf8'))
line = f'{self.user}:\\$sha1\\${hashed.hexdigest()}'
await self.exec(f'echo {line} >> {self.path}')
await self.target.exec(f'echo {line} >> {self.path}')

View File

@ -12,14 +12,13 @@ from .base import Action
class Packages(Action):
"""
The Packages visitor wraps around the container's package manager.
Package manager abstract layer with caching.
It's a central piece of the build process, and does iterate over other
container visitors in order to pick up packages. For example, the Pip
visitor will declare ``self.packages = dict(apt=['python3-pip'])``, and the
Packages visitor will pick it up.
"""
contextualize = ['mgr']
regexps = {
#r'Installing ([\w\d-]+)': '{cyan}\\1',
r'Installing': '{cyan}lol',
@ -106,12 +105,12 @@ class Packages(Action):
print(f'{self.container.name} | Waiting for update ...')
await asyncio.sleep(1)
async def call(self, *args, **kwargs):
cached = getattr(self, '_pagkages_mgr', None)
async def apply(self):
cached = getattr(self.target, 'pkgmgr', None)
if cached:
self.mgr = cached
else:
mgr = await self.which(*self.mgrs.keys())
mgr = await self.target.which(*self.mgrs.keys())
if mgr:
self.mgr = mgr[0].split('/')[-1]
@ -122,7 +121,7 @@ class Packages(Action):
if not getattr(self, '_packages_upgraded', None):
await self.update()
if self.kwargs.get('upgrade', True):
await self.rexec(self.cmds['upgrade'])
await self.target.exec(self.cmds['upgrade'], user='root')
self._packages_upgraded = True
packages = []
@ -136,7 +135,7 @@ class Packages(Action):
else:
packages.append(package)
await self.rexec(*self.cmds['install'].split(' ') + packages)
await self.target.exec(*self.cmds['install'].split(' ') + packages, user='root')
async def apk_setup(self):
cachedir = os.path.join(self.cache_root, self.mgr)

View File

@ -5,6 +5,8 @@ from .base import Action
class Pip(Action):
"""Pip abstraction layer."""
def __init__(self, *pip_packages, pip=None, requirements=None):
self.requirements = requirements
super().__init__(*pip_packages, pip=pip, requirements=requirements)

View File

@ -1,14 +1,14 @@
from ..targets.buildah import Buildah
from ..targets.docker import Docker
from .base import Action
class Run(Action):
"""Run a script or command on a target."""
async def call(self, *args, **kwargs):
image = self.kwargs.get('image', None)
if not image:
return await self.exec(*self.args, **self.kwargs)
from ..targets.buildah import Buildah
from ..targets.docker import Docker
if isinstance(image, Buildah):
breakpoint()
result = await self.action(image, *args, **kwargs)

View File

@ -4,6 +4,9 @@ from .base import Action
class Service(Action):
"""
Manage a systemd service.
"""
def __init__(self, *names, state=None):
self.state = state or 'started'
self.names = names

View File

@ -1,80 +1,165 @@
'''
shlax is a micro-framework to orchestrate commands.
"""
Shlax automation tool manual
shlax yourfile.py: to list actions you have declared.
shlax yourfile.py <action>: to execute a given action
#!/usr/bin/env shlax: when making yourfile.py an executable.
'''
Shlax is built mostly around 3 moving pieces:
- Target: a target host and protocol
- Action: execute a shlax action
- Strategy: defines how to apply actions on targets (scripted only)
Shlax executes mostly in 3 ways:
- Execute actions on targets with the command line
- With your shlaxfile as first argument: offer defined Actions
- With the name of a module in shlax.repo: a community maintained shlaxfile
"""
import asyncio
import cli2
import copy
import cli2
import inspect
import importlib
import glob
import os
import sys
from .exceptions import *
from .shlaxfile import Shlaxfile
from .targets import Localhost
from .actions.base import Action
from .exceptions import ShlaxException, WrongResult
from .strategies import Script
class ConsoleScript(cli2.ConsoleScript):
def __call__(self, *args, **kwargs):
self.shlaxfile = None
shlaxfile = sys.argv.pop(1) if len(sys.argv) > 1 else ''
if shlaxfile:
if not os.path.exists(shlaxfile):
try: # missing shlaxfile, what are we gonna do !!
mod = importlib.import_module('shlax.repo.' + shlaxfile)
except ImportError:
print('Could not find ' + shlaxfile)
self.exit_code = 1
return
shlaxfile = mod.__file__
class Parser(cli2.Parser):
def __init__(self, *args, **kwargs):
self.targets = dict()
super().__init__(*args, **kwargs)
self.shlaxfile = Shlaxfile()
self.shlaxfile.parse(shlaxfile)
self._doc = inspect.getdoc(mod)
if 'main' in self.shlaxfile.actions:
action = self.shlaxfile.actions['main']
for name, child in self.shlaxfile.actions['main'].menu.items():
self[name] = cli2.Callable(
name,
child.callable(),
options={
k: cli2.Option(name=k, **v)
for k, v in action.options.items()
},
color=getattr(action, 'color', cli2.YELLOW),
)
for name, action in self.shlaxfile.actions.items():
self[name] = cli2.Callable(
name,
action.callable(),
options={
k: cli2.Option(name=k, **v)
for k, v in action.options.items()
},
color=getattr(action, 'color', cli2.YELLOW),
doc=inspect.getdoc(getattr(action, name, None)) or action._doc,
)
def append(self, arg):
if '=' not in arg and '@' in arg:
if '://' in arg:
kind, spec = arg.split('://')
else:
kind = 'ssh'
spec = arg
mod = importlib.import_module('shlax.targets.' + kind)
target = getattr(mod, kind.capitalize())(spec)
self.targets[str(target)] = target
else:
super().append(arg)
def __call__(self):
if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
pass
else:
from shlax import repo
path = repo.__path__._path[0]
for shlaxfile in glob.glob(os.path.join(path, '*.py')):
name = shlaxfile.split('/')[-1].split('.')[0]
mod = importlib.import_module('shlax.repo.' + name)
self[name] = cli2.Callable(name, mod)
scripts = glob.glob(os.path.join(
os.path.dirname(__file__), 'actions', '*.py'))
for script in scripts:
modname = script.split('/')[-1].replace('.py', '')
mod = importlib.import_module('shlax.actions.' + modname)
for key, value in mod.__dict__.items():
if key.lower() != modname:
continue
break
self[modname] = cli2.Callable(
modname, self.action_class(value))
return super().__call__(*args, **kwargs)
scripts = glob.glob(os.path.join(
os.path.dirname(__file__), 'repo', '*.py'))
for script in scripts:
modname = script.split('/')[-1].replace('.py', '')
mod = importlib.import_module('shlax.repo.' + modname)
self[modname] = cli2.Group(key, doc=inspect.getdoc(mod))
for key, value in mod.__dict__.items():
if not isinstance(value, Action):
continue
doc = (inspect.getdoc(mod) or '').split("\n")[0]
if key == 'main':
if len(value.steps()) == 1:
self[modname] = cli2.Callable(
modname, self.action(value), doc=doc)
else:
for name, method in value.steps().items():
self[modname][name] = cli2.Callable(
modname, self.action(value),
doc=inspect.getdoc(method)
)
else:
if len(value.steps()) == 1:
self[modname][key] = cli2.Callable(
modname, self.action(value), doc=doc)
else:
self[modname][key] = cli2.Group('steps')
for step in value.steps():
self[modname][key][step] = cli2.Callable(
modname, self.action(value), doc='lol')
return super().__call__()
def action(self, action):
async def cb(*args, **kwargs):
options = dict(steps=args)
options.update(self.parser.options)
# UnboundLocalError: local variable 'action' referenced before assignment
# ??? gotta be missing something, commenting meanwhile
# action = copy.deepcopy(action)
return await action(*self.parser.targets, **options)
return cb
def action_class(self, action_class):
async def cb(*args, **kwargs):
argspec = inspect.getfullargspec(action_class)
required = argspec.args[1:]
missing = []
for i, name in enumerate(required):
if len(args) - 1 <= i:
continue
if name in kwargs:
continue
missing.append(name)
if missing:
if not args:
print('No args provided after action name ' + action_class.__name__.lower())
print('Required arguments: ' + ', '.join(argspec.args[1:]))
if args:
print('Provided: ' + ', '.join(args))
print('Missing arguments: ' + ', '.join(missing))
print('Try to just add args on the command line separated with a space')
print(inspect.getdoc(action_class))
example = 'Example: shlax action '
example += action_class.__name__.lower()
if args:
example += ' ' + ' '.join(args)
example += ' ' + ' '.join(missing)
print(example)
return
_args = []
steps = []
for arg in args:
if arg in action_class.steps():
steps.append(arg)
else:
_args.append(arg)
options = dict(steps=steps)
'''
varargs = argspec.varargs
if varargs:
extra = args[len(argspec.args) - 1:]
args = args[:len(argspec.args) - 1]
options = dict(steps=extra)
else:
extra = args[len(argspec.args) - 1:]
args = args[:len(argspec.args) - 1]
options = dict(steps=extra)
'''
options.update(self.parser.options)
return await action_class(*_args, **kwargs)(*self.parser.targets, **options)
cb.__doc__ = (inspect.getdoc(action_class) or '').split("\n")[0]
return cb
def call(self, command):
kwargs = copy.copy(self.parser.funckwargs)
kwargs.update(self.parser.options)
try:
return command(*self.parser.funcargs, **kwargs)
return super().call(command)
except WrongResult as e:
print(e)
self.exit_code = e.proc.rc
@ -82,5 +167,4 @@ class ConsoleScript(cli2.ConsoleScript):
print(e)
self.exit_code = 1
cli = ConsoleScript(__doc__).add_module('shlax.cli')

View File

@ -1,37 +0,0 @@
from copy import deepcopy
import yaml
from shlax import *
class GitLabCI(Script):
async def call(self, *args, write=True, **kwargs):
output = dict()
for key, value in self.kwargs.items():
if isinstance(value, dict):
output[key] = deepcopy(value)
image = output[key].get('image', 'alpine')
if hasattr(image, 'image'):
output[key]['image'] = image.image.repository + ':$CI_COMMIT_SHORT_SHA'
else:
output[key] = value
output = yaml.dump(output)
if kwargs['debug'] is True:
self.output(output)
if write:
with open('.gitlab-ci.yml', 'w+') as f:
f.write(output)
from shlax.cli import cli
for arg in args:
job = self.kwargs[arg]
_args = []
if not isinstance(job['image'], str):
image = str(job['image'].image)
else:
image = job['image']
await self.action('Docker', Run(job['script']), image=image)(*_args, **kwargs)
def colorized(self):
return type(self).__name__

View File

@ -104,7 +104,7 @@ class Output:
self.colors['purplebold'],
'! TEST ',
self.colors['reset'],
action.colorized(),
action.colorized(self.colors),
'\n',
]))
@ -114,7 +114,7 @@ class Output:
self.colors['bluebold'],
'+ CLEAN ',
self.colors['reset'],
action.colorized(),
action.colorized(self.colors),
'\n',
]))
@ -124,7 +124,7 @@ class Output:
self.colors['orangebold'],
'⚠ START ',
self.colors['reset'],
action.colorized(),
action.colorized(self.colors),
'\n',
]))
@ -134,7 +134,7 @@ class Output:
self.colors['greenbold'],
'✔ SUCCESS ',
self.colors['reset'],
action.colorized() if hasattr(action, 'colorized') else str(action),
action.colorized(self.colors) if hasattr(action, 'colorized') else str(action),
'\n',
]))
@ -144,6 +144,6 @@ class Output:
self.colors['redbold'],
'✘ FAIL ',
self.colors['reset'],
action.colorized() if hasattr(action, 'colorized') else str(action),
action.colorized(self.colors) if hasattr(action, 'colorized') else str(action),
'\n',
]))

9
shlax/play.py Normal file
View File

@ -0,0 +1,9 @@
from .targets import Localhost
class Play:
def __init__(self, *actions, targets=None, options=None):
self.options = options or {}
self.targets = targets or dict(localhost=Localhost())
self.actions =

View File

@ -3,11 +3,15 @@
Manage a traefik container maintained by Shlax community.
"""
from shlax import *
from shlax.shortcuts import *
main = Docker(
name='traefik',
image='traefik:v2.0.0',
install=Htpasswd(
'./htpasswd', 'root', doc='Install root user in ./htpasswd'
),
networks=['web'],
command=[
'--entrypoints.web.address=:80',
@ -28,14 +32,4 @@ main = Docker(
'traefik.http.routers.traefik.service=api@internal',
'traefik.http.routers.traefik.entrypoints=web',
],
doc='Current traefik instance',
)
install = Script(
Htpasswd('./htpasswd', 'root'),
main.bind('up'),
doc='Deploy a Traefik instance',
)
up = main.bind('up')
rm = main.bind('rm')

7
shlax/result.py Normal file
View File

@ -0,0 +1,7 @@
class Result:
def __init__(self, action, target):
self.action = action
self.target = target
self.status = 'pending'

View File

@ -16,7 +16,7 @@ class Shlaxfile:
spec.loader.exec_module(mod)
for name, value in mod.__dict__.items():
if isinstance(value, Action):
value.name = name
value.__name__ = name
self.actions[name] = value
elif callable(value) and getattr(value, '__name__', '').startswith('test_'):
self.tests[value.__name__] = value

14
shlax/shortcuts.py Normal file
View File

@ -0,0 +1,14 @@
from .actions.copy import Copy
from .actions.packages import Packages # noqa
from .actions.base import Action # noqa
from .actions.htpasswd import Htpasswd
from .actions.run import Run # noqa
from .actions.pip import Pip
from .actions.service import Service
from .targets.buildah import Buildah
from .targets.docker import Docker
from .targets.localhost import Localhost
from .targets.ssh import Ssh

7
shlax/strategies/test.py Normal file
View File

@ -0,0 +1,7 @@
from .script import Script
class Test(Script):
async def call(self, *args, backend=None, **kwargs):
backend = backend or 'Docker'
breakpoint()
return await self.action(backend, self.actions, **kwargs)

View File

@ -1,4 +0,0 @@
from .buildah import Buildah
from .docker import Docker
from .localhost import Localhost
from .ssh import Ssh

View File

@ -7,7 +7,8 @@ from .localhost import Localhost
class Docker(Localhost):
contextualize = Localhost.contextualize + ['mnt', 'ctr', 'mount']
"""Manage a docker container."""
default_steps = ['install', 'up']
def __init__(self, *args, **kwargs):
self.image = kwargs.get('image', 'alpine')
@ -51,33 +52,29 @@ class Docker(Localhost):
# raises=False
# )
# ).out.split('\n')[0]
if step('install') and 'install' in self.kwargs:
breakpoint()
await self.action(self.kwargs['install'], *args, **kwargs)
if step('rm'):
await self.rm(*args, **kwargs)
if step('down') and self.name:
await self.exec('docker', 'down', '-f', self.name)
if step('rm') and await self.exists():
await self.exec('docker', 'rm', '-f', self.name)
if step('up'):
await self.up(*args, **kwargs)
if await self.exists():
self.name = (await self.exec('docker', 'start', self.name)).out
else:
self.id = (await self.exec(
'docker', 'run', '-d', '--name', self.name, str(self.image))
).out
return await super().call(*args, **kwargs)
async def rm(self, *args, **kwargs):
return await self.exec('docker', 'rm', '-f', self.name)
async def down(self, *args, **kwargs):
"""Remove instance, except persistent data if any"""
if self.name:
self.name = (await self.exec('docker', 'start', self.name)).out
else:
self.name = (await self.exec('docker', 'run', '-d', '--name', self.name)).out
async def up(self, *args, **kwargs):
"""Perform start or run"""
if self.name:
self.name = (await self.exec('docker', 'start', self.name)).out
else:
self.id = (await self.exec('docker', 'run', '-d', '--name', self.name)).out
async def exists(self):
proc = await self.exec(
'docker', 'ps', '-aq', '--filter',
'name=' + self.name,
raises=False
)
return bool(proc.out.strip())
async def copy(self, *args):
src = args[:-1]
@ -97,3 +94,18 @@ class Docker(Localhost):
procs.append(self.exec(*args))
return await asyncio.gather(*procs)
async def up(self):
"""Ensure container is up and running."""
if await self.exists():
self.name = (await self.exec('docker', 'start', self.name)).out
else:
self.id = (await self.exec(
'docker', 'run', '-d', '--name', self.name, str(self.image))
).out
up.shlaxstep = True
async def rm(self):
"""Remove container."""
await self.exec('docker', 'rm', '-f', self.name)
rm.shlaxstep = True

View File

@ -35,9 +35,7 @@ class Localhost(Script):
return args, kwargs
async def exec(self, *args, **kwargs):
if 'debug' not in kwargs:
kwargs['debug'] = getattr(self, 'call_kwargs', {}).get('debug', False)
kwargs.setdefault('output', self.output)
kwargs['output'] = self.output
args, kwargs = self.shargs(*args, **kwargs)
proc = await Proc(*args, **kwargs)()
if kwargs.get('wait', True):

View File

@ -12,7 +12,7 @@ build = Buildah(
'quay.io/podman/stable',
Packages('python38', 'buildah', 'unzip', 'findutils', 'python3-yaml', upgrade=False),
Async(
# python3.8 on centos with pip dance ...
# dancing for pip on centos python3.8
Run('''
curl -o setuptools.zip https://files.pythonhosted.org/packages/42/3e/2464120172859e5d103e5500315fb5555b1e908c0dacc73d80d35a9480ca/setuptools-45.1.0.zip
unzip setuptools.zip