Developing a “recipe” extension
Developing a “recipe” extension
The objective of this tutorial is to illustrate roles, directives and domains. Once complete, we will be able to use this extension to describe a recipe and reference that recipe from elsewhere in our documentation.
Note
This tutorial is based on a guide first published on opensource.com and is provided here with the original author’s permission.
Overview
We want the extension to add the following to Sphinx:
- A
recipe
directive, containing some content describing the recipe steps, along with a:contains:
option highlighting the main ingredients of the recipe. - A
ref
role, which provides a cross-reference to the recipe itself. - A
recipe
domain, which allows us to tie together the above role and domain, along with things like indices.
For that, we will need to add the following elements to Sphinx:
- A new directive called
recipe
- New indexes to allow us to reference ingredient and recipes
- A new domain called
recipe
, which will contain therecipe
directive andref
role
Prerequisites
We need the same setup as in the previous extensions. This time, we will be putting out extension in a file called recipe.py
.
Here is an example of the folder structure you might obtain:
└── source
├── _ext
│ └── recipe.py
├── conf.py
└── index.rst
Writing the extension
Open recipe.py
and paste the following code in it, all of which we will explain in detail shortly:
1from collections import defaultdict
2
3from docutils.parsers.rst import directives
4
5from sphinx import addnodes
6from sphinx.directives import ObjectDescription
7from sphinx.domains import Domain, Index
8from sphinx.roles import XRefRole
9from sphinx.util.nodes import make_refnode
10
11
12class RecipeDirective(ObjectDescription):
13 """A custom directive that describes a recipe."""
14
15 has_content = True
16 required_arguments = 1
17 option_spec = {
18 'contains': directives.unchanged_required,
19 }
20
21 def handle_signature(self, sig, signode):
22 signode += addnodes.desc_name(text=sig)
23 return sig
24
25 def add_target_and_index(self, name_cls, sig, signode):
26 signode['ids'].append('recipe' + '-' + sig)
27 if 'contains' not in self.options:
28 ingredients = [
29 x.strip() for x in self.options.get('contains').split(',')]
30
31 recipes = self.env.get_domain('recipe')
32 recipes.add_recipe(sig, ingredients)
33
34
35class IngredientIndex(Index):
36 """A custom index that creates an ingredient matrix."""
37
38 name = 'ingredient'
39 localname = 'Ingredient Index'
40 shortname = 'Ingredient'
41
42 def generate(self, docnames=None):
43 content = defaultdict(list)
44
45 recipes = {name: (dispname, typ, docname, anchor)
46 for name, dispname, typ, docname, anchor, _
47 in self.domain.get_objects()}
48 recipe_ingredients = self.domain.data['recipe_ingredients']
49 ingredient_recipes = defaultdict(list)
50
51 # flip from recipe_ingredients to ingredient_recipes
52 for recipe_name, ingredients in recipe_ingredients.items():
53 for ingredient in ingredients:
54 ingredient_recipes[ingredient].append(recipe_name)
55
56 # convert the mapping of ingredient to recipes to produce the expected
57 # output, shown below, using the ingredient name as a key to group
58 #
59 # name, subtype, docname, anchor, extra, qualifier, description
60 for ingredient, recipe_names in ingredient_recipes.items():
61 for recipe_name in recipe_names:
62 dispname, typ, docname, anchor = recipes[recipe_name]
63 content[ingredient].append(
64 (dispname, 0, docname, anchor, docname, '', typ))
65
66 # convert the dict to the sorted list of tuples expected
67 content = sorted(content.items())
68
69 return content, True
70
71
72class RecipeIndex(Index):
73 """A custom index that creates an recipe matrix."""
74
75 name = 'recipe'
76 localname = 'Recipe Index'
77 shortname = 'Recipe'
78
79 def generate(self, docnames=None):
80 content = defaultdict(list)
81
82 # sort the list of recipes in alphabetical order
83 recipes = self.domain.get_objects()
84 recipes = sorted(recipes, key=lambda recipe: recipe[0])
85
86 # generate the expected output, shown below, from the above using the
87 # first letter of the recipe as a key to group thing
88 #
89 # name, subtype, docname, anchor, extra, qualifier, description
90 for name, dispname, typ, docname, anchor, _ in recipes:
91 content[dispname[0].lower()].append(
92 (dispname, 0, docname, anchor, docname, '', typ))
93
94 # convert the dict to the sorted list of tuples expected
95 content = sorted(content.items())
96
97 return content, True
98
99
100class RecipeDomain(Domain):
101
102 name = 'recipe'
103 label = 'Recipe Sample'
104 roles = {
105 'ref': XRefRole()
106 }
107 directives = {
108 'recipe': RecipeDirective,
109 }
110 indices = {
111 RecipeIndex,
112 IngredientIndex
113 }
114 initial_data = {
115 'recipes': [], # object list
116 'recipe_ingredients': {}, # name -> object
117 }
118
119 def get_full_qualified_name(self, node):
120 return '{}.{}'.format('recipe', node.arguments[0])
121
122 def get_objects(self):
123 for obj in self.data['recipes']:
124 yield(obj)
125
126 def resolve_xref(self, env, fromdocname, builder, typ, target, node,
127 contnode):
128 match = [(docname, anchor)
129 for name, sig, typ, docname, anchor, prio
130 in self.get_objects() if sig == target]
131
132 if len(match) > 0:
133 todocname = match[0][0]
134 targ = match[0][1]
135
136 return make_refnode(builder, fromdocname, todocname, targ,
137 contnode, targ)
138 else:
139 print('Awww, found nothing')
140 return None
141
142 def add_recipe(self, signature, ingredients):
143 """Add a new recipe to the domain."""
144 name = '{}.{}'.format('recipe', signature)
145 anchor = 'recipe-{}'.format(signature)
146
147 self.data['recipe_ingredients'][name] = ingredients
148 # name, dispname, type, docname, anchor, priority
149 self.data['recipes'].append(
150 (name, signature, 'Recipe', self.env.docname, anchor, 0))
151
152
153def setup(app):
154 app.add_domain(RecipeDomain)
155
156 return {
157 'version': '0.1',
158 'parallel_read_safe': True,
159 'parallel_write_safe': True,
160 }
Let’s look at each piece of this extension step-by-step to explain what’s going on.
The directive class
The first thing to examine is the RecipeDirective
directive:
1class RecipeDirective(ObjectDescription):
2 """A custom directive that describes a recipe."""
3
4 has_content = True
5 required_arguments = 1
6 option_spec = {
7 'contains': directives.unchanged_required,
8 }
9
10 def handle_signature(self, sig, signode):
11 signode += addnodes.desc_name(text=sig)
12 return sig
13
14 def add_target_and_index(self, name_cls, sig, signode):
15 signode['ids'].append('recipe' + '-' + sig)
16 if 'contains' not in self.options:
17 ingredients = [
18 x.strip() for x in self.options.get('contains').split(',')]
19
20 recipes = self.env.get_domain('recipe')
21 recipes.add_recipe(sig, ingredients)
Unlike Developing a “Hello world” extension and Developing a “TODO” extension, this directive doesn’t derive from docutils.parsers.rst.Directive and doesn’t define a run
method. Instead, it derives from sphinx.directives.ObjectDescription
and defines handle_signature
and add_target_and_index
methods. This is because ObjectDescription
is a special-purpose directive that’s intended for describing things like classes, functions, or, in our case, recipes. More specifically, handle_signature
implements parsing the signature of the directive and passes on the object’s name and type to its superclass, while add_taget_and_index
adds a target (to link to) and an entry to the index for this node.
We also see that this directive defines has_content
, required_arguments
and option_spec
. Unlike the TodoDirective
directive added in the previous tutorial, this directive takes a single argument, the recipe name, and an option, contains
, in addition to the nested reStructuredText in the body.
The index classes
Todo
Add brief overview of indices
1class IngredientIndex(Index):
2 """A custom index that creates an ingredient matrix."""
3
4 name = 'ingredient'
5 localname = 'Ingredient Index'
6 shortname = 'Ingredient'
7
8 def generate(self, docnames=None):
9 content = defaultdict(list)
10
11 recipes = {name: (dispname, typ, docname, anchor)
12 for name, dispname, typ, docname, anchor, _
13 in self.domain.get_objects()}
14 recipe_ingredients = self.domain.data['recipe_ingredients']
15 ingredient_recipes = defaultdict(list)
16
17 # flip from recipe_ingredients to ingredient_recipes
18 for recipe_name, ingredients in recipe_ingredients.items():
19 for ingredient in ingredients:
20 ingredient_recipes[ingredient].append(recipe_name)
21
22 # convert the mapping of ingredient to recipes to produce the expected
23 # output, shown below, using the ingredient name as a key to group
24 #
25 # name, subtype, docname, anchor, extra, qualifier, description
26 for ingredient, recipe_names in ingredient_recipes.items():
27 for recipe_name in recipe_names:
28 dispname, typ, docname, anchor = recipes[recipe_name]
29 content[ingredient].append(
30 (dispname, 0, docname, anchor, docname, '', typ))
31
32 # convert the dict to the sorted list of tuples expected
33 content = sorted(content.items())
34
35 return content, True
1class RecipeIndex(Index):
2 """A custom index that creates an recipe matrix."""
3
4 name = 'recipe'
5 localname = 'Recipe Index'
6 shortname = 'Recipe'
7
8 def generate(self, docnames=None):
9 content = defaultdict(list)
10
11 # sort the list of recipes in alphabetical order
12 recipes = self.domain.get_objects()
13 recipes = sorted(recipes, key=lambda recipe: recipe[0])
14
15 # generate the expected output, shown below, from the above using the
16 # first letter of the recipe as a key to group thing
17 #
18 # name, subtype, docname, anchor, extra, qualifier, description
19 for name, dispname, typ, docname, anchor, _ in recipes:
20 content[dispname[0].lower()].append(
21 (dispname, 0, docname, anchor, docname, '', typ))
22
23 # convert the dict to the sorted list of tuples expected
24 content = sorted(content.items())
25
26 return content, True
Both IngredientIndex
and RecipeIndex
are derived from Index. They implement custom logic to generate a tuple of values that define the index. Note that RecipeIndex
is a simple index that has only one entry. Extending it to cover more object types is not yet part of the code.
Both indices use the method Index.generate() to do their work. This method combines the information from our domain, sorts it, and returns it in a list structure that will be accepted by Sphinx. This might look complicated but all it really is is a list of tuples like ('tomato', 'TomatoSoup', 'test', 'rec-TomatoSoup',...)
. Refer to the domain API guide for more information on this API.
These index pages can be referred by combination of domain name and its name
using ref role. For example, RecipeIndex
can be referred by :ref:`recipe-recipe`
.
The domain
A Sphinx domain is a specialized container that ties together roles, directives, and indices, among other things. Let’s look at the domain we’re creating here.
1class RecipeDomain(Domain):
2
3 name = 'recipe'
4 label = 'Recipe Sample'
5 roles = {
6 'ref': XRefRole()
7 }
8 directives = {
9 'recipe': RecipeDirective,
10 }
11 indices = {
12 RecipeIndex,
13 IngredientIndex
14 }
15 initial_data = {
16 'recipes': [], # object list
17 'recipe_ingredients': {}, # name -> object
18 }
19
20 def get_full_qualified_name(self, node):
21 return '{}.{}'.format('recipe', node.arguments[0])
22
23 def get_objects(self):
24 for obj in self.data['recipes']:
25 yield(obj)
26
27 def resolve_xref(self, env, fromdocname, builder, typ, target, node,
28 contnode):
29 match = [(docname, anchor)
30 for name, sig, typ, docname, anchor, prio
31 in self.get_objects() if sig == target]
32
33 if len(match) > 0:
34 todocname = match[0][0]
35 targ = match[0][1]
36
37 return make_refnode(builder, fromdocname, todocname, targ,
38 contnode, targ)
39 else:
40 print('Awww, found nothing')
41 return None
42
43 def add_recipe(self, signature, ingredients):
44 """Add a new recipe to the domain."""
45 name = '{}.{}'.format('recipe', signature)
46 anchor = 'recipe-{}'.format(signature)
47
48 self.data['recipe_ingredients'][name] = ingredients
49 # name, dispname, type, docname, anchor, priority
50 self.data['recipes'].append(
51 (name, signature, 'Recipe', self.env.docname, anchor, 0))
There are some interesting things to note about this recipe
domain and domains in general. Firstly, we actually register our directives, roles and indices here, via the directives
, roles
and indices
attributes, rather than via calls later on in setup
. We can also note that we aren’t actually defining a custom role and are instead reusing the sphinx.roles.XRefRole
role and defining the sphinx.domains.Domain.resolve_xref method. This method takes two arguments, typ
and target
, which refer to the cross-reference type and its target name. We’ll use target
to resolve our destination from our domain’s recipes
because we currently have only one type of node.
Moving on, we can see that we’ve defined initial_data
. The values defined in initial_data
will be copied to env.domaindata[domain_name]
as the initial data of the domain, and domain instances can access it via self.data
. We see that we have defined two items in initial_data
: recipes
and recipe2ingredient
. These contain a list of all objects defined (i.e. all recipes) and a hash that maps a canonical ingredient name to the list of objects. The way we name objects is common across our extension and is defined in the get_full_qualified_name
method. For each object created, the canonical name is recipe.
, where is the name the documentation writer gives the object (a recipe). This enables the extension to use different object types that share the same name. Having a canonical name and central place for our objects is a huge advantage. Both our indices and our cross-referencing code use this feature.
The setup
function
As always, the setup
function is a requirement and is used to hook the various parts of our extension into Sphinx. Let’s look at the setup
function for this extension.
1def setup(app):
2 app.add_domain(RecipeDomain)
3
4 return {
5 'version': '0.1',
6 'parallel_read_safe': True,
7 'parallel_write_safe': True,
8 }
This looks a little different to what we’re used to seeing. There are no calls to add_directive() or even add_role(). Instead, we have a single call to add_domain() followed by some initialization of the standard domain. This is because we had already registered our directives, roles and indexes as part of the directive itself.
Using the extension
You can now use the extension throughout your project. For example:
index.rst
Joe's Recipes
=============
Below are a collection of my favourite recipes. I highly recommend the
:recipe:ref:`TomatoSoup` recipe in particular!
.. toctree::
tomato-soup
tomato-soup.rst
The recipe contains `tomato` and `cilantro`.
.. recipe:recipe:: TomatoSoup
:contains: tomato, cilantro, salt, pepper
This recipe is a tasty tomato soup, combine all ingredients
and cook.
The important things to note are the use of the :recipe:ref:
role to cross-reference the recipe actually defined elsewhere (using the :recipe:recipe:
directive.
Further reading
For more information, refer to the docutils documentation and Developing extensions for Sphinx.