blob: 4258f20ead2b1fbe8f118bbe15c88bf03a4548aa [file] [log] [blame] [view]
# 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
# scripts/slave/recipes/hello.py
DEPS = ['recipe_engine/step']
def RunSteps(api):
api.step('Print Hello World', ['echo', 'hello', 'world'])
def GenTests(api):
return ()
```
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.
The `GenTests` function currently does nothing, but a recipe is invalid and
cannot be run unless it defines a `GenTests` function.
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/slave/recipes.py run 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 `recipes.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 `recipes.py`.
***
## We should probably test as we go...
**All recipes MUST have corresponding tests, which achieve 100% code coverage.**
You can execute the tests for the recipes by running
$ scripts/slave/recipes.py test run
As part of running the tests the coverage of the recipes is checked, so you
should expect output similar to the following snippet to appear as part of the
output of the command.
Name Stmts Miss Cover Missing
--------------------------------------------------------------
scripts/slave/recipes/hello.py 5 1 80% 5
--------------------------------------------------------------
TOTAL 21132 1 99%
488 files skipped due to complete coverage.
FATAL: Insufficient coverage (99%)
----------------------------------------------------------------------
Ran 1940 tests in 61.135s
FAILED
The Stmts column indicates the number of statements that are in the recipe file.
The Miss column indicates the number of statements that do not have coverage.
The Missing column details the spans of code that are not covered, so currently
only the statement on line 4 is not covered. The other statements are the DEPS
and function definitions and the body of the `GenTests`.
So let's add a test to get the necessary coverage.
```python
# scripts/slave/recipes/hello.py
DEPS = ['recipe_engine/step']
def RunSteps(api):
api.step('Print Hello World', ['echo', 'hello', 'world'])
def GenTests(api):
yield api.test('basic')
```
The `GenTests` method takes a single parameter `api` that has methods for
defining test specifications. Calling `GenTests` must result in an iterable of
test specifications. `api.test('basic')` creates a test specification that
causes a test case to be generated named 'basic' that 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.
If you were to run the tests at this point, you would now get a failure
including the following output.
hello.basic failed:
--- expected
+++ actual
@@ -1 +1,15 @@
-None
+[
+ {
+ "cmd": [
+ "echo",
+ "hello",
+ "world"
+ ],
+ "name": "Print Hello World"
+ },
+ {
+ "name": "$result",
+ "recipe_result": null,
+ "status_code": 0
+ }
+]
Every test case has associated json files detailing the steps executed by the
recipe and the results of those actions. If the sequence of steps executed by
the recipe don't match the expected values in the json file then the test will
fail.
You can train the test by running
$ scripts/slave/recipes.py test train --filter hello
> The `--filter` flag can be used when running or training the tests to limit
> the tests that are executed. For details on the format, pass the `-h` flag to
> either `test run` or `test train`. Coverage will not be checked when using the
> `--filter` flag.
Training the test will generate or update the json expectation files. There
should now be a file `scripts/slave/recipes/hello.expected.basic.json` in your
working copy with content matching the steps executed by the recipe.
```json
[
{
"cmd": [
"echo",
"hello",
"world"
],
"name": "Print Hello World"
},
{
"name": "$result",
"recipe_result": null,
"status_code": 0
}
]
```
When making actual changes, these json files should be included as part of your
commit and reviewed for correctness.
Running the tests now would result in a passing run, printing OK.
### But we can do better
In reality, the json expectation files are something of a maintenance burden and
they don't do an effective job of making it clear what is being tested. You
still may need to know how to train expectations if you're making modifications
to existing recipes and modules that already use expectation files, but new
tests should instead use the post-process api which enables making assertions on
the steps that were run.
This functionality is exposed to `GenTests` by calling `api.post_process`. This
method requires a single parameter that is a function that will perform the
checks. The provided function must take two parameters: `check` and
`step_odict`.
* `check` is the function that performs the low-level check operation; it
evaluates a boolean expression and if it's false it records it as a failure.
When it records a failure, it also records the backtrace and the values of
variables used in the expression to provide helpful context when the
failures are displayed.
* `step_odict` is an `OrderedDict` mapping step name to the step's dictionary.
The dictionary for a step contains the same information that appears for the
step in the json expectation files.
`api.post_process` accepts arbitrary additional positional and keyword arguments
and these will be forwarded on to the assertion function.
The function can return a filtered subset of `step_odict` or it can return None
to indicate that there are no changes to `step_odict`. Multiple calls to
`api.post_process` can be made and each of the provided functions will be called
in order with the `step_odict` that results from prior assertion functions.
Let's re-write our test to use the post-process api:
```python
# scripts/slave/recipes/hello.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
DEPS = ['recipe_engine/step']
def RunSteps(api):
api.step('Print Hello World', ['echo', 'hello', 'world'])
def GenTests(api):
yield (
api.test('basic')
+ api.post_process(StepCommandRE, 'Print Hello World',
['echo', 'hello', 'world'])
+ api.post_process(DropExpectation)
)
```
Yes, elements of a test specification are combined with `+` and it's weird.
The call that includes `StepCommandRE` will check that the step named
'Print Hello World' has as its list of arguments ['echo', 'hello', 'world']
(each element can actually be a regular expression that must match the
corresponding argument). The call with just DropExpectation doesn't check
anything, it just inhibits the test from outputting a json expectation file.
If you were to change the command list passed when using `StepCommandRE` so that
it no longer matched, you would get output similar to the following:
hello.basic failed:
CHECK(FAIL):
.../infra/recipes-py/recipe_engine/post_process.py:224 - StepCommandRE()
`check(_fullmatch(expected, actual))`
expected: 'world2'
actual: 'world'
added .../build/scripts/slave/recipes/hello.py:14
StepCommandRE('Print Hello World', ['echo', 'hello', 'world2'])
This output provides the following information: a backtrace rooted from the
entry-point into the assertion function to the failed check call, the value of
variables in the expression provided to check (evaluate the expression in the
check call rather than before so that you get more helpful output) and the
location where the assertion was added.
See the documentation of `post_process` in
[recipe_test_api.py](https://chromium.googlesource.com/infra/luci/recipes-py/+/master/recipe_engine/recipe_test_api.py)
for more details about the post-processing api and see
[post_process.py](https://chromium.googlesource.com/infra/luci/recipes-py/+/master/recipe_engine/post_process.py)
for information on the available assertion functions.
Tests should use the post-process api to make assertions about the steps under
test. Just as when writing tests under any other frameworks, be careful not to
make your assertions too strict or you run the risk of needing to update tests
due to unrelated changes. Tests should also make sure to pass `DropExpectation`
to the final call to `api.post_process` to avoid creating json expectation
files. It's important for that to be the last call to `api.post_process` because
the functions passed to any later calls will receive an empty step dict
otherwise.
## 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.
This is now abstracted into the PROPERTIES top level declaration in your recipe.
You declare a dictionary of properties that your recipe accepts. The recipe
engine will extract the properties your recipe cares about from all the
properties it knows about, and pass them as arguments to your RunSteps function.
Let's see an example!
```python
# scripts/slave/recipes/hello.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
from recipe_engine.recipe_api import Property
DEPS = [
'recipe_engine/properties',
'recipe_engine/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')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Hello Bob'])
+ api.post_process(DropExpectation)
)
yield (
api.test('vader')
+ api.properties(target_of_admiration='DarthVader')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Die in a fire DarthVader!'])
+ api.post_process(DropExpectation)
)
yield (
api.test('infra rocks')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Hello Chrome Infra'])
+ api.post_process(DropExpectation)
)
```
The property list is a whitelist, so if the properties provided as inputs to the
current recipe run were
```python
{
'target_of_admiration': 'Darth Vader',
'some_other_chill_thing': 'so_chill',
}
```
then the recipe wouldn't know about the other `some_other_chill_thing` property
at all.
Note that properties without a default are required. If you don't want a
property to be required, just add `default=None` to the definition.
Each parameter to `RunSteps` besides the `api` parameter requires a matching
entry in the PROPERTIES dict.
To specify property values in a local run:
script/slaves/recipes.py run <recipe-name> opt=bob other=sally
Or, more explicitly::
script/slaves/recipes.py --properties-file <path/to/json>
Where `<path/to/json>` is a file containing a valid json `object` (i.e.
key:value pairs).
Note that we need to put a dependency on the 'recipe_engine/properties' module
in the DEPS because we use it to generate our tests, even though we don't
actually call the module in our code.
See this [crbug.com/532275](bug) for more info.
### Modules
There are all sorts of helper modules. They are found in the `recipe_modules`
directory alongside the `recipes` directory where the recipes go.
There are a whole bunch of modules which provide really helpful tools. You
should go take a look at them. `scripts/slave/recipes.py` is a
pretty helpful tool. If you want to know more about properties, step and path, I
would suggest starting with `scripts/slave/recipes.py doc`, and then delving
into the helpful docstrings in those helpful modules.
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.
The format of the strings in the DEPS entry depends on whether the depended-on
module is part of the current repo or a dependency repo (e.g. depot_tools or
recipe_engine for the build repo). Modules from dependency repos are specified
with the form `<repo-name>/<module-name>` as has been done for the `step` and
`properties` modules. Modules from the current repo are specified with just the
module name.
## 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.
```
scripts/slave/recipe_modules/
...
goma/
halt/
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
examples/ # (Recommended) Contains example recipes that show how to
# actually use the module
tests/ # (Optional) Contains test recipes that can be used to test
# individual behaviors of the module
...
```
First add an `__init__.py` with DEPS:
```python
# scripts/slave/recipe_modules/hello/__init__.py
from recipe_engine.recipe_api import Property
DEPS = [
'recipe_engine/path',
'recipe_engine/properties',
'recipe_engine/step',
]
PROPERTIES = {
'target_of_admiration': Property(default=None),
}
```
And your api.py should look something like:
```python
# scripts/slave/recipe_modules/hello/api.py
from recipe_engine import recipe_api
class HelloApi(recipe_api.RecipeApi):
def __init__(self, target_of_admiration, *args, **kwargs):
super(HelloApi, self).__init__(*args, **kwargs)
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('Greet Admired Individual',
['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
# scripts/slave/recipes/hello.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
DEPS = [
'hello',
'recipe_engine/properties',
]
def RunSteps(api):
api.hello.greet()
def GenTests(api):
yield (
api.test('basic')
+ api.properties(target_of_admiration='Bob')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Hello Bob'])
+ api.post_process(DropExpectation)
)
yield (
api.test('vader')
+ api.properties(target_of_admiration='DarthVader')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Die in a fire DarthVader!'])
+ api.post_process(DropExpectation)
)
```
If you were to run or train the tests without the `--filter` flag at this point,
you would experience a failure due to missing coverage of the hello module.
Before running any of the tests the following will appear in the output:
ERROR: The following modules lack test coverage: hello
And the coverage report will be as follows:
Name Stmts Miss Cover Missing
-------------------------------------------------------------------------
scripts/slave/recipe_modules/hello/api.py 10 6 40% 6-7, 10-13
-------------------------------------------------------------------------
TOTAL 21147 6 99%
To get coverage for a module you need to put recipes with tests in the examples
or tests subdirectory of the module. So let's move our hello recipe into the
examples subdirectory of our module.
```python
# scripts/slave/recipe_modules/hello/examples/simple.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
DEPS = [
'hello',
'recipe_engine/properties',
]
def RunSteps(api):
api.hello.greet()
def GenTests(api):
yield (
api.test('basic')
+ api.properties(target_of_admiration='Bob')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Hello Bob'])
+ api.post_process(DropExpectation)
)
yield (
api.test('vader')
+ api.properties(target_of_admiration='DarthVader')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
['echo', 'Die in a fire DarthVader!'])
+ api.post_process(DropExpectation)
)
```
Training the tests again now results in 100% coverage.
## 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 inordinately
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
# scripts/slave/recipe_modules/hello/config.py
from recipe_engine.config import config_item_context, ConfigGroup
from recipe_engine.config import Single, Static, BadConf
def BaseConfig(TARGET='Bob'):
# This is a schema for the 'config blobs' that the hello module deals with.
return ConfigGroup(
verb = Single(str),
# A config blob is not complete() until all required entries have a value.
tool = Single(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 = Static(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
# scripts/slave/recipe_modules/hello/config.py
...
# 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
# scripts/slave/recipe_modules/hello/api.py
from recipe_engine import recipe_api
class HelloApi(recipe_api.RecipeApi):
def __init__(self, target_of_admiration, *args, **kwargs):
super(HelloApi, self).__init__(*args, **kwargs)
self._target = target_of_admiration
def get_config_defaults(self):
defaults = {}
if self._target is not None:
defaults['TARGET'] = self._target
return defaults
def greet(self, default_verb=None):
self.m.step('Greet Admired Individual', [
self.m.path['start_dir'].join(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
# scripts/slave/recipe_modules/examples/simple.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
DEPS = [
'hello',
'recipe_engine/properties',
]
def RunSteps(api):
api.hello.set_config('default_tool')
api.hello.greet()
def GenTests(api):
yield (
api.test('bob')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
[r'.*\becho', 'Hello Bob'])
+ api.post_process(DropExpectation)
)
yield (
api.test('anya')
+ api.properties(target_of_admiration='anya')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
[r'.*\becho', 'Hello anya'])
+ api.post_process(DropExpectation)
)
```
Note the call to `set_config`. This method takes the configuration name
specified, 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.
Note that the first pattern provided when using StepCommandRE is now
`r'.*\becho'`. Because we are using the path module to locate the tool now, the
command line no longer has just echo. The patterns must match the entire
argument (this simplifies the case of matching against most constant strings),
so the `r'.*\b'` matches any number of leading characters and then a word
boundary so that we match a tool named 'echo' in some location.
100% coverage is required for `config.py` also, so lets add some additional
examples that call `set_config` differently to get different results:
```python
# scripts/slave/recipe_modules/examples/rainbow.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
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')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
[r'.*\bunicorn.py', 'Hello Charlie'])
+ api.post_process(DropExpectation)
)
```
```python
# scripts/slave/recipe_modules/examples/evil.py
from recipe_engine.post_process import StepCommandRE, DropExpectation
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')
+ api.post_process(StepCommandRE, 'Greet Admired Individual',
[r'.*\becho', 'Die in a fire DarthVader!'])
+ api.post_process(DropExpectation)
)
```
`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 relevant 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.
We still don't have coverage for the line in config.py that raises a `BadConf`.
This isn't an example of how the `hello` module should be used, so lets add a
recipe under the tests subdirectory to get the last bit of coverage.
```python
# scripts/slave/recipe_modules/hello/tests/badconf.py
from recipe_engine.post_process import DropExpectation
DEPS = ['hello']
def RunSteps(api):
api.hello.set_config('super_tool', TARGET='Not Charlie')
def GenTests(api):
yield (
api.test('badconf')
+ api.expect_exception('BadConf')
+ api.post_process(DropExpectation)
)
```
## What about getting data back from a step?
Consider this recipe:
```python
# scripts/slave/recipes/shake.py
from recipe_engine.post_process import DropExpectation, MustRun
DEPS = [
'recipe_engine/path',
'recipe_engine/step',
]
def RunSteps(api):
step_result = api.step(
'Determine blue moon',
[api.path['start_dir'].join('is_blue_moon.sh')],
ok_ret='any')
if step_result.retcode == 0:
api.step('HARLEM SHAKE!',
[api.path['start_dir'].join('do_the_harlem_shake.sh')])
else:
api.step('Boring',
[api.path['start_dir'].join('its_a_small_world.sh')])
def GenTests(api):
yield (
api.test('harlem')
+ api.step_data('Determine blue moon', retcode=0)
+ api.post_process(MustRun, 'HARLEM SHAKE!')
+ api.post_process(DropExpectation)
)
yield (
api.test('boring')
+ api.step_data('Determine blue moon', retcode=1)
+ api.post_process(MustRun, 'Boring')
+ api.post_process(DropExpectation)
)
```
The `ok_ret` parameter to `api.step()` is necessary if you wish to react to a
step's retcode. By default, any retcode except 0 will result in an exception.
Pass one of the strings 'any' or 'all' to continue execution regardless of the
retcode. Alternatively you can pass a tuple or set of ints to continue execution
if the step's retcode is one of the provided values.
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
# scripts/slave/recipes/war.py
from recipe_engine.post_process import DropExpectation, MustRun
DEPS = [
'recipe_engine/json',
'recipe_engine/path',
'recipe_engine/step',
]
def RunSteps(api):
step_result = api.step(
'run tests',
[api.path['start_dir'].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['start_dir'].join('do_a_dance.sh')])
elif num_passed > 200:
api.step('not defeated', [api.path['start_dir'].join('woohoo.sh')])
else:
api.step('deads!', [api.path['start_dir'].join('you_r_deads.sh')])
def GenTests(api):
yield (
api.test('winning')
+ api.step_data('run tests', api.json.output({'num_passed': 791}))
+ api.post_process(MustRun, 'victory')
+ api.post_process(DropExpectation)
)
yield (
api.test('not_dead_yet')
+ api.step_data('run tests', api.json.output({'num_passed': 302}))
+ api.post_process(MustRun, 'not defeated')
+ api.post_process(DropExpectation)
)
yield (
api.test('noooooo')
+ api.step_data('run tests', api.json.output({'num_passed': 10}))
+ api.post_process(MustRun, 'deads!')
+ api.post_process(DropExpectation)
)
```
### 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](#placeholders) 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](https://chromium.googlesource.com/chromium/tools/build.git/+/master/scripts/slave/recipe_modules/raw_io/examples/full.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](https://chromium.googlesource.com/chromium/tools/build.git/+/master/scripts/slave/recipe_modules/json/examples/full.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](https://chromium.googlesource.com/chromium/tools/build.git/+/master/scripts/slave/recipe_modules/json/examples/full.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/slave/recipes.py doc`. It's super effective!
## How do I run those tests you were talking about?
Each repo has a recipes.py entry point under `recipes_path` from `recipes.cfg` .
Execute the following commands:
`./recipes.py test run`
`./recipes.py test train`
Specifically, for `tools/build` repo, the commands to execute are:
`scripts/slave/recipes.py test run`
`scripts/slave/recipes.py test train`
## Where's the docs on `*.py`?
Check the docstrings in `*.py`. `<trollface text="Problem?"/>`
In addition, most recipe modules have example recipes in the `examples`
subfolder which exercises most of the code in the module for example purposes.
## <a name="placeholders"></a> What are Placeholders and how do they work?
Placeholders are wrappers around inputs and outputs from recipe steps. They
provide a mocking mechanism for tests, and data-processing capabilities.
### Example
```python
step_result = api.python('run a cool script', 'really_cool_script.py',
['--json-output-file', api.json.output()],
ok_ret=(0,1))
print step_result.json.output
```
There's quite a bit of magic happening underlying these two lines of code. Let's
dive in.
`api.json.output()` returns an instance of `JsonOutputPlaceholder`.
`JsonOutputPlaceholder` is a subclass of `OutputPlaceholder`, and has two
relevant public methods: `render()` and `result()`. The recipe engine will
replace each instance of `OutputPlaceholder` in the arguments list with
`OutputPlaceholder.render()`. `JsonOutputPlaceholder` creates a file and returns
its name in `render()`. For this example, let's assume that `render()` returns
`/tmp/output.json`.
So in this case, the recipe engine will actually execute:
```
python really_cool_script.py --json-output-file /tmp/output.json
```
When the program returns, the recipe engine will call
`JsonOutputPlaceholder.result()` and seed the result into
`step_result.json.output`. Here, `json` refers to the name of the recipe module,
and `output` was the name of the function that returned the
`JsonOutputPlaceholder`.
The implementation of `JsonOutputPlaceholder.result()` will parse the JSON from
`/tmp/output.json`.
### Tests and Mocks
```python
yield api.test('test really_cool_script.py') +
api.step_data('run a cool script', api.json.output({'json': 'object'}))
```
This test case will stub out the actual invocation of `really_cool_script.py`
and directly populate the test dictionary into
`api.step.active_result.json.output`.
Behind the scenes, this works because the `json` module has defined a
`test_api.py` class with a method `output`. The invocation of `api.json.output`
is actually calling a different function than the prior call to
`api.json.output`.