Adicionando um domínio de referência

O objetivo deste tutorial é ilustrar papéis, diretivas e domínios. Depois de concluído, poderemos usar esta extensão para descrever uma receita e fazer referência a essa receita em outras partes de nossa documentação.

Nota

Este tutorial é baseado em um guia publicado pela primeira vez em opensource.com e é fornecido aqui com a permissão do autor original.

Visão geral

Queremos que a extensão adicione o seguinte ao Sphinx:

  • Uma diretiva recipe, contendo algum conteúdo descrevendo as etapas da receita, junto com uma opção :contains: realçando os principais ingredientes da receita.

  • Um papel ref, que fornece uma referência cruzada para a própria receita.

  • Um domínio recipe, que nos permite unir o papel e o domínio acima, junto com coisas como índices.

Para isso, precisaremos adicionar os seguintes elementos ao Sphinx:

  • Uma nova diretiva chamada recipe

  • Novos índices para nos permitir referenciar ingredientes e receitas

  • Um novo domínio chamado recipe, que conterá a diretiva recipe e o papel ref

Pré-requisitos

Precisamos da mesma configuração que em as extensões anteriores. Desta vez, colocaremos a extensão em um arquivo chamado recipe.py.

Aqui está um exemplo da estrutura de pastas que você pode obter:

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

Escrevendo a extensão

Abra recipe.py e cole o seguinte código nele, que explicaremos em detalhes em breve:

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

Vejamos cada parte desta extensão passo a passo para explicar o que está acontecendo.

A classe da diretiva

A primeira coisa a examinar é a diretiva 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)

Ao contrário de Estendendo a sintaxe com papéis e diretivas e Estendendo o processo de construção, esta diretiva não deriva de docutils.parsers.rst.Directive e não define um método run. Em vez disso, ela deriva de sphinx.directives.ObjectDescription e define os métodos handle_signature e add_target_and_index. Isso ocorre porque ObjectDescription é uma diretiva de propósito especial que se destina a descrever coisas como classes, funções ou, no nosso caso, receitas. Mais especificamente, handle_signature implementa a análise da assinatura da diretiva e passa o nome e tipo do objeto para sua superclasse, enquanto add_target_and_index adiciona um alvo (para vincular) e uma entrada ao índice para este nó.

Vemos também que esta diretiva define has_content, required_arguments e option_spec. Ao contrário da diretiva TodoDirective adicionada no tutorial anterior, esta diretiva leva um único argumento, o nome da receita, e uma opção, contains, além do reStructuredText aninhado no corpo.

As classes de índices

 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

Ambos IngredientIndex e RecipeIndex são derivados de Index. Eles implementam lógica personalizada para gerar uma tupla de valores que definem o índice. Observe que RecipeIndex é um índice simples que possui apenas uma entrada. Estendê-lo para cobrir mais tipos de objetos ainda não faz parte do código.

Ambos os índices usam o método Index.generate() para fazer seu trabalho. Este método combina as informações do nosso domínio, classifica-as e retorna-as em uma estrutura de lista que será aceita pelo Sphinx. Isso pode parecer complicado, mas tudo o que realmente é é uma lista de tuplas como ('tomato', 'TomatoSoup', 'test', 'rec-TomatoSoup',...). Consulte o guia da API de domínio para obter mais informações sobre esta API.

Essas páginas de índice podem ser referenciadas com o papel ref combinando o nome de domínio e o valor do índice name. Por exemplo, RecipeIndex pode ser referenciado com :ref:`recipe-recipe` e IngredientIndex pode ser referenciado com :ref:`recipe-ingredient`.

O domínio

Um domínio do Sphinx é um contêiner especializado que une papéis, diretivas e índices, entre outras coisas. Vejamos o domínio que estamos criando aqui.

 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.current_document.docname,
54            anchor,
55            0,
56        ))

Há algumas coisas interessantes a serem observadas sobre este domínio de recipe e sobre os domínios em geral. Primeiramente, nós registramos nossas diretivas, papéis e índices aqui, através dos atributos directives, roles e indices, ao invés de através de chamadas posteriores em setup. Também podemos notar que não estamos realmente definindo um papel personalizado e, em vez disso, estamos reutilizando o papel sphinx.roles.XRefRole e definindo o método sphinx.domains.Domain.resolve_xref. Este método recebe dois argumentos, typ e target, que se referem ao tipo de referência cruzada e seu nome de alvo. Usaremos target para resolver nosso alvo a partir de recipes de nosso domínio porque atualmente temos apenas um tipo de nó.

Seguindo em frente, podemos ver que definimos initial_data. Os valores definidos em initial_data serão copiados para env.domaindata[domain_name] como os dados iniciais do domínio, e as instâncias do domínio podem acessá-los via self.data. Vemos que definimos dois itens em initial_data: recipes e recipe_ingredients. Cada um contém uma lista de todos os objetos definidos (ou seja, todas as receitas) e um hash que mapeia o nome de um ingrediente canônico para a lista de objetos. A forma como nomeamos objetos é comum em nossa extensão e é definida no método get_full_qualified_name. Para cada objeto criado, o nome canônico é recipe.<recipename>, onde <recipename> é o nome que o redator da documentação dá ao objeto (uma receita). Isso permite que a extensão use diferentes tipos de objetos que compartilham o mesmo nome. Ter um nome canônico e um local central para nossos objetos é uma grande vantagem. Tanto nossos índices quanto nosso código de referência cruzada usam esse recurso.

A função setup

Como sempre, a função setup é um requisito e é usada para conectar as várias partes de nossa extensão ao Sphinx. Vejamos a função setup para esta extensão.

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    }

Isso parece um pouco diferente do que estamos acostumados a ver. Não há chamadas para add_directive() ou mesmo add_role(). Em vez disso, temos uma única chamada para add_domain() seguida por alguma inicialização do domínio padrão. Isso ocorre porque já havíamos registrado nossas diretivas, papéis e índices como parte da própria diretiva.

Usando a extensão

Agora você pode usar a extensão em todo o seu projeto. Por exemplo:

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.

As coisas importantes a serem observadas são o uso do papel :recipe:ref: para fazer referência cruzada da receita realmente definida em outro lugar (usando a diretiva :recipe:recipe:).

Leitura adicional

Para mais informações, consulte a documentação do docutils e de API do Sphinx.

Se você deseja compartilhar sua extensão entre vários projetos ou com outras pessoas, confira a seção Extensões de terceiros.