blob: 9fc24d0f6bbbf064a6a11a83b9937a003b09d764 [file] [log] [blame] [edit]
# Copyright 2019 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.
import collections
import contextlib
import itertools
from ... import util
from ...types import StepData
class StepRunner(object):
"""A StepRunner is the interface to actually running steps.
These can actually make subprocess calls (SubprocessStepRunner), or just
pretend to run the steps with mock output (SimulationStepRunner).
"""
@property
def stream_engine(self):
"""Return the stream engine that this StepRunner uses, if meaningful.
Users of this method must be prepared to handle None.
"""
return None
def open_step(self, step_config):
"""Constructs an OpenStep object which can be used to actually run a step.
Args:
step_config (StepConfig): The step data.
Returns: an OpenStep object.
"""
raise NotImplementedError()
@contextlib.contextmanager
def run_context(self):
"""A context in which the entire engine run takes place.
This is typically used to catch exceptions thrown by the recipe.
"""
yield
class OpenStep(object):
"""An object that can be used to run a step.
We use this object instead of just running directly because, after a step
is run, it stays open (can be modified with step_text and links and things)
until another step at its nest level or lower is started.
"""
def run(self):
"""Starts the step, running its command."""
raise NotImplementedError()
def finalize(self):
"""Closes the step and finalizes any stored presentation."""
raise NotImplementedError()
@property
def stream(self):
"""The stream.StepStream that this step is using for output.
It is permitted to use this stream between run() and finalize() calls. """
raise NotImplementedError()
# Placeholders associated with a rendered step.
Placeholders = collections.namedtuple('Placeholders',
('inputs_cmd', 'outputs_cmd', 'stdout', 'stderr', 'stdin'))
# Result of 'render_step'.
#
# Fields:
# config (StepConfig): The step configuration.
# placeholders (Placeholders): Placeholders for this rendered step.
# followup_annotations (list): A list of followup annotation, populated during
# simulation test.
RenderedStep = collections.namedtuple('RenderedStep',
('config', 'placeholders', 'followup_annotations'))
# Singleton object to indicate a value is not set.
UNSET_VALUE = object()
def render_step(step_config, step_test):
"""Renders a step so that it can be fed to annotator.py.
Args:
step_config (StepConfig): The step config to render.
step_test: The test data json dictionary for this step, if any.
Passed through unaltered to each placeholder.
Returns (RenderedStep): the rendered step, including a Placeholders object
representing any placeholder instances that were found while rendering.
"""
# Process 'cmd', rendering placeholders there.
input_phs = collections.defaultdict(lambda: collections.defaultdict(list))
output_phs = collections.defaultdict(
lambda: collections.defaultdict(collections.OrderedDict))
new_cmd = []
for item in (step_config.cmd or ()):
if isinstance(item, util.Placeholder):
module_name, placeholder_name = item.namespaces
tdata = step_test.pop_placeholder(
module_name, placeholder_name, item.name)
new_cmd.extend(item.render(tdata))
if isinstance(item, util.InputPlaceholder):
input_phs[module_name][placeholder_name].append((item, tdata))
else:
assert isinstance(item, util.OutputPlaceholder), (
'Not an OutputPlaceholder: %r' % item)
# This assert ensures that:
# no two placeholders have the same name
# at most one placeholder has the default name
assert item.name not in output_phs[module_name][placeholder_name], (
'Step "%s" has multiple output placeholders of %s.%s. Please '
'specify explicit and different names for them.' % (
step_config.name, module_name, placeholder_name))
output_phs[module_name][placeholder_name][item.name] = (item, tdata)
else:
new_cmd.append(item)
step_config = step_config._replace(cmd=map(str, new_cmd))
# Process 'stdout', 'stderr' and 'stdin' placeholders, if given.
stdio_placeholders = {}
for key in ('stdout', 'stderr', 'stdin'):
placeholder = getattr(step_config, key)
tdata = None
if placeholder:
if key == 'stdin':
assert isinstance(placeholder, util.InputPlaceholder), (
'%s(%r) should be an InputPlaceholder.' % (key, placeholder))
else:
assert isinstance(placeholder, util.OutputPlaceholder), (
'%s(%r) should be an OutputPlaceholder.' % (key, placeholder))
tdata = getattr(step_test, key)
placeholder.render(tdata)
assert placeholder.backing_file is not None
step_config = step_config._replace(**{key:placeholder.backing_file})
stdio_placeholders[key] = (placeholder, tdata)
return RenderedStep(
config=step_config,
placeholders=Placeholders(
inputs_cmd=input_phs,
outputs_cmd=output_phs,
**stdio_placeholders),
followup_annotations=None,
)
def construct_step_result(rendered_step, retcode):
"""Constructs a StepData step result from step return data.
The main purpose of this function is to add output placeholder results into
the step result where output placeholders appeared in the input step.
Also give input placeholders the chance to do the clean-up if needed.
"""
step_result = StepData(rendered_step.config, retcode)
class BlankObject(object):
pass
# Input placeholders inside step |cmd|.
placeholders = rendered_step.placeholders
for _, pholders in placeholders.inputs_cmd.iteritems():
for _, items in pholders.iteritems():
for ph, td in items:
ph.cleanup(td.enabled)
# Output placeholders inside step |cmd|.
for module_name, pholders in placeholders.outputs_cmd.iteritems():
assert not hasattr(step_result, module_name)
o = BlankObject()
setattr(step_result, module_name, o)
for placeholder_name, instances in pholders.iteritems():
named_results = {}
default_result = UNSET_VALUE
for _, (ph, td) in instances.iteritems():
result = ph.result(step_result.presentation, td)
if ph.name is None:
default_result = result
else:
named_results[ph.name] = result
setattr(o, placeholder_name + "s", named_results)
if default_result is UNSET_VALUE and len(named_results) == 1:
# If only 1 output placeholder with an explicit name, we set the default
# output.
default_result = named_results.values()[0]
# If 2+ placeholders have explicit names, we don't set the default output.
if default_result is not UNSET_VALUE:
setattr(o, placeholder_name, default_result)
# Placeholders that are used with IO redirection.
for key in ('stdout', 'stderr', 'stdin'):
assert not hasattr(step_result, key)
ph, td = getattr(placeholders, key)
if ph:
if isinstance(ph, util.OutputPlaceholder):
setattr(step_result, key, ph.result(step_result.presentation, td))
else:
assert isinstance(ph, util.InputPlaceholder), (
'%s(%r) should be an InputPlaceholder.' % (key, ph))
ph.cleanup(td.enabled)
return step_result
class FakeEnviron(object):
"""This is a fake dictionary which is meant to emulate os.environ strictly for
the purposes of interacting with _merge_envs.
It supports:
* Any key access is answered with <key>, allowing this to be used as
a % format argument.
* Deleting/setting items sets them to None/value, appropriately.
* `in` checks always returns True
* copy() returns self
The 'formatted' result can be obtained by looking at .data.
"""
def __init__(self):
self.data = {}
def __getitem__(self, key):
return '<%s>' % key
def get(self, key, default=None):
return self[key]
def keys(self):
return self.data.keys()
def pop(self, key, default=None):
result = self.data.get(key, default)
self.data[key] = None
return result
def __delitem__(self, key):
self.data[key] = None
def __contains__(self, key):
return True
def __setitem__(self, key, value):
self.data[key] = value
def copy(self):
return self
def merge_envs(original, overrides, prefixes, suffixes, pathsep):
"""Merges two environments.
Returns a new environment dict with entries from |override| overwriting
corresponding entries in |original|. Keys whose value is None will completely
remove the environment variable. Values can contain %(KEY)s strings, which
will be substituted with the values from the original (useful for amending, as
opposed to overwriting, variables like PATH).
See StepConfig for environment construction rules.
"""
result = original.copy()
subst = (original if isinstance(original, FakeEnviron)
else collections.defaultdict(lambda: '', **original))
if not any((prefixes, suffixes, overrides)):
return result
merged = set()
for k in set(suffixes).union(prefixes):
pfxs = prefixes.get(k, ())
sfxs = suffixes.get(k, ())
if not (pfxs or sfxs):
continue
# If the same key is defined in "overrides", we need to incorporate with it.
# We'll do so here, and skip it in the "overrides" construction.
merged.add(k)
if k in overrides:
val = overrides[k]
if val is not None:
val = str(val) % subst
else:
# Not defined. Append "val" iff it is defined in "original" and not empty.
val = original.get(k, '')
if val:
pfxs += (val,)
result[k] = pathsep.join(
str(v) for v in itertools.chain(pfxs, sfxs))
for k, v in overrides.iteritems():
if k in merged:
continue
if v is None:
result.pop(k, None)
else:
result[k] = str(v) % subst
return result