| # |
| # 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']]) |