OLD | NEW |
(Empty) | |
| 1 # Copyright 2013-2015 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. |
| 4 |
| 5 import contextlib |
| 6 import imp |
| 7 import inspect |
| 8 import os |
| 9 import sys |
| 10 |
| 11 from .config import ConfigContext |
| 12 from .config_types import Path, ModuleBasePath, RECIPE_MODULE_PREFIX |
| 13 from .recipe_api import RecipeApi, RecipeApiPlain, Property, UndefinedPropertyEx
ception |
| 14 from .recipe_test_api import RecipeTestApi, DisabledTestData |
| 15 from .util import scan_directory |
| 16 |
| 17 |
| 18 class NoSuchRecipe(Exception): |
| 19 """Raised by load_recipe is recipe is not found.""" |
| 20 |
| 21 |
| 22 class RecipeScript(object): |
| 23 """Holds dict of an evaluated recipe script.""" |
| 24 |
| 25 def __init__(self, recipe_dict): |
| 26 recipe_dict.setdefault('PROPERTIES', {}) |
| 27 # Let each property object know about the property name. |
| 28 for name, value in recipe_dict['PROPERTIES'].items(): |
| 29 value.name = name |
| 30 |
| 31 for k, v in recipe_dict.iteritems(): |
| 32 setattr(self, k, v) |
| 33 |
| 34 @classmethod |
| 35 def from_script_path(cls, script_path, universe): |
| 36 """Evaluates a script and returns RecipeScript instance.""" |
| 37 |
| 38 script_vars = {} |
| 39 script_vars['__file__'] = script_path |
| 40 |
| 41 with _preserve_path(): |
| 42 execfile(script_path, script_vars) |
| 43 |
| 44 script_vars['LOADED_DEPS'] = universe.deps_from_mixed( |
| 45 script_vars.get('DEPS', []), os.path.basename(script_path)) |
| 46 return cls(script_vars) |
| 47 |
| 48 |
| 49 class Dependency(object): |
| 50 def load(self, universe): |
| 51 raise NotImplementedError() |
| 52 |
| 53 @property |
| 54 def local_name(self): |
| 55 raise NotImplementedError() |
| 56 |
| 57 @property |
| 58 def unique_name(self): |
| 59 """A unique identifier for the module that this dependency refers to. |
| 60 This must be generated without loading the module.""" |
| 61 raise NotImplementedError() |
| 62 |
| 63 |
| 64 class PathDependency(Dependency): |
| 65 def __init__(self, path, local_name, universe, base_path=None): |
| 66 self._path = _normalize_path(base_path, path) |
| 67 self._local_name = local_name |
| 68 |
| 69 # We forbid modules from living outside our main paths to keep clients |
| 70 # from going crazy before we have standardized recipe locations. |
| 71 mod_dir = os.path.dirname(path) |
| 72 assert mod_dir in universe.module_dirs, ( |
| 73 'Modules living outside of approved directories are forbidden: ' |
| 74 '%s is not in %s' % (mod_dir, universe.module_dirs)) |
| 75 |
| 76 def load(self, universe): |
| 77 return _load_recipe_module_module(self._path, universe) |
| 78 |
| 79 @property |
| 80 def local_name(self): |
| 81 return self._local_name |
| 82 |
| 83 @property |
| 84 def unique_name(self): |
| 85 return self._path |
| 86 |
| 87 |
| 88 class NamedDependency(PathDependency): |
| 89 def __init__(self, name, universe): |
| 90 for path in universe.module_dirs: |
| 91 mod_path = os.path.join(path, name) |
| 92 if _is_recipe_module_dir(mod_path): |
| 93 super(NamedDependency, self).__init__(mod_path, name, universe=universe) |
| 94 return |
| 95 raise NoSuchRecipe('Recipe module named %s does not exist' % name) |
| 96 |
| 97 |
| 98 class RecipeUniverse(object): |
| 99 def __init__(self, module_dirs, recipe_dirs): |
| 100 self._loaded = {} |
| 101 self._module_dirs = module_dirs[:] |
| 102 self._recipe_dirs = recipe_dirs[:] |
| 103 |
| 104 @property |
| 105 def module_dirs(self): |
| 106 return self._module_dirs |
| 107 |
| 108 @property |
| 109 def recipe_dirs(self): |
| 110 return self._recipe_dirs |
| 111 |
| 112 def load(self, dep): |
| 113 """Load a Dependency.""" |
| 114 name = dep.unique_name |
| 115 if name in self._loaded: |
| 116 mod = self._loaded[name] |
| 117 assert mod is not None, ( |
| 118 'Cyclic dependency when trying to load %s' % name) |
| 119 return mod |
| 120 else: |
| 121 self._loaded[name] = None |
| 122 mod = dep.load(self) |
| 123 self._loaded[name] = mod |
| 124 return mod |
| 125 |
| 126 def deps_from_names(self, deps): |
| 127 """Load dependencies given a list simple module names (old style).""" |
| 128 return { dep: self.load(NamedDependency(dep, universe=self)) |
| 129 for dep in deps } |
| 130 |
| 131 def deps_from_paths(self, deps, base_path): |
| 132 """Load dependencies given a dictionary of local names to module paths |
| 133 (new style).""" |
| 134 return { name: self.load(PathDependency(path, name, |
| 135 universe=self, base_path=base_path)) |
| 136 for name, path in deps.iteritems() } |
| 137 |
| 138 def deps_from_mixed(self, deps, base_path): |
| 139 """Load dependencies given either a new style or old style deps spec.""" |
| 140 if isinstance(deps, (list, tuple)): |
| 141 return self.deps_from_names(deps) |
| 142 elif isinstance(deps, dict): |
| 143 return self.deps_from_paths(deps, base_path) |
| 144 else: |
| 145 raise ValueError('%s is not a valid or known deps structure' % deps) |
| 146 |
| 147 def load_recipe(self, recipe): |
| 148 """Given name of a recipe, loads and returns it as RecipeScript instance. |
| 149 |
| 150 Args: |
| 151 recipe (str): name of a recipe, can be in form '<module>:<recipe>'. |
| 152 |
| 153 Returns: |
| 154 RecipeScript instance. |
| 155 |
| 156 Raises: |
| 157 NoSuchRecipe: recipe is not found. |
| 158 """ |
| 159 # If the recipe is specified as "module:recipe", then it is an recipe |
| 160 # contained in a recipe_module as an example. Look for it in the modules |
| 161 # imported by load_recipe_modules instead of the normal search paths. |
| 162 if ':' in recipe: |
| 163 module_name, example = recipe.split(':') |
| 164 assert example.endswith('example') |
| 165 for module_dir in self.module_dirs: |
| 166 for subitem in os.listdir(module_dir): |
| 167 if module_name == subitem: |
| 168 return RecipeScript.from_script_path( |
| 169 os.path.join(module_dir, subitem, 'example.py'), self) |
| 170 raise NoSuchRecipe(recipe, |
| 171 'Recipe example %s:%s does not exist' % |
| 172 (module_name, example)) |
| 173 else: |
| 174 for recipe_path in (os.path.join(p, recipe) for p in self.recipe_dirs): |
| 175 if os.path.exists(recipe_path + '.py'): |
| 176 return RecipeScript.from_script_path(recipe_path + '.py', self) |
| 177 raise NoSuchRecipe(recipe) |
| 178 |
| 179 def loop_over_recipe_modules(self): |
| 180 for path in self.module_dirs: |
| 181 if os.path.isdir(path): |
| 182 for item in os.listdir(path): |
| 183 subpath = os.path.join(path, item) |
| 184 if _is_recipe_module_dir(subpath): |
| 185 yield subpath |
| 186 |
| 187 def loop_over_recipes(self): |
| 188 """Yields pairs (path to recipe, recipe name). |
| 189 |
| 190 Enumerates real recipes in recipes/* as well as examples in recipe_modules/*
. |
| 191 """ |
| 192 for path in self.recipe_dirs: |
| 193 for recipe in scan_directory( |
| 194 path, lambda f: f.endswith('.py') and f[0] != '_'): |
| 195 yield recipe, recipe[len(path)+1:-len('.py')] |
| 196 for path in self.module_dirs: |
| 197 for recipe in scan_directory( |
| 198 path, lambda f: f.endswith('example.py')): |
| 199 module_name = os.path.dirname(recipe)[len(path)+1:] |
| 200 yield recipe, '%s:example' % module_name |
| 201 |
| 202 |
| 203 def _is_recipe_module_dir(path): |
| 204 return (os.path.isdir(path) and |
| 205 os.path.isfile(os.path.join(path, '__init__.py'))) |
| 206 |
| 207 |
| 208 @contextlib.contextmanager |
| 209 def _preserve_path(): |
| 210 old_path = sys.path[:] |
| 211 try: |
| 212 yield |
| 213 finally: |
| 214 sys.path = old_path |
| 215 |
| 216 |
| 217 def _normalize_path(base_path, path): |
| 218 if base_path is None or os.path.isabs(path): |
| 219 return os.path.realpath(path) |
| 220 else: |
| 221 return os.path.realpath(os.path.join(base_path, path)) |
| 222 |
| 223 |
| 224 def _find_and_load_module(fullname, modname, path): |
| 225 imp.acquire_lock() |
| 226 try: |
| 227 if fullname not in sys.modules: |
| 228 fil = None |
| 229 try: |
| 230 fil, pathname, descr = imp.find_module(modname, |
| 231 [os.path.dirname(path)]) |
| 232 imp.load_module(fullname, fil, pathname, descr) |
| 233 finally: |
| 234 if fil: |
| 235 fil.close() |
| 236 return sys.modules[fullname] |
| 237 finally: |
| 238 imp.release_lock() |
| 239 |
| 240 |
| 241 def _load_recipe_module_module(path, universe): |
| 242 modname = os.path.splitext(os.path.basename(path))[0] |
| 243 fullname = '%s.%s' % (RECIPE_MODULE_PREFIX, modname) |
| 244 mod = _find_and_load_module(fullname, modname, path) |
| 245 |
| 246 # This actually loads the dependencies. |
| 247 mod.LOADED_DEPS = universe.deps_from_mixed( |
| 248 getattr(mod, 'DEPS', []), os.path.basename(path)) |
| 249 |
| 250 # Prevent any modules that mess with sys.path from leaking. |
| 251 with _preserve_path(): |
| 252 # TODO(luqui): Remove this hack once configs are cleaned. |
| 253 sys.modules['%s.DEPS' % fullname] = mod.LOADED_DEPS |
| 254 _recursive_import(path, RECIPE_MODULE_PREFIX) |
| 255 _patchup_module(modname, mod) |
| 256 |
| 257 return mod |
| 258 |
| 259 |
| 260 def _recursive_import(path, prefix): |
| 261 modname = os.path.splitext(os.path.basename(path))[0] |
| 262 fullname = '%s.%s' % (prefix, modname) |
| 263 mod = _find_and_load_module(fullname, modname, path) |
| 264 if not os.path.isdir(path): |
| 265 return mod |
| 266 |
| 267 for subitem in os.listdir(path): |
| 268 subpath = os.path.join(path, subitem) |
| 269 subname = os.path.splitext(subitem)[0] |
| 270 if os.path.isdir(subpath): |
| 271 if not os.path.exists(os.path.join(subpath, '__init__.py')): |
| 272 continue |
| 273 elif not subpath.endswith('.py') or subitem.startswith('__init__.py'): |
| 274 continue |
| 275 |
| 276 submod = _recursive_import(subpath, fullname) |
| 277 |
| 278 if not hasattr(mod, subname): |
| 279 setattr(mod, subname, submod) |
| 280 else: |
| 281 prev = getattr(mod, subname) |
| 282 assert submod is prev, ( |
| 283 'Conflicting modules: %s and %s' % (prev, mod)) |
| 284 |
| 285 return mod |
| 286 |
| 287 |
| 288 def _patchup_module(name, submod): |
| 289 """Finds framework related classes and functions in a |submod| and adds |
| 290 them to |submod| as top level constants with well known names such as |
| 291 API, CONFIG_CTX, TEST_API, and PROPERTIES. |
| 292 |
| 293 |submod| is a recipe module (akin to python package) with submodules such as |
| 294 'api', 'config', 'test_api'. This function scans through dicts of that |
| 295 submodules to find subclasses of RecipeApi, RecipeTestApi, etc. |
| 296 """ |
| 297 submod.NAME = name |
| 298 submod.UNIQUE_NAME = name # TODO(luqui): use a luci-config unique name |
| 299 submod.MODULE_DIRECTORY = Path(ModuleBasePath(submod)) |
| 300 submod.CONFIG_CTX = getattr(submod, 'CONFIG_CTX', None) |
| 301 |
| 302 if hasattr(submod, 'config'): |
| 303 for v in submod.config.__dict__.itervalues(): |
| 304 if isinstance(v, ConfigContext): |
| 305 assert not submod.CONFIG_CTX, ( |
| 306 'More than one configuration context: %s, %s' % |
| 307 (submod.config, submod.CONFIG_CTX)) |
| 308 submod.CONFIG_CTX = v |
| 309 assert submod.CONFIG_CTX, 'Config file, but no config context?' |
| 310 |
| 311 submod.API = getattr(submod, 'API', None) |
| 312 for v in submod.api.__dict__.itervalues(): |
| 313 if inspect.isclass(v) and issubclass(v, RecipeApiPlain): |
| 314 assert not submod.API, ( |
| 315 '%s has more than one Api subclass: %s, %s' % (name, v, submod.api)) |
| 316 submod.API = v |
| 317 assert submod.API, 'Submodule has no api? %s' % (submod) |
| 318 |
| 319 submod.TEST_API = getattr(submod, 'TEST_API', None) |
| 320 if hasattr(submod, 'test_api'): |
| 321 for v in submod.test_api.__dict__.itervalues(): |
| 322 if inspect.isclass(v) and issubclass(v, RecipeTestApi): |
| 323 assert not submod.TEST_API, ( |
| 324 'More than one TestApi subclass: %s' % submod.api) |
| 325 submod.TEST_API = v |
| 326 assert submod.API, ( |
| 327 'Submodule has test_api.py but no TestApi subclass? %s' |
| 328 % (submod) |
| 329 ) |
| 330 |
| 331 submod.PROPERTIES = getattr(submod, 'PROPERTIES', {}) |
| 332 # Let each property object know about the property name. |
| 333 for name, value in submod.PROPERTIES.items(): |
| 334 value.name = name |
| 335 |
| 336 |
| 337 class DependencyMapper(object): |
| 338 """DependencyMapper topologically traverses the dependency DAG beginning at |
| 339 a module, executing a callback ("instantiator") for each module. |
| 340 |
| 341 For example, if the dependency DAG looked like this: |
| 342 |
| 343 A |
| 344 / \ |
| 345 B C |
| 346 \ / |
| 347 D |
| 348 |
| 349 (with D depending on B and C, etc.), DependencyMapper(f).instantiate(D) would |
| 350 construct |
| 351 |
| 352 f_A = f(A, {}) |
| 353 f_B = f(B, { 'A': f_A }) |
| 354 f_C = f(C, { 'A': f_A }) |
| 355 f_D = f(D, { 'B': f_B, 'C': f_C }) |
| 356 |
| 357 finally returning f_D. instantiate can be called multiple times, which reuses |
| 358 already-computed results. |
| 359 """ |
| 360 |
| 361 def __init__(self, instantiator): |
| 362 self._instantiator = instantiator |
| 363 self._instances = {} |
| 364 |
| 365 def instantiate(self, mod): |
| 366 if mod in self._instances: |
| 367 return self._instances[mod] |
| 368 deps_dict = { name: self.instantiate(dep) |
| 369 for name, dep in mod.LOADED_DEPS.iteritems() } |
| 370 self._instances[mod] = self._instantiator(mod, deps_dict) |
| 371 return self._instances[mod] |
| 372 |
| 373 def invoke_with_properties(callable_obj, all_props, prop_defs, |
| 374 **additional_args): |
| 375 """ |
| 376 Invokes callable with filtered, type-checked properties. |
| 377 |
| 378 Args: |
| 379 callable_obj: The function to call, or class to instantiate. |
| 380 This supports passing in either RunSteps, or a recipe module, |
| 381 which is a class. |
| 382 all_props: A dictionary containing all the properties |
| 383 currently defined in the system. |
| 384 prop_defs: A dictionary of name to property definitions for this callable. |
| 385 additional_args: kwargs to pass through to the callable. |
| 386 Note that the names of the arguments can correspond to |
| 387 positional arguments as well. |
| 388 |
| 389 Returns: |
| 390 The result of calling callable with the filtered properties |
| 391 and additional arguments. |
| 392 """ |
| 393 # To detect when they didn't specify a property that they have as a |
| 394 # function argument, list the arguments, through inspection, |
| 395 # and then comparing this list to the provided properties. We use a list |
| 396 # instead of a dict because getargspec returns a list which we would have to |
| 397 # convert to a dictionary, and the benefit of the dictionary is pretty small. |
| 398 props = [] |
| 399 if inspect.isclass(callable_obj): |
| 400 arg_names = inspect.getargspec(callable_obj.__init__).args |
| 401 |
| 402 arg_names.pop(0) |
| 403 else: |
| 404 arg_names = inspect.getargspec(callable_obj).args |
| 405 |
| 406 for arg in arg_names: |
| 407 if arg in additional_args: |
| 408 props.append(additional_args.pop(arg)) |
| 409 continue |
| 410 |
| 411 if arg not in prop_defs: |
| 412 raise UndefinedPropertyException( |
| 413 "Missing property definition for '{}'.".format(arg)) |
| 414 |
| 415 props.append(prop_defs[arg].interpret(all_props.get( |
| 416 arg, Property.sentinel))) |
| 417 |
| 418 return callable_obj(*props, **additional_args) |
| 419 |
| 420 def create_recipe_api(toplevel_deps, engine, test_data=DisabledTestData()): |
| 421 def instantiator(mod, deps): |
| 422 kwargs = { |
| 423 'module': mod, |
| 424 'engine': engine, |
| 425 # TODO(luqui): test_data will need to use canonical unique names. |
| 426 'test_data': test_data.get_module_test_data(mod.NAME) |
| 427 } |
| 428 prop_defs = mod.PROPERTIES |
| 429 mod_api = invoke_with_properties( |
| 430 mod.API, engine.properties, prop_defs, **kwargs) |
| 431 mod_api.test_api = (getattr(mod, 'TEST_API', None) |
| 432 or RecipeTestApi)(module=mod) |
| 433 for k, v in deps.iteritems(): |
| 434 setattr(mod_api.m, k, v) |
| 435 setattr(mod_api.test_api.m, k, v.test_api) |
| 436 return mod_api |
| 437 |
| 438 mapper = DependencyMapper(instantiator) |
| 439 api = RecipeApi(module=None, engine=engine, |
| 440 test_data=test_data.get_module_test_data(None)) |
| 441 for k, v in toplevel_deps.iteritems(): |
| 442 setattr(api, k, mapper.instantiate(v)) |
| 443 return api |
| 444 |
| 445 |
| 446 def create_test_api(toplevel_deps, universe): |
| 447 def instantiator(mod, deps): |
| 448 modapi = (getattr(mod, 'TEST_API', None) or RecipeTestApi)(module=mod) |
| 449 for k,v in deps.iteritems(): |
| 450 setattr(modapi.m, k, v) |
| 451 return modapi |
| 452 |
| 453 mapper = DependencyMapper(instantiator) |
| 454 api = RecipeTestApi(module=None) |
| 455 for k,v in toplevel_deps.iteritems(): |
| 456 setattr(api, k, mapper.instantiate(v)) |
| 457 return api |
| 458 |
| 459 |
| 460 |
OLD | NEW |