| OLD | NEW |
| 1 # Copyright 2013 The Chromium Authors. All rights reserved. | 1 # Copyright 2013 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
| 4 | 4 |
| 5 import functools | 5 import functools |
| 6 import imp | |
| 7 import inspect | |
| 8 import os | |
| 9 import sys | |
| 10 import tempfile | |
| 11 | 6 |
| 7 from .recipe_test_api import DisabledTestData, ModuleTestData, StepTestData |
| 12 | 8 |
| 13 class Placeholder(object): | 9 from .recipe_util import ModuleInjectionSite |
| 14 """Base class for json placeholders. Do not use directly.""" | |
| 15 def render(self, test_data): # pragma: no cover | |
| 16 """Return [cmd items]*""" | |
| 17 raise NotImplementedError | |
| 18 | |
| 19 def step_finished(self, presentation, step_result, test_data): | |
| 20 """Called after step completion. Intended to modify step_result.""" | |
| 21 pass | |
| 22 | |
| 23 | |
| 24 class InputDataPlaceholder(Placeholder): | |
| 25 def __init__(self, data, suffix): | |
| 26 assert isinstance(data, basestring) | |
| 27 self.data = data | |
| 28 self.suffix = suffix | |
| 29 self.input_file = None | |
| 30 super(InputDataPlaceholder, self).__init__() | |
| 31 | |
| 32 def render(self, test_data): | |
| 33 if test_data is not None: | |
| 34 # cheat and pretend like we're going to pass the data on the | |
| 35 # cmdline for test expectation purposes. | |
| 36 return [self.data] | |
| 37 else: # pragma: no cover | |
| 38 input_fd, self.input_file = tempfile.mkstemp(suffix=self.suffix) | |
| 39 os.write(input_fd, self.data) | |
| 40 os.close(input_fd) | |
| 41 return [self.input_file] | |
| 42 | |
| 43 def step_finished(self, presentation, step_result, test_data): | |
| 44 if test_data is None: # pragma: no cover | |
| 45 os.unlink(self.input_file) | |
| 46 | |
| 47 | |
| 48 class ModuleInjectionSite(object): | |
| 49 pass | |
| 50 | 10 |
| 51 | 11 |
| 52 class RecipeApi(object): | 12 class RecipeApi(object): |
| 53 """ | 13 """ |
| 54 Framework class for handling recipe_modules. | 14 Framework class for handling recipe_modules. |
| 55 | 15 |
| 56 Inherit from this in your recipe_modules/<name>/api.py . This class provides | 16 Inherit from this in your recipe_modules/<name>/api.py . This class provides |
| 57 wiring for your config context (in self.c and methods, and for dependency | 17 wiring for your config context (in self.c and methods, and for dependency |
| 58 injection (in self.m). | 18 injection (in self.m). |
| 59 | 19 |
| 60 Dependency injection takes place in load_recipe_modules() below. | 20 Dependency injection takes place in load_recipe_modules() below. |
| 61 """ | 21 """ |
| 62 def __init__(self, module=None, mock=None, **_kwargs): | 22 def __init__(self, module=None, test_data=DisabledTestData(), **_kwargs): |
| 63 """Note: Injected dependencies are NOT available in __init__().""" | 23 """Note: Injected dependencies are NOT available in __init__().""" |
| 64 self.c = None | 24 self.c = None |
| 65 self._module = module | 25 self._module = module |
| 66 self._mock = mock | 26 |
| 27 assert isinstance(test_data, (ModuleTestData, DisabledTestData)) |
| 28 self._test_data = test_data |
| 67 | 29 |
| 68 # If we're the 'root' api, inject directly into 'self'. | 30 # If we're the 'root' api, inject directly into 'self'. |
| 69 # Otherwise inject into 'self.m' | 31 # Otherwise inject into 'self.m' |
| 70 self.m = self if module is None else ModuleInjectionSite() | 32 self.m = self if module is None else ModuleInjectionSite() |
| 71 | 33 |
| 34 # If our module has a test api, it gets injected here. |
| 35 self.test_api = None |
| 36 |
| 37 @staticmethod |
| 38 def inject_test_data(func): |
| 39 """ |
| 40 Decorator which injects mock data from this module's test_api method into |
| 41 the return value of the decorated function. |
| 42 |
| 43 The return value of func MUST be a single step dictionary (specifically, |
| 44 |func| must not be a generator, nor must it return a list of steps, etc.) |
| 45 |
| 46 When the decorated function is called, |func| is called normally. If we are |
| 47 in test mode, we will then also call self.test_api.<func.__name__>, whose |
| 48 return value will be assigned into the step dictionary retuned by |func|. |
| 49 |
| 50 It is an error for the function to not exist in the test_api. |
| 51 It is an error for the return value of |func| to already contain test data. |
| 52 """ |
| 53 @functools.wraps(func) |
| 54 def inner(self, *args, **kwargs): |
| 55 ret = func(self, *args, **kwargs) |
| 56 if self._mock is not None: # pylint: disable=W0212 |
| 57 test_fn = getattr(self.test_api, func.__name__, None) |
| 58 assert test_fn, ( |
| 59 "Method %(meth)s in module %(mod)s is @inject_test_data, but test_api" |
| 60 " does not contain %(meth)s." |
| 61 % { |
| 62 'meth': func.__name__, |
| 63 'mod': self._module, # pylint: disable=W0212 |
| 64 }) |
| 65 assert 'default_test_data' not in ret |
| 66 ret['default_test_data'] = test_fn(*args, **kwargs) |
| 67 return ret |
| 68 return inner |
| 69 |
| 72 def get_config_defaults(self): # pylint: disable=R0201 | 70 def get_config_defaults(self): # pylint: disable=R0201 |
| 73 """ | 71 """ |
| 74 Allows your api to dynamically determine static default values for configs. | 72 Allows your api to dynamically determine static default values for configs. |
| 75 """ | 73 """ |
| 76 return {} | 74 return {} |
| 77 | 75 |
| 78 def make_config(self, config_name=None, optional=False, **CONFIG_VARS): | 76 def make_config(self, config_name=None, optional=False, **CONFIG_VARS): |
| 79 """Returns a 'config blob' for the current API.""" | 77 """Returns a 'config blob' for the current API.""" |
| 80 return self.make_config_params(config_name, optional, **CONFIG_VARS)[0] | 78 return self.make_config_params(config_name, optional, **CONFIG_VARS)[0] |
| 81 | 79 |
| (...skipping 47 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 129 self.c = config | 127 self.c = config |
| 130 # TODO(iannucci): This is 'inefficient', since if a dep comes up multiple | 128 # TODO(iannucci): This is 'inefficient', since if a dep comes up multiple |
| 131 # times in this recursion, it will get set_config()'d multiple times | 129 # times in this recursion, it will get set_config()'d multiple times |
| 132 for dep in self._module.DEPS: | 130 for dep in self._module.DEPS: |
| 133 getattr(self.m, dep).set_config(config_name, optional=True, **params) | 131 getattr(self.m, dep).set_config(config_name, optional=True, **params) |
| 134 | 132 |
| 135 def apply_config(self, config_name, config_object=None): | 133 def apply_config(self, config_name, config_object=None): |
| 136 """Apply a named configuration to the provided config object or self.""" | 134 """Apply a named configuration to the provided config object or self.""" |
| 137 self._module.CONFIG_CTX.CONFIG_ITEMS[config_name](config_object or self.c) | 135 self._module.CONFIG_CTX.CONFIG_ITEMS[config_name](config_object or self.c) |
| 138 | 136 |
| 139 | 137 @property |
| 140 def load_recipe_modules(mod_dirs): | 138 def name(self): |
| 141 def patchup_module(submod): | 139 return self._module.NAME |
| 142 submod.CONFIG_CTX = getattr(submod, 'CONFIG_CTX', None) | |
| 143 submod.API = getattr(submod, 'API', None) | |
| 144 submod.DEPS = frozenset(getattr(submod, 'DEPS', ())) | |
| 145 | |
| 146 if hasattr(submod, 'config'): | |
| 147 for v in submod.config.__dict__.itervalues(): | |
| 148 if hasattr(v, 'I_AM_A_CONFIG_CTX'): | |
| 149 assert not submod.CONFIG_CTX, ( | |
| 150 'More than one configuration context: %s' % (submod.config)) | |
| 151 submod.CONFIG_CTX = v | |
| 152 assert submod.CONFIG_CTX, 'Config file, but no config context?' | |
| 153 | |
| 154 for v in submod.api.__dict__.itervalues(): | |
| 155 if inspect.isclass(v) and issubclass(v, RecipeApi): | |
| 156 assert not submod.API, ( | |
| 157 'More than one Api subclass: %s' % submod.api) | |
| 158 submod.API = v | |
| 159 | |
| 160 assert submod.API, 'Submodule has no api? %s' % (submod) | |
| 161 | |
| 162 RM = 'RECIPE_MODULES' | |
| 163 def find_and_load(fullname, modname, path): | |
| 164 if fullname not in sys.modules or fullname == RM: | |
| 165 try: | |
| 166 fil, pathname, descr = imp.find_module(modname, | |
| 167 [os.path.dirname(path)]) | |
| 168 imp.load_module(fullname, fil, pathname, descr) | |
| 169 finally: | |
| 170 if fil: | |
| 171 fil.close() | |
| 172 return sys.modules[fullname] | |
| 173 | |
| 174 def recursive_import(path, prefix=None, skip_fn=lambda name: False): | |
| 175 modname = os.path.splitext(os.path.basename(path))[0] | |
| 176 if prefix: | |
| 177 fullname = '%s.%s' % (prefix, modname) | |
| 178 else: | |
| 179 fullname = RM | |
| 180 m = find_and_load(fullname, modname, path) | |
| 181 if not os.path.isdir(path): | |
| 182 return m | |
| 183 | |
| 184 for subitem in os.listdir(path): | |
| 185 subpath = os.path.join(path, subitem) | |
| 186 subname = os.path.splitext(subitem)[0] | |
| 187 if skip_fn(subname): | |
| 188 continue | |
| 189 if os.path.isdir(subpath): | |
| 190 if not os.path.exists(os.path.join(subpath, '__init__.py')): | |
| 191 continue | |
| 192 elif not subpath.endswith('.py') or subitem.startswith('__init__.py'): | |
| 193 continue | |
| 194 | |
| 195 submod = recursive_import(subpath, fullname, skip_fn=skip_fn) | |
| 196 | |
| 197 if not hasattr(m, subname): | |
| 198 setattr(m, subname, submod) | |
| 199 else: | |
| 200 prev = getattr(m, subname) | |
| 201 assert submod is prev, ( | |
| 202 'Conflicting modules: %s and %s' % (prev, m)) | |
| 203 | |
| 204 return m | |
| 205 | |
| 206 imp.acquire_lock() | |
| 207 try: | |
| 208 if RM not in sys.modules: | |
| 209 sys.modules[RM] = imp.new_module(RM) | |
| 210 # First import all the APIs and configs | |
| 211 for root in mod_dirs: | |
| 212 if os.path.isdir(root): | |
| 213 recursive_import(root, skip_fn=lambda name: name.endswith('_config')) | |
| 214 | |
| 215 # Then fixup all the modules | |
| 216 for name, submod in sys.modules[RM].__dict__.iteritems(): | |
| 217 if name[0] == '_': | |
| 218 continue | |
| 219 patchup_module(submod) | |
| 220 | |
| 221 # Then import all the config extenders. | |
| 222 for root in mod_dirs: | |
| 223 if os.path.isdir(root): | |
| 224 recursive_import(root) | |
| 225 return sys.modules[RM] | |
| 226 finally: | |
| 227 imp.release_lock() | |
| 228 | 140 |
| 229 | 141 |
| 230 def CreateRecipeApi(names, mod_dirs, mocks=None, **kwargs): | 142 def inject_test_data(func): |
| 231 """ | 143 """ |
| 232 Given a list of module names, return an instance of RecipeApi which contains | 144 Decorator which injects mock data from this module's test_api method into |
| 233 those modules as direct members. | 145 the return value of the decorated function. |
| 234 | 146 |
| 235 So, if you pass ['foobar'], you'll get an instance back which contains a | 147 The return value of func MUST be a single step dictionary (specifically, |
| 236 'foobar' attribute which itself is a RecipeApi instance from the 'foobar' | 148 |func| must not be a generator, nor must it return a list of steps, etc.) |
| 237 module. | |
| 238 | 149 |
| 239 Args: | 150 When the decorated function is called, |func| is called normally. If we are |
| 240 names (list): A list of module names to include in the returned RecipeApi. | 151 in test mode, we will then also call self.test_api.<func.__name__>, whose |
| 241 mod_dirs (list): A list of paths to directories which contain modules. | 152 return value will be assigned into the step dictionary retuned by |func|. |
| 242 mocks (dict): An optional dict of {<modname>: <mock data>}. Each module | 153 |
| 243 expects its own mock data. | 154 It is an error for the function to not exist in the test_api. |
| 244 **kwargs: Data passed to each module api. Usually this will contain: | 155 It is an error for the return value of |func| to already contain test data. |
| 245 properties (dict): the properties dictionary (used by the properties | |
| 246 module) | |
| 247 step_history (OrderedDict): the step history object (used by the | |
| 248 step_history module!) | |
| 249 """ | 156 """ |
| 250 | 157 @functools.wraps(func) |
| 251 recipe_modules = load_recipe_modules(mod_dirs) | 158 def inner(self, *args, **kwargs): |
| 252 | 159 assert isinstance(self, RecipeApi) |
| 253 inst_map = {None: RecipeApi()} | 160 ret = func(self, *args, **kwargs) |
| 254 dep_map = {None: set(names)} | 161 if self._test_data.enabled: # pylint: disable=W0212 |
| 255 def create_maps(name): | 162 test_fn = getattr(self.test_api, func.__name__, None) |
| 256 if name not in dep_map: | 163 assert test_fn, ( |
| 257 module = getattr(recipe_modules, name) | 164 "Method %(meth)s in module %(mod)s is @inject_test_data, but test_api" |
| 258 | 165 " does not contain %(meth)s." |
| 259 dep_map[name] = set(module.DEPS) | 166 % { |
| 260 map(create_maps, dep_map[name]) | 167 'meth': func.__name__, |
| 261 | 168 'mod': self._module, # pylint: disable=W0212 |
| 262 mock = None if mocks is None else mocks.get(name, {}) | 169 }) |
| 263 inst_map[name] = module.API(module=module, mock=mock, **kwargs) | 170 assert 'default_step_data' not in ret |
| 264 map(create_maps, names) | 171 data = test_fn(*args, **kwargs) |
| 265 | 172 assert isinstance(data, StepTestData) |
| 266 # NOTE: this is 'inefficient', but correct and compact. | 173 ret['default_step_data'] = data |
| 267 did_something = True | 174 return ret |
| 268 while dep_map: | 175 return inner |
| 269 did_something = False | |
| 270 to_pop = [] | |
| 271 for api_name, deps in dep_map.iteritems(): | |
| 272 to_remove = [] | |
| 273 for dep in [d for d in deps if d not in dep_map]: | |
| 274 # Grab the injection site | |
| 275 obj = inst_map[api_name].m | |
| 276 assert not hasattr(obj, dep) | |
| 277 setattr(obj, dep, inst_map[dep]) | |
| 278 to_remove.append(dep) | |
| 279 did_something = True | |
| 280 map(deps.remove, to_remove) | |
| 281 if not deps: | |
| 282 to_pop.append(api_name) | |
| 283 did_something = True | |
| 284 map(dep_map.pop, to_pop) | |
| 285 assert did_something, 'Did nothing on this loop. %s' % dep_map | |
| 286 | |
| 287 return inst_map[None] | |
| 288 | |
| 289 | |
| 290 def wrap_followup(kwargs, pre=False): | |
| 291 """ | |
| 292 Decorator for a new followup_fn. | |
| 293 | |
| 294 Will pop the existing fn out of kwargs (if any), and return a decorator for | |
| 295 the new folloup_fn. | |
| 296 | |
| 297 Args: | |
| 298 kwargs - dictionary possibly containing folloup_fn | |
| 299 pre - If true, the old folloup_fn is called before the wrapped function. | |
| 300 Otherwise, the old followup_fn is called after the wrapped function. | |
| 301 """ | |
| 302 null_fn = lambda _: None | |
| 303 old_followup = kwargs.pop('followup_fn', null_fn) | |
| 304 def decorator(f): | |
| 305 @functools.wraps(f) | |
| 306 def _inner(step_result): | |
| 307 if pre: | |
| 308 old_followup(step_result) | |
| 309 f(step_result) | |
| 310 else: | |
| 311 f(step_result) | |
| 312 old_followup(step_result) | |
| 313 if old_followup is not null_fn: | |
| 314 _inner.__name__ += '[%s]' % old_followup.__name__ | |
| 315 return _inner | |
| 316 return decorator | |
| OLD | NEW |