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

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: rebase 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
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 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
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
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698