blob: 3e497ff48bf188938178b9eaa5334e59b3e48273 [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Implements a standard mechanism for Chrome Infra Python environment setup.
This library provides a central location to define Chrome Infra environment
setup. It also provides several faculties to install this environment.
Within a cooperating script, the environment can be setup by importing this
module and running its 'Install' method:
# Install Chrome-Infra environment (replaces 'sys.path').
sys.path.insert(0,
os.path.join(os.path.dirname(__file__), os.pardir, ...))
# (/path/to/build/scripts)
import common.env
common.env.Install()
When attempting to export the Chrome Infra path to external scripts, this
script can be invoked as an executable with various subcommands to emit a valid
PYTHONPATH clause.
In addition, this module has several functions to construct the path.
The goal is to deploy this module universally among Chrome-Infra scripts,
BuildBot configurations, tool invocations, and tests to ensure that they all
execute with the same centrally-defined environment.
"""
import argparse
import collections
import contextlib
import imp
import itertools
import os
import sys
import traceback
# Export for bootstrapping.
__all__ = [
'Install',
'PythonPath',
]
# Name of enviornment extension file to seek.
ENV_EXTENSION_NAME = 'environment.cfg.py'
# Standard directories (based on this file's location in the <build> tree).
def path_if(*args):
if not all(args):
return None
path = os.path.abspath(os.path.join(*args))
return (path) if os.path.exists(path) else (None)
# The path to the <build> directory in which this script resides.
Build = path_if(os.path.dirname(__file__), os.pardir, os.pardir)
# The path to the <build_internal> directory.
BuildInternal = path_if(Build, os.pardir, 'build_internal')
def SetPythonPathEnv(value):
"""Sets the system's PYTHONPATH environemnt variable.
Args:
value (str): The value to use. If this is empty/None, the system's
PYTHONPATH will be cleared.
"""
# Since we can't assign None to the environment "dictionary", we have to
# either set or delete the key depending on the original value.
if value is not None:
os.environ['PYTHONPATH'] = str(value)
else:
os.environ.pop('PYTHONPATH', None)
def Install(**kwargs):
"""Replaces the current 'sys.path' with a hermetic Chrome-Infra path.
Args:
kwargs (dict): See GetInfraPythonPath arguments.
Returns (PythonPath): The PythonPath object that was installed.
"""
infra_python_path = GetInfraPythonPath(**kwargs)
infra_python_path.Install()
return infra_python_path
def SplitPath(path):
"""Returns (list): A list of path elements.
Splits a path into path elements. For example (assuming '/' is the local
system path separator):
>>> print SplitPath('/a/b/c/d')
['/', 'a', 'b', 'c', 'd']
>>> print SplitPath('a/b/c')
['a', 'b,' 'c']
"""
parts = []
while True:
path, component = os.path.split(path)
if not component:
if path:
parts.append(path)
break
parts.append(component)
parts.reverse()
return parts
def ExtendPath(base, root_dir):
"""Returns (PythonPath): The extended python path.
This method looks for the ENV_EXTENSION_NAME file within "root_dir". If
present, it will be loaded as a Python module and have its "Extend" method
called.
If no extension is found, the base PythonPath will be returned.
Args:
base (PythonPath): The base python path.
root_dir (str): The path to check for an extension.
"""
extension_path = os.path.join(root_dir, ENV_EXTENSION_NAME)
if not os.path.isfile(extension_path):
return base
with open(extension_path, 'r') as fd:
extension = fd.read()
extension_module = imp.new_module('env-extension')
# Execute the enviornment extension.
try:
exec extension in extension_module.__dict__
extend_func = getattr(extension_module, 'Extend', None)
assert extend_func, (
"The environment extension module is missing the 'Extend()' method.")
base = extend_func(base, root_dir)
if not isinstance(base, PythonPath):
raise TypeError("Extension module returned non-PythonPath object (%s)" % (
type(base).__name__,))
except Exception:
# Re-raise the exception, but include the configuration file name.
tb = traceback.format_exc()
raise RuntimeError("Environment extension [%s] raised exception: %s" % (
extension_path, tb))
return base
def IsSystemPythonPath(path):
"""Returns (bool): If a python path is user-installed.
Paths that are known to be user-installed paths can be ignored when setting
up a hermetic Python path environment to avoid user libraries that would not
be present in other environments falsely affecting code.
This function can be updated as-needed to exclude other non-system paths
encountered on bots and in the wild.
"""
components = SplitPath(path)
for component in components:
if component in ('dist-packages', 'site-packages'):
return False
return True
class PythonPath(collections.Sequence):
"""An immutable set of Python path elements.
All paths represented in this structure are absolute. If a relative path
is passed into this structure, it will be converted to absolute based on
the current working directory (via os.path.abspath).
"""
def __init__(self, components=None):
"""Initializes a new PythonPath instance.
Args:
components (list): A list of path component strings.
"""
seen = set()
self._components = []
for component in (components or ()):
component = os.path.abspath(component)
assert isinstance(component, basestring), (
"Path component '%s' is not a string (%s)" % (
component, type(component).__name__))
if component in seen:
continue
seen.add(component)
self._components.append(component)
def __getitem__(self, value):
return self._components[value]
def __len__(self):
return len(self._components)
def __iadd__(self, other):
return self.Append(other)
def __repr__(self):
return self.pathstr
def __eq__(self, other):
assert isinstance(other, type(self))
return self._components == other._components
@classmethod
def Flatten(cls, *paths):
"""Returns (list): A single-level list containing flattened path elements.
>>> print PythonPath.Flatten('a', ['b', ['c', 'd']])
['a', 'b', 'c', 'd']
"""
result = []
for path in paths:
if not isinstance(path, basestring):
# Assume it's an iterable of paths.
result += cls.Flatten(*path)
else:
result.append(path)
return result
@classmethod
def FromPaths(cls, *paths):
"""Returns (PythonPath): A PythonPath instantiated from path elements.
Args:
paths (tuple): A tuple of path elements or iterables containing path
elements (e.g., PythonPath instances).
"""
return cls(cls.Flatten(*paths))
@classmethod
def FromPathStr(cls, pathstr):
"""Returns (PythonPath): A PythonPath instantiated from the path string.
Args:
pathstr (str): An os.pathsep()-delimited path string.
"""
return cls(pathstr.split(os.pathsep))
@property
def pathstr(self):
"""Returns (str): A path string for the instance's path elements."""
return os.pathsep.join(self)
def IsHermetic(self):
"""Returns (bool): True if this instance contains only system paths."""
return all(IsSystemPythonPath(p) for p in self)
def GetHermetic(self):
"""Returns (PythonPath): derivative PythonPath containing only system paths.
"""
return type(self).FromPaths(*(p for p in self if IsSystemPythonPath(p)))
def Append(self, *paths):
"""Returns (PythonPath): derivative PythonPath with paths added to the end.
Args:
paths (tuple): A tuple of path elements to append to the current instance.
"""
return type(self)(itertools.chain(self, self.FromPaths(*paths)))
def Override(self, *paths):
"""Returns (PythonPath): derivative PythonPath with paths prepended.
Args:
paths (tuple): A tuple of path elements to prepend to the current
instance.
"""
return self.FromPaths(*paths).Append(self)
def Install(self):
"""Overwrites Python runtime variables based on the current instance.
Performs the following operations:
- Replaces sys.path with the current instance's path.
- Replaces os.environ['PYTHONPATH'] with the current instance's path
string.
"""
sys.path = list(self)
SetPythonPathEnv(self.pathstr)
@contextlib.contextmanager
def Enter(self):
"""Context manager wrapper for Install.
On exit, the context manager will restore the original environment.
"""
orig_sys_path = sys.path[:]
orig_pythonpath = os.environ.get('PYTHONPATH')
try:
self.Install()
yield
finally:
sys.path = orig_sys_path
SetPythonPathEnv(orig_pythonpath)
def GetSysPythonPath(hermetic=True):
"""Returns (PythonPath): A path based on 'sys.path'.
Args:
hermetic (bool): If True, prune any non-system path.
"""
path = PythonPath.FromPaths(*sys.path)
if hermetic:
path = path.GetHermetic()
return path
def GetEnvPythonPath():
"""Returns (PythonPath): A path based on the PYTHONPATH environment variable.
"""
pythonpath = os.environ.get('PYTHONPATH')
if not pythonpath:
return PythonPath.FromPaths()
return PythonPath.FromPathStr(pythonpath)
def GetMasterPythonPath(master_dir):
"""Returns (PythonPath): A path including a BuildBot master's directory.
Args:
master_dir (str): The BuildBot master root directory.
"""
return PythonPath.FromPaths(master_dir)
def GetBuildPythonPath():
"""Returns (PythonPath): The Chrome Infra build path."""
build_path = PythonPath.FromPaths()
for extension_dir in (
Build,
BuildInternal,
):
if extension_dir:
build_path = ExtendPath(build_path, extension_dir)
return build_path
def GetInfraPythonPath(hermetic=True, master_dir=None):
"""Returns (PythonPath): The full working Chrome Infra utility path.
This path is consistent for master, slave, and tool usage. It includes (in
this order):
- Any environment PYTHONPATH overrides.
- If 'master_dir' is supplied, the master's python path component.
- The Chrome Infra build path.
- The system python path.
Args:
hermetic (bool): True, prune any non-system path from the system path.
master_dir (str): If not None, include a master path component.
"""
path = GetEnvPythonPath()
if master_dir:
path += GetMasterPythonPath(master_dir)
path += GetBuildPythonPath()
path += GetSysPythonPath(hermetic=hermetic)
return path
def _InfraPathFromArgs(args):
"""Returns (PythonPath): A PythonPath populated from command-line arguments.
Args:
args (argparse.Namespace): The command-line arguments constructed by 'main'.
"""
return GetInfraPythonPath(
master_dir=args.master_dir,
)
def _Command_Echo(args, path):
"""Returns (int): Return code.
Command function for the 'echo' subcommand. Outputs the path string for
'path'.
Args:
args (argparse.Namespace): The command-line arguments constructed by 'main'.
path (PythonPath): The python path to use.
"""
args.output.write(path.pathstr)
return 0
def _Command_Print(args, path):
"""Returns (int): Return code.
Command function for the 'print' subcommand. Outputs each path component in
path on a separate line.
Args:
args (argparse.Namespace): The command-line arguments constructed by 'main'.
path (PythonPath): The python path to use.
"""
for component in path:
print >>args.output, component
return 0
def main():
"""Main execution function."""
parser = argparse.ArgumentParser()
parser.add_argument('-M', '--master_dir',
help="Augment the path with the master's directory.")
parser.add_argument('-o', '--output', metavar='PATH',
type=argparse.FileType('w'), default='-',
help="File to output to (use '-' for STDOUT).")
subparsers = parser.add_subparsers()
# 'echo'
subparser = subparsers.add_parser('echo')
subparser.set_defaults(func=_Command_Echo)
# 'print'
subparser = subparsers.add_parser('print')
subparser.set_defaults(func=_Command_Print)
# Parse
args = parser.parse_args()
# Execute our subcommand function, which will return the exit code.
path = _InfraPathFromArgs(args)
return args.func(args, path)
if __name__ == '__main__':
sys.exit(main())