开发 “recipe” 插件

本教程的目的是说明角色、指令和域。一旦完成,我们将能够使用这个插件来描述食谱,并在我们的文档的其他地方引用该食谱。

备注

本教程基于第一次发布在 opensource.com 的指南,并在原始作者的允许下提供。

概述

我们希望扩展添加以下 Sphinx:

  • recipe directive,包含一些描述食谱步骤的内容,以及 :contains: 选项,突出食谱的主要成分。

  • ref role,它提供了对配方本身的交叉引用。

  • recipe domain,它允许我们把上面的角色和域联系在一起,还有索引之类的东西。

为此,我们需要向 Sphinx 添加以下元素:

  • 叫做 recipe 的新指令

  • 新的索引允许我们参考 ingredient 和食谱

  • 名为 recipe 的新域名,它将包含 recipe 指令和 ref 角色

准备

我们需要相同的设置,如在 以前的插件。这一次,我们将把插件放在一个名为 recipe.py 的文件中。

下面是一个你可能获得的文件夹结构的例子:

└── source
    ├── _ext
    │   └── recipe.py
    ├── conf.py
    └── index.rst

编写插件

打开 recipe.py,然后粘贴下面的代码进去,我们稍后会详细解释:

  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        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    }

让我们看看这个插件的每一块一步一步地解释发生了什么。

指令类

首先要检查的是 RecipeDirective 指令:

 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)

不像 开发一个 “Hello world” 插件开发 “TODO” 插件 ,这个指令不是从 docutils.parsers.rst.Directive 派生出来的,没有定义 run 方法。相反,它源自 sphinx.directives.ObjectDescription 对象描述并定义了 handle_signatureadd_target_and_index 方法。这是因为 ObjectDescription 是一个特殊用途的指令,旨在描述诸如类、函数或在我们的例子中,食谱之类的东西。更具体地说, handle_signature 实现了对指令签名的解析,并将对象的名称和类型传递给它的超类,而 add_taget_and_index 则为该节点的索引添加了一个目标(要链接到)和一个条目。

我们还看到这个指令定义了 has_contentrequired_argumentsoption_spec。不像在: 上个教程 中添加的 TodoDirective,这个指令有一个参数,配方名,一个选项,contains,除了嵌套的结构函数体中的 reStructuredText。

index 类

待处理

添加索引的简要概述

 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

IngredientIndexRecipeIndex 都来源于 Index。它们实现定制逻辑来生成定义索引的值元组。请注意,RecipeIndex 是一个只有一个条目的简单索引。将其扩展到涵盖更多对象类型还不是代码的一部分。

两个指数都使用了 Index.generate() 的方法去做他们的工作。这个方法组合来自我们的域的信息,对其进行排序,并以 Sphinx 可以接受的列表结构返回信息。这看起来可能很复杂,但它实际上是一个元组列表,如 ('tomato','TomatoSoup', 'test', 'rec-TomatoSoup',...)。请参考 domain API guide 获取更多关于这个 API 的信息。

这些索引页可以通过域名和它的 name 的组合来引用,使用 ref 角色。例如,RecipeIndex 可以由 :ref:`recipe-recipe` 引用。

Sphinx 域是将角色、指令和索引等绑定在一起的专用容器。让我们看看在这里创建的域。

 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))

关于这个 recipe 域和一般域,有一些有趣的事情需要注意。首先,我们在这里通过 directivesrolesindices 属性来注册我们的指令、角色和索引,而不是稍后在 setup 中调用。还可以注意到,我们实际上并没有定义自定义角色,而是重用了 sphinx.roles.XRefRole 方法,并且定义了 sphinx.domains.Domain.resolve_xref 方法。该方法接受两个参数, typtarget,它们引用交叉引用类型及其目标名称。将使用 target 从我们的域 recipes 解析我们的目的地,因为我们目前只有一种类型的节点。

继续,可以看到我们已经定义了 initial_data。在 initial_data 中定义的值将被复制到 env.domaindata[domain_name] 中作为域的初始数据,域实例可以通过 self.data 访问它。我们看到在 initial_data 中定义了两个项:recipesrecipe2ingredient。它们包含所有已定义对象的列表(即所有菜谱)和一个将规范成分名称映射到对象列表的散列。我们命名对象的方式在扩展中是通用的,在 get_full_qualified_name 方法中定义。对于创建的每个对象,规范名称是 recipe.<recipename>,其中 <recipename> 是文档编写者给对象(recipe)的名称。这使得插件可以使用共享相同名称的不同对象类型。拥有一个规范的名称和对象的中心位置是一个巨大的优势。我们的索引和交叉引用代码都使用了这个特性。

setup 函数

As alwayssetup 函数是必需的,用于将扩展的各个部分钩到 Sphinx 中。让我们看看这个扩展的 setup 函数。

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    }

这看起来和我们以前看到的有点不同。没有回调 add_directive() 或者 add_role()。相反,我们有回调 add_domain() 后面跟着 standard domain 的初始化。这是因为我们已经将指令、角色和索引注册为指令本身的一部分。

使用插件

您现在可以在整个项目中使用插件。例如:

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.

需要注意的重要一点是 :recipe:ref: 角色的使用,以交叉引用实际在别处定义的食谱(使用 :recipe:recipe: 指令)。

进一步的阅读

更多信息,请参考 docutils documentation和 为 Sphinx 开发插件