OLD | NEW |
| (Empty) |
1 # Recipes | |
2 | |
3 Recipes are a domain-specific language (embedded in python) for specifying | |
4 sequences of subprocess calls in a cross-platform and testable way. | |
5 | |
6 [TOC] | |
7 | |
8 ## Background | |
9 | |
10 Chromium uses BuildBot for its builds. It requires master restarts to change | |
11 bot configs, which slows bot changes down. | |
12 | |
13 With Recipes, most build-related things happen in scripts that run on the | |
14 slave, which means that the master does not need to be restarted in order | |
15 to change something about a build configuration. | |
16 | |
17 Recipes also provide a way to unit test build scripts, by mocking commands and | |
18 recording "expectations" of what will happen when the script runs under various | |
19 conditions. This makes it easy to verify that the scope of a change is limited. | |
20 | |
21 ## Intro | |
22 | |
23 This README will seek to teach the ways of Recipes, so that you may do one or | |
24 more of the following: | |
25 | |
26 * Read them | |
27 * Make new recipes | |
28 * Fix bugs in recipes | |
29 * Create libraries (api modules) for others to use in their recipes. | |
30 | |
31 The document will build knowledge up in small steps using examples, and so it's | |
32 probably best to read the whole doc through from top to bottom once before using | |
33 it as a reference. | |
34 | |
35 ## Small Beginnings | |
36 | |
37 **Recipes are a means to cause a series of commands to run on a machine.** | |
38 | |
39 All recipes take the form of a python file whose body looks like this: | |
40 | |
41 ```python | |
42 DEPS = ['step'] | |
43 | |
44 def RunSteps(api): | |
45 api.step('Print Hello World', ['echo', 'hello', 'world']) | |
46 ``` | |
47 | |
48 The `RunSteps` function is expected to take at least a single argument `api` | |
49 (we'll get to that in more detail later), and run a series of steps by calling | |
50 api functions. All of these functions will eventually make calls to | |
51 `api.step()`, which is the only way to actually get anything done on the | |
52 machine. Using python libraries with OS side-effects is prohibited to enable | |
53 testing. | |
54 | |
55 For these examples we will work out of the | |
56 [tools/build](https://chromium.googlesource.com/chromium/tools/build/) | |
57 repository. | |
58 | |
59 Put this in a file under `scripts/slave/recipes/hello.py`. You can then | |
60 run this recipe by calling | |
61 | |
62 $ scripts/tools/run_recipe.py hello | |
63 | |
64 *** promo | |
65 Note: every recipe execution (e.g. build on buildbot) emits | |
66 a step log called `run_recipe` on the `setup_build` step which provides | |
67 a precise invocation for `run_recipe.py` correlating exactly with the current | |
68 recipe invocation. This is useful to locally repro a failing build without | |
69 having to guess at the parameters to `run_recipe.py`. | |
70 *** | |
71 | |
72 ## We should probably test as we go... | |
73 | |
74 **All recipes MUST have corresponding tests, which achieve 100% code coverage.** | |
75 | |
76 So, we have our recipe. Let's add a test to it. | |
77 | |
78 ```python | |
79 DEPS = ['step'] | |
80 | |
81 def RunSteps(api): | |
82 api.step('Print Hello World', ['echo', 'hello', 'world']) | |
83 | |
84 def GenTests(api): | |
85 yield api.test('basic') | |
86 ``` | |
87 | |
88 This causes a single test case to be generated, called 'basic', which has no | |
89 input parameters. As your recipe becomes more complex, you'll need to add more | |
90 tests to make sure that you maintain 100% code coverage. | |
91 | |
92 In order to run the tests, run | |
93 | |
94 $ scripts/slave/unittests/recipe_simulation_test.py train hello | |
95 | |
96 This will write the file `build/scripts/slave/recipes/hello.expected/basic.json` | |
97 summarizing the actions of the recipe under the boring conditions | |
98 specified by `api.test('basic')`. | |
99 | |
100 [ | |
101 { | |
102 "cmd": [ | |
103 "echo", | |
104 "hello", | |
105 "world" | |
106 ], | |
107 "cwd": "[SLAVE_BUILD]", | |
108 "name": "Print Hello World" | |
109 } | |
110 ] | |
111 | |
112 ## Let's do something useful | |
113 | |
114 ### Properties are the primary input for your recipes | |
115 | |
116 In order to do something useful, we need to pull in parameters from the outside | |
117 world. There's one primary source of input for recipes, which is `properties`. | |
118 | |
119 Properties are a relic from the days of BuildBot, though they have been | |
120 dressed up a bit to be more like we'll want them in the future. If you're | |
121 familiar with BuildBot, you'll probably know them as `factory_properties` and | |
122 `build_properties`. The new `properties` object is a merging of these two, and | |
123 is provided by the `properties` api module. | |
124 | |
125 ```python | |
126 from recipe_engine.recipe_api import Property | |
127 | |
128 DEPS = [ | |
129 'step', | |
130 ] | |
131 | |
132 PROPERTIES = { | |
133 'target_of_admiration': Property( | |
134 kind=str, help="Who you love and adore.", default="Chrome Infra"), | |
135 } | |
136 | |
137 def RunSteps(api, target_of_admiration): | |
138 verb = 'Hello, %s' | |
139 if target_of_admiration == 'DarthVader': | |
140 verb = 'Die in a fire, %s!' | |
141 api.step('Greet Admired Individual', ['echo', verb % target_of_admiration]) | |
142 | |
143 def GenTests(api): | |
144 yield api.test('basic') + api.properties(target_of_admiration='Bob') | |
145 yield api.test('vader') + api.properties(target_of_admiration='DarthVader') | |
146 yield api.test('infra rocks') | |
147 ``` | |
148 | |
149 Yes, elements of a test specification are combined with `+` and it's weird. | |
150 | |
151 To specify property values in a local run: | |
152 | |
153 build/scripts/tools/run_recipe.py <recipe-name> opt=bob other=sally | |
154 | |
155 Or, more explicitly:: | |
156 | |
157 build/scripts/tools/run_recipe.py --properties-file <path/to/json> | |
158 | |
159 Where `<path/to/json>` is a file containing a valid json `object` (i.e. | |
160 key:value pairs). | |
161 | |
162 ### Modules | |
163 | |
164 There are all sorts of helper modules. They are found in the `recipe_modules` | |
165 directory alongside the `recipes` directory where the recipes go. | |
166 | |
167 Notice the `DEPS` line in the recipe. Any modules named by string in DEPS are | |
168 'injected' into the `api` parameter that your recipe gets. If you leave them out | |
169 of DEPS, you'll get an AttributeError when you try to access them. The modules | |
170 are located primarily in `recipe_modules/`, and their name is their folder name. | |
171 | |
172 There are a whole bunch of modules which provide really helpful tools. You | |
173 should go take a look at them. `scripts/tools/show_me_the_modules.py` is a | |
174 pretty helpful tool. If you want to know more about properties, step and path, I | |
175 would suggest starting with `show_me_the_modules.py`, and then delving into the | |
176 helpful docstrings in those helpful modules. | |
177 | |
178 ## Making Modules | |
179 | |
180 **Modules are for grouping functionality together and exposing it across | |
181 recipes.** | |
182 | |
183 So now you feel like you're pretty good at recipes, but you want to share your | |
184 echo functionality across a couple recipes which all start the same way. To do | |
185 this, you need to add a module directory. | |
186 | |
187 ``` | |
188 recipe_modules/ | |
189 step/ | |
190 properties/ | |
191 path/ | |
192 hello/ | |
193 __init__.py # (Required) Contains optional `DEPS = list([other modules])` | |
194 api.py # (Required) Contains single required RecipeApi-derived class | |
195 config.py # (Optional) Contains configuration for your api | |
196 *_config.py # (Optional) These contain extensions to the configurations of | |
197 # your dependency APIs | |
198 ``` | |
199 | |
200 First add an `__init__.py` with DEPS: | |
201 | |
202 ```python | |
203 # recipe_modules/hello/__init__.py | |
204 from recipe_api import Property | |
205 | |
206 DEPS = ['properties', 'step'] | |
207 PROPERTIES = { | |
208 'target_of_admiration': Property(default=None), | |
209 } | |
210 ``` | |
211 | |
212 And your api.py should look something like: | |
213 | |
214 ```python | |
215 from slave import recipe_api | |
216 | |
217 class HelloApi(recipe_api.RecipeApi): | |
218 def __init__(self, target_of_admiration): | |
219 self._target = target_of_admiration | |
220 | |
221 def greet(self, default_verb=None): | |
222 verb = default_verb or 'Hello %s' | |
223 if self._target == 'DarthVader': | |
224 verb = 'Die in a fire %s!' | |
225 self.m.step('Hello World', | |
226 ['echo', verb % self._target]) | |
227 ``` | |
228 | |
229 Note that all the DEPS get injected into `self.m`. This logic is handled outside | |
230 of the object (i.e. not in `__init__`). | |
231 | |
232 > Because dependencies are injected after module initialization, *you do not | |
233 > have access to injected modules in your APIs `__init__` method*! | |
234 | |
235 And now, our refactored recipe: | |
236 | |
237 ```python | |
238 DEPS = ['hello'] | |
239 | |
240 def RunSteps(api): | |
241 api.hello.greet() | |
242 | |
243 def GenTests(api): | |
244 yield api.test('basic') + api.properties(target_of_admiration='Bob') | |
245 yield api.test('vader') + api.properties(target_of_admiration='DarthVader') | |
246 ``` | |
247 | |
248 > NOTE: all of the modules are also require 100% code coverage, but you only | |
249 > need coverage from SOME recipe. | |
250 | |
251 ## So how do I really write those tests? | |
252 | |
253 The basic form of tests is: | |
254 | |
255 ```python | |
256 def GenTests(api): | |
257 yield api.test('testname') + # other stuff | |
258 ``` | |
259 | |
260 Some modules define interfaces for specifying necessary step data; these are | |
261 injected into `api` from `DEPS` similarly to how it works for `RunSteps`. There | |
262 are a few other methods available to `GenTests`'s `api`. Common ones include: | |
263 | |
264 * `api.properties(buildername='foo_builder')` sets properties as we have seen. | |
265 * `api.platform('linux', 32)` sets the mock platform to 32-bit linux. | |
266 * `api.step_data('Hello World', retcode=1)` mocks the `'Hello World'` step | |
267 to have failed with exit code 1. | |
268 | |
269 By default all simulated steps succeed, the platform is 64-bit linux, and | |
270 there are no properties. The `api.properties.generic()` method populates some | |
271 common properties for Chromium recipes. | |
272 | |
273 The `api` passed to GenTests is confusingly **NOT** the same as the recipe api. | |
274 It's actually an instance of `recipe_test_api.py:RecipeTestApi()`. This is | |
275 admittedly pretty weak, and it would be great to have the test api | |
276 automatically created via modules. On the flip side, the test api is much less | |
277 necessary than the recipe api, so this transformation has not been designed yet. | |
278 | |
279 ## What is that config business? | |
280 | |
281 **Configs are a way for a module to expose it's "global" state in a reusable | |
282 way.** | |
283 | |
284 A common problem in Building Things is that you end up with an inordinantly | |
285 large matrix of configurations. Let's take chromium, for example. Here is a | |
286 sample list of axes of configuration which chromium needs to build and test: | |
287 | |
288 * BUILD_CONFIG | |
289 * HOST_PLATFORM | |
290 * HOST_ARCH | |
291 * HOST_BITS | |
292 * TARGET_PLATFORM | |
293 * TARGET_ARCH | |
294 * TARGET_BITS | |
295 * builder type (ninja? msvs? xcodebuild?) | |
296 * compiler | |
297 * ... | |
298 | |
299 Obviously there are a lot of combinations of those things, but only a relatively | |
300 small number of *valid* combinations of those things. How can we represent all | |
301 the valid states while still retaining our sanity? | |
302 | |
303 We begin by specifying a schema that configurations of the `hello` module | |
304 will follow, and the config context based on it that we will add configuration | |
305 items to. | |
306 | |
307 ```python | |
308 # recipe_modules/hello/config.py | |
309 from slave.recipe_config import config_item_context, ConfigGroup | |
310 from slave.recipe_config import SimpleConfig, StaticConfig, BadConf | |
311 | |
312 def BaseConfig(TARGET='Bob'): | |
313 # This is a schema for the 'config blobs' that the hello module deals with. | |
314 return ConfigGroup( | |
315 verb = SimpleConfig(str), | |
316 # A config blob is not complete() until all required entries have a value. | |
317 tool = SimpleConfig(str, required=True), | |
318 # Generally, your schema should take a series of CAPITAL args which will be | |
319 # set as StaticConfig data in the config blob. | |
320 TARGET = StaticConfig(str(TARGET)), | |
321 ) | |
322 | |
323 config_ctx = config_item_context(BaseConfig) | |
324 ``` | |
325 | |
326 The `BaseConfig` schema is expected to return a `ConfigGroup` instance of some | |
327 sort. All the configs that you get out of this file will be a modified version | |
328 of something returned by the schema method. The arguments should have sane | |
329 defaults, and should be named in `ALL_CAPS` (this is to avoid argument name | |
330 conflicts as we'll see later). | |
331 | |
332 `config_ctx` is the 'context' for all the config items in this file, and will | |
333 magically become the `CONFIG_CTX` for the entire module. Other modules may | |
334 extend this context, which we will get to later. | |
335 | |
336 Finally let's define some config items themselves. A config item is a function | |
337 decorated with the `config_ctx`, and takes a config blob as 'c'. The config item | |
338 updates the config blob, perhaps conditionally. There are many features to | |
339 `slave/recipe_config.py`. I would recommend reading the docstrings there | |
340 for all the details. | |
341 | |
342 ```python | |
343 # Each of these functions is a 'config item' in the context of config_ctx. | |
344 | |
345 # is_root means that every config item will apply this item first. | |
346 @config_ctx(is_root=True) | |
347 def BASE(c): | |
348 if c.TARGET == 'DarthVader': | |
349 c.verb = 'Die in a fire, %s!' | |
350 else: | |
351 c.verb = 'Hello, %s' | |
352 | |
353 @config_ctx(group='tool'): # items with the same group are mutually exclusive. | |
354 def super_tool(c): | |
355 if c.TARGET != 'Charlie': | |
356 raise BadConf('Can only use super tool for Charlie!') | |
357 c.tool = 'unicorn.py' | |
358 | |
359 @config_ctx(group='tool'): | |
360 def default_tool(c): | |
361 c.tool = 'echo' | |
362 ``` | |
363 | |
364 Now that we have our config, let's use it. | |
365 | |
366 ```python | |
367 # recipe_modules/hello/api.py | |
368 from slave import recipe_api | |
369 | |
370 class HelloApi(recipe_api.RecipeApi): | |
371 def __init__(self, target_of_admiration): | |
372 self._target = target_of_admiration | |
373 | |
374 def get_config_defaults(self, _config_name): | |
375 return {'TARGET': self._target} | |
376 | |
377 def greet(self): | |
378 self.m.step('Hello World', [ | |
379 self.m.path.build(self.c.tool), self.c.verb % self.c.TARGET]) | |
380 ``` | |
381 | |
382 Note that `recipe_api.RecipeApi` contains all the plumbing for dealing with | |
383 configs. If your module has a config, you can access its current value via | |
384 `self.c`. The users of your module (read: recipes) will need to set this value | |
385 in one way or another. Also note that c is a 'public' variable, which means that | |
386 recipes have direct access to the configuration state by `api.<modname>.c`. | |
387 | |
388 ```python | |
389 # recipes/hello.py | |
390 DEPS = ['hello'] | |
391 def RunSteps(api): | |
392 api.hello.set_config('default_tool') | |
393 api.hello.greet() # Greets 'target_of_admiration' or 'Bob' with echo. | |
394 | |
395 def GenTests(api): | |
396 yield api.test('bob') | |
397 yield api.test('anya') + api.properties(target_of_admiration='anya') | |
398 ``` | |
399 | |
400 Note the call to `set_config`. This method takes the configuration name | |
401 specifed, finds it in the given module (`'hello'` in this case), and sets | |
402 `api.hello.c` equal to the result of invoking the named config item | |
403 (`'default_tool'`) with the default configuration (the result of calling | |
404 `get_config_defaults`), merged over the static defaults specified by the schema. | |
405 | |
406 We can also call `set_config` differently to get different results: | |
407 | |
408 ```python | |
409 # recipes/rainbow_hello.py | |
410 DEPS = ['hello'] | |
411 def RunSteps(api): | |
412 api.hello.set_config('super_tool', TARGET='Charlie') | |
413 api.hello.greet() # Greets 'Charlie' with unicorn.py. | |
414 | |
415 def GenTests(api): | |
416 yield api.test('charlie') | |
417 ``` | |
418 | |
419 ```python | |
420 # recipes/evil_hello.py | |
421 DEPS = ['hello'] | |
422 def RunSteps(api): | |
423 api.hello.set_config('default_tool', TARGET='DarthVader') | |
424 api.hello.greet() # Causes 'DarthVader' to despair with echo | |
425 | |
426 def GenTests(api): | |
427 yield api.test('darth') | |
428 ``` | |
429 | |
430 `set_config()` also has one additional bit of magic. If a module (say, | |
431 `chromium`), depends on some other modules (say, `gclient`), if you do | |
432 `api.chromium.set_config('blink')`, it will apply the `'blink'` config item from | |
433 the chromium module, but it will also attempt to apply the `'blink'` config for | |
434 all the dependencies, too. This way, you can have the chromium module extend the | |
435 gclient config context with a 'blink' config item, and then `set_configs` will | |
436 stack across all the relevent contexts. (This has since been recognized as a | |
437 design mistake) | |
438 | |
439 `recipe_api.RecipeApi` also provides `make_config` and `apply_config`, which | |
440 allow recipes more-direct access to the config items. However, `set_config()` is | |
441 the most-preferred way to apply configurations. | |
442 | |
443 ## What about getting data back from a step? | |
444 | |
445 Consider this recipe: | |
446 | |
447 ```python | |
448 DEPS = ['step', 'path'] | |
449 | |
450 def RunSteps(api): | |
451 step_result = api.step('Determine blue moon', | |
452 [api.path['build'].join('is_blue_moon.sh')]) | |
453 | |
454 if step_result.retcode == 0: | |
455 api.step('HARLEM SHAKE!', [api.path['build'].join('do_the_harlem_shake.sh')]
) | |
456 else: | |
457 api.step('Boring', [api.path['build'].join('its_a_small_world.sh')]) | |
458 | |
459 def GenTests(api): | |
460 yield api.test('harlem') + api.step_data('Determine blue moon', retcode=0) | |
461 yield api.test('boring') + api.step_data('Determine blue moon', retcode=1) | |
462 ``` | |
463 | |
464 See how we use `step_result` to get the result of the last step? The item we get | |
465 back is a `recipe_engine.main.StepData` instance (really, just a basic object | |
466 with member data). The members of this object which are guaranteed to exist are: | |
467 * `retcode`: Pretty much what you think | |
468 * `step`: The actual step json which was sent to `annotator.py`. Not usually | |
469 useful for recipes, but it is used internally for the recipe tests | |
470 framework. | |
471 * `presentation`: An object representing how the step will show up on the | |
472 build page, including its exit status, links, and extra log text. This is a | |
473 `recipe_engine.main.StepPresentation` object. | |
474 See also | |
475 [How to change step presentation](#how-to-change-step-presentation). | |
476 | |
477 This is pretty neat... However, it turns out that returncodes suck bigtime for | |
478 communicating actual information. `api.json.output()` to the rescue! | |
479 | |
480 ```python | |
481 DEPS = ['step', 'path', 'step_history', 'json'] | |
482 | |
483 def RunSteps(api): | |
484 step_result = api.step( | |
485 'run tests', | |
486 [api.path['build'].join('do_test_things.sh'), api.json.output()]) | |
487 num_passed = step_result.json.output['num_passed'] | |
488 if num_passed > 500: | |
489 api.step('victory', [api.path['build'].join('do_a_dance.sh')]) | |
490 elif num_passed > 200: | |
491 api.step('not defeated', [api.path['build'].join('woohoo.sh')]) | |
492 else: | |
493 api.step('deads!', [api.path['build'].join('you_r_deads.sh')]) | |
494 | |
495 def GenTests(api): | |
496 yield (api.test('winning') + | |
497 api.step_data('run tests', api.json.output({'num_passed': 791})) | |
498 yield (api.test('not_dead_yet') + | |
499 api.step_data('run tests', api.json.output({'num_passed': 302})) | |
500 yield (api.test('noooooo') + | |
501 api.step_data('run tests', api.json.output({'num_passed': 10}))) | |
502 ``` | |
503 | |
504 ### How does THAT work!? | |
505 | |
506 `api.json.output()` returns a `recipe_api.Placeholder` which is meant to be | |
507 added into a step command list. When the step runs, the placeholder gets | |
508 rendered into some strings (in this case, like '/tmp/some392ra8'). When the step | |
509 finishes, the Placeholder adds data to the `StepData` object for the step which | |
510 just ran, namespaced by the module name (in this case, the 'json' module decided | |
511 to add an 'output' attribute to the `step_history` item). I'd encourage you to | |
512 take a peek at the implementation of the json module to see how this is | |
513 implemented. | |
514 | |
515 ### Example: write to standard input of a step | |
516 | |
517 ```python | |
518 api.step(..., stdin=api.raw_io.input('test input')) | |
519 ``` | |
520 | |
521 Also see [raw_io's | |
522 example.py](https://chromium.googlesource.com/chromium/tools/build.git/+/master/
scripts/slave/recipe_modules/raw_io/example.py). | |
523 | |
524 ### Example: read standard output of a step as json | |
525 | |
526 ```python | |
527 step_result = api.step(..., stdout=api.json.output()) | |
528 data = step_result.stdout | |
529 # data is a parsed JSON value, such as dict | |
530 ``` | |
531 | |
532 Also see [json's | |
533 example.py](https://chromium.googlesource.com/chromium/tools/build.git/+/master/
scripts/slave/recipe_modules/json/example.py). | |
534 | |
535 ### Example: write to standard input of a step as json | |
536 | |
537 ```python | |
538 data = {'value': 1} | |
539 api.step(..., stdin=api.json.input(data)) | |
540 ``` | |
541 | |
542 Also see [json's | |
543 example.py](https://chromium.googlesource.com/chromium/tools/build.git/+/master/
scripts/slave/recipe_modules/json/example.py). | |
544 | |
545 ### Example: simulated step output | |
546 | |
547 This example specifies the standard output that should be returned when | |
548 a step is executed in simulation mode. This is typically used for | |
549 specifying default test data in the recipe or recipe module and removes | |
550 the need to specify too much test data for each test in GenTests: | |
551 | |
552 ```python | |
553 api.step(..., step_test_data=api.raw_io.output('test data')) | |
554 ``` | |
555 | |
556 ### Example: simulated step output for a test case | |
557 | |
558 ```python | |
559 yield ( | |
560 api.test('my_test') + | |
561 api.step_data( | |
562 'step_name', | |
563 output=api.raw_io.output('test data'))) | |
564 ``` | |
565 | |
566 ## How to change step presentation? | |
567 | |
568 `step_result.presentation` allows modifying the appearance of a step: | |
569 | |
570 ### Logging | |
571 | |
572 ```python | |
573 step_result.presentation.logs['mylog'] = ['line1', 'line2'] | |
574 ``` | |
575 | |
576 Creates an extra log "mylog" under the step. | |
577 | |
578 ### Setting properties | |
579 | |
580 `api.properties` are immutable, but you can change and add new | |
581 properties at the buildbot level. | |
582 | |
583 ```python | |
584 step_result.presentation.properties['newprop'] = 1 | |
585 ``` | |
586 | |
587 ### Example: step text | |
588 | |
589 This modifies the text displayed next to a step name: | |
590 | |
591 ```python | |
592 step_result = api.step(...) | |
593 step_result.presentation.step_text = 'Dynamic step result text' | |
594 ``` | |
595 | |
596 * `presentaton.logs` allows creating extra logs of a step run. Example: | |
597 ```python | |
598 step_result.presentation.logs['mylog'] = ['line1', 'line2'] | |
599 ``` | |
600 * presentation.properties allows changing and adding new properties at the | |
601 buildbot level. Example: | |
602 ```python | |
603 step_result.presentation.properties['newprop'] = 1 | |
604 ``` | |
605 | |
606 ## How do I know what modules to use? | |
607 | |
608 Use `scripts/tools/show_me_the_modules.py`. It's super effective! | |
609 | |
610 ## How do I run those tests you were talking about? | |
611 | |
612 To test all the recipes/apis, use | |
613 `scripts/slave/unittests/recipe_simulation_test.py`. To set new expectations | |
614 `scripts/slave/unittests/recipe_simulation_test.py train`. | |
615 | |
616 ## Where's the docs on `*.py`? | |
617 | |
618 Check the docstrings in `*.py`. `<trollface text="Problem?"/>` | |
619 | |
620 In addition, most recipe modules have an `example.py` file which exercises most | |
621 of the code in the module for both test coverage and example purposes. | |
622 | |
623 If you want to know what keys a step dictionary can take, take a look at | |
624 `third_party/recipe_engine/main.py`. | |
625 | |
OLD | NEW |