#!/usr/bin/env python """Script to create self contained install. The goal of this script is simple: * Create a self contained install of the CLI that has requires no external resources during installation. It does this by using all the normal python tooling (virtualenv, pip) but provides a simple, easy to use interface for those not familiar with the python ecosystem. """ import os import sys import subprocess import shutil import tempfile import zipfile from contextlib import contextmanager EXTRA_RUNTIME_DEPS = [ # Use an up to date virtualenv/pip/setuptools on > 2.6. ('virtualenv', '16.7.8'), ('jmespath', '0.10.0'), ] PINNED_RUNTIME_DEPS = [ # The CLI has a relaxed pin for colorama, but versions >0.4.5 # require extra build time dependencies. We are pinning it to # a version that does not need those. ('colorama', '0.4.5'), # 2.0.0 of urllib3 started requiring hatchling as well ('urllib3', '1.26.20'), ] BUILDTIME_DEPS = [ ('setuptools', '75.4.0'), # start of >= 3.9 ('setuptools-scm', '3.3.3'), ('wheel', '0.45.1'), # 0.46.0+ requires packaging ] PIP_DOWNLOAD_ARGS = '--no-build-isolation --no-binary :all:' class BadRCError(Exception): pass @contextmanager def cd(dirname): original = os.getcwd() os.chdir(dirname) try: yield finally: os.chdir(original) def run(cmd): sys.stdout.write(f"Running cmd: {cmd}\n") p = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) stdout, stderr = p.communicate() rc = p.wait() if p.returncode != 0: raise BadRCError(f"Bad rc ({rc}) for cmd '{cmd}': {stderr + stdout}") return stdout def create_scratch_dir(): # This creates the dir where all the bundling occurs. # First we need a top level dir. dirname = tempfile.mkdtemp(prefix='bundle') # Then we need to create a dir where all the packages # will come from. os.mkdir(os.path.join(dirname, 'packages')) os.mkdir(os.path.join(dirname, 'packages', 'setup')) return dirname def download_package_tarballs(dirname, packages): with cd(dirname): for package, package_version in packages: run( f'{sys.executable} -m pip download {package}=={package_version}' f' {PIP_DOWNLOAD_ARGS}' ) def download_package_wheels(dirname, packages): with cd(dirname): for package, package_version in packages: run( f'{sys.executable} -m pip download {package}=={package_version}' f' --only-binary :all:' ) def validate_that_wheels_are_universal(dirname): with cd(dirname): for wheel_path in os.listdir(): if not wheel_path.endswith('py3-none-any.whl'): raise ValueError(f'Found a non universal wheel: {wheel_path}') def download_cli_deps(scratch_dir, packages): # pip download will always download a more recent version of a package # even if one exists locally. The list of packages supplied in `packages` # forces the use of a specific runtime dependency. awscli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) pinned_packages = " ".join( f"{name}=={version}" for (name, version) in packages ) with cd(scratch_dir): run(f"pip download {PIP_DOWNLOAD_ARGS} {pinned_packages} {awscli_dir}") def _remove_cli_zip(scratch_dir): clidir = [f for f in os.listdir(scratch_dir) if f.startswith('awscli')] assert len(clidir) == 1 os.remove(os.path.join(scratch_dir, clidir[0])) def add_cli_sdist(scratch_dir): awscli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if os.path.exists(os.path.join(awscli_dir, 'dist')): shutil.rmtree(os.path.join(awscli_dir, 'dist')) with cd(awscli_dir): run(f'{sys.executable} setup.py sdist') filename = os.listdir('dist')[0] shutil.move( os.path.join('dist', filename), os.path.join(scratch_dir, filename) ) def create_bootstrap_script(scratch_dir): install_script = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'install' ) shutil.copy(install_script, os.path.join(scratch_dir, 'install')) def zip_dir(scratch_dir): basename = 'awscli-bundle.zip' dirname, tmpdir = os.path.split(scratch_dir) final_dir_name = os.path.join(dirname, 'awscli-bundle') if os.path.isdir(final_dir_name): shutil.rmtree(final_dir_name) shutil.move(scratch_dir, final_dir_name) with cd(dirname): with zipfile.ZipFile(basename, 'w', zipfile.ZIP_DEFLATED) as zipped: for root, dirnames, filenames in os.walk('awscli-bundle'): for filename in filenames: zipped.write(os.path.join(root, filename)) return os.path.join(dirname, basename) def verify_preconditions(): # The pip version looks like: # 'pip 1.4.1 from ....' pip_version = run(f'{sys.executable} -m pip --version').strip().split()[1] # Virtualenv version just has the version string: '1.14.5\n' virtualenv_version = run( f'{sys.executable} -m virtualenv --version' ).strip() _min_version_required('9.0.1', pip_version, 'pip') _min_version_required('15.1.0', virtualenv_version, 'virtualenv') def _min_version_required(min_version, actual_version, name): # precondition: min_version is major.minor.patch # actual_version is major.minor.patch min_split = min_version.split('.') actual_split = actual_version.decode('utf-8').split('.') for min_version_part, actual_version_part in zip(min_split, actual_split): if int(actual_version_part) >= int(min_version_part): return raise ValueError( f'{name} requires at least version {min_version}, ' f'but version {actual_version} was found.' ) def main(): verify_preconditions() scratch_dir = create_scratch_dir() package_dir = os.path.join(scratch_dir, 'packages') print(f"Bundle dir at: {scratch_dir}") download_package_tarballs( package_dir, packages=EXTRA_RUNTIME_DEPS, ) # Some packages require setup time dependencies, and so we will need to # manually install them. We isolate them to a particular directory so we # can run the install before the things they're dependent on. We have to do # this because pip won't actually find them since it doesn't handle build # dependencies. We use wheels for this, to avoid bootstrapping setuptools # in 3.12+ where it's no longer included by default. setup_dir = os.path.join(package_dir, 'setup') download_package_wheels( setup_dir, packages=BUILDTIME_DEPS, ) validate_that_wheels_are_universal(setup_dir) download_cli_deps(package_dir, packages=PINNED_RUNTIME_DEPS) add_cli_sdist(package_dir) create_bootstrap_script(scratch_dir) zip_filename = zip_dir(scratch_dir) print(f"Zipped bundle installer is at: {zip_filename}") if __name__ == '__main__': main()