Skip to content

Instantly share code, notes, and snippets.

@cjlawson02
Last active October 7, 2018 23:33
Show Gist options
  • Save cjlawson02/9a628d000c83b5763667fef157402e7c to your computer and use it in GitHub Desktop.
Save cjlawson02/9a628d000c83b5763667fef157402e7c to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
'''
Greybots Environment Installer
'''
from argparse import ArgumentParser
from inspect import getdoc
from os import chdir, path as ospath
from pathlib import Path
from shutil import rmtree
from subprocess import Popen, CalledProcessError, PIPE, STDOUT
import sys
from platform import system
from logging import basicConfig, getLogger, INFO, DEBUG, WARNING
LOGGER = getLogger('greybots.installer')
####################
# Misc Functions #
####################
# User home directory
def user_home():
'''
User home directory returner.
Some python versions do not support Path.home()
'''
try:
return Path.home()
except AttributeError:
return ospath.expanduser('~')
# Yes no prompt
def query_yes_no(question, default='yes'):
'''Ask a yes/no question via input() and return their answer.
'question' is a string that is presented to the user.
'default' is the presumed answer if the user just hits <Enter>.
It must be 'yes' (the default), 'no' or None (meaning
an answer is required of the user).
The 'answer' return value is True for 'yes' or False for 'no'.
'''
valid = {'yes': True, 'y': True, 'ye': True,
'no': False, 'n': False}
if default is None:
prompt = ' [y/n] '
elif default == 'yes':
prompt = ' [Y/n] '
elif default == 'no':
prompt = ' [y/N] '
else:
raise GeneralError('Invalid default answer: {}'.format(default))
while True:
LOGGER.warning(question + prompt)
try:
choice = input().lower()
if default is not None and choice == '':
return valid[default]
elif choice in valid:
return valid[choice]
else:
LOGGER.error("Invalid option. Please respond with 'yes' or "
" 'no' (or 'y' or 'n')")
except ValueError:
LOGGER.error("An error occured. Try again.")
###################
# Error Classes #
###################
# A general error, typically used for unexpected failures.
class GeneralError(Exception):
'''
General error handler.
'''
def __init__(self, message, *args):
super(GeneralError, self).__init__(message)
self.message = message
LOGGER.exception('GeneralError: %s', self.message, *args)
exit(1)
# General shell command error handler.
class ShellError(Exception):
'''
A shell error. Used when a shell command doesn't exist or exits non-zero.
'''
def __init__(self, message, *args):
super(ShellError, self).__init__(message)
self.message = message
LOGGER.exception('ShellError: %s', self.message, *args)
exit(2)
####################
# Shell Commands #
####################
# Run a shell command.
def run(command):
'''
A subprocess.Popen wrapper with logging.
'''
LOGGER.debug('=> %s', command)
try:
process = Popen(command, stdout=PIPE, stderr=STDOUT)
exitcode = process.wait()
with process.stdout:
for stdout in iter(process.stdout.readline, b''):
LOGGER.debug('%s: %r', command, stdout)
if exitcode != 0:
raise CalledProcessError(exitcode, command)
except CalledProcessError as exception:
raise ShellError('{} Failed: {}'.format(command, exception))
else:
LOGGER.debug('%s finished successfully', command)
# Search if a particular shell application is installed.
def find_application(name):
'''
Application finder, used to check for preinstalled dependencies.
'''
LOGGER.debug('Searching for %s...', name)
try:
run(name)
except OSError:
LOGGER.debug('Could not find %s', name)
return False
else:
LOGGER.debug('Found %s', name)
return True
# Search if java is installed on the mac platform.
# TODO: find the actual exception type
def find_mac_jdk8():
'''
Special finder for JDK 8 on Mac... Mac has a fake library built in.
'''
LOGGER.debug('Searching for macOS JDK 8...')
try:
run([Path('/usr/libexec/java_home'), '-v', '1.8'])
except:
LOGGER.debug('Could not find macOS JDK 8')
return False
else:
LOGGER.debug('Found macOS JDK 8')
return True
# Apt package installer.
def apt_install(packages):
'''
Package installer for Debian-based ems with apt.
'''
LOGGER.debug('Installing %s using apt', packages)
run(['sudo', 'apt', 'install', '-y', packages])
# Apt source updater.
def apt_update():
'''
Update apt's source list.
'''
LOGGER.debug('Updating apt')
run(['sudo', 'apt', 'update'])
# Apt upgrader.
def apt_upgrade():
'''
Upgrade apt packages.
'''
LOGGER.debug('Upgrading apt packages')
run(['sudo', 'apt', 'upgrade', '-y'])
def get_distro_version():
'''
Version of your Linux distro.
'''
if system() == 'Linux':
with open('/etc/issue') as version:
return version.read().lower().split()[1]
#########################
# Installer Functions #
#########################
# Check command optional arguments.
def check_opts(check_parser):
'''
Optional arguments for the check option.
'''
# Run a dependency check.
def check(options):
'''
Check if dependencies and extras are installed.
'''
if system() != 'Windows':
LOGGER.info('Running dependency checker...')
LOGGER.debug('Arguments: %s', options)
LOGGER.info('Git: %s', find_application(['git', '--version']))
LOGGER.info('cURL: %s', find_application(['curl', '-V']))
if system() == 'Darwin':
LOGGER.info('Homebrew: %s', find_application(['brew', '-v']))
LOGGER.info('JDK 8: %s', find_mac_jdk8())
else:
LOGGER.info('Apt: %s', find_application(['apt', '-v']))
LOGGER.info('software-properties-common: %s', find_application([
'add-apt-repository', '-h']))
LOGGER.info('JDK 8: %s', find_application(['java', '-version']))
LOGGER.info('Clang: %s', find_application(['clang', '-v']))
LOGGER.info('Clang Format: %s', find_application(['clang-format',
'-version']))
LOGGER.info('frc-toolchain: %s',
find_application(['arm-frc-linux-gnueabi-g++',
'--version']))
LOGGER.info('Bazel: %s', find_application(['bazel', 'version']))
else:
raise GeneralError('Platform is not supported.')
# Install command optional arguments.
def install_opts(install_parser):
'''
Optional arguments for the install option.
'''
install_parser.add_argument('--build',
help='automatically compile code to test '
'Bazel',
action='store_true')
install_parser.add_argument('--clone-repo',
help='automatically clone the repo',
action='store_true')
install_parser.add_argument('--github',
help='clone/build repositories from a '
'specific directory other than '
'~/Documents/GitHub')
install_parser.add_argument('--repo',
help='specify a repo to use for clone/build '
'from Team973')
install_parser.add_argument('--travis',
help='Travis CI mode',
action='store_true')
# Install and configure the environment.
def install(options):
'''
Main installer for environment.
'''
if system() != 'Windows':
cache_root = Path(user_home(), 'greybots_installer_cache')
run(['mkdir', '-p', cache_root])
LOGGER.debug('Caching files at %s', cache_root)
LOGGER.info('Running installer...')
LOGGER.debug('Arguments: %s', options)
LOGGER.debug('Cache root: %s', cache_root)
if system() == 'Darwin':
######################
# Package Managers #
######################
# Homebrew should already be installed to run python3, but in some
# rare case, check for it.
if not find_application(['brew', '-v']):
raise GeneralError('Please install Homebrew before running.')
#############################
# Development Dependencies #
#############################
# JDK 8
if not find_mac_jdk8():
LOGGER.info('Installing Java Development Kit...')
run(['brew', 'tap',
'caskroom/versions'])
run(['brew', 'cask',
'install', 'java8'])
# clang, macOS has it in xcode-install
if not find_application(['clang-format', '-version']):
LOGGER.info('Installing Clang Format...')
run(['brew', 'install',
'clang-format'])
# frc-toolchain
if not find_application(['arm-frc-linux-gnueabi-g++',
'--version']):
LOGGER.info('Installing frc-toolchain...')
frc_tar = Path(cache_root, 'FRC-Toolchain.pkg.tar.gz')
run(['curl', '-s', '-o', frc_tar,
'http://first.wpi.edu/FRC/roborio/toolchains/'
'FRC-2018-OSX-Toolchain-5.5.pkg.tar.gz'])
run(['tar', 'xvf', frc_tar, '-C', cache_root])
run(['sudo', 'installer', '-pkg',
Path(cache_root, 'FRC ARM Toolchain.pkg/'), '-target',
'/'])
# Bazel
if not find_application(['bazel', 'version']):
LOGGER.info('Installing Bazel...')
run(['brew', 'install',
'bazel'])
elif system() == 'Linux':
######################
# Package Managers #
######################
# Apt
if not find_application(['apt', '-v']):
raise GeneralError('Apt not installed. Probably not a '
'compatible Debian platform.')
elif find_application(['apt', '-v']):
LOGGER.info('Updating and upgrading APT...')
apt_update()
apt_upgrade()
# software-properties-common
if not find_application(['add-apt-repository', '-h']):
LOGGER.info('Installing software-properties-common...')
apt_install('software-properties-common')
# git, required and repositories
if not find_application(['git', '--version']):
LOGGER.info('Installing Git...')
apt_install('git')
if not find_application(['curl', '-V']):
LOGGER.info('Installing cURL...')
apt_install('curl')
#############################
# Development Dependencies #
#############################
# JDK 8
if not find_application(['java', '-version']):
LOGGER.info('Installing Java Development Kit...')
apt_install('openjdk-8-jdk')
# clang
if not find_application(['clang', '-v']):
LOGGER.info('Installing Clang...')
apt_install('clang')
if not (find_application(['clang-format', '-version']) or options.travis):
LOGGER.info('Installing Clang Format...')
apt_install('clang-format')
# frc-toolchain
if not find_application(['arm-frc-linux-gnueabi-g++',
'--version']):
LOGGER.info('Installing frc-toolchain...')
# FIXME: Temporary fix for WPILIB's PPA.
if get_distro_version() == '18.04' or get_distro_version() == '18.04.1':
wpilib_apt = open(Path(cache_root, 'wpilib.list'), 'w')
wpilib_apt.write('deb http://ppa.launchpad.net/wpilib/toolchain/ubuntu xenial main')
wpilib_apt.close()
run(['sudo', 'cp', Path(cache_root, 'wpilib.list'),
'/etc/apt/sources.list.d/wpilib.list'])
run(['sudo', 'apt-key', 'adv', '--keyserver',
'keyserver.ubuntu.com', '--recv-keys',
'D51C1F785D5D961140546F3AA06B9F8D55FC4DAE'])
artful_apt = open(Path(cache_root, 'artful.list'),
'w')
artful_apt.write('deb http://cz.archive.ubuntu.com/ubuntu artful main')
artful_apt.close()
run(['sudo', 'cp', Path(cache_root, 'artful.list'),
'/etc/apt/sources.list.d/artful.list'])
else:
run(['sudo', 'add-apt-repository', 'ppa:wpilib/toolchain',
'-y'])
apt_update()
apt_install('frc-toolchain')
# Bazel
if not find_application(['bazel', 'version']):
LOGGER.info('Installing Bazel...')
run(['curl', '-o',
Path(cache_root, 'bazel-release.pub.gpg'),
'https://bazel.build/bazel-release.pub.gpg'])
run(['sudo', 'apt-key', 'add', Path(cache_root,
'bazel-release.pub.gpg')])
bazel_apt = open(Path(cache_root, 'bazel.list'), 'w')
bazel_apt.write('deb [arch=amd64] http://storage.googleapis.com/bazel-apt stable jdk1.8')
bazel_apt.close()
run(['sudo', 'cp', Path(cache_root, 'bazel.list'), '/etc/apt/sources.list.d/bazel.list'])
apt_update()
apt_install('bazel')
LOGGER.info('Finished installing dependencies')
######################
# Repository Setup #
######################
# Ask the user if they want to clone.
clone_yn = False
if not (options.travis or options.clone_repo):
if query_yes_no('Clone {} repository?'.format(options.repo)):
LOGGER.debug('User decided to clone')
clone_yn = True
# Clone the repository
if options.clone_repo or clone_yn and not options.travis:
github_dir = Path(options.github)
run(['mkdir', '-p', github_dir])
repo_url = 'https://github.com/team973/{}'.format(options.repo)
repo_dir = Path(github_dir, options.repo)
if not repo_dir.is_dir():
LOGGER.info('Cloning %s to %s', options.repo, options.github)
run(['git', 'clone', repo_url, repo_dir])
LOGGER.warning(
'Cloned using HTTPS. SSH needs additional steps.')
else:
LOGGER.warning('Repository already exists. Not cloning.')
################
# Test Build #
################
# Ask the user if they want to build.
build_yn = False
if not (options.travis or options.build):
if query_yes_no('Build {} with Bazel?'.format(options.repo)):
LOGGER.debug('User decided to build')
build_yn = True
# Build
if options.build or build_yn or options.travis:
LOGGER.info('Building using Bazel')
if options.travis:
chdir('/build')
else:
chdir(Path(options.github, options.repo))
if system() == 'Darwin':
run(['bazel', 'build', '//src:robot', '--cpu=roborio-darwin'])
elif system() == 'Linux':
run(['bazel', 'build', '//src:robot', '--cpu=roborio'])
#############
# Cleanup #
#############
LOGGER.info('Cleaning up...')
rmtree(cache_root)
else:
raise GeneralError('Platform is not supported')
####################
# Main functions #
####################
# Main function.
def main(args=None):
'''
Main function that runs at the start of the program.
'''
##########################
# Parser Configuration #
##########################
# Method handler
parser = ArgumentParser(description='Greybots Environement Installer')
# Argument handler
subparser = parser.add_subparsers(dest='command', help='Commands')
subparser.required = True
# Shared arguments
shared = ArgumentParser(add_help=False)
shared.add_argument('-v', '--verbose',
help='verbose output',
action='store_true')
shared.add_argument('-q', '--quiet',
help='output only warnings and errors',
action='store_true')
#####################################
# Avaiable Commands Configuration #
#####################################
# List of methods available to the user. Requires _opts method.
command_methods = [
'check',
'install'
]
# Setup avaiable methods
for command in command_methods:
cmd_method = getattr(sys.modules[__name__], command)
method_opt = getattr(sys.modules[__name__], command + '_opts')
cmd_parser = subparser.add_parser(command,
help=getdoc(cmd_method),
parents=[shared])
method_opt(cmd_parser)
cmd_parser.set_defaults(cmdobj=cmd_method, github=Path(
user_home(), 'Documents', 'GitHub'), repo='greybots-skeleton')
# Parse the arguments into an object
options = parser.parse_args(args)
#####################
# Logger Settings #
#####################
# Logger format
log_datefmt = '%H:%M:%S'
log_format = '%(asctime)s:%(msecs)03d %(levelname)-8s: %(name)-20s: %(message)s'
# Configure Logger
basicConfig(datefmt=log_datefmt, format=log_format)
# Logger output threshold customization
if options.verbose:
LOGGER.setLevel(DEBUG)
elif options.quiet:
LOGGER.setLevel(WARNING)
sys.tracebacklimit = 0
else:
LOGGER.setLevel(INFO)
sys.tracebacklimit = 0
###################
# Method Caller #
###################
# Start the requested script
LOGGER.info('Greybots Environement Installer')
options.cmdobj(options)
# Run the main function at start.
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment