blob: 2294a7e582913008599cf8f0aaf62bfc46f9e16c [file] [log] [blame]
#
# Copyright (C) 2018 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Tools for bootstrapping Python 3."""
import datetime
import logging
import multiprocessing
import os
import pipes
import shutil
import subprocess
import sys
import timeit
THIS_DIR = os.path.realpath(os.path.dirname(__file__))
def logger():
"""Returns the module level logger."""
return logging.getLogger(__name__)
def android_path(*args):
"""Returns the absolute path rooted within the top level source tree."""
return os.path.normpath(os.path.join(THIS_DIR, '../..', *args))
PYTHON_SOURCE = android_path('external/python/cpython3')
def _get_dir_from_env(default, env_var):
"""Returns the path to a directory specified by the environment.
If the environment variable is not set, the default will be used. The
directory is created if it does not exist.
Args:
default: The path used if the environment variable is not set.
env_var: The environment variable that contains the path, if any.
Returns:
The absolute path to the directory.
"""
path = os.path.realpath(os.getenv(env_var, default))
if not os.path.isdir(path):
os.makedirs(path)
return path
def get_out_dir():
"""Returns the out directory."""
return _get_dir_from_env(android_path('out'), 'OUT_DIR')
def get_dist_dir():
"""Returns the distribution directory.
The contents of the distribution directory are archived on the build
servers. Suitable for build logs and final artifacts.
"""
return _get_dir_from_env(os.path.join(get_out_dir(), 'dist'), 'DIST_DIR')
def path_in_out(dirname):
"""Returns a path within the out directory."
Args:
dirname: Name of the directory.
Returns:
Absolute path within the out directory.
"""
return os.path.join(get_out_dir(), dirname)
def log_failure_and_exit(output):
"""Logs the bootstrapping failure and exits.
Args:
output: Output of the failed command.
"""
log_dir = os.path.join(get_dist_dir(), 'logs')
if not os.path.exists(log_dir):
os.makedirs(log_dir)
log_path = os.path.join(log_dir, 'build_error.log')
with open(log_path, 'w') as error_log:
error_log.write('Bootstrapping failed!\n')
error_log.write(output)
logger().error(output)
sys.exit('Bootstrapping failed!')
def check_output(cmd):
"""Logged version of subprocess.check_output.
stderr is automatically forwarded to stdout.
Args:
cmd: argv style argument list for the process to be run.
Returns:
Output
"""
logger().debug('Runnning: %s', ' '.join([pipes.quote(a) for a in cmd]))
return subprocess.check_output(cmd, stderr=subprocess.STDOUT)
def build_python(install_dir, build_dir):
"""Builds and installs Python to the given directory.
Args:
install_dir: Install path for the built Python distribution.
build_dir: Directory to use for building Python.
"""
logger().info('Bootstrapping Python...')
if os.path.exists(build_dir):
shutil.rmtree(build_dir)
os.makedirs(build_dir)
old_cwd = os.getcwd()
try:
os.chdir(build_dir)
check_output([
os.path.join(PYTHON_SOURCE, 'configure'),
'--prefix=' + install_dir,
# This enables PGO and requires running all the Python tests to
# generate those profiles. If we end up repackaging this Python to
# ship in the NDK we should do this, but for now it makes
# bootstrapping take a lot longer and we don't need the perforance
# since our build time is dominated by non-Python code anyway.
# '--enable-optimizations',
])
check_output([
'make',
'-j',
str(multiprocessing.cpu_count()),
'install',
])
except subprocess.CalledProcessError as ex:
log_failure_and_exit(ex.output)
finally:
os.chdir(old_cwd)
def install_requirements(install_dir, requirements):
"""Installs required Python packages using pip.
Args:
install_dir: Directory in which Python 3 is installed.
requirements: Path to requirements.txt file to be passed to pip.
"""
logger().info('Installing additional requirements...')
try:
check_output([
os.path.join(install_dir, 'bin/pip3'),
'install',
'-r',
requirements,
])
except subprocess.CalledProcessError as ex:
log_failure_and_exit(ex.output)
class Timer(object): # pylint: disable=useless-object-inheritance
"""Execution timer.
Can be used explicitly with stop/start, but preferably is used as a context
manager:
>>> timer = Timer()
>>> with timer:
>>> do_something()
>>> print('do_something() took {}'.format(timer.duration))
"""
def __init__(self):
self.start_time = None
self.end_time = None
self.duration = None
def start(self):
"""Start the timer."""
self.start_time = timeit.default_timer()
def finish(self):
"""Stop the timer."""
self.end_time = timeit.default_timer()
# Not interested in partial seconds at this scale.
seconds = int(self.end_time - self.start_time)
self.duration = datetime.timedelta(seconds=seconds)
def __enter__(self):
self.start()
def __exit__(self, _exc_type, _exc_value, _traceback):
self.finish()
def read_requirements(requirements):
"""Returns the contents of a requirements file or None.
Args:
requirements: Path to a requirements.txt file that may or may not
exist, or none.
Returns:
The contents of the requirements file if it exists, or None if the
requirequirements file is None or does not exist.
"""
if requirements is None:
return None
if not os.path.exists(requirements):
return None
with open(requirements) as requirements_file:
return requirements_file.read()
class BootstrapManifest(object): # pylint: disable=useless-object-inheritance
"""Describes the contents of the bootstrapped directory."""
SOURCE_MANIFEST_PATH = os.path.join(PYTHON_SOURCE, 'README.rst')
def __init__(self, install_path, requirements):
self.install_path = install_path
self.manifest_file = os.path.join(self.install_path, '.bootstrapped')
self.requested_requirements_path = requirements
self.bootstrapped_requirements_path = os.path.join(
self.install_path, 'requirements.txt')
self.requested_requirements = read_requirements(
self.requested_requirements_path)
self.bootstrapped_requirements = read_requirements(
self.bootstrapped_requirements_path)
def is_up_to_date(self):
"""Returns True if the bootstrap install is up to date."""
if not os.path.exists(self.manifest_file):
return False
if not self.versions_match():
logger().info('Bootstrap out of date: Python has changed.')
return False
if self.requested_requirements != self.bootstrapped_requirements:
logger().info('Bootstrap out of date: requirements have changed.')
return False
return True
def versions_match(self):
"""Returns True if the bootstrap has an up to date Python."""
# Ideally this would be a check of the git revision of the Python
# source, but we can't assume that information is available on the
# build servers. For now, assume the README.rst will change for any
# update. This should be fine since updates should include a change to
# the version number.
# This function should not be called if this file does not exist.
assert os.path.exists(self.manifest_file)
with open(self.SOURCE_MANIFEST_PATH) as readme_rst:
source_manifest = readme_rst.read()
with open(self.manifest_file) as manifest_file:
bootstrapped_manifest = manifest_file.read()
return source_manifest == bootstrapped_manifest
def save(self):
"""Saves the bootstrap manifest to disk."""
self.save_python_version()
self.save_requirements()
def save_python_version(self):
shutil.copy2(self.SOURCE_MANIFEST_PATH, self.manifest_file)
def save_requirements(self):
if self.requested_requirements is not None:
shutil.copy2(self.requested_requirements_path,
self.bootstrapped_requirements_path)
# An existing bootstrap directory is removed if it needed to be
# updated, so no need to remove an existing requirements file in the
# case where a requirements file was used but no longer is.
def do_bootstrap(install_dir, requirements):
"""Helper function for bootstrapping.
Builds and installs Python 3 if necessary, but does not modify the
environment.
Args:
install_dir: Directory in which to install Python 3.
requirements: An optional path to a requirements.txt file. This will be
passed to pip to install additional dependencies. If None, no
additional packages will be installed.
Returns:
Python 3 install directory.
"""
build_dir = path_in_out('bootstrap-build')
bootstrap_manifest = BootstrapManifest(install_dir, requirements)
if bootstrap_manifest.is_up_to_date():
return
# If the bootstrap exists but is not up to date, purge it to ensure no
# stale files remain.
if os.path.exists(install_dir):
shutil.rmtree(install_dir)
timer = Timer()
with timer:
build_python(install_dir, build_dir)
if requirements is not None:
install_requirements(install_dir, requirements)
logger().info('Bootstrapping completed in %s', timer.duration)
bootstrap_manifest.save()
def bootstrap(requirements=None):
"""Creates a bootstrap Python 3 environment.
Builds and installs Python 3 for use on the current host. After execution,
the directory containing the python3 binary will be the first element in
the PATH.
Args:
requirements: An optional path to a requirements.txt file. This will be
passed to pip to install additional dependencies. If None, no
additional packages will be installed.
"""
install_dir = path_in_out('bootstrap')
do_bootstrap(install_dir, requirements)
bootstrap_bin = os.path.join(install_dir, 'bin')
os.environ['PATH'] = os.pathsep.join([bootstrap_bin, os.environ['PATH']])