blob: d742e4f5b48fbf37d7794db89ebdee688ca6e1ce [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2020 The Pigweed Authors
#
# 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
#
# https://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.
"""Environment setup script for Pigweed.
This script installs everything and writes out a file for the user's shell
to source.
For now, this is valid Python 2 and Python 3. Once we switch to running this
with PyOxidizer it can be upgraded to recent Python 3.
"""
from __future__ import print_function
import argparse
import copy
import glob
import inspect
import json
import os
import shutil
import subprocess
import sys
import time
# TODO(pwbug/67): Remove import hacks once the oxidized prebuilt binaries are
# proven stable for first-time bootstrapping. For now, continue to support
# running directly from source without assuming a functioning Python
# environment when running for the first time.
# If we're running oxidized, filesystem-centric import hacks won't work. In that
# case, jump straight to the imports and assume oxidation brought in the deps.
if not getattr(sys, 'oxidized', False):
old_sys_path = copy.deepcopy(sys.path)
filename = None
if hasattr(sys.modules[__name__], '__file__'):
filename = __file__
else:
# Try introspection in environments where __file__ is not populated.
frame = inspect.currentframe()
if frame is not None:
filename = inspect.getfile(frame)
# If none of our strategies worked, we're in a strange runtime environment.
# The imports are almost certainly going to fail.
if filename is None:
raise RuntimeError(
'Unable to locate pw_env_setup module; cannot continue.\n'
'\n'
'Try updating to one of the standard Python implemetations:\n'
' https://www.python.org/downloads/')
sys.path = [
os.path.abspath(os.path.join(filename, os.path.pardir, os.path.pardir))
]
import pw_env_setup # pylint: disable=unused-import
sys.path = old_sys_path
# pylint: disable=wrong-import-position
from pw_env_setup.cipd_setup import update as cipd_update
from pw_env_setup.cipd_setup import wrapper as cipd_wrapper
from pw_env_setup.colors import Color, enable_colors
from pw_env_setup import environment
from pw_env_setup import spinner
from pw_env_setup import virtualenv_setup
from pw_env_setup import windows_env_start
# TODO(pwbug/67, pwbug/68) switch to shutil.which().
def _which(executable,
pathsep=os.pathsep,
use_pathext=None,
case_sensitive=None):
if use_pathext is None:
use_pathext = (os.name == 'nt')
if case_sensitive is None:
case_sensitive = (os.name != 'nt' and sys.platform != 'darwin')
if not case_sensitive:
executable = executable.lower()
exts = None
if use_pathext:
exts = frozenset(os.environ['PATHEXT'].split(pathsep))
if not case_sensitive:
exts = frozenset(x.lower() for x in exts)
if not exts:
raise ValueError('empty PATHEXT')
paths = os.environ['PATH'].split(pathsep)
for path in paths:
try:
entries = frozenset(os.listdir(path))
if not case_sensitive:
entries = frozenset(x.lower() for x in entries)
except OSError:
continue
if exts:
for ext in exts:
if executable + ext in entries:
return os.path.join(path, executable + ext)
else:
if executable in entries:
return os.path.join(path, executable)
return None
class _Result:
class Status:
DONE = 'done'
SKIPPED = 'skipped'
FAILED = 'failed'
def __init__(self, status, *messages):
self._status = status
self._messages = list(messages)
def ok(self):
return self._status in {_Result.Status.DONE, _Result.Status.SKIPPED}
def status_str(self, duration=None):
if not duration:
return self._status
duration_parts = []
if duration > 60:
minutes = int(duration // 60)
duration %= 60
duration_parts.append('{}m'.format(minutes))
duration_parts.append('{:.1f}s'.format(duration))
return '{} ({})'.format(self._status, ''.join(duration_parts))
def messages(self):
return self._messages
class ConfigError(Exception):
pass
def result_func(glob_warnings=()):
def result(status, *args):
return _Result(status, *([str(x) for x in glob_warnings] + list(args)))
return result
class ConfigFileError(Exception):
pass
class MissingSubmodulesError(Exception):
pass
# TODO(mohrr) remove disable=useless-object-inheritance once in Python 3.
# pylint: disable=useless-object-inheritance
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments
class EnvSetup(object):
"""Run environment setup for Pigweed."""
def __init__(self, pw_root, cipd_cache_dir, shell_file, quiet, install_dir,
virtualenv_root, strict, virtualenv_gn_out_dir, json_file,
project_root, config_file, use_existing_cipd,
use_pinned_pip_packages, cipd_only, trust_cipd_hash):
self._env = environment.Environment()
self._project_root = project_root
self._pw_root = pw_root
self._setup_root = os.path.join(pw_root, 'pw_env_setup', 'py',
'pw_env_setup')
self._cipd_cache_dir = cipd_cache_dir
self._shell_file = shell_file
self._is_windows = os.name == 'nt'
self._quiet = quiet
self._install_dir = install_dir
self._virtualenv_root = (virtualenv_root
or os.path.join(install_dir, 'pigweed-venv'))
self._strict = strict
self._cipd_only = cipd_only
self._trust_cipd_hash = trust_cipd_hash
if os.path.isfile(shell_file):
os.unlink(shell_file)
if isinstance(self._pw_root, bytes) and bytes != str:
self._pw_root = self._pw_root.decode()
self._cipd_package_file = []
self._virtualenv_requirements = []
self._virtualenv_gn_targets = []
self._virtualenv_gn_args = []
self._use_pinned_pip_packages = use_pinned_pip_packages
self._optional_submodules = []
self._required_submodules = []
self._virtualenv_system_packages = False
self._pw_packages = []
self._root_variable = None
self._json_file = json_file
self._gni_file = None
self._config_file_name = getattr(config_file, 'name', 'config file')
self._env.set('_PW_ENVIRONMENT_CONFIG_FILE', self._config_file_name)
if config_file:
self._parse_config_file(config_file)
self._check_submodules()
self._use_existing_cipd = use_existing_cipd
self._virtualenv_gn_out_dir = virtualenv_gn_out_dir
if self._root_variable:
self._env.set(self._root_variable, project_root, deactivate=False)
self._env.set('PW_PROJECT_ROOT', project_root, deactivate=False)
self._env.set('PW_ROOT', pw_root, deactivate=False)
self._env.set('_PW_ACTUAL_ENVIRONMENT_ROOT', install_dir)
self._env.add_replacement('_PW_ACTUAL_ENVIRONMENT_ROOT', install_dir)
self._env.add_replacement('PW_ROOT', pw_root)
def _process_globs(self, globs):
unique_globs = []
for pat in globs:
if pat and pat not in unique_globs:
unique_globs.append(pat)
files = []
warnings = []
for pat in unique_globs:
if pat:
matches = glob.glob(pat)
if not matches:
warning = 'pattern "{}" matched 0 files'.format(pat)
warnings.append('warning: {}'.format(warning))
if self._strict:
raise ConfigError(warning)
files.extend(matches)
if globs and not files:
warnings.append('warning: matched 0 total files')
if self._strict:
raise ConfigError('matched 0 total files')
return files, warnings
def _parse_config_file(self, config_file):
config = json.load(config_file)
self._root_variable = config.pop('root_variable', None)
if 'json_file' in config:
self._json_file = config.pop('json_file')
self._gni_file = config.pop('gni_file', None)
self._optional_submodules.extend(config.pop('optional_submodules', ()))
self._required_submodules.extend(config.pop('required_submodules', ()))
if self._optional_submodules and self._required_submodules:
raise ValueError(
'{} contains both "optional_submodules" and '
'"required_submodules", but these options are mutually '
'exclusive'.format(self._config_file_name))
self._cipd_package_file.extend(
os.path.join(self._project_root, x)
for x in config.pop('cipd_package_files', ()))
for pkg in config.pop('pw_packages', ()):
self._pw_packages.append(pkg)
virtualenv = config.pop('virtualenv', {})
if virtualenv.get('gn_root'):
root = os.path.join(self._project_root, virtualenv.pop('gn_root'))
else:
root = self._project_root
for target in virtualenv.pop('gn_targets', ()):
self._virtualenv_gn_targets.append(
virtualenv_setup.GnTarget('{}#{}'.format(root, target)))
self._virtualenv_gn_args = virtualenv.pop('gn_args', ())
self._virtualenv_system_packages = virtualenv.pop(
'system_packages', False)
if virtualenv:
raise ConfigFileError(
'unrecognized option in {}: "virtualenv.{}"'.format(
self._config_file_name, next(iter(virtualenv))))
if config:
raise ConfigFileError('unrecognized option in {}: "{}"'.format(
self._config_file_name, next(iter(config))))
def _check_submodules(self):
unitialized = set()
# Don't check submodule presence if using the Android Repo Tool.
if os.path.isdir(os.path.join(self._project_root, '.repo')):
return
cmd = ['git', 'submodule', 'status', '--recursive']
for line in subprocess.check_output(
cmd, cwd=self._project_root).splitlines():
if isinstance(line, bytes):
line = line.decode()
# Anything but an initial '-' means the submodule is initialized.
if not line.startswith('-'):
continue
unitialized.add(line.split()[1])
missing = unitialized - set(self._optional_submodules)
if self._required_submodules:
missing = set(self._required_submodules) & unitialized
if missing:
print(
'Not all submodules are initialized. Please run the '
'following commands.',
file=sys.stderr)
print('', file=sys.stderr)
for miss in missing:
print(' git submodule update --init {}'.format(miss),
file=sys.stderr)
print('', file=sys.stderr)
if self._required_submodules:
print(
'If these submodules are not required, remove them from '
'the "required_submodules"',
file=sys.stderr)
else:
print(
'If these submodules are not required, add them to the '
'"optional_submodules"',
file=sys.stderr)
print('list in the environment config JSON file:', file=sys.stderr)
print(' {}'.format(self._config_file_name), file=sys.stderr)
print('', file=sys.stderr)
raise MissingSubmodulesError(', '.join(sorted(missing)))
def _write_gni_file(self):
gni_file = os.path.join(self._project_root, 'build_overrides',
'pigweed_environment.gni')
if self._gni_file:
gni_file = os.path.join(self._project_root, self._gni_file)
with open(gni_file, 'w') as outs:
self._env.gni(outs, self._project_root)
def _log(self, *args, **kwargs):
# Not using logging module because it's awkward to flush a log handler.
if self._quiet:
return
flush = kwargs.pop('flush', False)
print(*args, **kwargs)
if flush:
sys.stdout.flush()
def setup(self):
"""Runs each of the env_setup steps."""
if os.name == 'nt':
windows_env_start.print_banner(bootstrap=True, no_shell_file=False)
else:
enable_colors()
steps = [
('CIPD package manager', self.cipd),
('Python environment', self.virtualenv),
('pw packages', self.pw_package),
('Host tools', self.host_tools),
]
if self._is_windows:
steps.append(("Windows scripts", self.win_scripts))
if self._cipd_only:
steps = [('CIPD package manager', self.cipd)]
self._log(
Color.bold('Downloading and installing packages into local '
'source directory:\n'))
max_name_len = max(len(name) for name, _ in steps)
self._env.comment('''
This file is automatically generated. DO NOT EDIT!
For details, see $PW_ROOT/pw_env_setup/py/pw_env_setup/env_setup.py and
$PW_ROOT/pw_env_setup/py/pw_env_setup/environment.py.
'''.strip())
if not self._is_windows:
self._env.comment('''
For help debugging errors in this script, uncomment the next line.
set -x
Then use `set +x` to go back to normal.
'''.strip())
self._env.echo(
Color.bold(
'Activating environment (setting environment variables):'))
self._env.echo('')
for name, step in steps:
self._log(' Setting up {name:.<{width}}...'.format(
name=name, width=max_name_len),
end='',
flush=True)
self._env.echo(
' Setting environment variables for {name:.<{width}}...'.
format(name=name, width=max_name_len),
newline=False,
)
start = time.time()
spin = spinner.Spinner(self._quiet)
with spin():
result = step(spin)
stop = time.time()
self._log(result.status_str(stop - start))
self._env.echo(result.status_str())
for message in result.messages():
sys.stderr.write('{}\n'.format(message))
self._env.echo(message)
if not result.ok():
return -1
# Log the environment state at the end of each step for debugging.
log_dir = os.path.join(self._install_dir, 'logs')
if not os.path.isdir(log_dir):
os.makedirs(log_dir)
actions_json = os.path.join(
log_dir, 'post-{}.json'.format(name.replace(' ', '_')))
with open(actions_json, 'w') as outs:
self._env.json(outs)
# This file needs to be written after the CIPD step and before the
# Python virtualenv step. It also needs to be rewritten after the
# Python virtualenv step, so it's easiest to just write it after
# every step.
self._write_gni_file()
self._log('')
self._env.echo('')
self._env.finalize()
self._env.echo(Color.bold('Checking the environment:'))
self._env.echo()
self._env.doctor()
self._env.echo()
self._env.echo(
Color.bold('Environment looks good, you are ready to go!'))
self._env.echo()
# Don't write new files if all we did was update CIPD packages.
if self._cipd_only:
return 0
with open(self._shell_file, 'w') as outs:
self._env.write(outs)
deactivate = os.path.join(
self._install_dir,
'deactivate{}'.format(os.path.splitext(self._shell_file)[1]))
with open(deactivate, 'w') as outs:
self._env.write_deactivate(outs)
config = {
# Skipping sysname and nodename in os.uname(). nodename could change
# based on the current network. sysname won't change, but is
# redundant because it's contained in release or version, and
# skipping it here simplifies logic.
'uname': ' '.join(getattr(os, 'uname', lambda: ())()[2:]),
'os': os.name,
}
with open(os.path.join(self._install_dir, 'config.json'), 'w') as outs:
outs.write(
json.dumps(config, indent=4, separators=(',', ': ')) + '\n')
json_file = (self._json_file
or os.path.join(self._install_dir, 'actions.json'))
with open(json_file, 'w') as outs:
self._env.json(outs)
return 0
def cipd(self, spin):
"""Set up cipd and install cipd packages."""
install_dir = os.path.join(self._install_dir, 'cipd')
# There's no way to get to the UnsupportedPlatform exception if this
# flag is set, but this flag should only be set in LUCI builds which
# will always have CIPD.
if self._use_existing_cipd:
cipd_client = 'cipd'
else:
try:
cipd_client = cipd_wrapper.init(install_dir, silent=True)
except cipd_wrapper.UnsupportedPlatform as exc:
return result_func((' {!r}'.format(exc), ))(
_Result.Status.SKIPPED,
' abandoning CIPD setup',
)
package_files, glob_warnings = self._process_globs(
self._cipd_package_file)
result = result_func(glob_warnings)
if not package_files:
return result(_Result.Status.SKIPPED)
if not cipd_update.update(cipd=cipd_client,
root_install_dir=install_dir,
package_files=package_files,
cache_dir=self._cipd_cache_dir,
env_vars=self._env,
spin=spin,
trust_hash=self._trust_cipd_hash):
return result(_Result.Status.FAILED)
return result(_Result.Status.DONE)
def virtualenv(self, unused_spin):
"""Setup virtualenv."""
requirements, req_glob_warnings = self._process_globs(
self._virtualenv_requirements)
result = result_func(req_glob_warnings)
orig_python3 = _which('python3')
with self._env():
new_python3 = _which('python3')
# There is an issue with the virtualenv module on Windows where it
# expects sys.executable to be called "python.exe" or it fails to
# properly execute. If we installed Python 3 in the CIPD step we need
# to address this. Detect if we did so and if so create a copy of
# python3.exe called python.exe so that virtualenv works.
if orig_python3 != new_python3 and self._is_windows:
python3_copy = os.path.join(os.path.dirname(new_python3),
'python.exe')
if not os.path.exists(python3_copy):
shutil.copyfile(new_python3, python3_copy)
new_python3 = python3_copy
if not requirements and not self._virtualenv_gn_targets:
return result(_Result.Status.SKIPPED)
if not virtualenv_setup.install(
project_root=self._project_root,
venv_path=self._virtualenv_root,
requirements=requirements,
gn_args=self._virtualenv_gn_args,
gn_targets=self._virtualenv_gn_targets,
gn_out_dir=self._virtualenv_gn_out_dir,
python=new_python3,
env=self._env,
system_packages=self._virtualenv_system_packages,
use_pinned_pip_packages=self._use_pinned_pip_packages,
):
return result(_Result.Status.FAILED)
return result(_Result.Status.DONE)
def pw_package(self, unused_spin):
"""Install "default" pw packages."""
result = result_func()
if not self._pw_packages:
return result(_Result.Status.SKIPPED)
logdir = os.path.join(self._install_dir, 'packages')
if not os.path.isdir(logdir):
os.makedirs(logdir)
for pkg in self._pw_packages:
print('installing {}'.format(pkg))
cmd = ['pw', 'package', 'install', pkg]
log = os.path.join(logdir, '{}.log'.format(pkg))
try:
with open(log, 'w') as outs, self._env():
print(*cmd, file=outs)
subprocess.check_call(cmd,
cwd=self._project_root,
stdout=outs,
stderr=subprocess.STDOUT)
except subprocess.CalledProcessError:
with open(log, 'r') as ins:
sys.stderr.write(ins.read())
raise
return result(_Result.Status.DONE)
def host_tools(self, unused_spin):
# The host tools are grabbed from CIPD, at least initially. If the
# user has a current host build, that build will be used instead.
# TODO(mohrr) find a way to do stuff like this for all projects.
host_dir = os.path.join(self._pw_root, 'out', 'host')
self._env.prepend('PATH', os.path.join(host_dir, 'host_tools'))
return _Result(_Result.Status.DONE)
def win_scripts(self, unused_spin):
# These scripts act as a compatibility layer for windows.
env_setup_dir = os.path.join(self._pw_root, 'pw_env_setup')
self._env.prepend('PATH', os.path.join(env_setup_dir,
'windows_scripts'))
return _Result(_Result.Status.DONE)
def parse(argv=None):
"""Parse command-line arguments."""
parser = argparse.ArgumentParser()
pw_root = os.environ.get('PW_ROOT', None)
if not pw_root:
try:
with open(os.devnull, 'w') as outs:
pw_root = subprocess.check_output(
['git', 'rev-parse', '--show-toplevel'],
stderr=outs).strip()
except subprocess.CalledProcessError:
pw_root = None
parser.add_argument(
'--pw-root',
default=pw_root,
required=not pw_root,
)
project_root = os.environ.get('PW_PROJECT_ROOT', None) or pw_root
parser.add_argument(
'--project-root',
default=project_root,
required=not project_root,
)
parser.add_argument(
'--cipd-cache-dir',
default=os.environ.get('CIPD_CACHE_DIR',
os.path.expanduser('~/.cipd-cache-dir')),
)
parser.add_argument(
'--trust-cipd-hash',
action='store_true',
help='Only run the cipd executable if the ensure file or command-line '
'has changed. Defaults to false since files could have been deleted '
'from the installation directory and cipd would add them back.',
)
parser.add_argument(
'--shell-file',
help='Where to write the file for shells to source.',
required=True,
)
parser.add_argument(
'--quiet',
help='Reduce output.',
action='store_true',
default='PW_ENVSETUP_QUIET' in os.environ,
)
parser.add_argument(
'--install-dir',
help='Location to install environment.',
required=True,
)
parser.add_argument(
'--config-file',
help='JSON file describing CIPD and virtualenv requirements.',
type=argparse.FileType('r'),
required=True,
)
parser.add_argument(
'--virtualenv-gn-out-dir',
help=('Output directory to use when building and installing Python '
'packages with GN; defaults to a unique path in the environment '
'directory.'))
parser.add_argument(
'--virtualenv-root',
help=('Root of virtualenv directory. Default: '
'<install_dir>/pigweed-venv'),
default=None,
)
parser.add_argument('--json-file', help=argparse.SUPPRESS, default=None)
parser.add_argument(
'--use-existing-cipd',
help='Use cipd executable from the environment instead of fetching it.',
action='store_true',
)
parser.add_argument(
'--strict',
help='Fail if there are any warnings.',
action='store_true',
)
parser.add_argument(
'--unpin-pip-packages',
dest='use_pinned_pip_packages',
help='Do not use pins of pip packages.',
action='store_false',
)
parser.add_argument(
'--cipd-only',
help='Skip non-CIPD steps.',
action='store_true',
)
args = parser.parse_args(argv)
return args
def main():
try:
return EnvSetup(**vars(parse())).setup()
except subprocess.CalledProcessError as err:
print()
print(err.output)
raise
if __name__ == '__main__':
sys.exit(main())