开发“开发“食谱”扩展名

本教程的目的是说明角色,指令和域。 完成后,我们将能够使用此扩展来描述配方并从我们文档的其他地方引用该配方。

备注

本教程基于首先在`opensource.com`_上发布的指南,并在得到原始作者许可的情况下提供。

概述

我们希望插件将以下内容添加到Sphinx中:

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

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

  • ``recipe’’:term:domain,它使我们可以将上述角色和领域以及索引等东西结合在一起。

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

  • 一个名为``recipe’’的新指令

  • 新索引使我们可以参考成分和配方

  • 一个名为“ recipe”的新域,其中将包含“ recipe”指令和“ ref”角色

系统需求

我们需要与以前的扩展<todo>中的文档相同的设置。 这次,我们将把扩展名放到一个名为:recipe.py 的文件中。

这是您可能获得的文件夹结构的示例:

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

编写扩展

打开:file:recipe.py 并将以下代码粘贴到其中,我们将在稍后详细解释所有这些代码:

  1from collections import defaultdict
  2
  3from docutils.parsers.rst import directives
  4
  5from sphinx import addnodes
  6from sphinx.application import Sphinx
  7from sphinx.directives import ObjectDescription
  8from sphinx.domains import Domain, Index
  9from sphinx.roles import XRefRole
 10from sphinx.util.nodes import make_refnode
 11from sphinx.util.typing import ExtensionMetadata
 12
 13
 14class RecipeDirective(ObjectDescription):
 15    """A custom directive that describes a recipe."""
 16
 17    has_content = True
 18    required_arguments = 1
 19    option_spec = {
 20        'contains': directives.unchanged_required,
 21    }
 22
 23    def handle_signature(self, sig, signode):
 24        signode += addnodes.desc_name(text=sig)
 25        return sig
 26
 27    def add_target_and_index(self, name_cls, sig, signode):
 28        signode['ids'].append('recipe' + '-' + sig)
 29        if 'contains' in self.options:
 30            ingredients = [x.strip() for x in self.options.get('contains').split(',')]
 31
 32            recipes = self.env.get_domain('recipe')
 33            recipes.add_recipe(sig, ingredients)
 34
 35
 36class IngredientIndex(Index):
 37    """A custom index that creates an ingredient matrix."""
 38
 39    name = 'ingredient'
 40    localname = 'Ingredient Index'
 41    shortname = 'Ingredient'
 42
 43    def generate(self, docnames=None):
 44        content = defaultdict(list)
 45
 46        recipes = {
 47            name: (dispname, typ, docname, anchor)
 48            for name, dispname, typ, docname, anchor, _ in self.domain.get_objects()
 49        }
 50        recipe_ingredients = self.domain.data['recipe_ingredients']
 51        ingredient_recipes = defaultdict(list)
 52
 53        # flip from recipe_ingredients to ingredient_recipes
 54        for recipe_name, ingredients in recipe_ingredients.items():
 55            for ingredient in ingredients:
 56                ingredient_recipes[ingredient].append(recipe_name)
 57
 58        # convert the mapping of ingredient to recipes to produce the expected
 59        # output, shown below, using the ingredient name as a key to group
 60        #
 61        # name, subtype, docname, anchor, extra, qualifier, description
 62        for ingredient, recipe_names in ingredient_recipes.items():
 63            for recipe_name in recipe_names:
 64                dispname, typ, docname, anchor = recipes[recipe_name]
 65                content[ingredient].append((dispname, 0, docname, anchor, docname, '', typ))
 66
 67        # convert the dict to the sorted list of tuples expected
 68        content = sorted(content.items())
 69
 70        return content, True
 71
 72
 73class RecipeIndex(Index):
 74    """A custom index that creates an recipe matrix."""
 75
 76    name = 'recipe'
 77    localname = 'Recipe Index'
 78    shortname = 'Recipe'
 79
 80    def generate(self, docnames=None):
 81        content = defaultdict(list)
 82
 83        # sort the list of recipes in alphabetical order
 84        recipes = self.domain.get_objects()
 85        recipes = sorted(recipes, key=lambda recipe: recipe[0])
 86
 87        # generate the expected output, shown below, from the above using the
 88        # first letter of the recipe as a key to group thing
 89        #
 90        # name, subtype, docname, anchor, extra, qualifier, description
 91        for _name, dispname, typ, docname, anchor, _priority in recipes:
 92            content[dispname[0].lower()].append((
 93                dispname,
 94                0,
 95                docname,
 96                anchor,
 97                docname,
 98                '',
 99                typ,
100            ))
101
102        # convert the dict to the sorted list of tuples expected
103        content = sorted(content.items())
104
105        return content, True
106
107
108class RecipeDomain(Domain):
109    name = 'recipe'
110    label = 'Recipe Sample'
111    roles = {
112        'ref': XRefRole(),
113    }
114    directives = {
115        'recipe': RecipeDirective,
116    }
117    indices = {
118        RecipeIndex,
119        IngredientIndex,
120    }
121    initial_data = {
122        'recipes': [],  # object list
123        'recipe_ingredients': {},  # name -> object
124    }
125    data_version = 0
126
127    def get_full_qualified_name(self, node):
128        return f'recipe.{node.arguments[0]}'
129
130    def get_objects(self):
131        yield from self.data['recipes']
132
133    def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode):
134        match = [
135            (docname, anchor)
136            for name, sig, typ, docname, anchor, prio in self.get_objects()
137            if sig == target
138        ]
139
140        if len(match) > 0:
141            todocname = match[0][0]
142            targ = match[0][1]
143
144            return make_refnode(builder, fromdocname, todocname, targ, contnode, targ)
145        else:
146            print('Awww, found nothing')
147            return None
148
149    def add_recipe(self, signature, ingredients):
150        """Add a new recipe to the domain."""
151        name = f'recipe.{signature}'
152        anchor = f'recipe-{signature}'
153
154        self.data['recipe_ingredients'][name] = ingredients
155        # name, dispname, type, docname, anchor, priority
156        self.data['recipes'].append((name, signature, 'Recipe', self.env.docname, anchor, 0))
157
158
159def setup(app: Sphinx) -> ExtensionMetadata:
160    app.add_domain(RecipeDomain)
161
162    return {
163        'version': '0.1',
164        'parallel_read_safe': True,
165        'parallel_write_safe': True,
166    }

让我们逐步看一下该扩展的每一部分,以解释发生了什么。

指令集

首先要检查的是 “ 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 = [x.strip() for x in self.options.get('contains').split(',')]
18
19            recipes = self.env.get_domain('recipe')
20            recipes.add_recipe(sig, ingredients)

Unlike 开发了一个 “Hello world” 扩展 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_target_and_index adds a target (to link to) and an entry to the index for this node.

我们还看到该指令定义了has_content,required_arguments和option_spec。 与先前教程1中的doc中添加的``TodoDirective’’指令不同,该指令除了主体中嵌套的reStructuredText外,还接受单个参数,配方名称和选项``contains’’ 。

索引类

待处理

新增索引的简要概述

 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 = {
12            name: (dispname, typ, docname, anchor)
13            for name, dispname, typ, docname, anchor, _ in self.domain.get_objects()
14        }
15        recipe_ingredients = self.domain.data['recipe_ingredients']
16        ingredient_recipes = defaultdict(list)
17
18        # flip from recipe_ingredients to ingredient_recipes
19        for recipe_name, ingredients in recipe_ingredients.items():
20            for ingredient in ingredients:
21                ingredient_recipes[ingredient].append(recipe_name)
22
23        # convert the mapping of ingredient to recipes to produce the expected
24        # output, shown below, using the ingredient name as a key to group
25        #
26        # name, subtype, docname, anchor, extra, qualifier, description
27        for ingredient, recipe_names in ingredient_recipes.items():
28            for recipe_name in recipe_names:
29                dispname, typ, docname, anchor = recipes[recipe_name]
30                content[ingredient].append((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,
22                0,
23                docname,
24                anchor,
25                docname,
26                '',
27                typ,
28            ))
29
30        # convert the dict to the sorted list of tuples expected
31        content = sorted(content.items())
32
33        return content, True

IngredientIndex 和 RecipeIndex 都是从 Index 派生的。 它们实现自定义逻辑以生成定义索引的值的元组。 注意“RecipeIndex”是一个只有一个条目的简单索引。 扩展它以涵盖更多对象类型尚不是代码的一部分。

两个索引都使用方法:meth:Index.generate`来完成工作。 该方法将来自我们域的信息进行组合,排序,然后以Sphinx接受的列表结构返回。 这看起来可能很复杂,但实际上只是一个元组列表,如``(’tomato’,’TomatoSoup’,’test’,’rec-TomatoSoup’…)’’。 有关此API的更多信息,请参阅:doc:`域API指南</extdev/domainapi>

These index pages can be referenced with the ref role by combining the domain name and the index name value. For example, RecipeIndex can be referenced with :ref:`recipe-recipe` and IngredientIndex can be referenced with :ref:`recipe-ingredient`.

Sphinx 域是一个专门的容器,将角色,指令和索引等联系在一起。现在, 让我们一起看看我们在这里创建的域。

 1class RecipeDomain(Domain):
 2    name = 'recipe'
 3    label = 'Recipe Sample'
 4    roles = {
 5        'ref': XRefRole(),
 6    }
 7    directives = {
 8        'recipe': RecipeDirective,
 9    }
10    indices = {
11        RecipeIndex,
12        IngredientIndex,
13    }
14    initial_data = {
15        'recipes': [],  # object list
16        'recipe_ingredients': {},  # name -> object
17    }
18    data_version = 0
19
20    def get_full_qualified_name(self, node):
21        return f'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, contnode):
27        match = [
28            (docname, anchor)
29            for name, sig, typ, docname, anchor, prio in self.get_objects()
30            if sig == target
31        ]
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, 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 = f'recipe.{signature}'
45        anchor = f'recipe-{signature}'
46
47        self.data['recipe_ingredients'][name] = ingredients
48        # name, dispname, type, docname, anchor, priority
49        self.data['recipes'].append((name, signature, 'Recipe', self.env.docname, anchor, 0))

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

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 recipe_ingredients. Each contains 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.

setup 功能

:doc:一如往常<todo>``setup’’功能是必需的,用于将我们扩展的各个部分挂接到Sphinx中。 让我们看一下这个扩展的``setup’’功能。

1def setup(app: Sphinx) -> ExtensionMetadata:
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.

使用扩展

现在,您可以在整个项目中使用扩展名。 例如:

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

延伸阅读

有关更多信息,请参考 docutils 文档和:doc:/ extdev / index。