Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(69)

Side by Side Diff: scripts/slave/recipe_api.py

Issue 23889036: Refactor the way that TestApi works so that it is actually useful. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/build
Patch Set: License headers Created 7 years, 3 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
« no previous file with comments | « scripts/slave/annotated_run.py ('k') | scripts/slave/recipe_config.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
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 RecipeAbort(Exception): 9 from .recipe_util import ModuleInjectionSite
14 pass
15
16
17 class Placeholder(object):
18 """Base class for json placeholders. Do not use directly."""
19 def render(self, test_data): # pragma: no cover
20 """Return [cmd items]*"""
21 raise NotImplementedError
22
23 def step_finished(self, presentation, step_result, test_data):
24 """Called after step completion. Intended to modify step_result."""
25 pass
26
27
28 class InputDataPlaceholder(Placeholder):
29 def __init__(self, data, suffix):
30 assert isinstance(data, basestring)
31 self.data = data
32 self.suffix = suffix
33 self.input_file = None
34 super(InputDataPlaceholder, self).__init__()
35
36 def render(self, test_data):
37 if test_data is not None:
38 # cheat and pretend like we're going to pass the data on the
39 # cmdline for test expectation purposes.
40 return [self.data]
41 else: # pragma: no cover
42 input_fd, self.input_file = tempfile.mkstemp(suffix=self.suffix)
43 os.write(input_fd, self.data)
44 os.close(input_fd)
45 return [self.input_file]
46
47 def step_finished(self, presentation, step_result, test_data):
48 if test_data is None: # pragma: no cover
49 os.unlink(self.input_file)
50
51
52 class ModuleInjectionSite(object):
53 pass
54 10
55 11
56 class RecipeApi(object): 12 class RecipeApi(object):
57 """ 13 """
58 Framework class for handling recipe_modules. 14 Framework class for handling recipe_modules.
59 15
60 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
61 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
62 injection (in self.m). 18 injection (in self.m).
63 19
64 Dependency injection takes place in load_recipe_modules() below. 20 Dependency injection takes place in load_recipe_modules() below.
65 """ 21 """
66 def __init__(self, module=None, mock=None, **_kwargs): 22 def __init__(self, module=None, test_data=DisabledTestData(), **_kwargs):
67 """Note: Injected dependencies are NOT available in __init__().""" 23 """Note: Injected dependencies are NOT available in __init__()."""
68 self.c = None 24 self.c = None
69 self._module = module 25 self._module = module
70 self._mock = mock 26
27 assert isinstance(test_data, (ModuleTestData, DisabledTestData))
28 self._test_data = test_data
71 29
72 # If we're the 'root' api, inject directly into 'self'. 30 # If we're the 'root' api, inject directly into 'self'.
73 # Otherwise inject into 'self.m' 31 # Otherwise inject into 'self.m'
74 self.m = self if module is None else ModuleInjectionSite() 32 self.m = self if module is None else ModuleInjectionSite()
75 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
76 def get_config_defaults(self): # pylint: disable=R0201 70 def get_config_defaults(self): # pylint: disable=R0201
77 """ 71 """
78 Allows your api to dynamically determine static default values for configs. 72 Allows your api to dynamically determine static default values for configs.
79 """ 73 """
80 return {} 74 return {}
81 75
82 def make_config(self, config_name=None, optional=False, **CONFIG_VARS): 76 def make_config(self, config_name=None, optional=False, **CONFIG_VARS):
83 """Returns a 'config blob' for the current API.""" 77 """Returns a 'config blob' for the current API."""
84 return self.make_config_params(config_name, optional, **CONFIG_VARS)[0] 78 return self.make_config_params(config_name, optional, **CONFIG_VARS)[0]
85 79
(...skipping 47 matching lines...) Expand 10 before | Expand all | Expand 10 after
133 self.c = config 127 self.c = config
134 # 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
135 # 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
136 for dep in self._module.DEPS: 130 for dep in self._module.DEPS:
137 getattr(self.m, dep).set_config(config_name, optional=True, **params) 131 getattr(self.m, dep).set_config(config_name, optional=True, **params)
138 132
139 def apply_config(self, config_name, config_object=None): 133 def apply_config(self, config_name, config_object=None):
140 """Apply a named configuration to the provided config object or self.""" 134 """Apply a named configuration to the provided config object or self."""
141 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)
142 136
143 137 @property
144 def load_recipe_modules(mod_dirs): 138 def name(self):
145 def patchup_module(submod): 139 return self._module.NAME
146 submod.CONFIG_CTX = getattr(submod, 'CONFIG_CTX', None)
147 submod.API = getattr(submod, 'API', None)
148 submod.DEPS = frozenset(getattr(submod, 'DEPS', ()))
149
150 if hasattr(submod, 'config'):
151 for v in submod.config.__dict__.itervalues():
152 if hasattr(v, 'I_AM_A_CONFIG_CTX'):
153 assert not submod.CONFIG_CTX, (
154 'More than one configuration context: %s' % (submod.config))
155 submod.CONFIG_CTX = v
156 assert submod.CONFIG_CTX, 'Config file, but no config context?'
157
158 for v in submod.api.__dict__.itervalues():
159 if inspect.isclass(v) and issubclass(v, RecipeApi):
160 assert not submod.API, (
161 'More than one Api subclass: %s' % submod.api)
162 submod.API = v
163
164 assert submod.API, 'Submodule has no api? %s' % (submod)
165
166 RM = 'RECIPE_MODULES'
167 def find_and_load(fullname, modname, path):
168 if fullname not in sys.modules or fullname == RM:
169 try:
170 fil, pathname, descr = imp.find_module(modname,
171 [os.path.dirname(path)])
172 imp.load_module(fullname, fil, pathname, descr)
173 finally:
174 if fil:
175 fil.close()
176 return sys.modules[fullname]
177
178 def recursive_import(path, prefix=None, skip_fn=lambda name: False):
179 modname = os.path.splitext(os.path.basename(path))[0]
180 if prefix:
181 fullname = '%s.%s' % (prefix, modname)
182 else:
183 fullname = RM
184 m = find_and_load(fullname, modname, path)
185 if not os.path.isdir(path):
186 return m
187
188 for subitem in os.listdir(path):
189 subpath = os.path.join(path, subitem)
190 subname = os.path.splitext(subitem)[0]
191 if skip_fn(subname):
192 continue
193 if os.path.isdir(subpath):
194 if not os.path.exists(os.path.join(subpath, '__init__.py')):
195 continue
196 elif not subpath.endswith('.py') or subitem.startswith('__init__.py'):
197 continue
198
199 submod = recursive_import(subpath, fullname, skip_fn=skip_fn)
200
201 if not hasattr(m, subname):
202 setattr(m, subname, submod)
203 else:
204 prev = getattr(m, subname)
205 assert submod is prev, (
206 'Conflicting modules: %s and %s' % (prev, m))
207
208 return m
209
210 imp.acquire_lock()
211 try:
212 if RM not in sys.modules:
213 sys.modules[RM] = imp.new_module(RM)
214 # First import all the APIs and configs
215 for root in mod_dirs:
216 if os.path.isdir(root):
217 recursive_import(root, skip_fn=lambda name: name.endswith('_config'))
218
219 # Then fixup all the modules
220 for name, submod in sys.modules[RM].__dict__.iteritems():
221 if name[0] == '_':
222 continue
223 patchup_module(submod)
224
225 # Then import all the config extenders.
226 for root in mod_dirs:
227 if os.path.isdir(root):
228 recursive_import(root)
229 return sys.modules[RM]
230 finally:
231 imp.release_lock()
232 140
233 141
234 def CreateRecipeApi(names, mod_dirs, mocks=None, **kwargs): 142 def inject_test_data(func):
235 """ 143 """
236 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
237 those modules as direct members. 145 the return value of the decorated function.
238 146
239 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,
240 '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.)
241 module.
242 149
243 Args: 150 When the decorated function is called, |func| is called normally. If we are
244 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
245 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|.
246 mocks (dict): An optional dict of {<modname>: <mock data>}. Each module 153
247 expects its own mock data. 154 It is an error for the function to not exist in the test_api.
248 **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.
249 properties (dict): the properties dictionary (used by the properties
250 module)
251 step_history (OrderedDict): the step history object (used by the
252 step_history module!)
253 """ 156 """
254 157 @functools.wraps(func)
255 recipe_modules = load_recipe_modules(mod_dirs) 158 def inner(self, *args, **kwargs):
256 159 assert isinstance(self, RecipeApi)
257 inst_map = {None: RecipeApi()} 160 ret = func(self, *args, **kwargs)
258 dep_map = {None: set(names)} 161 if self._test_data.enabled: # pylint: disable=W0212
259 def create_maps(name): 162 test_fn = getattr(self.test_api, func.__name__, None)
260 if name not in dep_map: 163 assert test_fn, (
261 module = getattr(recipe_modules, name) 164 "Method %(meth)s in module %(mod)s is @inject_test_data, but test_api"
262 165 " does not contain %(meth)s."
263 dep_map[name] = set(module.DEPS) 166 % {
264 map(create_maps, dep_map[name]) 167 'meth': func.__name__,
265 168 'mod': self._module, # pylint: disable=W0212
266 mock = None if mocks is None else mocks.get(name, {}) 169 })
267 inst_map[name] = module.API(module=module, mock=mock, **kwargs) 170 assert 'default_step_data' not in ret
268 map(create_maps, names) 171 data = test_fn(*args, **kwargs)
269 172 assert isinstance(data, StepTestData)
270 # NOTE: this is 'inefficient', but correct and compact. 173 ret['default_step_data'] = data
271 did_something = True 174 return ret
272 while dep_map: 175 return inner
273 did_something = False
274 to_pop = []
275 for api_name, deps in dep_map.iteritems():
276 to_remove = []
277 for dep in [d for d in deps if d not in dep_map]:
278 # Grab the injection site
279 obj = inst_map[api_name].m
280 assert not hasattr(obj, dep)
281 setattr(obj, dep, inst_map[dep])
282 to_remove.append(dep)
283 did_something = True
284 map(deps.remove, to_remove)
285 if not deps:
286 to_pop.append(api_name)
287 did_something = True
288 map(dep_map.pop, to_pop)
289 assert did_something, 'Did nothing on this loop. %s' % dep_map
290
291 return inst_map[None]
292
293
294 def wrap_followup(kwargs, pre=False):
295 """
296 Decorator for a new followup_fn.
297
298 Will pop the existing fn out of kwargs (if any), and return a decorator for
299 the new folloup_fn.
300
301 Args:
302 kwargs - dictionary possibly containing folloup_fn
303 pre - If true, the old folloup_fn is called before the wrapped function.
304 Otherwise, the old followup_fn is called after the wrapped function.
305 """
306 null_fn = lambda _: None
307 old_followup = kwargs.pop('followup_fn', null_fn)
308 def decorator(f):
309 @functools.wraps(f)
310 def _inner(step_result):
311 if pre:
312 old_followup(step_result)
313 f(step_result)
314 else:
315 f(step_result)
316 old_followup(step_result)
317 if old_followup is not null_fn:
318 _inner.__name__ += '[%s]' % old_followup.__name__
319 return _inner
320 return decorator
OLDNEW
« no previous file with comments | « scripts/slave/annotated_run.py ('k') | scripts/slave/recipe_config.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698