blob: 46751260c6474f7750b97a130128d2f2ef2972a5 [file] [log] [blame]
# Copyright 2013 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.
from io import StringIO
import contextlib
import datetime
import functools
import logging
import os
import sys
import time
import traceback
from builtins import map, range
from builtins import str as text
from future.utils import iteritems
from past.builtins import basestring
def sentinel(name, **attrs):
"""Create a sentinel object.
The sentinel's type is a class with the given name that has no behavior except
that it's string representation is also the given name. The sentinel is
intended for use where some special behavior is required where there is no
acceptable special value in the type of an argument. An identity check (x is
SENTINEL) can be used to check for the sentinel.
Any additional attributes can be passed via `attrs`. This can be useful to
associate metadata with the sentinel object.
"""
all_attrs = dict(attrs)
all_attrs.update({
'__repr__': lambda _: name,
'__copy__': lambda self: self,
'__deepcopy__': lambda self, _: self,
})
return type(name, (), all_attrs)()
class RecipeAbort(Exception):
pass
class ModuleInjectionError(AttributeError):
pass
class ModuleInjectionSite(object):
def __init__(self, owner_module=None):
self.owner_module = owner_module
def __getattr__(self, key):
if self.owner_module is None:
raise ModuleInjectionError(
"RecipeApi has no dependency %r. (Add it to DEPS?)" % (key,))
else:
raise ModuleInjectionError(
"Recipe Module %r has no dependency %r. (Add it to __init__.py:DEPS?)"
% (self.owner_module.name, key))
class Placeholder(object):
"""Base class for command line argument placeholders. Do not use directly."""
def __init__(self, name=None):
if name is not None:
assert isinstance(name, basestring), (
'Expect a string name for a placeholder, but got %r' % name)
self.name = name
self.namespaces = None
@property
def backing_file(self): # pragma: no cover
"""Return path to a temp file that holds or receives the data.
Valid only after 'render' has been called.
"""
raise NotImplementedError
def render(self, test): # pragma: no cover
"""Return [cmd items]*"""
raise NotImplementedError
@property
def label(self):
if self.name is None:
return "%s.%s" % self.namespaces
else:
return "%s.%s[%s]" % (self.namespaces[0], self.namespaces[1], self.name)
class InputPlaceholder(Placeholder):
"""Base class for json/raw_io input placeholders. Do not use directly."""
def cleanup(self, test_enabled):
"""Called after step completion.
Args:
test_enabled (bool) - indicate whether running in simulation mode.
"""
pass
class OutputPlaceholder(Placeholder):
"""Base class for json/raw_io output placeholders. Do not use directly."""
def result(self, presentation, test):
"""Called after step completion.
Args:
presentation (StepPresentation) - for the current step.
test (PlaceholderTestData) - test data for this placeholder.
May optionally modify presentation as a side-effect.
Returned value will be added to the step result.
"""
pass
def static_wraps(func):
wrapped_fn = func
if isinstance(func, staticmethod):
# __get__(obj) is the way to get the function contained in the staticmethod.
# python 2.7+ has a __func__ member, but previous to this the attribute
# doesn't exist. It doesn't matter what the obj is, as long as it's not
# None.
wrapped_fn = func.__get__(object)
return functools.wraps(wrapped_fn)
def static_call(obj, func, *args, **kwargs):
if isinstance(func, staticmethod):
return func.__get__(obj)(*args, **kwargs)
else:
return func(obj, *args, **kwargs)
def static_name(obj, func):
if isinstance(func, staticmethod):
return func.__get__(obj).__name__
else:
return func.__name__
def _returns_placeholder(func, alternate_name=None):
@static_wraps(func)
def inner(self, *args, **kwargs):
ret = static_call(self, func, *args, **kwargs)
assert isinstance(ret, Placeholder)
ret.namespaces = (self.name, alternate_name or static_name(self, func))
return ret
# prevent this placeholder-returning function from becoming a composite_step.
inner._non_step = True # pylint: disable=protected-access
return inner
def returns_placeholder(func):
"""Decorates a RecipeApi placeholder-returning method to set the namespace
of the returned PlaceHolder.
The default namespace will be a tuple of (RECIPE_MODULE_NAME, method_name).
You can also decorate the method by `@returns_placeholder(alternate_name)` so
that the placeholder will have namespace (RECIPE_MODULE_NAME, alternate_name).
"""
if callable(func) or isinstance(func, staticmethod):
return _returns_placeholder(func)
elif isinstance(func, str) and func:
def decorator(f):
return _returns_placeholder(f, func)
return decorator
else:
raise ValueError('Expected either a function or string; got %r' % func)
class StringListIO(object):
def __init__(self):
self.lines = [StringIO()]
def write(self, s):
while s:
i = s.find('\n')
if i == -1:
self.lines[-1].write(text(s))
break
self.lines[-1].write(text(s[:i]))
self.lines[-1] = self.lines[-1].getvalue()
self.lines.append(StringIO())
s = s[i+1:]
def close(self):
if isinstance(self.lines[-1], StringIO):
self.lines[-1] = self.lines[-1].getvalue()
class exponential_retry(object):
"""Decorator which retries the function if an exception is encountered."""
def __init__(self, retries=None, delay=None, condition=None):
"""Creates a new exponential retry decorator.
Args:
retries (int): Maximum number of retries before giving up.
delay (datetime.timedelta): Amount of time to wait before retrying. This
will double every retry attempt (exponential).
condition (func): If not None, a function that will be passed the
exception as its one argument. Retries will only happen if this
function returns True. If None, retries will always happen.
"""
self.retries = retries or 5
self.delay = delay or datetime.timedelta(seconds=1)
self.condition = condition or (lambda e: True)
def __call__(self, f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
retry_delay = self.delay
for i in range(self.retries):
try:
return f(*args, **kwargs)
except Exception as e:
if (i+1) >= self.retries or not self.condition(e):
raise
logging.exception('Exception encountered, retrying in %s',
retry_delay)
time.sleep(retry_delay.total_seconds())
retry_delay *= 2
return wrapper
class MultiException(Exception):
"""An exception that aggregates multiple exceptions and summarizes them."""
class Builder(object):
"""Iteratively constructs a MultiException."""
def __init__(self):
self._exceptions = []
def append(self, exc):
if exc is not None:
self._exceptions.append(exc)
def get(self):
"""Returns (MultiException or None): The constructed MultiException.
If no exceptions have been appended, None will be returned.
"""
return MultiException(*self._exceptions) if self._exceptions else (None)
def raise_if_any(self):
mexc = self.get()
if mexc is not None:
raise mexc
@contextlib.contextmanager
def catch(self, *exc_types):
"""ContextManager that catches any exception raised during its execution
and adds them to the MultiException.
Args:
exc_types (list): A list of exception classes to catch. If empty,
Exception will be used.
"""
exc_types = exc_types or (Exception,)
try:
yield
except exc_types as exc:
self.append(exc)
def __init__(self, *base):
super(MultiException, self).__init__()
# Determine base Exception text.
if len(base) == 0:
self.message = 'No exceptions'
elif len(base) == 1:
self.message = str(base[0])
else:
self.message = str(base[0]) + ', and %d more...' % (len(base)-1)
self._inner = base
def __bool__(self):
return bool(self._inner)
__nonzero__ = __bool__ # py2 compatibility
def __len__(self):
return len(self._inner)
def __getitem__(self, key):
return self._inner[key]
def __iter__(self):
return iter(self._inner)
def __str__(self):
return '%s(%s)' % (type(self).__name__, self.message)
@contextlib.contextmanager
def map_defer_exceptions(fn, it, *exc_types):
"""Executes "fn" for each element in "it". Any exceptions thrown by "fn" will
be deferred until the end of "it", then raised as a single MultiException.
Args:
fn (callable): A function to call for each element in "it".
it (iterable): An iterable to traverse.
exc_types (list): An optional list of specific exception types to defer.
If empty, Exception will be used. Any Exceptions not referenced by this
list will skip deferring and be immediately raised.
"""
mexc_builder = MultiException.Builder()
for e in it:
with mexc_builder.catch(*exc_types):
fn(e)
mexc_builder.raise_if_any()
MIN_SAFE_INTEGER = -((2**53) - 1)
MAX_SAFE_INTEGER = (2**53) - 1
def fix_json_object(obj):
"""Recursively:
* Re-encodes strings as utf-8 inside |obj| (python2 only).
* Replaces floats with ints when:
* The value is a whole number
* The value is outside of [-(2 ** 53 - 1), 2 ** 53 - 1]
Returns the result.
"""
if sys.version_info.major == 2 and isinstance(obj, unicode):
return obj.encode('utf-8', 'replace')
if isinstance(obj, list):
return list(map(fix_json_object, obj))
if isinstance(obj, float):
if obj.is_integer() and (MIN_SAFE_INTEGER <= obj <= MAX_SAFE_INTEGER):
return int(obj)
return obj
if isinstance(obj, dict):
new_obj = type(obj)(
(fix_json_object(k), fix_json_object(v)) for k, v in iteritems(obj))
return new_obj
return obj
_FULL_STACKS = True
_RECIPE_ENGINE_BASE = os.path.dirname(__file__)
def enable_filtered_stacks():
"""Sets an internal option to filter engine implementation details out of
stack traces.
Only set during tests when '--full-stacks' is not specified.
"""
global _FULL_STACKS
_FULL_STACKS = False
def remove_engine_impl_from_stack(stack):
"""Takes a 'processed' stack (e.g. from traceback.extract_stack) and removes
recipe engine implementation frames (i.e. not from recipes or recipe modules).
This filtering starts in the stack at the first "non-engine" code; This means
that if the engine code DOES crash, the full stack will be shown anyway, and
if the exception originates from inside the engine code, these frames will be
shown as well.
This will also trim frames which are from known third-party libraries (like
gevent).
Is a NO-OP unless enable_filtered_stacks() has been called.
"""
if _FULL_STACKS:
return stack
def _is_engine_impl(frame):
return (
frame[0].startswith(_RECIPE_ENGINE_BASE) or
frame[2].startswith('gevent.')
)
# stack is organized from deep->shallow. We process the stack backwards; We
# walk until we find some `not _is_engine_impl` frame, and then above that we
# filter all `_is_engine_impl` frames.
ret = []
found_user_code = False
for frame in reversed(stack):
is_engine = _is_engine_impl(frame)
if found_user_code and is_engine:
continue
ret.append(frame)
found_user_code = found_user_code or (not is_engine)
return list(reversed(ret))
def extract_tb(tb):
"""Return a 'processed' stack (as from traceback.extract_tb).
If enable_filtered_stacks() was called, this trims the stack per
remove_engine_impl_from_stack.
"""
return remove_engine_impl_from_stack(traceback.extract_tb(tb))
# Convert some known py3 err msg to py2 err msg, otherwise, convert to a
# constant err msg.
# TODO(crbug.com/1147793): remove it after py3 migration is done.
def unify_json_load_err(err):
py2_err = 'No JSON object could be decoded'
if (err.startswith('Expecting property name') or
err.startswith('Expecting value')):
return py2_err
return py2_err if err.startswith(py2_err) else 'Wrong JSON object format'
def format_ex(ex):
"""Return the same format of string representation for Exception objects in
both python2 and python3.
"""
return "%s(%s)" % (type(ex).__name__,
', '.join("'%s'" % str(arg) for arg in ex.args))