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 |