| OLD | NEW |
| 1 # Copyright 2013 The LUCI Authors. All rights reserved. | 1 # Copyright 2016 The LUCI Authors. All rights reserved. |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
| 4 | 4 |
| 5 import collections | |
| 6 import contextlib | 5 import contextlib |
| 6 import inspect |
| 7 |
| 8 from collections import namedtuple, defaultdict |
| 7 | 9 |
| 8 from .util import ModuleInjectionSite, static_call, static_wraps | 10 from .util import ModuleInjectionSite, static_call, static_wraps |
| 9 from .types import freeze | 11 from .types import freeze |
| 10 | 12 |
| 11 def combineify(name, dest, a, b, overwrite=False): | 13 def combineify(name, dest, a, b, overwrite=False): |
| 12 """ | 14 """ |
| 13 Combines dictionary members in two objects into a third one using addition. | 15 Combines dictionary members in two objects into a third one using addition. |
| 14 | 16 |
| 15 Args: | 17 Args: |
| 16 name - the name of the member | 18 name - the name of the member |
| (...skipping 41 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 58 class StepTestData(BaseTestData): | 60 class StepTestData(BaseTestData): |
| 59 """ | 61 """ |
| 60 Mutable container for per-step test data. | 62 Mutable container for per-step test data. |
| 61 | 63 |
| 62 This data is consumed while running the recipe (during | 64 This data is consumed while running the recipe (during |
| 63 annotated_run.run_steps). | 65 annotated_run.run_steps). |
| 64 """ | 66 """ |
| 65 def __init__(self): | 67 def __init__(self): |
| 66 super(StepTestData, self).__init__() | 68 super(StepTestData, self).__init__() |
| 67 # { (module, placeholder, name) -> data }. Data are for output placeholders. | 69 # { (module, placeholder, name) -> data }. Data are for output placeholders. |
| 68 self.placeholder_data = collections.defaultdict(dict) | 70 self.placeholder_data = defaultdict(dict) |
| 69 self.override = False | 71 self.override = False |
| 70 self._stdout = None | 72 self._stdout = None |
| 71 self._stderr = None | 73 self._stderr = None |
| 72 self._retcode = None | 74 self._retcode = None |
| 73 self._times_out_after = None | 75 self._times_out_after = None |
| 74 | 76 |
| 75 def __add__(self, other): | 77 def __add__(self, other): |
| 76 assert isinstance(other, StepTestData) | 78 assert isinstance(other, StepTestData) |
| 77 | 79 |
| 78 if other.override: | 80 if other.override: |
| (...skipping 95 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 174 assert isinstance(other, ModuleTestData) | 176 assert isinstance(other, ModuleTestData) |
| 175 ret = ModuleTestData() | 177 ret = ModuleTestData() |
| 176 ret.update(self) | 178 ret.update(self) |
| 177 ret.update(other) | 179 ret.update(other) |
| 178 return ret | 180 return ret |
| 179 | 181 |
| 180 def __repr__(self): | 182 def __repr__(self): |
| 181 return "ModuleTestData(%r)" % super(ModuleTestData, self).__repr__() | 183 return "ModuleTestData(%r)" % super(ModuleTestData, self).__repr__() |
| 182 | 184 |
| 183 | 185 |
| 186 PostprocessHook = namedtuple( |
| 187 'PostprocessHook', 'func args kwargs filename lineno') |
| 188 |
| 189 |
| 184 class TestData(BaseTestData): | 190 class TestData(BaseTestData): |
| 185 def __init__(self, name=None): | 191 def __init__(self, name=None): |
| 186 super(TestData, self).__init__() | 192 super(TestData, self).__init__() |
| 187 self.name = name | 193 self.name = name |
| 188 self.properties = {} # key -> val | 194 self.properties = {} # key -> val |
| 189 self.mod_data = collections.defaultdict(ModuleTestData) | 195 self.mod_data = defaultdict(ModuleTestData) |
| 190 self.step_data = collections.defaultdict(StepTestData) | 196 self.step_data = defaultdict(StepTestData) |
| 191 self.depend_on_data = {} | 197 self.depend_on_data = {} |
| 192 self.expected_exception = None | 198 self.expected_exception = None |
| 193 self.whitelist_data = {} # step_name -> fields | 199 self.post_process_hooks = [] # list(PostprocessHook) |
| 194 | 200 |
| 195 def __add__(self, other): | 201 def __add__(self, other): |
| 196 assert isinstance(other, TestData) | 202 assert isinstance(other, TestData) |
| 197 ret = TestData(self.name or other.name) | 203 ret = TestData(self.name or other.name) |
| 198 | 204 |
| 199 ret.properties.update(self.properties) | 205 ret.properties.update(self.properties) |
| 200 ret.properties.update(other.properties) | 206 ret.properties.update(other.properties) |
| 201 | 207 |
| 202 combineify('mod_data', ret, self, other) | 208 combineify('mod_data', ret, self, other) |
| 203 combineify('step_data', ret, self, other) | 209 combineify('step_data', ret, self, other) |
| 204 combineify('depend_on_data', ret, self, other) | 210 combineify('depend_on_data', ret, self, other) |
| 205 combineify('whitelist_data', ret, self, other) | 211 |
| 212 ret.post_process_hooks.extend(self.post_process_hooks) |
| 213 ret.post_process_hooks.extend(other.post_process_hooks) |
| 214 |
| 206 ret.expected_exception = self.expected_exception | 215 ret.expected_exception = self.expected_exception |
| 207 if other.expected_exception: | 216 if other.expected_exception: |
| 208 ret.expected_exception = other.expected_exception | 217 ret.expected_exception = other.expected_exception |
| 209 | 218 |
| 210 return ret | 219 return ret |
| 211 | 220 |
| 212 @property | 221 @property |
| 213 def consumed(self): | 222 def consumed(self): |
| 214 return not (self.step_data or self.expected_exception) | 223 return not (self.step_data or self.expected_exception) |
| 215 | 224 |
| (...skipping 48 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 264 if not should_raise: | 273 if not should_raise: |
| 265 self.expected_exception = None | 274 self.expected_exception = None |
| 266 | 275 |
| 267 def depend_on(self, recipe, properties, result): | 276 def depend_on(self, recipe, properties, result): |
| 268 tup = freeze((recipe, properties)) | 277 tup = freeze((recipe, properties)) |
| 269 if tup in self.depend_on_data: | 278 if tup in self.depend_on_data: |
| 270 raise ValueError('Already gave test data for recipe %s with properties %r' | 279 raise ValueError('Already gave test data for recipe %s with properties %r' |
| 271 % tup) | 280 % tup) |
| 272 self.depend_on_data[tup] = freeze(result) | 281 self.depend_on_data[tup] = freeze(result) |
| 273 | 282 |
| 274 def whitelist(self, step_name, fields): | 283 def post_process(self, func, args, kwargs, filename, lineno): |
| 275 self.whitelist_data[step_name] = frozenset(fields) | 284 self.post_process_hooks.append(PostprocessHook( |
| 285 func, args, kwargs, filename, lineno)) |
| 276 | 286 |
| 277 def __repr__(self): | 287 def __repr__(self): |
| 278 return "TestData(%r)" % ({ | 288 return "TestData(%r)" % ({ |
| 279 'name': self.name, | 289 'name': self.name, |
| 280 'properties': self.properties, | 290 'properties': self.properties, |
| 281 'mod_data': dict(self.mod_data.iteritems()), | 291 'mod_data': dict(self.mod_data.iteritems()), |
| 282 'step_data': dict(self.step_data.iteritems()), | 292 'step_data': dict(self.step_data.iteritems()), |
| 283 'expected_exception': self.expected_exception, | 293 'expected_exception': self.expected_exception, |
| 284 'depend_on_data': self.depend_on_data, | 294 'depend_on_data': self.depend_on_data, |
| 285 },) | 295 },) |
| (...skipping 257 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 543 def expect_exception(self, exc_type): #pylint: disable=R0201 | 553 def expect_exception(self, exc_type): #pylint: disable=R0201 |
| 544 ret = TestData(None) | 554 ret = TestData(None) |
| 545 ret.expect_exception(exc_type) | 555 ret.expect_exception(exc_type) |
| 546 return ret | 556 return ret |
| 547 | 557 |
| 548 def depend_on(self, recipe, properties, result): | 558 def depend_on(self, recipe, properties, result): |
| 549 ret = TestData() | 559 ret = TestData() |
| 550 ret.depend_on(recipe, properties, result) | 560 ret.depend_on(recipe, properties, result) |
| 551 return ret | 561 return ret |
| 552 | 562 |
| 553 def whitelist(self, step_name, *fields): | 563 def post_process(self, func, *args, **kwargs): |
| 554 """Calling this enables step whitelisting for the expectations on this test. | 564 """Calling this adds a post-processing hook for this test's expectations. |
| 555 You may call it multiple times, once per step_name that you want to have | |
| 556 show in the JSON expectations file for this test. | |
| 557 | 565 |
| 558 You may also optionally specify fields that you want to show up in the JSON | 566 `func` should be a callable whose signature is in the form of: |
| 559 expectations. By default, all fields of the step will appear, but you may | 567 func(check, step_odict, *args, **kwargs) -> (step_odict or None) |
| 560 only be interested in e.g. 'cmd' or 'env', for example. The 'name' field is | |
| 561 always included, regardless. | |
| 562 | 568 |
| 563 Keep in mind that the ultimate result of the recipe (the return value from | 569 Where: |
| 564 RunSteps) is on a virtual step named '$result'. | 570 * `step_odict` is an ordered dictionary of step dictionaries, as would be |
| 571 recorded into the JSON expectation file for this test. The dictionary key |
| 572 is the step's name. |
| 573 |
| 574 * `check` is a semi-magical function which you can use to test things. |
| 575 Using `check` will allow you to see all the violated assertions from your |
| 576 post_process functions simultaneously. Always call `check` directly (i.e. |
| 577 with parens) to produce helpful check messages. `check` also has a second |
| 578 form that takes a human hint to print when the `check` fails. Hints should |
| 579 be written as the ___ in the sentence 'check that ___.'. Essentially, |
| 580 check has the function signatures: |
| 581 |
| 582 `def check(<bool expression>)` |
| 583 `def check(hint, <bool expression>)` |
| 584 |
| 585 If the hint is omitted, then the boolean expression itself becomes the |
| 586 hint when the check failure message is printed. |
| 587 |
| 588 Note that check DOES NOT stop your function. It is not an assert. Your |
| 589 function will continue to execute after invoking the check function. If |
| 590 the boolean expression is False, the check will produce a helpful error |
| 591 message and cause the test case to fail. |
| 592 |
| 593 * args and kwargs are optional, and completely up to your implementation. |
| 594 They will be passed straight through to your function, and are provided to |
| 595 eliminate an extra `lambda` if your function needs to take additional |
| 596 inputs. |
| 597 |
| 598 Raising an exception will print the exception and will halt the |
| 599 postprocessing chain entirely. |
| 600 |
| 601 The function must return either `None`, or it may return a filtered subset |
| 602 of step_odict (e.g. ommitting some steps and/or dictionary keys). This will |
| 603 be the new value of step_odict for the test. Returning an empty dict or |
| 604 OrderedDict will remove the expectations from disk altogether. Returning |
| 605 `None` (Python's implicit default return value) is equivalent to returning |
| 606 the unmodified step_odict. 'name' will always be preserved in every step, |
| 607 even if you remove it. |
| 608 |
| 609 Calling post_process multiple times will apply each function in order, |
| 610 chaining the output of one function to the input of the next function. This |
| 611 is intended to be use to compose the effects of multiple re-usable |
| 612 postprocessing functions, some of which are pre-defined in |
| 613 `recipe_engine.post_process` which you can import in your recipe. |
| 565 | 614 |
| 566 Example: | 615 Example: |
| 567 yield api.test('assert entire recipe') | 616 from recipe_engine.post_process import (Filter, DoesNotRun, |
| 617 DropExpectation) |
| 568 | 618 |
| 569 yield (api.test('assert only thing step') | 619 def GenTests(api): |
| 570 + api.whitelist('thing step') | 620 yield api.test('no post processing') |
| 571 ) | |
| 572 | 621 |
| 573 yield (api.test('assert only thing step\'s cmd') | 622 yield (api.test('only thing_step') |
| 574 + api.whitelist('thing step', 'cmd') | 623 + api.post_process(Filter('thing_step')) |
| 575 ) | 624 ) |
| 576 | 625 |
| 577 yield (api.test('assert thing step and other step') | 626 tstepFilt = Filter() |
| 578 + api.whitelist('thing step') | 627 tstepFilt = tstepFilt.include('thing_step', 'cmd') |
| 579 + api.whitelist('other step') | 628 yield (api.test('only thing_step\'s cmd') |
| 580 ) | 629 + api.post_process(tstepFilt) |
| 630 ) |
| 581 | 631 |
| 582 yield (api.test('only care about the result') | 632 yield (api.test('assert bob_step does not run') |
| 583 + api.whitelist('$result') | 633 + api.post_process(DoesNotRun, 'bob_step') |
| 584 ) | 634 ) |
| 635 |
| 636 yield (api.test('only care one step and the result') |
| 637 + api.post_process(Filter('one_step', '$result')) |
| 638 ) |
| 639 |
| 640 def assertStuff(check, step_odict, to_check): |
| 641 check(to_check in step_odict['step_name']['cmd']) |
| 642 |
| 643 yield (api.test('assert something and have NO expectation file') |
| 644 + api.post_process(assertStuff, 'to_check_arg') |
| 645 + api.post_process(DropExpectation) |
| 646 ) |
| 585 """ | 647 """ |
| 586 ret = TestData() | 648 ret = TestData() |
| 587 ret.whitelist(step_name, fields) | 649 try: |
| 650 stk = inspect.stack() |
| 651 _, filename, lineno, _, _, _ = stk[1] |
| 652 finally: |
| 653 del stk |
| 654 ret.post_process(func, args, kwargs, filename, lineno) |
| 588 return ret | 655 return ret |
| OLD | NEW |