Erweiterung des Build-Prozesses

Ziel dieses Tutorials ist die Erstellung einer umfassenderen Erweiterung als die in Erweiterung der Syntax mit Rollen und Direktiven. Während dieser Leitfaden nur das Schreiben einer benutzerdefinierten Rolle und Direktive behandelte, behandelt dieser Leitfaden eine komplexere Erweiterung des Sphinx-Build-Prozesses; das Hinzufügen mehrerer Direktiven zusammen mit benutzerdefinierten Knoten, zusätzlichen Konfigurationswerten und benutzerdefinierten Ereignisbehandlern.

Zu diesem Zweck behandeln wir eine todo-Erweiterung, die Funktionen zum Einfügen von Todo-Einträgen in die Dokumentation hinzufügt und diese an einem zentralen Ort sammelt. Dies ähnelt der mit Sphinx verteilten Erweiterung sphinx.ext.todo.

Übersicht

Hinweis

Um das Design dieser Erweiterung zu verstehen, siehe Wichtige Objekte und Build-Phasen.

Wir möchten, dass die Erweiterung Sphinx Folgendes hinzufügt

  • Eine todo-Direktive, die einen Inhalt enthält, der mit „TODO“ markiert ist und nur in der Ausgabe angezeigt wird, wenn ein neuer Konfigurationswert gesetzt ist. Todo-Einträge sollten standardmäßig nicht in der Ausgabe enthalten sein.

  • Eine todolist-Direktive, die eine Liste aller Todo-Einträge im gesamten Dokumentation erstellt.

Dafür müssen wir Sphinx die folgenden Elemente hinzufügen

  • Neue Direktiven, genannt todo und todolist.

  • Neue Dokumentenstruktur-Knoten zur Darstellung dieser Direktiven, konventionell ebenfalls todo und todolist genannt. Wir bräuchten keine neuen Knoten, wenn die neuen Direktiven nur Inhalte erzeugen würden, die durch vorhandene Knoten darstellbar sind.

  • Ein neuer Konfigurationswert todo_include_todos (Konfigurationswertnamen sollten mit dem Erweiterungsnamen beginnen, um eindeutig zu bleiben), der steuert, ob Todo-Einträge in die Ausgabe gelangen.

  • Neue Ereignisbehandler: einer für das doctree-resolved-Ereignis, um die Todo- und Todolist-Knoten zu ersetzen, einer für env-merge-info, um Zwischenergebnisse aus parallelen Builds zusammenzuführen, und einer für env-purge-doc (der Grund dafür wird später behandelt).

Voraussetzungen

Wie bei Erweiterung der Syntax mit Rollen und Direktiven werden wir dieses Plugin nicht über PyPI vertreiben, daher benötigen wir wieder ein Sphinx-Projekt, das es aufrufen kann. Sie können ein vorhandenes Projekt verwenden oder mit sphinx-quickstart ein neues erstellen.

Wir gehen davon aus, dass Sie separate Quellcode- (source) und Build- (build) Ordner verwenden. Ihre Erweiterungsdatei könnte sich in jedem Ordner Ihres Projekts befinden. In unserem Fall gehen wir wie folgt vor

  1. Erstellen Sie einen Ordner _ext im Ordner source

  2. Erstellen Sie eine neue Python-Datei im Ordner _ext mit dem Namen todo.py

Hier ist ein Beispiel für die Ordnerstruktur, die Sie erhalten könnten

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

Schreiben der Erweiterung

Öffnen Sie todo.py und fügen Sie den folgenden Code ein, den wir im Folgenden detailliert erläutern werden

  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    }

Dies ist eine weitaus umfangreichere Erweiterung als die in Erweiterung der Syntax mit Rollen und Direktiven beschriebene, jedoch werden wir jedes Stück Schritt für Schritt betrachten, um zu erklären, was passiert.

Die Knotenklassen

Beginnen wir mit den Knotenklassen

 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

Knotenklassen müssen normalerweise nichts tun, außer von den Standard-Docutils-Klassen zu erben, die in docutils.nodes definiert sind. todo erbt von Admonition, da es wie eine Notiz oder Warnung behandelt werden sollte, todolist ist lediglich ein „allgemeiner“ Knoten.

Hinweis

Viele Erweiterungen müssen keine eigenen Knotenklassen erstellen und funktionieren gut mit den bereits von docutils und Sphinx bereitgestellten Knoten.

Aufmerksamkeit

Es ist wichtig zu wissen, dass Sie Sphinx zwar erweitern können, ohne Ihre conf.py zu verlassen, wenn Sie dort einen geerbten Knoten deklarieren, Sie jedoch auf einen nicht offensichtlichen PickleError stoßen werden. Stellen Sie also bitte sicher, dass Sie geerbte Knoten in ein separates Python-Modul legen, wenn etwas schiefgeht.

Für weitere Details siehe

Die Direktivenklassen

Eine Direktivenklasse ist eine Klasse, die normalerweise von docutils.parsers.rst.Directive abgeleitet ist. Die Direktiven-Schnittstelle wird auch im docutils Dokumentation detailliert behandelt; wichtig ist, dass die Klasse Attribute zur Konfiguration der zulässigen Markup-Elemente und eine run-Methode enthält, die eine Liste von Knoten zurückgibt.

Betrachten wir zuerst die TodolistDirective-Direktive

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

Sie ist sehr einfach und erstellt und gibt eine Instanz unserer todolist-Knotenklasse zurück. Die TodolistDirective-Direktive selbst hat weder Inhalt noch Argumente, die behandelt werden müssen. Damit kommen wir zur TodoDirective-Direktive

 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]

Hier werden mehrere wichtige Dinge behandelt. Erstens erben wir jetzt, wie Sie sehen können, von der Hilfsklasse sphinx.util.docutils.SphinxDirective anstelle der üblichen Directive-Klasse. Dies gibt uns Zugriff auf die Build-Umgebungsinstanz über die Eigenschaft self.env. Ohne dies müssten wir das eher umständliche self.state.document.settings.env verwenden. Dann muss die TodoDirective-Direktive, um als Linkziel zu dienen (von TodolistDirective), zusätzlich zum todo-Knoten einen Zielknoten zurückgeben. Die Ziel-ID (in HTML wird dies der Ankername sein) wird durch die Verwendung von env.new_serialno generiert, die bei jedem Aufruf eine neue eindeutige Ganzzahl zurückgibt und daher eindeutige Zielnamen erzeugt. Der Zielknoten wird ohne Text instanziiert (die ersten beiden Argumente).

Beim Erstellen eines Admonition-Knotens wird der Inhaltskörper der Direktive mit self.parse_content_to_nodes() analysiert. Anschließend wird der todo-Knoten zur Umgebung hinzugefügt. Dies ist notwendig, um eine Liste aller Todo-Einträge im gesamten Dokumentation erstellen zu können, an der Stelle, an der der Autor eine todolist-Direktive platziert. Für diesen Fall wird das Umgebungsattribut todo_all_todos verwendet (auch hier sollte der Name eindeutig sein, daher wird er mit dem Erweiterungsnamen präfixiert). Es existiert nicht, wenn eine neue Umgebung erstellt wird, daher muss die Direktive dies prüfen und bei Bedarf erstellen. Verschiedene Informationen über den Speicherort des Todo-Eintrags werden zusammen mit einer Kopie des Knotens gespeichert.

In der letzten Zeile werden die Knoten zurückgegeben, die in den Doctree eingefügt werden sollen: der Zielknoten und der Admonition-Knoten.

Die von der Direktive zurückgegebene Knotenstruktur sieht wie folgt aus

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

Die Ereignisbehandler

Ereignisbehandler sind eines der mächtigsten Features von Sphinx und bieten eine Möglichkeit, sich in jeden Teil des Dokumentationsprozesses einzuhaken. Es gibt viele von Sphinx selbst bereitgestellte Ereignisse, wie im API-Handbuch beschrieben, und wir werden hier eine Teilmenge davon verwenden.

Schauen wir uns die im obigen Beispiel verwendeten Ereignisbehandler an. Zuerst den für das env-purge-doc-Ereignis

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    ]

Da wir Informationen aus Quelldateien in der persistenten Umgebung speichern, können diese veraltet sein, wenn sich die Quelldatei ändert. Daher werden vor dem Lesen jeder Quelldatei die Aufzeichnungen der Umgebung darüber gelöscht, und das env-purge-doc-Ereignis gibt Erweiterungen die Möglichkeit, dasselbe zu tun. Hier löschen wir alle Todos, deren docname mit dem angegebenen übereinstimmt, aus der todo_all_todos-Liste. Wenn noch Todos im Dokument verbleiben, werden diese während des Parsens erneut hinzugefügt.

Der nächste Behandler, für das env-merge-info-Ereignis, wird während paralleler Builds verwendet. Da bei parallelen Builds alle Threads ihre eigene env haben, gibt es mehrere todo_all_todos-Listen, die zusammengeführt werden müssen

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)

Der andere Behandler gehört zum doctree-resolved-Ereignis

 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)

Das doctree-resolved-Ereignis wird für jedes Dokument ausgelöst, das am Ende von Phase 3 (Auflösung) geschrieben werden soll, und ermöglicht die benutzerdefinierte Auflösung für dieses Dokument. Der Behandler, den wir für dieses Ereignis geschrieben haben, ist etwas komplexer. Wenn der Konfigurationswert todo_include_todos (den wir kurz beschreiben werden) falsch ist, werden alle todo- und todolist-Knoten aus den Dokumenten entfernt. Wenn nicht, bleiben die todo-Knoten dort und so, wie sie sind. todolist-Knoten werden durch eine Liste von Todo-Einträgen ersetzt, komplett mit Backlinks zum Speicherort, von dem sie stammen. Die Listenpunkte bestehen aus den Knoten des todo-Eintrags und dynamisch erstellten Docutils-Knoten: ein Absatz für jeden Eintrag, der Text enthält, der den Speicherort angibt, und ein Link (Referenzknoten, der einen kursiven Knoten enthält) mit der Rückreferenz. Die Referenz-URI wird von sphinx.builders.Builder.get_relative_uri() erstellt, die eine geeignete URI basierend auf dem verwendeten Builder erstellt, und die ID des Todo-Knotens (des Ziels) als Ankernamen angehängt.

Die setup Funktion

Wie bereits erwähnt (vorher), ist die Funktion setup eine Voraussetzung und wird zum Einbinden von Direktiven in Sphinx verwendet. Wir verwenden sie aber auch, um die anderen Teile unserer Erweiterung zu verbinden. Schauen wir uns unsere setup-Funktion an

 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    }

Die Aufrufe in dieser Funktion beziehen sich auf die zuvor hinzugefügten Klassen und Funktionen. Was die einzelnen Aufrufe tun, ist Folgendes

  • add_config_value() teilt Sphinx mit, dass es den neuen *Konfigurationswert* todo_include_todos erkennen soll, dessen Standardwert False ist (was Sphinx auch mitteilt, dass es sich um einen booleschen Wert handelt).

    Wenn das dritte Argument 'html' wäre, würden HTML-Dokumente vollständig neu aufgebaut, wenn sich der Konfigurationswert ändert. Dies ist für Konfigurationswerte erforderlich, die das Lesen beeinflussen (Phase 1 (Lesen) des Builds).

  • add_node() fügt dem Build-System eine neue *Knotenklasse* hinzu. Es kann auch Besucherfunktionen für jedes unterstützte Ausgabeformat angeben. Diese Besucherfunktionen sind erforderlich, wenn die neuen Knoten bis Phase 4 (Schreiben) bestehen bleiben. Da der todolist-Knoten immer in Phase 3 (Auflösung) ersetzt wird, benötigt er keine.

  • add_directive() fügt eine neue *Direktive* hinzu, gegeben durch Name und Klasse.

  • Schließlich fügt connect() einen *Ereignisbehandler* zum Ereignis hinzu, dessen Name durch das erste Argument gegeben ist. Die Ereignisbehandler-Funktion wird mit mehreren Argumenten aufgerufen, die mit dem Ereignis dokumentiert sind.

Damit ist unsere Erweiterung abgeschlossen.

Verwenden der Erweiterung

Wie zuvor müssen wir die Erweiterung aktivieren, indem wir sie in unserer conf.py-Datei deklarieren. Hierfür sind zwei Schritte erforderlich

  1. Fügen Sie das Verzeichnis _ext mit sys.path.append zum Python-Pfad hinzu. Dies sollte am Anfang der Datei platziert werden.

  2. Aktualisieren oder erstellen Sie die Liste extensions und fügen Sie den Namen der Erweiterungsdatei zur Liste hinzu

Zusätzlich können wir den Konfigurationswert todo_include_todos setzen wollen. Wie oben erwähnt, ist dieser standardmäßig False, aber wir können ihn explizit setzen.

Zum Beispiel

import sys
from pathlib import Path

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

extensions = ['todo']

todo_include_todos = False

Sie können die Erweiterung nun in Ihrem gesamten Projekt verwenden. Zum Beispiel

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

.. toctree::
   somefile.rst
   someotherfile.rst

Hello world. Below is the list of TODOs.

.. todolist::
somefile.rst
foo
===

Some intro text here...

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

Some more text here...

.. todo:: Fix that

Da wir todo_include_todos auf False gesetzt haben, werden wir für die Direktiven todo und todolist tatsächlich nichts gerendert sehen. Wenn wir dies jedoch auf true setzen, sehen wir die zuvor beschriebene Ausgabe.

Weitere Lektüre

Weitere Informationen finden Sie in der docutils-Dokumentation und in der Sphinx API.

Wenn Sie Ihre Erweiterung über mehrere Projekte oder mit anderen teilen möchten, lesen Sie den Abschnitt Drittanbieter-Erweiterungen.