Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 # Copyright 2017 The LUCI Authors. All rights reserved. | |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 | |
| 3 # that can be found in the LICENSE file. | |
| 4 | |
| 5 from __future__ import print_function | |
| 6 | |
| 7 import argparse | |
| 8 import cStringIO | |
| 9 import contextlib | |
| 10 import copy | |
| 11 import coverage | |
| 12 import datetime | |
| 13 import difflib | |
| 14 import functools | |
| 15 import json | |
| 16 import multiprocessing | |
| 17 import os | |
| 18 import pprint | |
| 19 import re | |
| 20 import signal | |
| 21 import sys | |
| 22 import tempfile | |
| 23 import traceback | |
| 24 | |
| 25 from . import checker | |
| 26 from . import config_types | |
| 27 from . import loader | |
| 28 from . import run | |
| 29 from . import step_runner | |
| 30 from . import stream | |
| 31 | |
| 32 | |
| 33 # These variables must be set in the dynamic scope of the functions in this | |
| 34 # file. We do this instead of passing because they're not picklable, and | |
| 35 # that's required by multiprocessing. | |
| 36 _UNIVERSE_VIEW = None | |
| 37 _ENGINE_FLAGS = None | |
| 38 | |
| 39 | |
| 40 # An event to signal exit, for example on Ctrl-C. | |
| 41 _KILL_SWITCH = multiprocessing.Event() | |
| 42 | |
| 43 | |
| 44 # This maps from (recipe_name,test_name) -> yielded test_data. It's outside of | |
| 45 # run_recipe so that it can persist between RunRecipe calls in the same process. | |
| 46 _GEN_TEST_CACHE = {} | |
| 47 | |
| 48 | |
| 49 # Allow regex patterns to be 'deep copied' by using them as-is. | |
| 50 copy._deepcopy_dispatch[re._pattern_type] = copy._deepcopy_atomic | |
| 51 | |
| 52 | |
| 53 class PostProcessError(ValueError): | |
| 54 """Exception raised when any of the post-process hooks fails.""" | |
| 55 pass | |
| 56 | |
| 57 | |
| 58 @contextlib.contextmanager | |
| 59 def coverage_context(include=None): | |
| 60 """Context manager that records coverage data.""" | |
| 61 c = coverage.coverage(config_file=False, include=include) | |
| 62 | |
| 63 # Sometimes our strict include lists will result in a run | |
| 64 # not adding any coverage info. That's okay, avoid output spam. | |
| 65 c._warn_no_data = False | |
| 66 | |
| 67 c.start() | |
| 68 try: | |
| 69 yield c | |
| 70 finally: | |
| 71 c.stop() | |
| 72 | |
| 73 | |
| 74 class TestFailure(object): | |
|
iannucci
2017/03/10 01:20:09
wdyt about putting some of this test code in a sub
Paweł Hajdan Jr.
2017/03/10 17:20:30
With slightly over 500 lines, I don't see big bene
| |
| 75 """Generic class for different kinds of test failures.""" | |
|
iannucci
2017/03/10 01:20:08
Generic or 'Base' class? I assume this is never me
Paweł Hajdan Jr.
2017/03/10 17:20:29
Yeah, changed to base. Obviously it's not meant to
| |
| 76 | |
| 77 def format(self): | |
| 78 """Returns a human-readable description of the failure.""" | |
| 79 raise NotImplementedError() | |
| 80 | |
| 81 | |
| 82 class DiffFailure(TestFailure): | |
| 83 """Failure when simulated recipe commands don't match recorded expectations. | |
| 84 """ | |
| 85 | |
| 86 def __init__(self, diff): | |
| 87 self.diff = diff | |
| 88 | |
| 89 def format(self): | |
| 90 return self.diff | |
| 91 | |
| 92 | |
| 93 class CheckFailure(TestFailure): | |
| 94 """Failure when any of the post-process checks fails.""" | |
| 95 | |
| 96 def __init__(self, check): | |
| 97 self.check = check | |
| 98 | |
| 99 def format(self): | |
| 100 return self.check.format(indent=4) | |
| 101 | |
| 102 | |
| 103 class TestResult(object): | |
| 104 """Result of running a test.""" | |
| 105 | |
| 106 def __init__(self, test_description, failures, coverage_data): | |
| 107 self.test_description = test_description | |
| 108 self.failures = failures | |
| 109 self.coverage_data = coverage_data | |
| 110 | |
| 111 | |
| 112 class TestDescription(object): | |
| 113 """Identifies a specific test. | |
| 114 | |
| 115 Deliberately small and picklable for use with multiprocessing.""" | |
| 116 | |
| 117 def __init__(self, recipe_name, test_name, expect_dir, covers): | |
| 118 self.recipe_name = recipe_name | |
| 119 self.test_name = test_name | |
| 120 self.expect_dir = expect_dir | |
| 121 self.covers = covers | |
| 122 | |
| 123 @property | |
| 124 def full_name(self): | |
| 125 return '%s.%s' % (self.recipe_name, self.test_name) | |
| 126 | |
| 127 | |
| 128 def expectation_path(expect_dir, test_name): | |
| 129 """Returns path where serialized expectation data is stored.""" | |
| 130 return os.path.join(expect_dir, test_name + '.json') | |
| 131 | |
| 132 | |
| 133 def run_test(test_description): | |
| 134 """Runs a test. Returns TestResults object.""" | |
| 135 expected = None | |
| 136 path = expectation_path( | |
| 137 test_description.expect_dir, test_description.test_name) | |
| 138 if os.path.exists(path): | |
| 139 with open(path) as f: | |
| 140 # TODO(phajdan.jr): why do we need to re-encode golden data files? | |
| 141 expected = re_encode(json.load(f)) | |
| 142 | |
| 143 actual, failed_checks, coverage_data = run_recipe( | |
| 144 test_description.recipe_name, test_description.test_name, | |
| 145 test_description.covers) | |
| 146 actual = re_encode(actual) | |
| 147 | |
| 148 failures = [] | |
| 149 | |
| 150 # TODO(phajdan.jr): handle exception (errors) in the recipe execution. | |
| 151 if failed_checks: | |
| 152 sys.stdout.write('C') | |
|
iannucci
2017/03/10 01:20:09
are these writes going to happen from multiple thr
Paweł Hajdan Jr.
2017/03/10 17:20:29
Yes.
| |
| 153 failures.extend([CheckFailure(c) for c in failed_checks]) | |
| 154 elif actual != expected: | |
| 155 diff = '\n'.join(list(difflib.unified_diff( | |
|
iannucci
2017/03/10 01:20:09
is the 'list' necessary? afaik, ''.join will work
Paweł Hajdan Jr.
2017/03/10 17:20:30
Done.
| |
| 156 pprint.pformat(expected).splitlines(), | |
| 157 pprint.pformat(actual).splitlines(), | |
| 158 fromfile='expected', tofile='actual', | |
| 159 n=4, lineterm=''))) | |
| 160 | |
| 161 failures.append(DiffFailure(diff)) | |
| 162 sys.stdout.write('F') | |
| 163 else: | |
| 164 sys.stdout.write('.') | |
| 165 sys.stdout.flush() | |
| 166 | |
| 167 return TestResult(test_description, failures, coverage_data) | |
| 168 | |
| 169 | |
| 170 def run_recipe(recipe_name, test_name, covers): | |
| 171 """Runs the recipe under test in simulation mode. | |
| 172 | |
| 173 Returns a tuple: | |
| 174 - expectation data | |
| 175 - failed post-process checks (if any) | |
| 176 - coverage data | |
| 177 """ | |
| 178 config_types.ResetTostringFns() | |
| 179 | |
| 180 # Reload test data in the worker to avoid pickling complex data structures. | |
| 181 cache_key = (recipe_name, test_name) | |
| 182 if cache_key not in _GEN_TEST_CACHE: | |
| 183 recipe_script = _UNIVERSE_VIEW.load_recipe(recipe_name) | |
| 184 test_api = loader.create_test_api(recipe_script.LOADED_DEPS, _UNIVERSE_VIEW) | |
| 185 for test_data in recipe_script.gen_tests(test_api): | |
| 186 _GEN_TEST_CACHE[(recipe_name, test_data.name)] = copy.deepcopy(test_data) | |
|
iannucci
2017/03/10 01:20:09
use cache_key? we had a bug before where we were w
Paweł Hajdan Jr.
2017/03/10 17:20:30
We can't use cache_key on this line, because test_
iannucci
2017/03/10 18:14:33
Oh, heh, yeah I'm dumb.
| |
| 187 test_data = _GEN_TEST_CACHE[cache_key] | |
| 188 | |
| 189 annotator = SimulationAnnotatorStreamEngine() | |
| 190 with stream.StreamEngineInvariants.wrap(annotator) as stream_engine: | |
| 191 runner = step_runner.SimulationStepRunner( | |
| 192 stream_engine, test_data, annotator) | |
| 193 | |
| 194 props = test_data.properties.copy() | |
| 195 props['recipe'] = recipe_name | |
| 196 engine = run.RecipeEngine( | |
| 197 runner, props, _UNIVERSE_VIEW, engine_flags=_ENGINE_FLAGS) | |
| 198 with coverage_context(include=covers) as cov: | |
| 199 # Run recipe loading under coverage context. This ensures we collect | |
| 200 # coverage of all definitions and globals. | |
| 201 recipe_script = _UNIVERSE_VIEW.load_recipe(recipe_name, engine=engine) | |
| 202 | |
| 203 api = loader.create_recipe_api( | |
| 204 _UNIVERSE_VIEW.universe.package_deps.root_package, | |
| 205 recipe_script.LOADED_DEPS, | |
| 206 recipe_script.path, engine, test_data) | |
| 207 result = engine.run(recipe_script, api, test_data.properties) | |
| 208 coverage_data = cov.get_data() | |
| 209 | |
| 210 raw_expectations = runner.steps_ran.copy() | |
| 211 # Don't include tracebacks in expectations because they are too sensitive | |
| 212 # to change. | |
| 213 result.result.pop('traceback', None) | |
|
iannucci
2017/03/10 01:20:09
should we replace the traceback with something ind
Paweł Hajdan Jr.
2017/03/10 17:20:29
Added a TODO.
| |
| 214 raw_expectations[result.result['name']] = result.result | |
| 215 | |
| 216 failed_checks = [] | |
| 217 | |
| 218 for hook, args, kwargs, filename, lineno in test_data.post_process_hooks: | |
| 219 input_odict = copy.deepcopy(raw_expectations) | |
| 220 # We ignore the input_odict so that it never gets printed in full. | |
| 221 # Usually the check invocation itself will index the input_odict or | |
| 222 # will use it only for a key membership comparison, which provides | |
| 223 # enough debugging context. | |
| 224 checker_obj = checker.Checker( | |
| 225 filename, lineno, hook, args, kwargs, input_odict) | |
| 226 | |
| 227 with coverage_context(include=covers) as cov: | |
| 228 # Run the hook itself under coverage. There may be custom post-process | |
| 229 # functions in recipe test code. | |
|
iannucci
2017/03/10 01:20:09
+1
| |
| 230 rslt = hook(checker_obj, input_odict, *args, **kwargs) | |
| 231 coverage_data.update(cov.get_data()) | |
| 232 | |
| 233 failed_checks += checker_obj.failed_checks | |
| 234 if rslt is not None: | |
| 235 msg = checker.VerifySubset(rslt, raw_expectations) | |
| 236 if msg: | |
| 237 raise PostProcessError('post_process: steps'+msg) | |
| 238 # restore 'name' | |
| 239 for k, v in rslt.iteritems(): | |
| 240 if 'name' not in v: | |
| 241 v['name'] = k | |
| 242 raw_expectations = rslt | |
| 243 | |
| 244 # empty means drop expectation | |
| 245 result_data = raw_expectations.values() if raw_expectations else None | |
| 246 return (result_data, failed_checks, coverage_data) | |
| 247 | |
| 248 | |
| 249 def get_tests(): | |
| 250 """Returns a list of tests for current recipe package.""" | |
| 251 tests = [] | |
| 252 coverage_data = coverage.CoverageData() | |
| 253 | |
| 254 all_modules = set(_UNIVERSE_VIEW.loop_over_recipe_modules()) | |
| 255 covered_modules = set() | |
| 256 | |
| 257 base_covers = [] | |
| 258 | |
| 259 for module in all_modules: | |
| 260 # Run module loading under coverage context. This ensures we collect | |
| 261 # coverage of all definitions and globals. | |
| 262 coverage_include = os.path.join(_UNIVERSE_VIEW.module_dir, '*', '*.py') | |
|
iannucci
2017/03/10 01:20:09
nit: this could be moved outside the loop
Paweł Hajdan Jr.
2017/03/10 17:20:30
Done.
FWIW, there's a slight tradeoff between "ef
| |
| 263 with coverage_context(include=coverage_include) as cov: | |
| 264 mod = _UNIVERSE_VIEW.load_recipe_module(module) | |
| 265 coverage_data.update(cov.get_data()) | |
| 266 | |
| 267 # Recipe modules can only be covered by tests inside the same module. | |
| 268 # To make transition possible for existing code (which will require | |
| 269 # writing tests), a temporary escape hatch is added. | |
| 270 # TODO(phajdan.jr): remove DISABLE_STRICT_COVERAGE (crbug/693058). | |
| 271 if mod.DISABLE_STRICT_COVERAGE: | |
| 272 covered_modules.add(module) | |
| 273 # Make sure disabling strict coverage also disables our additional check | |
| 274 # for module coverage. Note that coverage will still raise an error if | |
| 275 # the module is executed by any of the tests, but having less than 100% | |
| 276 # coverage. | |
| 277 base_covers.append(os.path.join( | |
| 278 _UNIVERSE_VIEW.module_dir, module, '*.py')) | |
| 279 | |
| 280 for recipe_path, recipe_name in _UNIVERSE_VIEW.loop_over_recipes(): | |
| 281 try: | |
| 282 covers = [recipe_path] + base_covers | |
| 283 | |
| 284 # Example/test recipes in a module always cover that module. | |
| 285 if ':' in recipe_name: | |
| 286 module, _ = recipe_name.split(':', 1) | |
| 287 covered_modules.add(module) | |
| 288 covers.append(os.path.join(_UNIVERSE_VIEW.module_dir, module, '*.py')) | |
| 289 | |
| 290 with coverage_context(include=covers) as cov: | |
| 291 # Run recipe loading under coverage context. This ensures we collect | |
| 292 # coverage of all definitions and globals. | |
| 293 recipe = _UNIVERSE_VIEW.load_recipe(recipe_name) | |
| 294 test_api = loader.create_test_api(recipe.LOADED_DEPS, _UNIVERSE_VIEW) | |
| 295 | |
| 296 root, name = os.path.split(recipe_path) | |
| 297 name = os.path.splitext(name)[0] | |
| 298 # TODO(phajdan.jr): move expectation tree outside of the recipe tree. | |
| 299 expect_dir = os.path.join(root, '%s.expected' % name) | |
| 300 | |
| 301 # Immediately convert to list to force running the generator under | |
| 302 # coverage context. Otherwise coverage would only report executing | |
| 303 # the function definition, not GenTests body. | |
| 304 recipe_tests = list(recipe.gen_tests(test_api)) | |
| 305 coverage_data.update(cov.get_data()) | |
| 306 | |
| 307 for test_data in recipe_tests: | |
| 308 tests.append(TestDescription( | |
| 309 recipe_name, test_data.name, expect_dir, covers)) | |
| 310 except: | |
| 311 info = sys.exc_info() | |
| 312 new_exec = Exception('While generating results for %r: %s: %s' % ( | |
| 313 recipe_name, info[0].__name__, str(info[1]))) | |
| 314 raise new_exec.__class__, new_exec, info[2] | |
| 315 | |
| 316 uncovered_modules = all_modules.difference(covered_modules) | |
| 317 return (tests, coverage_data, uncovered_modules) | |
| 318 | |
| 319 | |
| 320 def run_list(): | |
| 321 """Implementation of the 'list' command.""" | |
| 322 tests, _coverage_data, _uncovered_modules = get_tests() | |
| 323 print('\n'.join(sorted(t.full_name for t in tests))) | |
| 324 return 0 | |
| 325 | |
| 326 | |
| 327 def cover_omit(): | |
| 328 """Returns list of patterns to omit from coverage analysis.""" | |
| 329 omit = [ ] | |
| 330 | |
| 331 mod_dir_base = _UNIVERSE_VIEW.module_dir | |
| 332 if os.path.isdir(mod_dir_base): | |
| 333 omit.append(os.path.join(mod_dir_base, '*', 'resources', '*')) | |
|
iannucci
2017/03/10 01:20:09
is the final * necessary? because some modules hav
Paweł Hajdan Jr.
2017/03/10 17:20:30
Yes. Otherwise simulation tests inside recipe engi
| |
| 334 | |
| 335 # Exclude recipe engine files from simulation test coverage. Simulation tests | |
| 336 # should cover "user space" recipe code (recipes and modules), not the engine. | |
| 337 # The engine is covered by unit tests, not simulation tests. | |
| 338 omit.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '*')) | |
| 339 | |
| 340 return omit | |
| 341 | |
| 342 | |
| 343 def report_coverage_version(): | |
| 344 """Prints info about coverage module (for debugging).""" | |
| 345 print('Using coverage %s from %r' % (coverage.__version__, coverage.__file__)) | |
| 346 | |
| 347 | |
| 348 def worker(f): | |
| 349 """Wrapper for a multiprocessing worker function. | |
| 350 | |
| 351 This addresses known issues with multiprocessing workers: | |
| 352 | |
| 353 - they can hang on uncaught exceptions | |
| 354 - we need explicit kill switch to clearly terminate parent""" | |
| 355 @functools.wraps(f) | |
| 356 def wrapper(*args, **kwargs): | |
| 357 try: | |
| 358 if _KILL_SWITCH.is_set(): | |
| 359 return (False, 'kill switch') | |
| 360 return (True, f(*args, **kwargs)) | |
| 361 except Exception: | |
| 362 return (False, traceback.format_exc()) | |
| 363 return wrapper | |
| 364 | |
| 365 | |
| 366 @worker | |
| 367 def run_worker(test): | |
| 368 """Worker for 'run' command (note decorator above).""" | |
| 369 return run_test(test) | |
| 370 | |
| 371 | |
| 372 def run_run(jobs): | |
| 373 """Implementation of the 'run' command.""" | |
| 374 start_time = datetime.datetime.now() | |
| 375 | |
| 376 report_coverage_version() | |
| 377 | |
| 378 tests, coverage_data, uncovered_modules = get_tests() | |
|
iannucci
2017/03/10 01:20:09
should this run with the kill switch too? or does
Paweł Hajdan Jr.
2017/03/10 17:20:30
kill_switch is only needed for multiprocessing.
| |
| 379 if uncovered_modules: | |
| 380 raise Exception('The following modules lack test coverage: %s' % ( | |
| 381 ','.join(sorted(uncovered_modules)))) | |
| 382 | |
| 383 with kill_switch(): | |
| 384 pool = multiprocessing.Pool(jobs) | |
| 385 results = pool.map(run_worker, tests) | |
| 386 | |
| 387 print() | |
| 388 | |
| 389 rc = 0 | |
| 390 for success, details in results: | |
| 391 if success: | |
| 392 assert isinstance(details, TestResult) | |
| 393 if details.failures: | |
| 394 rc = 1 | |
| 395 print('%s failed:' % details.test_description.full_name) | |
| 396 for failure in details.failures: | |
| 397 print(failure.format()) | |
| 398 coverage_data.update(details.coverage_data) | |
| 399 else: | |
| 400 rc = 1 | |
| 401 print('Internal failure:') | |
| 402 print(details) | |
| 403 | |
| 404 try: | |
| 405 # TODO(phajdan.jr): Add API to coverage to load data from memory. | |
| 406 with tempfile.NamedTemporaryFile(delete=False) as coverage_file: | |
| 407 coverage_data.write_file(coverage_file.name) | |
| 408 | |
| 409 cov = coverage.coverage( | |
| 410 data_file=coverage_file.name, config_file=False, omit=cover_omit()) | |
| 411 cov.load() | |
| 412 outf = cStringIO.StringIO() | |
| 413 percentage = cov.report(file=outf, show_missing=True, skip_covered=True) | |
| 414 if int(percentage) != 100: | |
| 415 rc = 1 | |
| 416 print(outf.getvalue()) | |
| 417 print('FATAL: Insufficient coverage (%.f%%)' % int(percentage)) | |
| 418 finally: | |
| 419 os.unlink(coverage_file.name) | |
| 420 | |
| 421 finish_time = datetime.datetime.now() | |
| 422 print('-' * 70) | |
| 423 print('Ran %d tests in %0.3fs' % ( | |
| 424 len(tests), (finish_time - start_time).total_seconds())) | |
| 425 print() | |
| 426 print('OK' if rc == 0 else 'FAILED') | |
| 427 | |
| 428 return rc | |
| 429 | |
| 430 | |
| 431 class SimulationAnnotatorStreamEngine(stream.AnnotatorStreamEngine): | |
| 432 """Stream engine which just records generated commands.""" | |
| 433 | |
| 434 def __init__(self): | |
| 435 self._step_buffer_map = {} | |
| 436 super(SimulationAnnotatorStreamEngine, self).__init__( | |
| 437 self.step_buffer(None)) | |
| 438 | |
| 439 def step_buffer(self, step_name): | |
| 440 return self._step_buffer_map.setdefault(step_name, cStringIO.StringIO()) | |
| 441 | |
| 442 def new_step_stream(self, step_config): | |
| 443 return self._create_step_stream(step_config, | |
| 444 self.step_buffer(step_config.name)) | |
| 445 | |
| 446 | |
| 447 def handle_killswitch(*_): | |
| 448 """Function invoked by ctrl-c. Signals worker processes to exit.""" | |
| 449 _KILL_SWITCH.set() | |
| 450 | |
| 451 # Reset the signal to DFL so that double ctrl-C kills us for sure. | |
|
iannucci
2017/03/10 01:20:09
print "Got Ctrl-C: Stopping. Ctrl-C again to shut
Paweł Hajdan Jr.
2017/03/10 17:20:29
This would be non-trivial. If done as-is, it'd pri
| |
| 452 signal.signal(signal.SIGINT, signal.SIG_DFL) | |
| 453 signal.signal(signal.SIGTERM, signal.SIG_DFL) | |
| 454 | |
| 455 | |
| 456 @contextlib.contextmanager | |
| 457 def kill_switch(): | |
| 458 """Context manager to handle ctrl-c properly with multiprocessing.""" | |
| 459 orig_sigint = signal.signal(signal.SIGINT, handle_killswitch) | |
| 460 try: | |
| 461 orig_sigterm = signal.signal(signal.SIGTERM, handle_killswitch) | |
| 462 try: | |
| 463 yield | |
| 464 finally: | |
| 465 signal.signal(signal.SIGTERM, orig_sigterm) | |
| 466 finally: | |
| 467 signal.signal(signal.SIGINT, orig_sigint) | |
| 468 | |
| 469 if _KILL_SWITCH.is_set(): | |
| 470 sys.exit(1) | |
| 471 | |
| 472 | |
| 473 def re_encode(obj): | |
|
iannucci
2017/03/10 01:20:08
todo: look into custom decoder class: https://docs
Paweł Hajdan Jr.
2017/03/10 17:20:29
Added a TODO. FWIW, this matches what current code
| |
| 474 """Ensure consistent encoding for common python data structures.""" | |
| 475 if isinstance(obj, dict): | |
| 476 return {re_encode(k): re_encode(v) for k, v in obj.iteritems()} | |
| 477 elif isinstance(obj, list): | |
| 478 return [re_encode(i) for i in obj] | |
| 479 elif isinstance(obj, (unicode, str)): | |
| 480 if isinstance(obj, str): | |
| 481 obj = obj.decode('utf-8', 'replace') | |
| 482 return obj.encode('utf-8', 'replace') | |
| 483 else: | |
| 484 return obj | |
| 485 | |
| 486 | |
| 487 def parse_args(args): | |
| 488 """Returns parsed command line arguments.""" | |
| 489 parser = argparse.ArgumentParser() | |
| 490 | |
| 491 subp = parser.add_subparsers() | |
| 492 | |
| 493 list_p = subp.add_parser('list', description='Print all test names') | |
| 494 list_p.set_defaults(func=lambda opts: run_list()) | |
| 495 | |
| 496 # TODO(phajdan.jr): support running a subset of tests. | |
| 497 run_p = subp.add_parser('run', description='Run the tests') | |
| 498 run_p.set_defaults(func=lambda opts: run_run(opts.jobs)) | |
| 499 run_p.add_argument( | |
| 500 '--jobs', metavar='N', type=int, | |
| 501 default=multiprocessing.cpu_count(), | |
| 502 help='run N jobs in parallel (default %(default)s)') | |
| 503 | |
| 504 return parser.parse_args(args) | |
| 505 | |
| 506 | |
| 507 def main(universe_view, raw_args, engine_flags): | |
| 508 """Runs simulation tests on a given repo of recipes. | |
| 509 | |
| 510 Args: | |
| 511 universe_view: an UniverseView object to operate on | |
| 512 raw_args: command line arguments to simulation_test_ng | |
| 513 engine_flags: recipe engine command-line flags | |
| 514 Returns: | |
| 515 Exit code | |
| 516 """ | |
| 517 global _UNIVERSE_VIEW | |
| 518 _UNIVERSE_VIEW = universe_view | |
| 519 global _ENGINE_FLAGS | |
| 520 _ENGINE_FLAGS = engine_flags | |
| 521 | |
| 522 args = parse_args(raw_args) | |
| 523 return args.func(args) | |
| OLD | NEW |