参照ドメインの追加

このチュートリアルの目的は、ロール、ディレクティブ、ドメインを説明することです。完了すると、この拡張機能を使用してレシピを記述し、ドキュメントの他の場所からそのレシピを参照できるようになります。

注記

このチュートリアルは、opensource.comで最初に公開されたガイドに基づいており、元の著者の許可を得てここに提供されています。

概要

この拡張機能によって、Sphinx に以下の機能を追加したいと考えています。

  • recipe ディレクティブ。レシピの手順を記述するコンテンツが含まれ、:contains: オプションを使用してレシピの主要な材料を強調表示します。

  • ref ロール。レシピ自体への相互参照を提供します。

  • recipe ドメイン。上記のロールとドメインを、インデックスなどの要素と結び付けることができます。

そのため、Sphinx に以下の要素を追加する必要があります。

  • recipe という新しいディレクティブ

  • 材料とレシピを参照するための新しいインデックス

  • 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.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((
 66                    dispname,
 67                    0,
 68                    docname,
 69                    anchor,
 70                    docname,
 71                    '',
 72                    typ,
 73                ))
 74
 75        # convert the dict to the sorted list of tuples expected
 76        content = sorted(content.items())
 77
 78        return content, True
 79
 80
 81class RecipeIndex(Index):
 82    """A custom index that creates an recipe matrix."""
 83
 84    name = 'recipe'
 85    localname = 'Recipe Index'
 86    shortname = 'Recipe'
 87
 88    def generate(self, docnames=None):
 89        content = defaultdict(list)
 90
 91        # sort the list of recipes in alphabetical order
 92        recipes = self.domain.get_objects()
 93        recipes = sorted(recipes, key=lambda recipe: recipe[0])
 94
 95        # generate the expected output, shown below, from the above using the
 96        # first letter of the recipe as a key to group thing
 97        #
 98        # name, subtype, docname, anchor, extra, qualifier, description
 99        for _name, dispname, typ, docname, anchor, _priority in recipes:
100            content[dispname[0].lower()].append((
101                dispname,
102                0,
103                docname,
104                anchor,
105                docname,
106                '',
107                typ,
108            ))
109
110        # convert the dict to the sorted list of tuples expected
111        content = sorted(content.items())
112
113        return content, True
114
115
116class RecipeDomain(Domain):
117    name = 'recipe'
118    label = 'Recipe Sample'
119    roles = {
120        'ref': XRefRole(),
121    }
122    directives = {
123        'recipe': RecipeDirective,
124    }
125    indices = {
126        RecipeIndex,
127        IngredientIndex,
128    }
129    initial_data = {
130        'recipes': [],  # object list
131        'recipe_ingredients': {},  # name -> object
132    }
133    data_version = 0
134
135    def get_full_qualified_name(self, node):
136        return f'recipe.{node.arguments[0]}'
137
138    def get_objects(self):
139        yield from self.data['recipes']
140
141    def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode):
142        match = [
143            (docname, anchor)
144            for name, sig, typ, docname, anchor, prio in self.get_objects()
145            if sig == target
146        ]
147
148        if len(match) > 0:
149            todocname = match[0][0]
150            targ = match[0][1]
151
152            return make_refnode(builder, fromdocname, todocname, targ, contnode, targ)
153        else:
154            print('Awww, found nothing')
155            return None
156
157    def add_recipe(self, signature, ingredients):
158        """Add a new recipe to the domain."""
159        name = f'recipe.{signature}'
160        anchor = f'recipe-{signature}'
161
162        self.data['recipe_ingredients'][name] = ingredients
163        # name, dispname, type, docname, anchor, priority
164        self.data['recipes'].append((
165            name,
166            signature,
167            'Recipe',
168            self.env.docname,
169            anchor,
170            0,
171        ))
172
173
174def setup(app: Sphinx) -> ExtensionMetadata:
175    app.add_domain(RecipeDomain)
176
177    return {
178        'version': '0.1',
179        'parallel_read_safe': True,
180        'parallel_write_safe': True,
181    }

この拡張機能の各部分を段階的に見て、何が起こっているのかを説明しましょう。

ディレクティブクラス

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)

ロールとディレクティブによる構文の拡張ビルドプロセスの拡張とは異なり、このディレクティブはdocutils.parsers.rst.Directiveを継承せず、runメソッドを定義しません。代わりに、sphinx.directives.ObjectDescriptionを継承し、handle_signatureメソッドとadd_target_and_indexメソッドを定義します。これは、ObjectDescriptionが、クラス、関数、またはこの場合はレシピなどの記述を目的とした特殊なディレクティブであるためです。より具体的には、handle_signatureはディレクティブのシグネチャの解析を実装し、オブジェクトの名前とタイプをスーパークラスに渡します。一方add_target_and_indexはこのノードへのターゲット(リンク先)とインデックスへのエントリを追加します。

また、このディレクティブはhas_contentrequired_argumentsoption_specを定義していることがわかります。前のチュートリアルに追加されたTodoDirectiveディレクティブとは異なり、このディレクティブは、レシピ名という1つの引数と、containsオプションに加えて、本文内のネストされたreStructuredTextを受け取ります。

インデックスクラス

 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((
31                    dispname,
32                    0,
33                    docname,
34                    anchor,
35                    docname,
36                    '',
37                    typ,
38                ))
39
40        # convert the dict to the sorted list of tuples expected
41        content = sorted(content.items())
42
43        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

IngredientIndexRecipeIndexの両方はIndexを継承しています。これらは、インデックスを定義する値のタプルを生成するカスタムロジックを実装しています。RecipeIndexは、エントリが1つだけの単純なインデックスであることに注意してください。より多くのオブジェクトタイプをカバーするように拡張するのは、まだコードの一部ではありません。

両方のインデックスは、作業を行うためにIndex.generate()メソッドを使用しています。このメソッドは、ドメインからの情報を組み合わせ、ソートし、Sphinxで受け入れられるリスト構造で返します。これは複雑に見えるかもしれませんが、実際は('tomato', 'TomatoSoup', 'test', 'rec-TomatoSoup',...)のようなタプルのリストです。ドメインAPIガイドで、このAPIの詳細を参照してください。

これらのインデックスページは、ドメイン名とインデックスのname値を組み合わせることで、refロールを使用して参照できます。たとえば、RecipeIndex:ref:`recipe-recipe`で参照でき、IngredientIndex: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((
50            name,
51            signature,
52            'Recipe',
53            self.env.docname,
54            anchor,
55            0,
56        ))

このrecipeドメインとドメイン全般について、注目すべき点がいくつかあります。まず、実際にはdirectivesrolesindices属性を介して、setupの後で呼び出すのではなく、ここでディレクティブ、ロール、インデックスを登録します。また、カスタムロールを実際には定義しておらず、代わりにsphinx.roles.XRefRoleロールを再利用し、sphinx.domains.Domain.resolve_xrefメソッドを定義していることにも注目できます。このメソッドは、相互参照タイプとそのターゲット名を指すtyptargetの2つの引数を取ります。現在、ノードのタイプは1つだけなので、ドメインのrecipesから宛先を解決するためにtargetを使用します。

次に、initial_dataを定義していることがわかります。initial_dataで定義された値は、ドメインの初期データとしてenv.domaindata[domain_name]にコピーされ、ドメインインスタンスはself.dataを介してアクセスできます。initial_dataにはrecipesrecipe_ingredientsの2つの項目を定義していることがわかります。それぞれ、定義されているすべてのオブジェクト(つまり、すべてのレシピ)のリストと、標準的な材料名とオブジェクトのリストをマッピングするハッシュが含まれています。オブジェクトの命名方法は、拡張機能全体で共通しており、get_full_qualified_nameメソッドで定義されています。作成された各オブジェクトについて、標準名はrecipe.<recipename>です。ここで<recipename>は、ドキュメント作成者がオブジェクト(レシピ)に付ける名前です。これにより、拡張機能は同じ名前を共有する異なるオブジェクトタイプを使用できるようになります。標準名とオブジェクトの中央リポジトリを持つことは、大きな利点です。インデックスと相互参照コードの両方がこの機能を使用しています。

setup関数

常にそうであるように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    }

これは、私たちが普段見ているものとは少し違います。add_directive()add_role() への呼び出しがありません。代わりに、add_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 のドキュメントと Sphinx API を参照してください。

拡張機能を複数のプロジェクト間または他の人と共有したい場合は、サードパーティの拡張機能 セクションを確認してください。