Estendendo o processo de construção

O objetivo deste tutorial é criar uma extensão mais abrangente do que a criada em Estendendo a sintaxe com papéis e diretivas. Considerando que esse guia apenas cobriu a escrita de papel e diretiva personalizados, este guia cobre uma extensão mais complexa do processo de construção do Sphinx; adicionando várias diretivas, juntamente com nós personalizados, valores de configuração adicionais e manipuladores de eventos personalizados.

Para este fim, iremos cobrir uma extensão todo que adiciona capacidades para incluir entradas de tarefas na documentação, e coletá-las em um local central. Isto é semelhante à extensão sphinx.ext.todo distribuída com o Sphinx.

Visão geral

Nota

Para entender o design desta extensão, consulte Objetos importantes e Fases da construção.

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

  • Uma diretiva todo, contendo algum conteúdo marcado com “TODO” e somente mostrado na saída se um novo valor de configuração for definido. As entradas “todo” não devem estar na saída por padrão.

  • Uma diretiva todolist que cria uma lista de todas as entradas de tarefas em toda a documentação.

Para isso, precisaremos adicionar os seguintes elementos ao Sphinx:

  • Novas diretivas, chamadas todo e todolist.

  • Novos nós de árvore de documentos para representar essas diretivas, convencionalmente também chamados de todo e todolist. Não precisaríamos de novos nós se as novas diretivas produzissem apenas algum conteúdo representável pelos nós existentes.

  • Um novo valor de configuração todo_include_todos (os nomes dos valores de configuração devem começar com o nome da extensão, para permanecerem únicos) que controla se as entradas de tarefas chegam à saída.

  • Novos manipuladores de eventos: um para o evento doctree-resolved, para substituir os nós de todo e todolist, um para env-merge-info para mesclar resultados intermediários de construções paralelas, e um para env-purge-doc (a razão para isso será abordada mais tarde).

Pré-requisitos

Assim como acontece com Estendendo a sintaxe com papéis e diretivas, não distribuiremos este plugin via PyPI, então mais uma vez precisamos de um projeto Sphinx para chamar isso. Você pode usar um projeto existente ou criar um novo usando sphinx-quickstart.

Presumimos que você esteja usando pastas de fonte (source) e de construção (build) separadas. Seu arquivo de extensão pode estar em qualquer pasta do seu projeto. No nosso caso, vamos fazer o seguinte:

  1. Crie uma pasta _ext em source

  2. Crie um novo arquivo Python na pasta _ext chamado todo.py

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

└── source
    ├── _ext
    │   └── todo.py
    ├── _static
    ├── conf.py
    ├── somefolder
    ├── index.rst
    ├── somefile.rst
    └── someotherfile.rst

Escrevendo a extensão

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

  1from docutils import nodes
  2from docutils.parsers.rst import Directive
  3
  4from sphinx.application import Sphinx
  5from sphinx.locale import _
  6from sphinx.util.docutils import SphinxDirective
  7from sphinx.util.typing import ExtensionMetadata
  8
  9
 10class todo(nodes.Admonition, nodes.Element):
 11    pass
 12
 13
 14class todolist(nodes.General, nodes.Element):
 15    pass
 16
 17
 18def visit_todo_node(self, node):
 19    self.visit_admonition(node)
 20
 21
 22def depart_todo_node(self, node):
 23    self.depart_admonition(node)
 24
 25
 26class TodolistDirective(Directive):
 27    def run(self):
 28        return [todolist('')]
 29
 30
 31class TodoDirective(SphinxDirective):
 32    # this enables content in the directive
 33    has_content = True
 34
 35    def run(self):
 36        targetid = 'todo-%d' % self.env.new_serialno('todo')
 37        targetnode = nodes.target('', '', ids=[targetid])
 38
 39        todo_node = todo('\n'.join(self.content))
 40        todo_node += nodes.title(_('Todo'), _('Todo'))
 41        todo_node += self.parse_content_to_nodes()
 42
 43        if not hasattr(self.env, 'todo_all_todos'):
 44            self.env.todo_all_todos = []
 45
 46        self.env.todo_all_todos.append({
 47            'docname': self.env.current_document.docname,
 48            'lineno': self.lineno,
 49            'todo': todo_node.deepcopy(),
 50            'target': targetnode,
 51        })
 52
 53        return [targetnode, todo_node]
 54
 55
 56def purge_todos(app, env, docname):
 57    if not hasattr(env, 'todo_all_todos'):
 58        return
 59
 60    env.todo_all_todos = [
 61        todo for todo in env.todo_all_todos if todo['docname'] != docname
 62    ]
 63
 64
 65def merge_todos(app, env, docnames, other):
 66    if not hasattr(env, 'todo_all_todos'):
 67        env.todo_all_todos = []
 68    if hasattr(other, 'todo_all_todos'):
 69        env.todo_all_todos.extend(other.todo_all_todos)
 70
 71
 72def process_todo_nodes(app, doctree, fromdocname):
 73    if not app.config.todo_include_todos:
 74        for node in doctree.findall(todo):
 75            node.parent.remove(node)
 76
 77    # Replace all todolist nodes with a list of the collected todos.
 78    # Augment each todo with a backlink to the original location.
 79    env = app.env
 80
 81    if not hasattr(env, 'todo_all_todos'):
 82        env.todo_all_todos = []
 83
 84    for node in doctree.findall(todolist):
 85        if not app.config.todo_include_todos:
 86            node.replace_self([])
 87            continue
 88
 89        content = []
 90
 91        for todo_info in env.todo_all_todos:
 92            para = nodes.paragraph()
 93            filename = env.doc2path(todo_info['docname'], base=None)
 94            description = _(
 95                '(The original entry is located in %s, line %d and can be found '
 96            ) % (filename, todo_info['lineno'])
 97            para += nodes.Text(description)
 98
 99            # Create a reference
100            newnode = nodes.reference('', '')
101            innernode = nodes.emphasis(_('here'), _('here'))
102            newnode['refdocname'] = todo_info['docname']
103            newnode['refuri'] = app.builder.get_relative_uri(
104                fromdocname, todo_info['docname']
105            )
106            newnode['refuri'] += '#' + todo_info['target']['refid']
107            newnode.append(innernode)
108            para += newnode
109            para += nodes.Text('.)')
110
111            # Insert into the todolist
112            content.extend((
113                todo_info['todo'],
114                para,
115            ))
116
117        node.replace_self(content)
118
119
120def setup(app: Sphinx) -> ExtensionMetadata:
121    app.add_config_value('todo_include_todos', False, 'html')
122
123    app.add_node(todolist)
124    app.add_node(
125        todo,
126        html=(visit_todo_node, depart_todo_node),
127        latex=(visit_todo_node, depart_todo_node),
128        text=(visit_todo_node, depart_todo_node),
129    )
130
131    app.add_directive('todo', TodoDirective)
132    app.add_directive('todolist', TodolistDirective)
133    app.connect('doctree-resolved', process_todo_nodes)
134    app.connect('env-purge-doc', purge_todos)
135    app.connect('env-merge-info', merge_todos)
136
137    return {
138        'version': '0.1',
139        'env_version': 1,
140        'parallel_read_safe': True,
141        'parallel_write_safe': True,
142    }

Esta é uma extensão muito mais extensa do que a detalhada em Estendendo a sintaxe com papéis e diretivas, no entanto, examinaremos cada parte passo a passo para explicar o que está acontecendo.

As classes de nós

Vamos começar com as classes de nós:

 1
 2
 3class todo(nodes.Admonition, nodes.Element):
 4    pass
 5
 6
 7class todolist(nodes.General, nodes.Element):
 8    pass
 9
10
11def visit_todo_node(self, node):
12    self.visit_admonition(node)
13
14

As classes de nós geralmente não precisam fazer nada, exceto herdar das classes padrão do docutils definidas em docutils.nodes. todo herda de Admonition porque deve ser tratado como uma nota ou aviso, todolist é apenas um nó “geral”.

Nota

Muitas extensões não terão que criar suas próprias classes de nós e funcionarão bem com os nós já fornecidos por docutils e Sphinx.

Atenção

É importante saber que embora você possa estender o Sphinx sem sair do seu conf.py, se você declarar um nó herdado ali mesmo, você encontrará um PickleError não óbvio. Portanto, se algo der errado, certifique-se de colocar os nós herdados em um módulo Python separado.

Para mais detalhes, consulte:

As classes de diretiva

Uma classe de diretiva é uma classe que normalmente deriva de docutils.parsers.rst.Directive. A interface da diretiva também é abordada em detalhes na documentação do docutils; o importante é que a classe possua atributos que configurem a marcação permitida, e um método run que retorne uma lista de nós.

Olhando primeiro para a diretiva TodolistDirective:

1class TodolistDirective(Directive):
2    def run(self):
3        return [todolist('')]

É muito simples criar e retornar uma instância da nossa classe de nó todolist. A diretiva TodolistDirective em si não possui conteúdo nem argumentos que precisam ser manipulados. Isso nos leva à diretiva TodoDirective:

 1class TodoDirective(SphinxDirective):
 2    # this enables content in the directive
 3    has_content = True
 4
 5    def run(self):
 6        targetid = 'todo-%d' % self.env.new_serialno('todo')
 7        targetnode = nodes.target('', '', ids=[targetid])
 8
 9        todo_node = todo('\n'.join(self.content))
10        todo_node += nodes.title(_('Todo'), _('Todo'))
11        todo_node += self.parse_content_to_nodes()
12
13        if not hasattr(self.env, 'todo_all_todos'):
14            self.env.todo_all_todos = []
15
16        self.env.todo_all_todos.append({
17            'docname': self.env.current_document.docname,
18            'lineno': self.lineno,
19            'todo': todo_node.deepcopy(),
20            'target': targetnode,
21        })
22
23        return [targetnode, todo_node]

Várias coisas importantes são abordadas aqui. Primeiro, como você pode ver, agora estamos criando uma subclasse da classe auxiliar SphinxDirective em vez da classe Directive usual. Isso nos dá acesso à instância de ambiente de construção usando a propriedade self.env. Sem isso, teríamos que usar o bastante complicado self.state.document.settings.env. Então, para atuar como um link alvo (de TodolistDirective), a diretiva TodoDirective precisa retornar um nó alvo além do nó todo. O ID do alvo (em HTML, este será o nome da âncora) é gerado usando env.new_serialno que retorna um novo número inteiro exclusivo em cada chamada e, portanto, leva a nomes de alvos exclusivos. O nó alvo é instanciado sem nenhum texto (os dois primeiros argumentos).

Ao criar o nó de admonição, o corpo do conteúdo da diretiva é analisado usando self.parse_content_to_nodes(). Em seguida, o nó todo é adicionado ao ambiente. Isto é necessário para poder criar uma lista de todas as entradas de tarefas ao longo da documentação, no local onde o autor coloca uma diretiva todolist. Para este caso, o atributo de ambiente todo_all_todos é usado (novamente, o nome deve ser único, portanto é prefixado pelo nome da extensão). Não existe quando um novo ambiente é criado, portanto a diretiva deve verificá-lo e criá-lo se necessário. Várias informações sobre a localização da entrada de tarefas são armazenadas junto com uma cópia do nó.

Na última linha são retornados os nós que devem ser colocados na doctree: o nó alvo e o nó de admonição.

A estrutura do nó que a diretiva retorna é semelhante a esta:

+--------------------+
| target node        |
+--------------------+
+--------------------+
| todo node          |
+--------------------+
  \__+--------------------+
     | admonition title   |
     +--------------------+
     | paragraph          |
     +--------------------+
     | ...                |
     +--------------------+

OS manipuladores de eventos

Os manipuladores de eventos são um dos recursos mais poderosos do Sphinx, fornecendo uma maneira de se conectar a qualquer parte do processo de documentação. Existem muitos eventos fornecidos pelo próprio Sphinx, conforme detalhado no guia de API, e usaremos um subconjunto deles aqui.

Vejamos os manipuladores de eventos usados ​​no exemplo acima. Primeiro, aquele para o evento env-purge-doc:

1def purge_todos(app, env, docname):
2    if not hasattr(env, 'todo_all_todos'):
3        return
4
5    env.todo_all_todos = [
6        todo for todo in env.todo_all_todos if todo['docname'] != docname
7    ]

Como armazenamos informações dos arquivos fonte no ambiente, que é persistente, elas podem ficar desatualizadas quando o arquivo fonte for alterado. Portanto, antes de cada arquivo fonte ser lido, os registros do ambiente são limpos, e o evento env-purge-doc dá às extensões a chance de fazer o mesmo. Aqui limpamos todos os todos cujo nome de documento corresponde ao fornecido na lista todo_all_todos. Se ainda houver todos no documento, eles serão adicionados novamente durante a análise.

O próximo manipulador, para o evento env-merge-info, é usado durante construções paralelas. Como durante construções paralelas todas as threads possuem seu próprio env, existem múltiplas listas de todo_all_todos que precisam ser mescladas:

1def merge_todos(app, env, docnames, other):
2    if not hasattr(env, 'todo_all_todos'):
3        env.todo_all_todos = []
4    if hasattr(other, 'todo_all_todos'):
5        env.todo_all_todos.extend(other.todo_all_todos)

O outro manipulador pertence ao evento doctree-resolved:

 1def process_todo_nodes(app, doctree, fromdocname):
 2    if not app.config.todo_include_todos:
 3        for node in doctree.findall(todo):
 4            node.parent.remove(node)
 5
 6    # Replace all todolist nodes with a list of the collected todos.
 7    # Augment each todo with a backlink to the original location.
 8    env = app.env
 9
10    if not hasattr(env, 'todo_all_todos'):
11        env.todo_all_todos = []
12
13    for node in doctree.findall(todolist):
14        if not app.config.todo_include_todos:
15            node.replace_self([])
16            continue
17
18        content = []
19
20        for todo_info in env.todo_all_todos:
21            para = nodes.paragraph()
22            filename = env.doc2path(todo_info['docname'], base=None)
23            description = _(
24                '(The original entry is located in %s, line %d and can be found '
25            ) % (filename, todo_info['lineno'])
26            para += nodes.Text(description)
27
28            # Create a reference
29            newnode = nodes.reference('', '')
30            innernode = nodes.emphasis(_('here'), _('here'))
31            newnode['refdocname'] = todo_info['docname']
32            newnode['refuri'] = app.builder.get_relative_uri(
33                fromdocname, todo_info['docname']
34            )
35            newnode['refuri'] += '#' + todo_info['target']['refid']
36            newnode.append(innernode)
37            para += newnode
38            para += nodes.Text('.)')
39
40            # Insert into the todolist
41            content.extend((
42                todo_info['todo'],
43                para,
44            ))
45
46        node.replace_self(content)

O evento doctree-resolved é emitido para cada documento que está prestes a ser escrito no final da fase 3 (resolução) e permite que a resolução personalizada seja feita naquele documento. O manipulador que escrevemos para este evento é um pouco mais envolvente. Se o valor de configuração todo_include_todos (que descreveremos em breve) for falso, todos os nós todo e todolist serão removidos dos documentos. Caso contrário, os nós todo apenas permanecem onde e como estão. Os nós todolist são substituídos por uma lista de entradas de tarefas completas com backlinks para o local de onde elas vêm. Os itens da lista são compostos pelos nós da entrada todo e nós docutils criados dinamicamente: um parágrafo para cada entrada, contendo texto que fornece o local, e um link (nó de referência contendo um nó em itálico) com o referência retroativa. O URI de referência é construído por sphinx.builders.Builder.get_relative_uri() que cria um URI adequado dependendo do construtor usado e anexando o ID do nó de tarefas (o alvo) como o nome da âncora.

A função setup

Como observado anteriormente, a função setup é um requisito e é usada para inserir diretivas no Sphinx. No entanto, também o usamos para conectar as outras partes da nossa extensão. Vejamos nossa função setup:

 1def setup(app: Sphinx) -> ExtensionMetadata:
 2    app.add_config_value('todo_include_todos', False, 'html')
 3
 4    app.add_node(todolist)
 5    app.add_node(
 6        todo,
 7        html=(visit_todo_node, depart_todo_node),
 8        latex=(visit_todo_node, depart_todo_node),
 9        text=(visit_todo_node, depart_todo_node),
10    )
11
12    app.add_directive('todo', TodoDirective)
13    app.add_directive('todolist', TodolistDirective)
14    app.connect('doctree-resolved', process_todo_nodes)
15    app.connect('env-purge-doc', purge_todos)
16    app.connect('env-merge-info', merge_todos)
17
18    return {
19        'version': '0.1',
20        'env_version': 1,
21        'parallel_read_safe': True,
22        'parallel_write_safe': True,
23    }

As chamadas nesta função fazem referência às classes e funções que adicionamos anteriormente. O que as chamadas individuais fazem é o seguinte:

  • add_config_value() permite ao Sphinx saber que ele deve reconhecer o novo valor de configuração todo_include_todos, cujo valor padrão é False (o que também diz ao Sphinx que é um valor booleano) .

    Se o terceiro argumento fosse 'html', os documentos HTML seriam completamente reconstruídos se o valor de configuração mudasse seu valor. Isto é necessário para valores de configuração que influenciam a leitura (fase 1 (leitura) da construção).

  • add_node() adiciona uma nova classe de nó ao sistema de construção. Ele também pode especificar funções de visitante para cada formato de saída compatível. Estas funções visitantes são necessárias quando os novos nós permanecem até a fase 4 (escrita). Como o nó todolist é sempre substituído na fase 3 (resolução), ele não precisa de nenhum.

  • add_directive() adiciona uma nova diretiva, dada pelo nome e pela classe.

  • Finalmente, connect() adiciona um manipulador de eventos ao evento cujo nome é dado pelo primeiro argumento. A função manipuladora de eventos é chamada com vários argumentos que são documentados com o evento.

Com isso, nossa extensão está completa.

Usando a extensão

Como antes, precisamos habilitar a extensão declarando-a em nosso arquivo conf.py. Existem duas etapas necessárias aqui:

  1. Adicione o diretório _ext ao caminho do Python usando sys.path.append. Isso deve ser colocado no topo do arquivo.

  2. Atualize ou crie a lista extensions e adicione o nome do arquivo de extensão à lista

Além disso, podemos desejar definir o valor de configuração todo_include_todos. Como observado acima, o padrão é False, mas podemos defini-lo explicitamente.

Por exemplo:

import sys
from pathlib import Path

sys.path.append(str(Path('_ext').resolve()))

extensions = ['todo']

todo_include_todos = False

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

index.rst
Hello, world
============

.. toctree::
   somefile.rst
   someotherfile.rst

Hello world. Below is the list of TODOs.

.. todolist::
algumarquivo.rst
foo
===

Some intro text here...

.. todo:: Fix this
outroarquivo.rst
bar
===

Some more text here...

.. todo:: Fix that

Como configuramos todo_include_todos para False, na verdade não veremos nada renderizado para as diretivas todo e todolist. No entanto, se mudarmos para true, veremos a saída descrita anteriormente.

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.