blob: cec7c764476d80fa68cd32ae0e7fc9fc06c8cc29 [file] [log] [blame]
# Copyright 2017 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""The context module provides APIs for manipulating a few pieces of 'ambient'
data that affect how steps are run.
The pieces of information which can be modified are:
* cwd - The current working directory.
* env - The environment variables.
* infra_step - Whether or not failures should be treated as infrastructure
failures vs. normal failures.
The values here are all scoped using Python's `with` statement; there's no
mechanism to make an open-ended adjustment to these values (i.e. there's no way
to change the cwd permanently for a recipe, except by surrounding the entire
recipe with a with statement). This is done to avoid the surprises that
typically arise with things like os.environ or os.chdir in a normal python
program.
Example:
```python
with api.context(cwd=api.path['start_dir'].join('subdir')):
# this step is run inside of the subdir directory.
api.step("cat subdir/foo", ['cat', './foo'])
```
"""
import collections
from contextlib import contextmanager
from recipe_engine.config_types import Path
from recipe_engine.recipe_api import RecipeApi
from recipe_engine.types import PerGreenletState
def check_type(name, var, expect):
if not isinstance(var, expect): # pragma: no cover
raise TypeError('%s is not %s: %r (%s)' % (
name, expect.__name__, var, type(var).__name__))
class State(PerGreenletState):
cwd = None
env_prefixes = {}
env_suffixes = {}
env = {}
infra_steps = False
def _get_setter_on_spawn(self):
old_cwd = self.cwd
old_env_prefixes = self.env_prefixes
old_env_suffixes = self.env_suffixes
old_env = self.env
old_infra_steps = self.infra_steps
def _inner():
self.cwd = old_cwd
self.env_prefixes = old_env_prefixes
self.env_suffixes = old_env_suffixes
self.env = old_env
self.infra_steps = old_infra_steps
return _inner
class ContextApi(RecipeApi):
# TODO(iannucci): move implementation of these data directly into this class.
def __init__(self, **kwargs):
super(ContextApi, self).__init__(**kwargs)
self._state = State()
@contextmanager
def __call__(self, cwd=None, env_prefixes=None, env_suffixes=None, env=None,
infra_steps=None):
"""Allows adjustment of multiple context values in a single call.
Args:
* cwd (Path) - the current working directory to use for all steps.
To 'reset' to the original cwd at the time recipes started, pass
`api.path['start_dir']`.
* env_prefixes (dict) - Environmental variable prefix augmentations. See
below for more info.
* env_suffixes (dict) - Environmental variable suffix augmentations. See
below for more info.
* env (dict) - Environmental variable overrides. See below for more info.
* infra_steps (bool) - if steps in this context should be considered
infrastructure steps. On failure, these will raise InfraFailure
exceptions instead of StepFailure exceptions.
Environmental Variable Overrides:
Env is a mapping of environment variable name to the value you want that
environment variable to have. The value is one of:
* None, indicating that the environment variable should be removed from
the environment when the step runs.
* A string value. Note that string values will be %-formatted with the
current value of the environment at the time the step runs. This means
that you can have a value like:
"/path/to/my/stuff:%(PATH)s"
Which, at the time the step executes, will inject the current value of
$PATH.
"env_prefix" and "env_suffix" are a list of Path or strings that get
prefixed (or suffixed) to their respective environment variables, delimited
with the system's path separator. This can be used to add entries to
environment variables such as "PATH" and "PYTHONPATH". If prefixes are
specified and a value is also defined in "env", the value will be installed
as the last path component if it is not empty.
Look at the examples in "examples/" for examples of context module usage.
"""
# Mapping of state member to value to assign on exit of this function.
deferred_assignments = {}
def _push(state_member, new):
deferred_assignments[state_member] = _get_current(state_member)
setattr(self._state, state_member, new)
def _get_current(state_member):
return getattr(self._state, state_member)
def _add_to_context(state_member, to_add, adder_func):
if to_add is not None and to_add:
check_type(state_member, to_add, dict)
new = dict(_get_current(state_member))
for key, val in to_add.iteritems():
adder_func(key, val, new)
_push(state_member, new)
def _as_env_prefixes(key, val, new):
if val:
new[key] = tuple(val) + new.get(key, ())
def _as_env_suffixes(key, val, new):
if val:
new[key] = new.get(key, ()) + tuple(val)
def _as_env(key, val, new):
if val is not None:
val = str(val)
try:
# This odd little piece of code does the following:
# * add a bogus dictionary format %(foo)s to val. This forces %
# into 'dictionary lookup' mode
# * format the result with a defaultdict. This allows all
# `%(key)s` format lookups to succeed, but any sequential `%s`
# lookups to fail.
# If the string contains any accidental sequential lookups, this
# will raise an exception. If not, then this is a plausible format
# string.
('%(foo)s' + val) % collections.defaultdict(str)
except Exception:
raise ValueError(('Invalid %%-formatting parameter in envvar, '
'only %%(ENVVAR)s allowed: %r') % (val,))
new[key] = val
if cwd is not None:
check_type('cwd', cwd, Path)
_push('cwd', cwd)
if infra_steps is not None:
check_type('infra_steps', infra_steps, bool)
_push('infra_steps', infra_steps)
_add_to_context('env_prefixes', env_prefixes, _as_env_prefixes)
_add_to_context('env_suffixes', env_suffixes, _as_env_suffixes)
_add_to_context('env', env, _as_env)
try:
yield
finally:
for state_member, val in deferred_assignments.iteritems():
setattr(self._state, state_member, val)
@property
def cwd(self):
"""Returns the current working directory that steps will run in.
**Returns (Path|None)** - The current working directory. A value of None is
equivalent to api.path['start_dir'], though only occurs if no cwd has been
set (e.g. in the outermost context of RunSteps).
"""
return self._state.cwd
@property
def env(self):
"""Returns modifications to the environment.
By default this is empty; There's no facility to observe the program's
startup environment. If you want to pass data to the recipe, it should be
done with properties.
**Returns (dict)** - The env-key -> value mapping of current environment
modifications.
"""
# TODO(iannucci): store env in an immutable way to avoid excessive copies.
# TODO(iannucci): handle case-insensitive keys on windows
return dict(self._state.env)
@property
def env_prefixes(self):
"""Returns Path prefix modifications to the environment.
This will return a mapping of environment key to Path tuple for Path
prefixes registered with the environment.
**Returns (dict)** - The env-key -> value(Path) mapping of current
environment prefix modifications.
"""
# TODO(iannucci): store env in an immutable way to avoid excessive copies.
# TODO(iannucci): handle case-insensitive keys on windows
return dict(self._state.env_prefixes)
@property
def env_suffixes(self):
"""Returns Path suffix modifications to the environment.
This will return a mapping of environment key to Path tuple for Path
suffixes registered with the environment.
**Returns (dict)** - The env-key -> value(Path) mapping of current
environment suffix modifications.
"""
# TODO(iannucci): store env in an immutable way to avoid excessive copies.
# TODO(iannucci): handle case-insensitive keys on windows
return dict(self._state.env_suffixes)
@property
def infra_step(self):
"""Returns the current value of the infra_step setting.
**Returns (bool)** - True iff steps are currently considered infra steps.
"""
return self._state.infra_steps