| OLD | NEW |
| (Empty) |
| 1 Recipes | |
| 2 ======= | |
| 3 Recipes are a flexible way to specify How to Do Things, without knowing too much | |
| 4 about those Things. | |
| 5 | |
| 6 | |
| 7 Background | |
| 8 ---------- | |
| 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 reduces the number of master restarts needed when changing build | |
| 15 configuration. | |
| 16 | |
| 17 Intro | |
| 18 ----- | |
| 19 This README will seek to teach the ways of Recipes, so that you may do one or | |
| 20 more of the following: | |
| 21 | |
| 22 * Read them | |
| 23 * Make new recipes | |
| 24 * Fix bugs in recipes | |
| 25 * Create libraries (api modules) for others to use in their recipes. | |
| 26 | |
| 27 The document will build knowledge up in small steps using examples, and so it's | |
| 28 probably best to read the whole doc through from top to bottom once before using | |
| 29 it as a reference. | |
| 30 | |
| 31 | |
| 32 Small Beginnings | |
| 33 ---------------- | |
| 34 **Recipes are a means to cause a series of actions to occur on a machine.** | |
| 35 | |
| 36 All recipes take the form of a python file whose body looks like this: | |
| 37 | |
| 38 ```python | |
| 39 def GenSteps(api): | |
| 40 yield { | |
| 41 'name': 'Hello World', | |
| 42 'cmd': ['/b/build/echo.sh', 'hello', 'world'] | |
| 43 } | |
| 44 ``` | |
| 45 | |
| 46 The GenSteps function is expected to take a single argument `api` (we'll get to | |
| 47 that in more detail later), and yield a series of 'stepish' items. A stepish | |
| 48 item can be: | |
| 49 | |
| 50 * A single step (a dictionary as in the example above) | |
| 51 * A series (list or tuple) of stepish items | |
| 52 * A python-generator of stepish items | |
| 53 | |
| 54 | |
| 55 We should probably test as we go... | |
| 56 ----------------------------------- | |
| 57 **All recipes MUST have corresponding tests, which achieve 100% code coverage.** | |
| 58 | |
| 59 So, we have our recipe. Let's add a test to it. | |
| 60 | |
| 61 ```python | |
| 62 def GenSteps(api): | |
| 63 yield { | |
| 64 'name': 'Hello World', | |
| 65 'cmd': ['/b/build/echo.sh', 'hello', 'world'] | |
| 66 } | |
| 67 | |
| 68 def GenTests(api): | |
| 69 yield 'basic', {} | |
| 70 ``` | |
| 71 | |
| 72 This causes a single test case to be generated, called 'basic', which has no | |
| 73 input parameters. As your recipe becomes more complex, you'll need to add more | |
| 74 tests to make sure that you maintain 100% code coverage. | |
| 75 | |
| 76 | |
| 77 Let's do something useful | |
| 78 ------------------------- | |
| 79 **Properties is the primary input for your recipes.** | |
| 80 | |
| 81 In order to do something useful, we need to pull in parameters from the outside | |
| 82 world. There's one primary source of input for recipes, which is `properties`. | |
| 83 | |
| 84 `properties` are a relic from the days of BuildBot, though they have been | |
| 85 dressed up a bit to be more like we'll want them in the future. If you're | |
| 86 familiar with BuildBot, you'll probably know them as `factory_properties` and | |
| 87 `build_properties`. The new `properties` object is a merging of these two, and | |
| 88 is provided by the `properties` api module. | |
| 89 | |
| 90 ```python | |
| 91 DEPS = ['properties'] | |
| 92 | |
| 93 def GenSteps(api): | |
| 94 verb = 'Hello, %s' | |
| 95 target = api.properties['target_of_admiration'] | |
| 96 if target == 'DarthVader': | |
| 97 verb = 'Die in a fire, %s!' | |
| 98 yield {'name': 'Hello World', 'cmd': ['/b/build/echo.sh', verb % target]} | |
| 99 | |
| 100 def GenTests(api): | |
| 101 yield 'basic', { | |
| 102 'properties': {'target_of_admiration': 'Bob'} | |
| 103 } | |
| 104 | |
| 105 yield 'vader', { | |
| 106 'properties': {'target_of_admiration': 'DarthVader'} | |
| 107 } | |
| 108 ``` | |
| 109 | |
| 110 Ok, I lied. It wasn't very useful. | |
| 111 | |
| 112 | |
| 113 Let's make it a bit prettier | |
| 114 ---------------------------- | |
| 115 **You can add modules to your recipe by simply adding them to DEPS.** | |
| 116 | |
| 117 So there are all sorts of helper modules. I'll add in the 'step' and 'path' | |
| 118 modules here as an example. | |
| 119 | |
| 120 ```python | |
| 121 DEPS = ['properties', 'step', 'path'] | |
| 122 | |
| 123 def GenSteps(api): | |
| 124 verb = 'Hello, %s' | |
| 125 target = api.properties['target_of_admiration'] | |
| 126 if target == 'DarthVader': | |
| 127 verb = 'Die in a fire, %s!' | |
| 128 yield api.step('Hello World', [api.path['build'].join('echo.sh'), | |
| 129 verb % target]) | |
| 130 | |
| 131 def GenTests(api): | |
| 132 yield 'basic', { | |
| 133 'properties': {'target_of_admiration': 'Bob'} | |
| 134 } | |
| 135 | |
| 136 yield 'vader', { | |
| 137 'properties': {'target_of_admiration': 'DarthVader'} | |
| 138 } | |
| 139 ``` | |
| 140 | |
| 141 Notice the `DEPS` line in the recipe. Any modules named by string in DEPS are | |
| 142 'injected' into the `api` parameter that your recipe gets. If you leave them out | |
| 143 of DEPS, you'll get an AttributeError when you try to access them. The modules | |
| 144 are located primarily in `recipe_modules/`, and their name is their folder name. | |
| 145 | |
| 146 > The full list of module locations which get added are in `recipe_util.py` in | |
| 147 > the `MODULE_DIRS` variable. | |
| 148 | |
| 149 There are a whole bunch of modules which provide really helpful tools. You | |
| 150 should go take a look at them. `show_me_the_modules.py` is a pretty helpful | |
| 151 tool. If you want to know more about properties, step and path, I would suggest | |
| 152 starting with `show_me_the_modules.py`, and then delving into the docstrings in | |
| 153 those modules. | |
| 154 | |
| 155 | |
| 156 Making Modules | |
| 157 -------------- | |
| 158 **Modules are for grouping functionality together and exposing it across | |
| 159 recipes.** | |
| 160 | |
| 161 So now you feel like you're pretty good at recipes, but you want to share your | |
| 162 echo functionality across a couple recipes which all start the same way. To do | |
| 163 this, you need to add a module directory. | |
| 164 | |
| 165 ``` | |
| 166 recipe_modules/ | |
| 167 step/ | |
| 168 properties/ | |
| 169 path/ | |
| 170 hello/ | |
| 171 __init__.py # (Required) Contains optional `DEPS = list([other modules])` | |
| 172 api.py # (Required) Contains single required RecipeApi-derived class | |
| 173 config.py # (Optional) Contains configuration for your api | |
| 174 *_config.py # (Optional) These contain extensions to the configurations of | |
| 175 # your dependency APIs | |
| 176 ``` | |
| 177 | |
| 178 First add an `__init__.py` with DEPS: | |
| 179 | |
| 180 ```python | |
| 181 # recipe_modules/hello/__init__.py | |
| 182 DEPS = ['properties', 'path', 'step'] | |
| 183 ``` | |
| 184 | |
| 185 And your api.py should look something like: | |
| 186 | |
| 187 ```python | |
| 188 from slave import recipe_api | |
| 189 | |
| 190 class HelloApi(recipe_api.RecipeApi): | |
| 191 def greet(self, default_verb=None, target=None): | |
| 192 verb = default_verb or 'Hello %s' | |
| 193 target = target or self.m.properties['target_of_admiration'] | |
| 194 if target == 'DarthVader': | |
| 195 verb = 'Die in a fire %s!' | |
| 196 yield self.m.step('Hello World', | |
| 197 [self.m.path.build('echo.sh'), verb % target]) | |
| 198 ``` | |
| 199 | |
| 200 See that all the DEPS get injected into `self.m`. This logic is handled outside | |
| 201 of the object (i.e. not in `__init__`) in the loading function creatively named | |
| 202 `load_recipe_modules()`, which resides in `recipe_api.py`. | |
| 203 | |
| 204 > Because dependencies are injected after module initialization, *you do not hav
e | |
| 205 > access to injected modules in your APIs `__init__` method*! | |
| 206 | |
| 207 And now, our refactored recipe: | |
| 208 | |
| 209 ```python | |
| 210 DEPS = ['hello'] | |
| 211 | |
| 212 def GenSteps(api): | |
| 213 yield api.hello.greet() | |
| 214 | |
| 215 def GenTests(api): | |
| 216 yield 'basic', { | |
| 217 'properties': {'target_of_admiration': 'Bob'} | |
| 218 } | |
| 219 | |
| 220 yield 'vader', { | |
| 221 'properties': {'target_of_admiration': 'DarthVader'} | |
| 222 } | |
| 223 ``` | |
| 224 | |
| 225 > NOTE: all of the modules are also under code coverage, but you only need some | |
| 226 > test SOMEWHERE to cover each line. | |
| 227 | |
| 228 | |
| 229 So how do I really write those tests? | |
| 230 ------------------------------------- | |
| 231 **Tests are yielded as a (name, test data) tuple.** | |
| 232 | |
| 233 The basic form of tests is: | |
| 234 ```python | |
| 235 def GenTests(api): | |
| 236 yield 'testname', { | |
| 237 # Test data | |
| 238 } | |
| 239 ``` | |
| 240 | |
| 241 Test data can contain any of the following keys: | |
| 242 * *properties*: This represents the merged factory properties and build | |
| 243 properties which will show up as api.properties for the duration of the | |
| 244 test. This dictionary is in simple `{<prop name>: <prop value>}` form. | |
| 245 * *mock*: Some modules need to have their behavior altered before the recipe | |
| 246 starts. For example, you could mock which platform is being tested, or mock | |
| 247 which paths exist. This dictionary is in the form of `{<mod name>: <mod | |
| 248 data>}`. See module docstrings to see what they accept for mocks. | |
| 249 * *step_mocks*: This is a dictionary which defines the mock data for | |
| 250 various `recipe_api.Placeholder` objects. These are explained more in | |
| 251 a later section. This dictionary is in the form of `{<step name>: {<mod | |
| 252 name>: <mod data>}}` | |
| 253 * There is one 'special' mod name, which is '$R'. This module refers to the | |
| 254 return code of the step, and takes an integer. If it is missing, it is | |
| 255 assumed that the step succeeded with a retcode of 0. | |
| 256 | |
| 257 The `api` passed to GenTests is confusingly **NOT** the same as the recipe api. | |
| 258 It's actually an instance of `recipe_test_api.py:RecipeTestApi()`. This is | |
| 259 admittedly pretty weak, and it would be great to have the test api | |
| 260 automatically created via modules. On the flip side, the test api is much less | |
| 261 necessary than the recipe api, so this transformation has not been designed yet. | |
| 262 | |
| 263 | |
| 264 What is that config business? | |
| 265 ----------------------------- | |
| 266 **Configs are a way for a module to expose it's "global" state in a reusable | |
| 267 way.** | |
| 268 | |
| 269 A common problem in Building Things is that you end up with an inordinantly | |
| 270 large matrix of configurations. Let's take chromium, for example. Here is a | |
| 271 sample list of axes of configuration which chromium needs to build and test: | |
| 272 | |
| 273 * BUILD_CONFIG | |
| 274 * HOST_PLATFORM | |
| 275 * HOST_ARCH | |
| 276 * HOST_BITS | |
| 277 * TARGET_PLATFORM | |
| 278 * TARGET_ARCH | |
| 279 * TARGET_BITS | |
| 280 * builder type (ninja? msvs? xcodebuild?) | |
| 281 * compiler | |
| 282 * ... | |
| 283 | |
| 284 Obviously there are a lot of combinations of those things, but only a relatively | |
| 285 small number of *valid* combinations of those things. How can we represent all | |
| 286 the valid states while still retaining our sanity? | |
| 287 | |
| 288 ```python | |
| 289 # recipe_modules/hello/config.py | |
| 290 from slave.recipe_config import config_item_context, ConfigGroup | |
| 291 from slave.recipe_config import SimpleConfig, StaticConfig, BadConf | |
| 292 | |
| 293 def BaseConfig(TARGET='Bob'): | |
| 294 # This is a schema for the 'config blobs' that the hello module deals with. | |
| 295 return ConfigGroup( | |
| 296 verb = SimpleConfig(str), | |
| 297 # A config blob is not complete() until all required entries have a value. | |
| 298 tool = SimpleConfig(str, required=True), | |
| 299 # Generally, your schema should take a series of CAPITAL args which will be | |
| 300 # set as StaticConfig data in the config blob. | |
| 301 TARGET = StaticConfig(str(TARGET)), | |
| 302 ) | |
| 303 | |
| 304 VAR_TEST_MAP = { | |
| 305 'TARGET': ('Bob', 'DarthVader', 'Charlie'), | |
| 306 } | |
| 307 config_ctx = config_item_context(BaseConfig, VAR_TEST_MAP, '%(TARGET)s') | |
| 308 | |
| 309 # Each of these functions is a 'config item' in the context of config_ctx. | |
| 310 | |
| 311 # is_root means that every config item will apply this item first. | |
| 312 @config_ctx(is_root=True) | |
| 313 def BASE(c): | |
| 314 if c.TARGET == 'DarthVader': | |
| 315 c.verb = 'Die in a fire, %s!' | |
| 316 else: | |
| 317 c.verb = 'Hello, %s' | |
| 318 | |
| 319 @config_ctx(group='tool'): # items with the same group are mutually exclusive. | |
| 320 def super_tool(c): | |
| 321 if c.TARGET != 'Charlie': | |
| 322 raise BadConf('Can only use super tool for Charlie!') | |
| 323 c.tool = 'unicorn.py' | |
| 324 | |
| 325 @config_ctx(group='tool'): | |
| 326 def default_tool(c): | |
| 327 c.tool = 'echo.sh' | |
| 328 ``` | |
| 329 | |
| 330 Ok, this config file looks a bit intimidating. Let's decompose it. The first | |
| 331 portion is the schema `BaseConfig`. This is expected to return a ConfigGroup | |
| 332 instance of some sort. All the configs that you get out of this file will be | |
| 333 a modified version something returned by the schema method. The arguments should | |
| 334 have sane defaults, and should be named in `ALL_CAPS` (this is to avoid argument | |
| 335 name conflicts as we'll see later). | |
| 336 | |
| 337 The `VAR_TEST_MAP` is a mapping of argument name for the schema (in this case, | |
| 338 just 'TARGET'), to a sequence of values to test. The test harness will | |
| 339 automatically generate the product of all arguments in this map, and will run | |
| 340 through each config function in the file to generate the expected config blob | |
| 341 for each. In this config, it would essentially be: | |
| 342 | |
| 343 ```python | |
| 344 for TARGET in ('Bob', 'DarthVader', 'Charlie'): | |
| 345 for test_function in (super_tool, default_tool): | |
| 346 yield TestCase(test_function(BasicSchema(TARGET))) | |
| 347 ``` | |
| 348 | |
| 349 Next, we get to the `config_ctx`. This is the 'context' for all the config | |
| 350 items in this file, and will become the `CONFIG_CTX` for the entire module. | |
| 351 Other modules may add into this config context (for example, they could have | |
| 352 a `hello_config.py` file, which imports this config context like | |
| 353 | |
| 354 ```python | |
| 355 import DEPS | |
| 356 CONFIG_CTX = DEPS['hello'].CONFIG_CTX | |
| 357 ``` | |
| 358 | |
| 359 | |
| 360 This will be useful for separation of concerns with the `set_config()` | |
| 361 method.). The string format argument that `config_item_context` takes will be | |
| 362 used to format the test case names and test expectation file names. Not | |
| 363 terribly useful here, but it can be useful for making the test names more | |
| 364 obvious in more complex cases. | |
| 365 | |
| 366 Finally we get to the config items themselves. A config item is a function | |
| 367 decorated with the `config_ctx`, and takes a config blob as 'c'. The config item | |
| 368 updates the config blob, perhaps conditionally. There are many features to | |
| 369 `slave/recipe_config.py`. I would recommend reading the docstrings there | |
| 370 for all the details. | |
| 371 | |
| 372 Now that we have our config, let's use it. | |
| 373 | |
| 374 ```python | |
| 375 # recipe_modules/hello/api.py | |
| 376 from slave import recipe_api | |
| 377 | |
| 378 class HelloApi(recipe_api.RecipeApi): | |
| 379 def get_config_defaults(self, _config_name): | |
| 380 return {'TARGET': self.m.properties['target_of_admiration']} | |
| 381 | |
| 382 def greet(self): | |
| 383 yield self.m.step('Hello World', [ | |
| 384 self.m.path.build(self.c.tool), self.c.verb % self.c.TARGET]) | |
| 385 ``` | |
| 386 | |
| 387 Note that `recipe_api.RecipeApi` contains all the plumbing for dealing with | |
| 388 configs. If your module has a config, you can access its current value via | |
| 389 `self.c`. The users of your module (read: recipes) will need to set this value | |
| 390 in one way or another. Also note that c is a 'public' variable, which means that | |
| 391 recipes have direct access to the configuration state by `api.<modname>.c`. | |
| 392 | |
| 393 ```python | |
| 394 # recipes/hello.py | |
| 395 DEPS = ['hello'] | |
| 396 def GenSteps(api): | |
| 397 api.hello.set_config('default_tool') | |
| 398 yield api.hello.greet() # Greets 'target_of_admiration' or 'Bob' with echo.sh
. | |
| 399 | |
| 400 def GenTests(api): | |
| 401 yield 'bob', {} | |
| 402 yield 'anya', {'properties': {'target_of_admiration': 'anya'}} | |
| 403 ``` | |
| 404 | |
| 405 Note the call to `set_config`. This method takes the configuration name | |
| 406 specifed, finds it in the given module (`'hello'` in this case), and sets | |
| 407 `api.hello.c` equal to the result of invoking the named config item | |
| 408 (`'default_tool'`) with the default configuration (the result of calling | |
| 409 `get_config_defaults`), merged over the static defaults specified by the schema. | |
| 410 In this case, the schema will be initialized by essentially the following calls: | |
| 411 | |
| 412 ```python | |
| 413 raw = BaseConfig(**api.hello.get_config_defaults()) | |
| 414 api.hello.c = default_tool(BASE(raw)) | |
| 415 ``` | |
| 416 | |
| 417 We can also call `set_config` differently to get different results: | |
| 418 | |
| 419 ```python | |
| 420 # recipes/rainbow_hello.py | |
| 421 DEPS = ['hello'] | |
| 422 def GenSteps(api): | |
| 423 api.hello.set_config('super_tool', TARGET='Charlie') | |
| 424 yield api.hello.greet() # Greets 'Charlie' with unicorn.py. | |
| 425 | |
| 426 def GenTests(api): | |
| 427 yield 'charlie', {} | |
| 428 ``` | |
| 429 | |
| 430 ```python | |
| 431 # recipes/evil_hello.py | |
| 432 DEPS = ['hello'] | |
| 433 def GenSteps(api): | |
| 434 api.hello.set_config('default_tool', TARGET='DarthVader') | |
| 435 yield api.hello.greet() # Causes 'DarthVader' to despair with echo.sh | |
| 436 | |
| 437 def GenTests(api): | |
| 438 yield 'darth', {} | |
| 439 ``` | |
| 440 | |
| 441 `set_config()` also has one additional bit of magic. If a module (say, | |
| 442 chromium), depends on some other modules (say, gclient), if you do | |
| 443 `api.chromium.set_config('blink')`, it will apply the 'blink' config item from | |
| 444 the chromium module, but it will also attempt to apply the 'blink' config for | |
| 445 all the dependencies, too. This way, you can have the chromium module extend the | |
| 446 gclient config context with a 'blink' config item, and then set_configs will | |
| 447 stack across all the relevent contexts. | |
| 448 | |
| 449 `recipe_api.RecipeApi` also provides `make_config` and `apply_config`, which | |
| 450 allow recipes more-direct access to the config items. However, `set_config()` is | |
| 451 the most-preferred way to apply configurations. | |
| 452 | |
| 453 | |
| 454 What about getting data back from a step? | |
| 455 ----------------------------------------- | |
| 456 **If you need you recipe to be conditional on something that a step does, you'll | |
| 457 need to make use of the `step_history` api.** | |
| 458 | |
| 459 Consider this recipe: | |
| 460 ```python | |
| 461 DEPS = ['step', 'path', 'step_history'] | |
| 462 | |
| 463 def GenSteps(api): | |
| 464 yield api.step('Determine blue moon', [api.path['build'].join('is_blue_moon.sh
')]) | |
| 465 if api.step_history.last_step().retcode == 0: | |
| 466 yield api.step('HARLEM SHAKE!', [api.path['build'].join('do_the_harlem_shake
.sh')]) | |
| 467 else: | |
| 468 yield api.step('Boring', [api.path['build'].join('its_a_small_world.sh')]) | |
| 469 | |
| 470 def GenTests(api): | |
| 471 yield 'harlem', { | |
| 472 'step_mocks': {'Determine blue moon': {'$R': 0}} | |
| 473 } | |
| 474 yield 'boring', { | |
| 475 'step_mocks': {'Determine blue moon': {'$R': 1}} | |
| 476 } | |
| 477 ``` | |
| 478 | |
| 479 See how we use `step_history` to get the result of the last step? The item we | |
| 480 get back is an `annotated_run.RecipeData` instance (really, just a basic object | |
| 481 with member data). The members of this object which are guaranteed to exist are: | |
| 482 * retcode: Pretty much what you think | |
| 483 * step: The actual step json which was sent to `annotator.py`. Not usually | |
| 484 useful for recipes, but it is used internally for the recipe tests | |
| 485 framework. | |
| 486 | |
| 487 This is pretty neat... However, it turns out that returncodes suck bigtime for | |
| 488 communicating actual information. `api.json.output()` to the rescue! | |
| 489 | |
| 490 ```python | |
| 491 DEPS = ['step', 'path', 'step_history', 'json'] | |
| 492 | |
| 493 def GenSteps(api): | |
| 494 yield api.step('run tests', [ | |
| 495 api.path['build'].join('do_test_things.sh'), api.json.output()]) | |
| 496 num_passed = api.step_history.last_step().json.output['num_passed'] | |
| 497 if num_passed > 500: | |
| 498 yield api.step('victory', [api.path['build'].join('do_a_dance.sh')]) | |
| 499 elif num_passed > 200: | |
| 500 yield api.step('not defeated', [api.path['build'].join('woohoo.sh')]) | |
| 501 else: | |
| 502 yield api.step('deads!', [api.path['build'].join('you_r_deads.sh')]) | |
| 503 | |
| 504 def GenTests(api): | |
| 505 yield 'winning', { | |
| 506 'step_mocks': {'run tests': {'json': {'output': {'num_passed': 791}}}} | |
| 507 } | |
| 508 yield 'not_dead_yet', { | |
| 509 'step_mocks': {'run tests': {'json': {'output': {'num_passed': 302}}}} | |
| 510 } | |
| 511 yield 'nooooo', { | |
| 512 'step_mocks': {'run tests': {'json': {'output': {'num_passed': 10}}}} | |
| 513 } | |
| 514 ``` | |
| 515 | |
| 516 How does THAT work!? | |
| 517 | |
| 518 `api.json.output()` returns a `recipe_api.Placeholder` which is meant to be | |
| 519 added into a step command list. When the step runs, the placeholder gets | |
| 520 rendered into some strings (in this case, like `['--output-json', | |
| 521 '/tmp/some392ra8'`]). When the step finishes, the Placeholder is allowed to add | |
| 522 data to the step history for the step which just ran, namespaced by the module | |
| 523 name (in this case, the 'json' module decided to add an 'output' attribute to | |
| 524 the `step_history` item). I'd encourage you to take a peek at the implementation | |
| 525 of the json module to see how this is implemented. | |
| 526 | |
| 527 | |
| 528 How do I know what modules to use? | |
| 529 ---------------------------------- | |
| 530 Use `tools/show_me_the_modules.py`. It's super effective! | |
| 531 | |
| 532 | |
| 533 How do I run those tests you were talking about? | |
| 534 ------------------------------------------------ | |
| 535 To test all the recipes/apis, use `slave/unittests/recipe_simulation_test.py`. | |
| 536 To set new expectations `slave/unittests/recipe_simulation_test.py train`. | |
| 537 | |
| 538 | |
| 539 | |
| 540 Where's the docs on `*.py`? | |
| 541 -------------------------------------------- | |
| 542 Check the docstrings in `*.py`. `<trollface text="Problem?"/>` | |
| 543 | |
| 544 In addition, most recipe modules have an `example.py` file which exercises most | |
| 545 of the code in the module for both test coverage and example purposes. | |
| 546 | |
| 547 If you want to know what keys a step dictionary can take, take a look at | |
| 548 `common/annotator.py`. | |
| 549 | |
| OLD | NEW |