1# -*- coding: utf-8 -*-
2"""
3    pyspecific.py
4    ~~~~~~~~~~~~~
5
6    Sphinx extension with Python doc-specific markup.
7
8    :copyright: 2008-2014 by Georg Brandl.
9    :license: Python license.
10"""
11
12import re
13import io
14from os import getenv, path
15from time import asctime
16from pprint import pformat
17from docutils.io import StringOutput
18from docutils.parsers.rst import Directive
19from docutils.utils import new_document
20
21from docutils import nodes, utils
22
23from sphinx import addnodes
24from sphinx.builders import Builder
25try:
26    from sphinx.errors import NoUri
27except ImportError:
28    from sphinx.environment import NoUri
29from sphinx.locale import _ as sphinx_gettext
30from sphinx.util import status_iterator, logging
31from sphinx.util.docutils import SphinxDirective
32from sphinx.util.nodes import split_explicit_title
33from sphinx.writers.text import TextWriter, TextTranslator
34from sphinx.writers.latex import LaTeXTranslator
35
36try:
37    from sphinx.domains.python import PyFunction, PyMethod
38except ImportError:
39    from sphinx.domains.python import PyClassmember as PyMethod
40    from sphinx.domains.python import PyModulelevel as PyFunction
41
42# Support for checking for suspicious markup
43
44import suspicious
45
46
47ISSUE_URI = 'https://bugs.python.org/issue?@action=redirect&bpo=%s'
48GH_ISSUE_URI = 'https://github.com/python/cpython/issues/%s'
49SOURCE_URI = 'https://github.com/python/cpython/tree/3.11/%s'
50
51# monkey-patch reST parser to disable alphabetic and roman enumerated lists
52from docutils.parsers.rst.states import Body
53Body.enum.converters['loweralpha'] = \
54    Body.enum.converters['upperalpha'] = \
55    Body.enum.converters['lowerroman'] = \
56    Body.enum.converters['upperroman'] = lambda x: None
57
58
59# Support for marking up and linking to bugs.python.org issues
60
61def issue_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
62    issue = utils.unescape(text)
63    # sanity check: there are no bpo issues within these two values
64    if 47261 < int(issue) < 400000:
65        msg = inliner.reporter.error(f'The BPO ID {text!r} seems too high -- '
66                                     'use :gh:`...` for GitHub IDs', line=lineno)
67        prb = inliner.problematic(rawtext, rawtext, msg)
68        return [prb], [msg]
69    text = 'bpo-' + issue
70    refnode = nodes.reference(text, text, refuri=ISSUE_URI % issue)
71    return [refnode], []
72
73
74# Support for marking up and linking to GitHub issues
75
76def gh_issue_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
77    issue = utils.unescape(text)
78    # sanity check: all GitHub issues have ID >= 32426
79    # even though some of them are also valid BPO IDs
80    if int(issue) < 32426:
81        msg = inliner.reporter.error(f'The GitHub ID {text!r} seems too low -- '
82                                     'use :issue:`...` for BPO IDs', line=lineno)
83        prb = inliner.problematic(rawtext, rawtext, msg)
84        return [prb], [msg]
85    text = 'gh-' + issue
86    refnode = nodes.reference(text, text, refuri=GH_ISSUE_URI % issue)
87    return [refnode], []
88
89
90# Support for linking to Python source files easily
91
92def source_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
93    has_t, title, target = split_explicit_title(text)
94    title = utils.unescape(title)
95    target = utils.unescape(target)
96    refnode = nodes.reference(title, title, refuri=SOURCE_URI % target)
97    return [refnode], []
98
99
100# Support for marking up implementation details
101
102class ImplementationDetail(Directive):
103
104    has_content = True
105    final_argument_whitespace = True
106
107    # This text is copied to templates/dummy.html
108    label_text = 'CPython implementation detail:'
109
110    def run(self):
111        self.assert_has_content()
112        pnode = nodes.compound(classes=['impl-detail'])
113        label = sphinx_gettext(self.label_text)
114        content = self.content
115        add_text = nodes.strong(label, label)
116        self.state.nested_parse(content, self.content_offset, pnode)
117        content = nodes.inline(pnode[0].rawsource, translatable=True)
118        content.source = pnode[0].source
119        content.line = pnode[0].line
120        content += pnode[0].children
121        pnode[0].replace_self(nodes.paragraph(
122            '', '', add_text, nodes.Text(' '), content, translatable=False))
123        return [pnode]
124
125
126# Support for documenting platform availability
127
128class Availability(SphinxDirective):
129
130    has_content = True
131    required_arguments = 1
132    optional_arguments = 0
133    final_argument_whitespace = True
134
135    # known platform, libc, and threading implementations
136    known_platforms = frozenset({
137        "AIX", "Android", "BSD", "DragonFlyBSD", "Emscripten", "FreeBSD",
138        "Linux", "NetBSD", "OpenBSD", "POSIX", "Solaris", "Unix", "VxWorks",
139        "WASI", "Windows", "macOS",
140        # libc
141        "BSD libc", "glibc", "musl",
142        # POSIX platforms with pthreads
143        "pthreads",
144    })
145
146    def run(self):
147        availability_ref = ':ref:`Availability <availability>`: '
148        avail_nodes, avail_msgs = self.state.inline_text(
149            availability_ref + self.arguments[0],
150            self.lineno)
151        pnode = nodes.paragraph(availability_ref + self.arguments[0],
152                                '', *avail_nodes, *avail_msgs)
153        self.set_source_info(pnode)
154        cnode = nodes.container("", pnode, classes=["availability"])
155        self.set_source_info(cnode)
156        if self.content:
157            self.state.nested_parse(self.content, self.content_offset, cnode)
158        self.parse_platforms()
159
160        return [cnode]
161
162    def parse_platforms(self):
163        """Parse platform information from arguments
164
165        Arguments is a comma-separated string of platforms. A platform may
166        be prefixed with "not " to indicate that a feature is not available.
167
168        Example::
169
170           .. availability:: Windows, Linux >= 4.2, not Emscripten, not WASI
171
172        Arguments like "Linux >= 3.17 with glibc >= 2.27" are currently not
173        parsed into separate tokens.
174        """
175        platforms = {}
176        for arg in self.arguments[0].rstrip(".").split(","):
177            arg = arg.strip()
178            platform, _, version = arg.partition(" >= ")
179            if platform.startswith("not "):
180                version = False
181                platform = platform[4:]
182            elif not version:
183                version = True
184            platforms[platform] = version
185
186        unknown = set(platforms).difference(self.known_platforms)
187        if unknown:
188            cls = type(self)
189            logger = logging.getLogger(cls.__qualname__)
190            logger.warn(
191                f"Unknown platform(s) or syntax '{' '.join(sorted(unknown))}' "
192                f"in '.. availability:: {self.arguments[0]}', see "
193                f"{__file__}:{cls.__qualname__}.known_platforms for a set "
194                "known platforms."
195            )
196
197        return platforms
198
199
200
201# Support for documenting audit event
202
203def audit_events_purge(app, env, docname):
204    """This is to remove from env.all_audit_events old traces of removed
205    documents.
206    """
207    if not hasattr(env, 'all_audit_events'):
208        return
209    fresh_all_audit_events = {}
210    for name, event in env.all_audit_events.items():
211        event["source"] = [(d, t) for d, t in event["source"] if d != docname]
212        if event["source"]:
213            # Only keep audit_events that have at least one source.
214            fresh_all_audit_events[name] = event
215    env.all_audit_events = fresh_all_audit_events
216
217
218def audit_events_merge(app, env, docnames, other):
219    """In Sphinx parallel builds, this merges env.all_audit_events from
220    subprocesses.
221
222    all_audit_events is a dict of names, with values like:
223    {'source': [(docname, target), ...], 'args': args}
224    """
225    if not hasattr(other, 'all_audit_events'):
226        return
227    if not hasattr(env, 'all_audit_events'):
228        env.all_audit_events = {}
229    for name, value in other.all_audit_events.items():
230        if name in env.all_audit_events:
231            env.all_audit_events[name]["source"].extend(value["source"])
232        else:
233            env.all_audit_events[name] = value
234
235
236class AuditEvent(Directive):
237
238    has_content = True
239    required_arguments = 1
240    optional_arguments = 2
241    final_argument_whitespace = True
242
243    _label = [
244        "Raises an :ref:`auditing event <auditing>` {name} with no arguments.",
245        "Raises an :ref:`auditing event <auditing>` {name} with argument {args}.",
246        "Raises an :ref:`auditing event <auditing>` {name} with arguments {args}.",
247    ]
248
249    @property
250    def logger(self):
251        cls = type(self)
252        return logging.getLogger(cls.__module__ + "." + cls.__name__)
253
254    def run(self):
255        name = self.arguments[0]
256        if len(self.arguments) >= 2 and self.arguments[1]:
257            args = (a.strip() for a in self.arguments[1].strip("'\"").split(","))
258            args = [a for a in args if a]
259        else:
260            args = []
261
262        label = sphinx_gettext(self._label[min(2, len(args))])
263        text = label.format(name="``{}``".format(name),
264                            args=", ".join("``{}``".format(a) for a in args if a))
265
266        env = self.state.document.settings.env
267        if not hasattr(env, 'all_audit_events'):
268            env.all_audit_events = {}
269
270        new_info = {
271            'source': [],
272            'args': args
273        }
274        info = env.all_audit_events.setdefault(name, new_info)
275        if info is not new_info:
276            if not self._do_args_match(info['args'], new_info['args']):
277                self.logger.warn(
278                    "Mismatched arguments for audit-event {}: {!r} != {!r}"
279                    .format(name, info['args'], new_info['args'])
280                )
281
282        ids = []
283        try:
284            target = self.arguments[2].strip("\"'")
285        except (IndexError, TypeError):
286            target = None
287        if not target:
288            target = "audit_event_{}_{}".format(
289                re.sub(r'\W', '_', name),
290                len(info['source']),
291            )
292            ids.append(target)
293
294        info['source'].append((env.docname, target))
295
296        pnode = nodes.paragraph(text, classes=["audit-hook"], ids=ids)
297        pnode.line = self.lineno
298        if self.content:
299            self.state.nested_parse(self.content, self.content_offset, pnode)
300        else:
301            n, m = self.state.inline_text(text, self.lineno)
302            pnode.extend(n + m)
303
304        return [pnode]
305
306    # This list of sets are allowable synonyms for event argument names.
307    # If two names are in the same set, they are treated as equal for the
308    # purposes of warning. This won't help if number of arguments is
309    # different!
310    _SYNONYMS = [
311        {"file", "path", "fd"},
312    ]
313
314    def _do_args_match(self, args1, args2):
315        if args1 == args2:
316            return True
317        if len(args1) != len(args2):
318            return False
319        for a1, a2 in zip(args1, args2):
320            if a1 == a2:
321                continue
322            if any(a1 in s and a2 in s for s in self._SYNONYMS):
323                continue
324            return False
325        return True
326
327
328class audit_event_list(nodes.General, nodes.Element):
329    pass
330
331
332class AuditEventListDirective(Directive):
333
334    def run(self):
335        return [audit_event_list('')]
336
337
338# Support for documenting decorators
339
340class PyDecoratorMixin(object):
341    def handle_signature(self, sig, signode):
342        ret = super(PyDecoratorMixin, self).handle_signature(sig, signode)
343        signode.insert(0, addnodes.desc_addname('@', '@'))
344        return ret
345
346    def needs_arglist(self):
347        return False
348
349
350class PyDecoratorFunction(PyDecoratorMixin, PyFunction):
351    def run(self):
352        # a decorator function is a function after all
353        self.name = 'py:function'
354        return PyFunction.run(self)
355
356
357# TODO: Use sphinx.domains.python.PyDecoratorMethod when possible
358class PyDecoratorMethod(PyDecoratorMixin, PyMethod):
359    def run(self):
360        self.name = 'py:method'
361        return PyMethod.run(self)
362
363
364class PyCoroutineMixin(object):
365    def handle_signature(self, sig, signode):
366        ret = super(PyCoroutineMixin, self).handle_signature(sig, signode)
367        signode.insert(0, addnodes.desc_annotation('coroutine ', 'coroutine '))
368        return ret
369
370
371class PyAwaitableMixin(object):
372    def handle_signature(self, sig, signode):
373        ret = super(PyAwaitableMixin, self).handle_signature(sig, signode)
374        signode.insert(0, addnodes.desc_annotation('awaitable ', 'awaitable '))
375        return ret
376
377
378class PyCoroutineFunction(PyCoroutineMixin, PyFunction):
379    def run(self):
380        self.name = 'py:function'
381        return PyFunction.run(self)
382
383
384class PyCoroutineMethod(PyCoroutineMixin, PyMethod):
385    def run(self):
386        self.name = 'py:method'
387        return PyMethod.run(self)
388
389
390class PyAwaitableFunction(PyAwaitableMixin, PyFunction):
391    def run(self):
392        self.name = 'py:function'
393        return PyFunction.run(self)
394
395
396class PyAwaitableMethod(PyAwaitableMixin, PyMethod):
397    def run(self):
398        self.name = 'py:method'
399        return PyMethod.run(self)
400
401
402class PyAbstractMethod(PyMethod):
403
404    def handle_signature(self, sig, signode):
405        ret = super(PyAbstractMethod, self).handle_signature(sig, signode)
406        signode.insert(0, addnodes.desc_annotation('abstractmethod ',
407                                                   'abstractmethod '))
408        return ret
409
410    def run(self):
411        self.name = 'py:method'
412        return PyMethod.run(self)
413
414
415# Support for documenting version of removal in deprecations
416
417class DeprecatedRemoved(Directive):
418    has_content = True
419    required_arguments = 2
420    optional_arguments = 1
421    final_argument_whitespace = True
422    option_spec = {}
423
424    _deprecated_label = 'Deprecated since version {deprecated}, will be removed in version {removed}'
425    _removed_label = 'Deprecated since version {deprecated}, removed in version {removed}'
426
427    def run(self):
428        node = addnodes.versionmodified()
429        node.document = self.state.document
430        node['type'] = 'deprecated-removed'
431        version = (self.arguments[0], self.arguments[1])
432        node['version'] = version
433        env = self.state.document.settings.env
434        current_version = tuple(int(e) for e in env.config.version.split('.'))
435        removed_version = tuple(int(e) for e in self.arguments[1].split('.'))
436        if current_version < removed_version:
437            label = self._deprecated_label
438        else:
439            label = self._removed_label
440
441        label = sphinx_gettext(label)
442        text = label.format(deprecated=self.arguments[0], removed=self.arguments[1])
443        if len(self.arguments) == 3:
444            inodes, messages = self.state.inline_text(self.arguments[2],
445                                                      self.lineno+1)
446            para = nodes.paragraph(self.arguments[2], '', *inodes, translatable=False)
447            node.append(para)
448        else:
449            messages = []
450        if self.content:
451            self.state.nested_parse(self.content, self.content_offset, node)
452        if len(node):
453            if isinstance(node[0], nodes.paragraph) and node[0].rawsource:
454                content = nodes.inline(node[0].rawsource, translatable=True)
455                content.source = node[0].source
456                content.line = node[0].line
457                content += node[0].children
458                node[0].replace_self(nodes.paragraph('', '', content, translatable=False))
459            node[0].insert(0, nodes.inline('', '%s: ' % text,
460                                           classes=['versionmodified']))
461        else:
462            para = nodes.paragraph('', '',
463                                   nodes.inline('', '%s.' % text,
464                                                classes=['versionmodified']),
465                                   translatable=False)
466            node.append(para)
467        env = self.state.document.settings.env
468        env.get_domain('changeset').note_changeset(node)
469        return [node] + messages
470
471
472# Support for including Misc/NEWS
473
474issue_re = re.compile('(?:[Ii]ssue #|bpo-)([0-9]+)', re.I)
475gh_issue_re = re.compile('(?:gh-issue-|gh-)([0-9]+)', re.I)
476whatsnew_re = re.compile(r"(?im)^what's new in (.*?)\??$")
477
478
479class MiscNews(Directive):
480    has_content = False
481    required_arguments = 1
482    optional_arguments = 0
483    final_argument_whitespace = False
484    option_spec = {}
485
486    def run(self):
487        fname = self.arguments[0]
488        source = self.state_machine.input_lines.source(
489            self.lineno - self.state_machine.input_offset - 1)
490        source_dir = getenv('PY_MISC_NEWS_DIR')
491        if not source_dir:
492            source_dir = path.dirname(path.abspath(source))
493        fpath = path.join(source_dir, fname)
494        self.state.document.settings.record_dependencies.add(fpath)
495        try:
496            with io.open(fpath, encoding='utf-8') as fp:
497                content = fp.read()
498        except Exception:
499            text = 'The NEWS file is not available.'
500            node = nodes.strong(text, text)
501            return [node]
502        content = issue_re.sub(r':issue:`\1`', content)
503        # Fallback handling for the GitHub issue
504        content = gh_issue_re.sub(r':gh:`\1`', content)
505        content = whatsnew_re.sub(r'\1', content)
506        # remove first 3 lines as they are the main heading
507        lines = ['.. default-role:: obj', ''] + content.splitlines()[3:]
508        self.state_machine.insert_input(lines, fname)
509        return []
510
511
512# Support for building "topic help" for pydoc
513
514pydoc_topic_labels = [
515    'assert', 'assignment', 'async', 'atom-identifiers', 'atom-literals',
516    'attribute-access', 'attribute-references', 'augassign', 'await',
517    'binary', 'bitwise', 'bltin-code-objects', 'bltin-ellipsis-object',
518    'bltin-null-object', 'bltin-type-objects', 'booleans',
519    'break', 'callable-types', 'calls', 'class', 'comparisons', 'compound',
520    'context-managers', 'continue', 'conversions', 'customization', 'debugger',
521    'del', 'dict', 'dynamic-features', 'else', 'exceptions', 'execmodel',
522    'exprlists', 'floating', 'for', 'formatstrings', 'function', 'global',
523    'id-classes', 'identifiers', 'if', 'imaginary', 'import', 'in', 'integers',
524    'lambda', 'lists', 'naming', 'nonlocal', 'numbers', 'numeric-types',
525    'objects', 'operator-summary', 'pass', 'power', 'raise', 'return',
526    'sequence-types', 'shifting', 'slicings', 'specialattrs', 'specialnames',
527    'string-methods', 'strings', 'subscriptions', 'truth', 'try', 'types',
528    'typesfunctions', 'typesmapping', 'typesmethods', 'typesmodules',
529    'typesseq', 'typesseq-mutable', 'unary', 'while', 'with', 'yield'
530]
531
532
533class PydocTopicsBuilder(Builder):
534    name = 'pydoc-topics'
535
536    default_translator_class = TextTranslator
537
538    def init(self):
539        self.topics = {}
540        self.secnumbers = {}
541
542    def get_outdated_docs(self):
543        return 'all pydoc topics'
544
545    def get_target_uri(self, docname, typ=None):
546        return ''  # no URIs
547
548    def write(self, *ignored):
549        writer = TextWriter(self)
550        for label in status_iterator(pydoc_topic_labels,
551                                     'building topics... ',
552                                     length=len(pydoc_topic_labels)):
553            if label not in self.env.domaindata['std']['labels']:
554                self.env.logger.warn('label %r not in documentation' % label)
555                continue
556            docname, labelid, sectname = self.env.domaindata['std']['labels'][label]
557            doctree = self.env.get_and_resolve_doctree(docname, self)
558            document = new_document('<section node>')
559            document.append(doctree.ids[labelid])
560            destination = StringOutput(encoding='utf-8')
561            writer.write(document, destination)
562            self.topics[label] = writer.output
563
564    def finish(self):
565        f = open(path.join(self.outdir, 'topics.py'), 'wb')
566        try:
567            f.write('# -*- coding: utf-8 -*-\n'.encode('utf-8'))
568            f.write(('# Autogenerated by Sphinx on %s\n' % asctime()).encode('utf-8'))
569            f.write(('topics = ' + pformat(self.topics) + '\n').encode('utf-8'))
570        finally:
571            f.close()
572
573
574# Support for documenting Opcodes
575
576opcode_sig_re = re.compile(r'(\w+(?:\+\d)?)(?:\s*\((.*)\))?')
577
578
579def parse_opcode_signature(env, sig, signode):
580    """Transform an opcode signature into RST nodes."""
581    m = opcode_sig_re.match(sig)
582    if m is None:
583        raise ValueError
584    opname, arglist = m.groups()
585    signode += addnodes.desc_name(opname, opname)
586    if arglist is not None:
587        paramlist = addnodes.desc_parameterlist()
588        signode += paramlist
589        paramlist += addnodes.desc_parameter(arglist, arglist)
590    return opname.strip()
591
592
593# Support for documenting pdb commands
594
595pdbcmd_sig_re = re.compile(r'([a-z()!]+)\s*(.*)')
596
597# later...
598# pdbargs_tokens_re = re.compile(r'''[a-zA-Z]+  |  # identifiers
599#                                   [.,:]+     |  # punctuation
600#                                   [\[\]()]   |  # parens
601#                                   \s+           # whitespace
602#                                   ''', re.X)
603
604
605def parse_pdb_command(env, sig, signode):
606    """Transform a pdb command signature into RST nodes."""
607    m = pdbcmd_sig_re.match(sig)
608    if m is None:
609        raise ValueError
610    name, args = m.groups()
611    fullname = name.replace('(', '').replace(')', '')
612    signode += addnodes.desc_name(name, name)
613    if args:
614        signode += addnodes.desc_addname(' '+args, ' '+args)
615    return fullname
616
617
618def process_audit_events(app, doctree, fromdocname):
619    for node in doctree.traverse(audit_event_list):
620        break
621    else:
622        return
623
624    env = app.builder.env
625
626    table = nodes.table(cols=3)
627    group = nodes.tgroup(
628        '',
629        nodes.colspec(colwidth=30),
630        nodes.colspec(colwidth=55),
631        nodes.colspec(colwidth=15),
632        cols=3,
633    )
634    head = nodes.thead()
635    body = nodes.tbody()
636
637    table += group
638    group += head
639    group += body
640
641    row = nodes.row()
642    row += nodes.entry('', nodes.paragraph('', nodes.Text('Audit event')))
643    row += nodes.entry('', nodes.paragraph('', nodes.Text('Arguments')))
644    row += nodes.entry('', nodes.paragraph('', nodes.Text('References')))
645    head += row
646
647    for name in sorted(getattr(env, "all_audit_events", ())):
648        audit_event = env.all_audit_events[name]
649
650        row = nodes.row()
651        node = nodes.paragraph('', nodes.Text(name))
652        row += nodes.entry('', node)
653
654        node = nodes.paragraph()
655        for i, a in enumerate(audit_event['args']):
656            if i:
657                node += nodes.Text(", ")
658            node += nodes.literal(a, nodes.Text(a))
659        row += nodes.entry('', node)
660
661        node = nodes.paragraph()
662        backlinks = enumerate(sorted(set(audit_event['source'])), start=1)
663        for i, (doc, label) in backlinks:
664            if isinstance(label, str):
665                ref = nodes.reference("", nodes.Text("[{}]".format(i)), internal=True)
666                try:
667                    ref['refuri'] = "{}#{}".format(
668                        app.builder.get_relative_uri(fromdocname, doc),
669                        label,
670                    )
671                except NoUri:
672                    continue
673                node += ref
674        row += nodes.entry('', node)
675
676        body += row
677
678    for node in doctree.traverse(audit_event_list):
679        node.replace_self(table)
680
681
682def patch_pairindextypes(app, _env) -> None:
683    """Remove all entries from ``pairindextypes`` before writing POT files.
684
685    We want to run this just before writing output files, as the check to
686    circumvent is in ``I18nBuilder.write_doc()``.
687    As such, we link this to ``env-check-consistency``, even though it has
688    nothing to do with the environment consistency check.
689    """
690    if app.builder.name != 'gettext':
691        return
692
693    # allow translating deprecated index entries
694    try:
695        from sphinx.domains.python import pairindextypes
696    except ImportError:
697        pass
698    else:
699        # Sphinx checks if a 'pair' type entry on an index directive is one of
700        # the Sphinx-translated pairindextypes values. As we intend to move
701        # away from this, we need Sphinx to believe that these values don't
702        # exist, by deleting them when using the gettext builder.
703        pairindextypes.clear()
704
705
706def setup(app):
707    app.add_role('issue', issue_role)
708    app.add_role('gh', gh_issue_role)
709    app.add_role('source', source_role)
710    app.add_directive('impl-detail', ImplementationDetail)
711    app.add_directive('availability', Availability)
712    app.add_directive('audit-event', AuditEvent)
713    app.add_directive('audit-event-table', AuditEventListDirective)
714    app.add_directive('deprecated-removed', DeprecatedRemoved)
715    app.add_builder(PydocTopicsBuilder)
716    app.add_builder(suspicious.CheckSuspiciousMarkupBuilder)
717    app.add_object_type('opcode', 'opcode', '%s (opcode)', parse_opcode_signature)
718    app.add_object_type('pdbcommand', 'pdbcmd', '%s (pdb command)', parse_pdb_command)
719    app.add_object_type('2to3fixer', '2to3fixer', '%s (2to3 fixer)')
720    app.add_directive_to_domain('py', 'decorator', PyDecoratorFunction)
721    app.add_directive_to_domain('py', 'decoratormethod', PyDecoratorMethod)
722    app.add_directive_to_domain('py', 'coroutinefunction', PyCoroutineFunction)
723    app.add_directive_to_domain('py', 'coroutinemethod', PyCoroutineMethod)
724    app.add_directive_to_domain('py', 'awaitablefunction', PyAwaitableFunction)
725    app.add_directive_to_domain('py', 'awaitablemethod', PyAwaitableMethod)
726    app.add_directive_to_domain('py', 'abstractmethod', PyAbstractMethod)
727    app.add_directive('miscnews', MiscNews)
728    app.connect('env-check-consistency', patch_pairindextypes)
729    app.connect('doctree-resolved', process_audit_events)
730    app.connect('env-merge-info', audit_events_merge)
731    app.connect('env-purge-doc', audit_events_purge)
732    return {'version': '1.0', 'parallel_read_safe': True}
733