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
recipedirective, containing some content describing the recipe steps, along with a:contains:option highlighting the main ingredients of the recipe.A
refrole, which provides a cross-reference to the recipe itself.A
recipedomain, 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
recipeNew indexes to allow us to reference ingredient and recipes
A new domain called
recipe, which will contain therecipedirective andrefrole
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' 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, _priority 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        yield from self.data['recipes']
124
125    def resolve_xref(self, env, fromdocname, builder, typ, target, node,
126                     contnode):
127        match = [(docname, anchor)
128                 for name, sig, typ, docname, anchor, prio
129                 in self.get_objects() if sig == target]
130
131        if len(match) > 0:
132            todocname = match[0][0]
133            targ = match[0][1]
134
135            return make_refnode(builder, fromdocname, todocname, targ,
136                                contnode, targ)
137        else:
138            print('Awww, found nothing')
139            return None
140
141    def add_recipe(self, signature, ingredients):
142        """Add a new recipe to the domain."""
143        name = '{}.{}'.format('recipe', signature)
144        anchor = 'recipe-{}'.format(signature)
145
146        self.data['recipe_ingredients'][name] = ingredients
147        # name, dispname, type, docname, anchor, priority
148        self.data['recipes'].append(
149            (name, signature, 'Recipe', self.env.docname, anchor, 0))
150
151
152def setup(app):
153    app.add_domain(RecipeDomain)
154
155    return {
156        'version': '0.1',
157        'parallel_read_safe': True,
158        'parallel_write_safe': True,
159    }
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' 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, _priority 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        yield from self.data['recipes']
25
26    def resolve_xref(self, env, fromdocname, builder, typ, target, node,
27                     contnode):
28        match = [(docname, anchor)
29                 for name, sig, typ, docname, anchor, prio
30                 in self.get_objects() if sig == target]
31
32        if len(match) > 0:
33            todocname = match[0][0]
34            targ = match[0][1]
35
36            return make_refnode(builder, fromdocname, todocname, targ,
37                                contnode, targ)
38        else:
39            print('Awww, found nothing')
40            return None
41
42    def add_recipe(self, signature, ingredients):
43        """Add a new recipe to the domain."""
44        name = '{}.{}'.format('recipe', signature)
45        anchor = 'recipe-{}'.format(signature)
46
47        self.data['recipe_ingredients'][name] = ingredients
48        # name, dispname, type, docname, anchor, priority
49        self.data['recipes'].append(
50            (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.<recipename>, where <recipename> 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:
Joe's Recipes
=============
Below are a collection of my favourite recipes. I highly recommend the
:recipe:ref:`TomatoSoup` recipe in particular!
.. toctree::
   tomato-soup
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.