| Index: third_party/recipe_engine/doc/user_guide.md
|
| diff --git a/third_party/recipe_engine/doc/user_guide.md b/third_party/recipe_engine/doc/user_guide.md
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..d596ee5e933be8f09fc118556a3d5293b1edbf7e
|
| --- /dev/null
|
| +++ b/third_party/recipe_engine/doc/user_guide.md
|
| @@ -0,0 +1,625 @@
|
| +# Recipes
|
| +
|
| +Recipes are a domain-specific language (embedded in python) for specifying
|
| +sequences of subprocess calls in a cross-platform and testable way.
|
| +
|
| +[TOC]
|
| +
|
| +## Background
|
| +
|
| +Chromium uses BuildBot for its builds. It requires master restarts to change
|
| +bot configs, which slows bot changes down.
|
| +
|
| +With Recipes, most build-related things happen in scripts that run on the
|
| +slave, which means that the master does not need to be restarted in order
|
| +to change something about a build configuration.
|
| +
|
| +Recipes also provide a way to unit test build scripts, by mocking commands and
|
| +recording "expectations" of what will happen when the script runs under various
|
| +conditions. This makes it easy to verify that the scope of a change is limited.
|
| +
|
| +## Intro
|
| +
|
| +This README will seek to teach the ways of Recipes, so that you may do one or
|
| +more of the following:
|
| +
|
| + * Read them
|
| + * Make new recipes
|
| + * Fix bugs in recipes
|
| + * Create libraries (api modules) for others to use in their recipes.
|
| +
|
| +The document will build knowledge up in small steps using examples, and so it's
|
| +probably best to read the whole doc through from top to bottom once before using
|
| +it as a reference.
|
| +
|
| +## Small Beginnings
|
| +
|
| +**Recipes are a means to cause a series of commands to run on a machine.**
|
| +
|
| +All recipes take the form of a python file whose body looks like this:
|
| +
|
| +```python
|
| +DEPS = ['step']
|
| +
|
| +def RunSteps(api):
|
| + api.step('Print Hello World', ['echo', 'hello', 'world'])
|
| +```
|
| +
|
| +The `RunSteps` function is expected to take at least a single argument `api`
|
| +(we'll get to that in more detail later), and run a series of steps by calling
|
| +api functions. All of these functions will eventually make calls to
|
| +`api.step()`, which is the only way to actually get anything done on the
|
| +machine. Using python libraries with OS side-effects is prohibited to enable
|
| +testing.
|
| +
|
| +For these examples we will work out of the
|
| +[tools/build](https://chromium.googlesource.com/chromium/tools/build/)
|
| +repository.
|
| +
|
| +Put this in a file under `scripts/slave/recipes/hello.py`. You can then
|
| +run this recipe by calling
|
| +
|
| + $ scripts/tools/run_recipe.py hello
|
| +
|
| +*** promo
|
| +Note: every recipe execution (e.g. build on buildbot) emits
|
| +a step log called `run_recipe` on the `setup_build` step which provides
|
| +a precise invocation for `run_recipe.py` correlating exactly with the current
|
| +recipe invocation. This is useful to locally repro a failing build without
|
| +having to guess at the parameters to `run_recipe.py`.
|
| +***
|
| +
|
| +## We should probably test as we go...
|
| +
|
| +**All recipes MUST have corresponding tests, which achieve 100% code coverage.**
|
| +
|
| +So, we have our recipe. Let's add a test to it.
|
| +
|
| +```python
|
| +DEPS = ['step']
|
| +
|
| +def RunSteps(api):
|
| + api.step('Print Hello World', ['echo', 'hello', 'world'])
|
| +
|
| +def GenTests(api):
|
| + yield api.test('basic')
|
| +```
|
| +
|
| +This causes a single test case to be generated, called 'basic', which has no
|
| +input parameters. As your recipe becomes more complex, you'll need to add more
|
| +tests to make sure that you maintain 100% code coverage.
|
| +
|
| +In order to run the tests, run
|
| +
|
| + $ scripts/slave/unittests/recipe_simulation_test.py train hello
|
| +
|
| +This will write the file `build/scripts/slave/recipes/hello.expected/basic.json`
|
| +summarizing the actions of the recipe under the boring conditions
|
| +specified by `api.test('basic')`.
|
| +
|
| + [
|
| + {
|
| + "cmd": [
|
| + "echo",
|
| + "hello",
|
| + "world"
|
| + ],
|
| + "cwd": "[SLAVE_BUILD]",
|
| + "name": "Print Hello World"
|
| + }
|
| + ]
|
| +
|
| +## Let's do something useful
|
| +
|
| +### Properties are the primary input for your recipes
|
| +
|
| +In order to do something useful, we need to pull in parameters from the outside
|
| +world. There's one primary source of input for recipes, which is `properties`.
|
| +
|
| +Properties are a relic from the days of BuildBot, though they have been
|
| +dressed up a bit to be more like we'll want them in the future. If you're
|
| +familiar with BuildBot, you'll probably know them as `factory_properties` and
|
| +`build_properties`. The new `properties` object is a merging of these two, and
|
| +is provided by the `properties` api module.
|
| +
|
| +```python
|
| +from recipe_engine.recipe_api import Property
|
| +
|
| +DEPS = [
|
| + 'step',
|
| +]
|
| +
|
| +PROPERTIES = {
|
| + 'target_of_admiration': Property(
|
| + kind=str, help="Who you love and adore.", default="Chrome Infra"),
|
| +}
|
| +
|
| +def RunSteps(api, target_of_admiration):
|
| + verb = 'Hello, %s'
|
| + if target_of_admiration == 'DarthVader':
|
| + verb = 'Die in a fire, %s!'
|
| + api.step('Greet Admired Individual', ['echo', verb % target_of_admiration])
|
| +
|
| +def GenTests(api):
|
| + yield api.test('basic') + api.properties(target_of_admiration='Bob')
|
| + yield api.test('vader') + api.properties(target_of_admiration='DarthVader')
|
| + yield api.test('infra rocks')
|
| +```
|
| +
|
| +Yes, elements of a test specification are combined with `+` and it's weird.
|
| +
|
| +To specify property values in a local run:
|
| +
|
| + build/scripts/tools/run_recipe.py <recipe-name> opt=bob other=sally
|
| +
|
| +Or, more explicitly::
|
| +
|
| + build/scripts/tools/run_recipe.py --properties-file <path/to/json>
|
| +
|
| +Where `<path/to/json>` is a file containing a valid json `object` (i.e.
|
| +key:value pairs).
|
| +
|
| +### Modules
|
| +
|
| +There are all sorts of helper modules. They are found in the `recipe_modules`
|
| +directory alongside the `recipes` directory where the recipes go.
|
| +
|
| +Notice the `DEPS` line in the recipe. Any modules named by string in DEPS are
|
| +'injected' into the `api` parameter that your recipe gets. If you leave them out
|
| +of DEPS, you'll get an AttributeError when you try to access them. The modules
|
| +are located primarily in `recipe_modules/`, and their name is their folder name.
|
| +
|
| +There are a whole bunch of modules which provide really helpful tools. You
|
| +should go take a look at them. `scripts/tools/show_me_the_modules.py` is a
|
| +pretty helpful tool. If you want to know more about properties, step and path, I
|
| +would suggest starting with `show_me_the_modules.py`, and then delving into the
|
| +helpful docstrings in those helpful modules.
|
| +
|
| +## Making Modules
|
| +
|
| +**Modules are for grouping functionality together and exposing it across
|
| +recipes.**
|
| +
|
| +So now you feel like you're pretty good at recipes, but you want to share your
|
| +echo functionality across a couple recipes which all start the same way. To do
|
| +this, you need to add a module directory.
|
| +
|
| +```
|
| +recipe_modules/
|
| + step/
|
| + properties/
|
| + path/
|
| + hello/
|
| + __init__.py # (Required) Contains optional `DEPS = list([other modules])`
|
| + api.py # (Required) Contains single required RecipeApi-derived class
|
| + config.py # (Optional) Contains configuration for your api
|
| + *_config.py # (Optional) These contain extensions to the configurations of
|
| + # your dependency APIs
|
| +```
|
| +
|
| +First add an `__init__.py` with DEPS:
|
| +
|
| +```python
|
| +# recipe_modules/hello/__init__.py
|
| +from recipe_api import Property
|
| +
|
| +DEPS = ['properties', 'step']
|
| +PROPERTIES = {
|
| + 'target_of_admiration': Property(default=None),
|
| +}
|
| +```
|
| +
|
| +And your api.py should look something like:
|
| +
|
| +```python
|
| +from slave import recipe_api
|
| +
|
| +class HelloApi(recipe_api.RecipeApi):
|
| + def __init__(self, target_of_admiration):
|
| + self._target = target_of_admiration
|
| +
|
| + def greet(self, default_verb=None):
|
| + verb = default_verb or 'Hello %s'
|
| + if self._target == 'DarthVader':
|
| + verb = 'Die in a fire %s!'
|
| + self.m.step('Hello World',
|
| + ['echo', verb % self._target])
|
| +```
|
| +
|
| +Note that all the DEPS get injected into `self.m`. This logic is handled outside
|
| +of the object (i.e. not in `__init__`).
|
| +
|
| +> Because dependencies are injected after module initialization, *you do not
|
| +> have access to injected modules in your APIs `__init__` method*!
|
| +
|
| +And now, our refactored recipe:
|
| +
|
| +```python
|
| +DEPS = ['hello']
|
| +
|
| +def RunSteps(api):
|
| + api.hello.greet()
|
| +
|
| +def GenTests(api):
|
| + yield api.test('basic') + api.properties(target_of_admiration='Bob')
|
| + yield api.test('vader') + api.properties(target_of_admiration='DarthVader')
|
| +```
|
| +
|
| +> NOTE: all of the modules are also require 100% code coverage, but you only
|
| +> need coverage from SOME recipe.
|
| +
|
| +## So how do I really write those tests?
|
| +
|
| +The basic form of tests is:
|
| +
|
| +```python
|
| +def GenTests(api):
|
| + yield api.test('testname') + # other stuff
|
| +```
|
| +
|
| +Some modules define interfaces for specifying necessary step data; these are
|
| +injected into `api` from `DEPS` similarly to how it works for `RunSteps`. There
|
| +are a few other methods available to `GenTests`'s `api`. Common ones include:
|
| +
|
| + * `api.properties(buildername='foo_builder')` sets properties as we have seen.
|
| + * `api.platform('linux', 32)` sets the mock platform to 32-bit linux.
|
| + * `api.step_data('Hello World', retcode=1)` mocks the `'Hello World'` step
|
| + to have failed with exit code 1.
|
| +
|
| +By default all simulated steps succeed, the platform is 64-bit linux, and
|
| +there are no properties. The `api.properties.generic()` method populates some
|
| +common properties for Chromium recipes.
|
| +
|
| +The `api` passed to GenTests is confusingly **NOT** the same as the recipe api.
|
| +It's actually an instance of `recipe_test_api.py:RecipeTestApi()`. This is
|
| +admittedly pretty weak, and it would be great to have the test api
|
| +automatically created via modules. On the flip side, the test api is much less
|
| +necessary than the recipe api, so this transformation has not been designed yet.
|
| +
|
| +## What is that config business?
|
| +
|
| +**Configs are a way for a module to expose it's "global" state in a reusable
|
| +way.**
|
| +
|
| +A common problem in Building Things is that you end up with an inordinantly
|
| +large matrix of configurations. Let's take chromium, for example. Here is a
|
| +sample list of axes of configuration which chromium needs to build and test:
|
| +
|
| + * BUILD_CONFIG
|
| + * HOST_PLATFORM
|
| + * HOST_ARCH
|
| + * HOST_BITS
|
| + * TARGET_PLATFORM
|
| + * TARGET_ARCH
|
| + * TARGET_BITS
|
| + * builder type (ninja? msvs? xcodebuild?)
|
| + * compiler
|
| + * ...
|
| +
|
| +Obviously there are a lot of combinations of those things, but only a relatively
|
| +small number of *valid* combinations of those things. How can we represent all
|
| +the valid states while still retaining our sanity?
|
| +
|
| +We begin by specifying a schema that configurations of the `hello` module
|
| +will follow, and the config context based on it that we will add configuration
|
| +items to.
|
| +
|
| +```python
|
| +# recipe_modules/hello/config.py
|
| +from slave.recipe_config import config_item_context, ConfigGroup
|
| +from slave.recipe_config import SimpleConfig, StaticConfig, BadConf
|
| +
|
| +def BaseConfig(TARGET='Bob'):
|
| + # This is a schema for the 'config blobs' that the hello module deals with.
|
| + return ConfigGroup(
|
| + verb = SimpleConfig(str),
|
| + # A config blob is not complete() until all required entries have a value.
|
| + tool = SimpleConfig(str, required=True),
|
| + # Generally, your schema should take a series of CAPITAL args which will be
|
| + # set as StaticConfig data in the config blob.
|
| + TARGET = StaticConfig(str(TARGET)),
|
| + )
|
| +
|
| +config_ctx = config_item_context(BaseConfig)
|
| +```
|
| +
|
| +The `BaseConfig` schema is expected to return a `ConfigGroup` instance of some
|
| +sort. All the configs that you get out of this file will be a modified version
|
| +of something returned by the schema method. The arguments should have sane
|
| +defaults, and should be named in `ALL_CAPS` (this is to avoid argument name
|
| +conflicts as we'll see later).
|
| +
|
| +`config_ctx` is the 'context' for all the config items in this file, and will
|
| +magically become the `CONFIG_CTX` for the entire module. Other modules may
|
| +extend this context, which we will get to later.
|
| +
|
| +Finally let's define some config items themselves. A config item is a function
|
| +decorated with the `config_ctx`, and takes a config blob as 'c'. The config item
|
| +updates the config blob, perhaps conditionally. There are many features to
|
| +`slave/recipe_config.py`. I would recommend reading the docstrings there
|
| +for all the details.
|
| +
|
| +```python
|
| +# Each of these functions is a 'config item' in the context of config_ctx.
|
| +
|
| +# is_root means that every config item will apply this item first.
|
| +@config_ctx(is_root=True)
|
| +def BASE(c):
|
| + if c.TARGET == 'DarthVader':
|
| + c.verb = 'Die in a fire, %s!'
|
| + else:
|
| + c.verb = 'Hello, %s'
|
| +
|
| +@config_ctx(group='tool'): # items with the same group are mutually exclusive.
|
| +def super_tool(c):
|
| + if c.TARGET != 'Charlie':
|
| + raise BadConf('Can only use super tool for Charlie!')
|
| + c.tool = 'unicorn.py'
|
| +
|
| +@config_ctx(group='tool'):
|
| +def default_tool(c):
|
| + c.tool = 'echo'
|
| +```
|
| +
|
| +Now that we have our config, let's use it.
|
| +
|
| +```python
|
| +# recipe_modules/hello/api.py
|
| +from slave import recipe_api
|
| +
|
| +class HelloApi(recipe_api.RecipeApi):
|
| + def __init__(self, target_of_admiration):
|
| + self._target = target_of_admiration
|
| +
|
| + def get_config_defaults(self, _config_name):
|
| + return {'TARGET': self._target}
|
| +
|
| + def greet(self):
|
| + self.m.step('Hello World', [
|
| + self.m.path.build(self.c.tool), self.c.verb % self.c.TARGET])
|
| +```
|
| +
|
| +Note that `recipe_api.RecipeApi` contains all the plumbing for dealing with
|
| +configs. If your module has a config, you can access its current value via
|
| +`self.c`. The users of your module (read: recipes) will need to set this value
|
| +in one way or another. Also note that c is a 'public' variable, which means that
|
| +recipes have direct access to the configuration state by `api.<modname>.c`.
|
| +
|
| +```python
|
| +# recipes/hello.py
|
| +DEPS = ['hello']
|
| +def RunSteps(api):
|
| + api.hello.set_config('default_tool')
|
| + api.hello.greet() # Greets 'target_of_admiration' or 'Bob' with echo.
|
| +
|
| +def GenTests(api):
|
| + yield api.test('bob')
|
| + yield api.test('anya') + api.properties(target_of_admiration='anya')
|
| +```
|
| +
|
| +Note the call to `set_config`. This method takes the configuration name
|
| +specifed, finds it in the given module (`'hello'` in this case), and sets
|
| +`api.hello.c` equal to the result of invoking the named config item
|
| +(`'default_tool'`) with the default configuration (the result of calling
|
| +`get_config_defaults`), merged over the static defaults specified by the schema.
|
| +
|
| +We can also call `set_config` differently to get different results:
|
| +
|
| +```python
|
| +# recipes/rainbow_hello.py
|
| +DEPS = ['hello']
|
| +def RunSteps(api):
|
| + api.hello.set_config('super_tool', TARGET='Charlie')
|
| + api.hello.greet() # Greets 'Charlie' with unicorn.py.
|
| +
|
| +def GenTests(api):
|
| + yield api.test('charlie')
|
| +```
|
| +
|
| +```python
|
| +# recipes/evil_hello.py
|
| +DEPS = ['hello']
|
| +def RunSteps(api):
|
| + api.hello.set_config('default_tool', TARGET='DarthVader')
|
| + api.hello.greet() # Causes 'DarthVader' to despair with echo
|
| +
|
| +def GenTests(api):
|
| + yield api.test('darth')
|
| +```
|
| +
|
| +`set_config()` also has one additional bit of magic. If a module (say,
|
| +`chromium`), depends on some other modules (say, `gclient`), if you do
|
| +`api.chromium.set_config('blink')`, it will apply the `'blink'` config item from
|
| +the chromium module, but it will also attempt to apply the `'blink'` config for
|
| +all the dependencies, too. This way, you can have the chromium module extend the
|
| +gclient config context with a 'blink' config item, and then `set_configs` will
|
| +stack across all the relevent contexts. (This has since been recognized as a
|
| +design mistake)
|
| +
|
| +`recipe_api.RecipeApi` also provides `make_config` and `apply_config`, which
|
| +allow recipes more-direct access to the config items. However, `set_config()` is
|
| +the most-preferred way to apply configurations.
|
| +
|
| +## What about getting data back from a step?
|
| +
|
| +Consider this recipe:
|
| +
|
| +```python
|
| +DEPS = ['step', 'path']
|
| +
|
| +def RunSteps(api):
|
| + step_result = api.step('Determine blue moon',
|
| + [api.path['build'].join('is_blue_moon.sh')])
|
| +
|
| + if step_result.retcode == 0:
|
| + api.step('HARLEM SHAKE!', [api.path['build'].join('do_the_harlem_shake.sh')])
|
| + else:
|
| + api.step('Boring', [api.path['build'].join('its_a_small_world.sh')])
|
| +
|
| +def GenTests(api):
|
| + yield api.test('harlem') + api.step_data('Determine blue moon', retcode=0)
|
| + yield api.test('boring') + api.step_data('Determine blue moon', retcode=1)
|
| +```
|
| +
|
| +See how we use `step_result` to get the result of the last step? The item we get
|
| +back is a `recipe_engine.main.StepData` instance (really, just a basic object
|
| +with member data). The members of this object which are guaranteed to exist are:
|
| + * `retcode`: Pretty much what you think
|
| + * `step`: The actual step json which was sent to `annotator.py`. Not usually
|
| + useful for recipes, but it is used internally for the recipe tests
|
| + framework.
|
| + * `presentation`: An object representing how the step will show up on the
|
| + build page, including its exit status, links, and extra log text. This is a
|
| + `recipe_engine.main.StepPresentation` object.
|
| + See also
|
| + [How to change step presentation](#how-to-change-step-presentation).
|
| +
|
| +This is pretty neat... However, it turns out that returncodes suck bigtime for
|
| +communicating actual information. `api.json.output()` to the rescue!
|
| +
|
| +```python
|
| +DEPS = ['step', 'path', 'step_history', 'json']
|
| +
|
| +def RunSteps(api):
|
| + step_result = api.step(
|
| + 'run tests',
|
| + [api.path['build'].join('do_test_things.sh'), api.json.output()])
|
| + num_passed = step_result.json.output['num_passed']
|
| + if num_passed > 500:
|
| + api.step('victory', [api.path['build'].join('do_a_dance.sh')])
|
| + elif num_passed > 200:
|
| + api.step('not defeated', [api.path['build'].join('woohoo.sh')])
|
| + else:
|
| + api.step('deads!', [api.path['build'].join('you_r_deads.sh')])
|
| +
|
| +def GenTests(api):
|
| + yield (api.test('winning') +
|
| + api.step_data('run tests', api.json.output({'num_passed': 791}))
|
| + yield (api.test('not_dead_yet') +
|
| + api.step_data('run tests', api.json.output({'num_passed': 302}))
|
| + yield (api.test('noooooo') +
|
| + api.step_data('run tests', api.json.output({'num_passed': 10})))
|
| +```
|
| +
|
| +### How does THAT work!?
|
| +
|
| +`api.json.output()` returns a `recipe_api.Placeholder` which is meant to be
|
| +added into a step command list. When the step runs, the placeholder gets
|
| +rendered into some strings (in this case, like '/tmp/some392ra8'). When the step
|
| +finishes, the Placeholder adds data to the `StepData` object for the step which
|
| +just ran, namespaced by the module name (in this case, the 'json' module decided
|
| +to add an 'output' attribute to the `step_history` item). I'd encourage you to
|
| +take a peek at the implementation of the json module to see how this is
|
| +implemented.
|
| +
|
| +### Example: write to standard input of a step
|
| +
|
| +```python
|
| +api.step(..., stdin=api.raw_io.input('test input'))
|
| +```
|
| +
|
| +Also see [raw_io's
|
| +example.py](https://chromium.googlesource.com/chromium/tools/build.git/+/master/scripts/slave/recipe_modules/raw_io/example.py).
|
| +
|
| +### Example: read standard output of a step as json
|
| +
|
| +```python
|
| +step_result = api.step(..., stdout=api.json.output())
|
| +data = step_result.stdout
|
| +# data is a parsed JSON value, such as dict
|
| +```
|
| +
|
| +Also see [json's
|
| +example.py](https://chromium.googlesource.com/chromium/tools/build.git/+/master/scripts/slave/recipe_modules/json/example.py).
|
| +
|
| +### Example: write to standard input of a step as json
|
| +
|
| +```python
|
| +data = {'value': 1}
|
| +api.step(..., stdin=api.json.input(data))
|
| +```
|
| +
|
| +Also see [json's
|
| +example.py](https://chromium.googlesource.com/chromium/tools/build.git/+/master/scripts/slave/recipe_modules/json/example.py).
|
| +
|
| +### Example: simulated step output
|
| +
|
| +This example specifies the standard output that should be returned when
|
| +a step is executed in simulation mode. This is typically used for
|
| +specifying default test data in the recipe or recipe module and removes
|
| +the need to specify too much test data for each test in GenTests:
|
| +
|
| +```python
|
| +api.step(..., step_test_data=api.raw_io.output('test data'))
|
| +```
|
| +
|
| +### Example: simulated step output for a test case
|
| +
|
| +```python
|
| +yield (
|
| + api.test('my_test') +
|
| + api.step_data(
|
| + 'step_name',
|
| + output=api.raw_io.output('test data')))
|
| +```
|
| +
|
| +## How to change step presentation?
|
| +
|
| +`step_result.presentation` allows modifying the appearance of a step:
|
| +
|
| +### Logging
|
| +
|
| +```python
|
| +step_result.presentation.logs['mylog'] = ['line1', 'line2']
|
| +```
|
| +
|
| +Creates an extra log "mylog" under the step.
|
| +
|
| +### Setting properties
|
| +
|
| +`api.properties` are immutable, but you can change and add new
|
| +properties at the buildbot level.
|
| +
|
| +```python
|
| +step_result.presentation.properties['newprop'] = 1
|
| +```
|
| +
|
| +### Example: step text
|
| +
|
| +This modifies the text displayed next to a step name:
|
| +
|
| +```python
|
| +step_result = api.step(...)
|
| +step_result.presentation.step_text = 'Dynamic step result text'
|
| +```
|
| +
|
| +* `presentaton.logs` allows creating extra logs of a step run. Example:
|
| + ```python
|
| + step_result.presentation.logs['mylog'] = ['line1', 'line2']
|
| + ```
|
| +* presentation.properties allows changing and adding new properties at the
|
| + buildbot level. Example:
|
| + ```python
|
| + step_result.presentation.properties['newprop'] = 1
|
| + ```
|
| +
|
| +## How do I know what modules to use?
|
| +
|
| +Use `scripts/tools/show_me_the_modules.py`. It's super effective!
|
| +
|
| +## How do I run those tests you were talking about?
|
| +
|
| +To test all the recipes/apis, use
|
| +`scripts/slave/unittests/recipe_simulation_test.py`. To set new expectations
|
| +`scripts/slave/unittests/recipe_simulation_test.py train`.
|
| +
|
| +## Where's the docs on `*.py`?
|
| +
|
| +Check the docstrings in `*.py`. `<trollface text="Problem?"/>`
|
| +
|
| +In addition, most recipe modules have an `example.py` file which exercises most
|
| +of the code in the module for both test coverage and example purposes.
|
| +
|
| +If you want to know what keys a step dictionary can take, take a look at
|
| +`third_party/recipe_engine/main.py`.
|
| +
|
|
|