• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
17
18from docutils import nodes
19from docutils.io import StringOutput
20from docutils.parsers.rst import directives
21from docutils.utils import new_document, unescape
22from sphinx import addnodes
23from sphinx.builders import Builder
24from sphinx.domains.changeset import VersionChange, versionlabels, versionlabel_classes
25from sphinx.domains.python import PyFunction, PyMethod, PyModule
26from sphinx.locale import _ as sphinx_gettext
27from sphinx.util.docutils import SphinxDirective
28from sphinx.writers.text import TextWriter, TextTranslator
29from sphinx.util.display import status_iterator
30
31
32ISSUE_URI = 'https://bugs.python.org/issue?@action=redirect&bpo=%s'
33GH_ISSUE_URI = 'https://github.com/python/cpython/issues/%s'
34# Used in conf.py and updated here by python/release-tools/run_release.py
35SOURCE_URI = 'https://github.com/python/cpython/tree/3.13/%s'
36
37# monkey-patch reST parser to disable alphabetic and roman enumerated lists
38from docutils.parsers.rst.states import Body
39Body.enum.converters['loweralpha'] = \
40    Body.enum.converters['upperalpha'] = \
41    Body.enum.converters['lowerroman'] = \
42    Body.enum.converters['upperroman'] = lambda x: None
43
44# monkey-patch the productionlist directive to allow hyphens in group names
45# https://github.com/sphinx-doc/sphinx/issues/11854
46from sphinx.domains import std
47
48std.token_re = re.compile(r'`((~?[\w-]*:)?\w+)`')
49
50# backport :no-index:
51PyModule.option_spec['no-index'] = directives.flag
52
53
54# Support for marking up and linking to bugs.python.org issues
55
56def issue_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
57    issue = unescape(text)
58    # sanity check: there are no bpo issues within these two values
59    if 47261 < int(issue) < 400000:
60        msg = inliner.reporter.error(f'The BPO ID {text!r} seems too high -- '
61                                     'use :gh:`...` for GitHub IDs', line=lineno)
62        prb = inliner.problematic(rawtext, rawtext, msg)
63        return [prb], [msg]
64    text = 'bpo-' + issue
65    refnode = nodes.reference(text, text, refuri=ISSUE_URI % issue)
66    return [refnode], []
67
68
69# Support for marking up and linking to GitHub issues
70
71def gh_issue_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
72    issue = unescape(text)
73    # sanity check: all GitHub issues have ID >= 32426
74    # even though some of them are also valid BPO IDs
75    if int(issue) < 32426:
76        msg = inliner.reporter.error(f'The GitHub ID {text!r} seems too low -- '
77                                     'use :issue:`...` for BPO IDs', line=lineno)
78        prb = inliner.problematic(rawtext, rawtext, msg)
79        return [prb], [msg]
80    text = 'gh-' + issue
81    refnode = nodes.reference(text, text, refuri=GH_ISSUE_URI % issue)
82    return [refnode], []
83
84
85# Support for marking up implementation details
86
87class ImplementationDetail(SphinxDirective):
88
89    has_content = True
90    final_argument_whitespace = True
91
92    # This text is copied to templates/dummy.html
93    label_text = sphinx_gettext('CPython implementation detail:')
94
95    def run(self):
96        self.assert_has_content()
97        pnode = nodes.compound(classes=['impl-detail'])
98        content = self.content
99        add_text = nodes.strong(self.label_text, self.label_text)
100        self.state.nested_parse(content, self.content_offset, pnode)
101        content = nodes.inline(pnode[0].rawsource, translatable=True)
102        content.source = pnode[0].source
103        content.line = pnode[0].line
104        content += pnode[0].children
105        pnode[0].replace_self(nodes.paragraph(
106            '', '', add_text, nodes.Text(' '), content, translatable=False))
107        return [pnode]
108
109
110# Support for documenting decorators
111
112class PyDecoratorMixin(object):
113    def handle_signature(self, sig, signode):
114        ret = super(PyDecoratorMixin, self).handle_signature(sig, signode)
115        signode.insert(0, addnodes.desc_addname('@', '@'))
116        return ret
117
118    def needs_arglist(self):
119        return False
120
121
122class PyDecoratorFunction(PyDecoratorMixin, PyFunction):
123    def run(self):
124        # a decorator function is a function after all
125        self.name = 'py:function'
126        return PyFunction.run(self)
127
128
129# TODO: Use sphinx.domains.python.PyDecoratorMethod when possible
130class PyDecoratorMethod(PyDecoratorMixin, PyMethod):
131    def run(self):
132        self.name = 'py:method'
133        return PyMethod.run(self)
134
135
136class PyCoroutineMixin(object):
137    def handle_signature(self, sig, signode):
138        ret = super(PyCoroutineMixin, self).handle_signature(sig, signode)
139        signode.insert(0, addnodes.desc_annotation('coroutine ', 'coroutine '))
140        return ret
141
142
143class PyAwaitableMixin(object):
144    def handle_signature(self, sig, signode):
145        ret = super(PyAwaitableMixin, self).handle_signature(sig, signode)
146        signode.insert(0, addnodes.desc_annotation('awaitable ', 'awaitable '))
147        return ret
148
149
150class PyCoroutineFunction(PyCoroutineMixin, PyFunction):
151    def run(self):
152        self.name = 'py:function'
153        return PyFunction.run(self)
154
155
156class PyCoroutineMethod(PyCoroutineMixin, PyMethod):
157    def run(self):
158        self.name = 'py:method'
159        return PyMethod.run(self)
160
161
162class PyAwaitableFunction(PyAwaitableMixin, PyFunction):
163    def run(self):
164        self.name = 'py:function'
165        return PyFunction.run(self)
166
167
168class PyAwaitableMethod(PyAwaitableMixin, PyMethod):
169    def run(self):
170        self.name = 'py:method'
171        return PyMethod.run(self)
172
173
174class PyAbstractMethod(PyMethod):
175
176    def handle_signature(self, sig, signode):
177        ret = super(PyAbstractMethod, self).handle_signature(sig, signode)
178        signode.insert(0, addnodes.desc_annotation('abstractmethod ',
179                                                   'abstractmethod '))
180        return ret
181
182    def run(self):
183        self.name = 'py:method'
184        return PyMethod.run(self)
185
186
187# Support for documenting version of changes, additions, deprecations
188
189def expand_version_arg(argument, release):
190    """Expand "next" to the current version"""
191    if argument == 'next':
192        return sphinx_gettext('{} (unreleased)').format(release)
193    return argument
194
195
196class PyVersionChange(VersionChange):
197    def run(self):
198        # Replace the 'next' special token with the current development version
199        self.arguments[0] = expand_version_arg(self.arguments[0],
200                                               self.config.release)
201        return super().run()
202
203
204class DeprecatedRemoved(VersionChange):
205    required_arguments = 2
206
207    _deprecated_label = sphinx_gettext('Deprecated since version %s, will be removed in version %s')
208    _removed_label = sphinx_gettext('Deprecated since version %s, removed in version %s')
209
210    def run(self):
211        # Replace the first two arguments (deprecated version and removed version)
212        # with a single tuple of both versions.
213        version_deprecated = expand_version_arg(self.arguments[0],
214                                                self.config.release)
215        version_removed = self.arguments.pop(1)
216        if version_removed == 'next':
217            raise ValueError(
218                'deprecated-removed:: second argument cannot be `next`')
219        self.arguments[0] = version_deprecated, version_removed
220
221        # Set the label based on if we have reached the removal version
222        current_version = tuple(map(int, self.config.version.split('.')))
223        removed_version = tuple(map(int,  version_removed.split('.')))
224        if current_version < removed_version:
225            versionlabels[self.name] = self._deprecated_label
226            versionlabel_classes[self.name] = 'deprecated'
227        else:
228            versionlabels[self.name] = self._removed_label
229            versionlabel_classes[self.name] = 'removed'
230        try:
231            return super().run()
232        finally:
233            # reset versionlabels and versionlabel_classes
234            versionlabels[self.name] = ''
235            versionlabel_classes[self.name] = ''
236
237
238# Support for including Misc/NEWS
239
240issue_re = re.compile('(?:[Ii]ssue #|bpo-)([0-9]+)', re.I)
241gh_issue_re = re.compile('(?:gh-issue-|gh-)([0-9]+)', re.I)
242whatsnew_re = re.compile(r"(?im)^what's new in (.*?)\??$")
243
244
245class MiscNews(SphinxDirective):
246    has_content = False
247    required_arguments = 1
248    optional_arguments = 0
249    final_argument_whitespace = False
250    option_spec = {}
251
252    def run(self):
253        fname = self.arguments[0]
254        source = self.state_machine.input_lines.source(
255            self.lineno - self.state_machine.input_offset - 1)
256        source_dir = getenv('PY_MISC_NEWS_DIR')
257        if not source_dir:
258            source_dir = path.dirname(path.abspath(source))
259        fpath = path.join(source_dir, fname)
260        self.env.note_dependency(path.abspath(fpath))
261        try:
262            with io.open(fpath, encoding='utf-8') as fp:
263                content = fp.read()
264        except Exception:
265            text = 'The NEWS file is not available.'
266            node = nodes.strong(text, text)
267            return [node]
268        content = issue_re.sub(r':issue:`\1`', content)
269        # Fallback handling for the GitHub issue
270        content = gh_issue_re.sub(r':gh:`\1`', content)
271        content = whatsnew_re.sub(r'\1', content)
272        # remove first 3 lines as they are the main heading
273        lines = ['.. default-role:: obj', ''] + content.splitlines()[3:]
274        self.state_machine.insert_input(lines, fname)
275        return []
276
277
278# Support for building "topic help" for pydoc
279
280pydoc_topic_labels = [
281    'assert', 'assignment', 'assignment-expressions', 'async',  'atom-identifiers',
282    'atom-literals', 'attribute-access', 'attribute-references', 'augassign', 'await',
283    'binary', 'bitwise', 'bltin-code-objects', 'bltin-ellipsis-object',
284    'bltin-null-object', 'bltin-type-objects', 'booleans',
285    'break', 'callable-types', 'calls', 'class', 'comparisons', 'compound',
286    'context-managers', 'continue', 'conversions', 'customization', 'debugger',
287    'del', 'dict', 'dynamic-features', 'else', 'exceptions', 'execmodel',
288    'exprlists', 'floating', 'for', 'formatstrings', 'function', 'global',
289    'id-classes', 'identifiers', 'if', 'imaginary', 'import', 'in', 'integers',
290    'lambda', 'lists', 'naming', 'nonlocal', 'numbers', 'numeric-types',
291    'objects', 'operator-summary', 'pass', 'power', 'raise', 'return',
292    'sequence-types', 'shifting', 'slicings', 'specialattrs', 'specialnames',
293    'string-methods', 'strings', 'subscriptions', 'truth', 'try', 'types',
294    'typesfunctions', 'typesmapping', 'typesmethods', 'typesmodules',
295    'typesseq', 'typesseq-mutable', 'unary', 'while', 'with', 'yield'
296]
297
298
299class PydocTopicsBuilder(Builder):
300    name = 'pydoc-topics'
301
302    default_translator_class = TextTranslator
303
304    def init(self):
305        self.topics = {}
306        self.secnumbers = {}
307
308    def get_outdated_docs(self):
309        return 'all pydoc topics'
310
311    def get_target_uri(self, docname, typ=None):
312        return ''  # no URIs
313
314    def write(self, *ignored):
315        writer = TextWriter(self)
316        for label in status_iterator(pydoc_topic_labels,
317                                     'building topics... ',
318                                     length=len(pydoc_topic_labels)):
319            if label not in self.env.domaindata['std']['labels']:
320                self.env.logger.warning(f'label {label!r} not in documentation')
321                continue
322            docname, labelid, sectname = self.env.domaindata['std']['labels'][label]
323            doctree = self.env.get_and_resolve_doctree(docname, self)
324            document = new_document('<section node>')
325            document.append(doctree.ids[labelid])
326            destination = StringOutput(encoding='utf-8')
327            writer.write(document, destination)
328            self.topics[label] = writer.output
329
330    def finish(self):
331        f = open(path.join(self.outdir, 'topics.py'), 'wb')
332        try:
333            f.write('# -*- coding: utf-8 -*-\n'.encode('utf-8'))
334            f.write(('# Autogenerated by Sphinx on %s\n' % asctime()).encode('utf-8'))
335            f.write('# as part of the release process.\n'.encode('utf-8'))
336            f.write(('topics = ' + pformat(self.topics) + '\n').encode('utf-8'))
337        finally:
338            f.close()
339
340
341# Support for documenting Opcodes
342
343opcode_sig_re = re.compile(r'(\w+(?:\+\d)?)(?:\s*\((.*)\))?')
344
345
346def parse_opcode_signature(env, sig, signode):
347    """Transform an opcode signature into RST nodes."""
348    m = opcode_sig_re.match(sig)
349    if m is None:
350        raise ValueError
351    opname, arglist = m.groups()
352    signode += addnodes.desc_name(opname, opname)
353    if arglist is not None:
354        paramlist = addnodes.desc_parameterlist()
355        signode += paramlist
356        paramlist += addnodes.desc_parameter(arglist, arglist)
357    return opname.strip()
358
359
360# Support for documenting pdb commands
361
362pdbcmd_sig_re = re.compile(r'([a-z()!]+)\s*(.*)')
363
364# later...
365# pdbargs_tokens_re = re.compile(r'''[a-zA-Z]+  |  # identifiers
366#                                   [.,:]+     |  # punctuation
367#                                   [\[\]()]   |  # parens
368#                                   \s+           # whitespace
369#                                   ''', re.X)
370
371
372def parse_pdb_command(env, sig, signode):
373    """Transform a pdb command signature into RST nodes."""
374    m = pdbcmd_sig_re.match(sig)
375    if m is None:
376        raise ValueError
377    name, args = m.groups()
378    fullname = name.replace('(', '').replace(')', '')
379    signode += addnodes.desc_name(name, name)
380    if args:
381        signode += addnodes.desc_addname(' '+args, ' '+args)
382    return fullname
383
384
385def parse_monitoring_event(env, sig, signode):
386    """Transform a monitoring event signature into RST nodes."""
387    signode += addnodes.desc_addname('sys.monitoring.events.', 'sys.monitoring.events.')
388    signode += addnodes.desc_name(sig, sig)
389    return sig
390
391
392def patch_pairindextypes(app, _env) -> None:
393    """Remove all entries from ``pairindextypes`` before writing POT files.
394
395    We want to run this just before writing output files, as the check to
396    circumvent is in ``I18nBuilder.write_doc()``.
397    As such, we link this to ``env-check-consistency``, even though it has
398    nothing to do with the environment consistency check.
399    """
400    if app.builder.name != 'gettext':
401        return
402
403    # allow translating deprecated index entries
404    try:
405        from sphinx.domains.python import pairindextypes
406    except ImportError:
407        pass
408    else:
409        # Sphinx checks if a 'pair' type entry on an index directive is one of
410        # the Sphinx-translated pairindextypes values. As we intend to move
411        # away from this, we need Sphinx to believe that these values don't
412        # exist, by deleting them when using the gettext builder.
413        pairindextypes.clear()
414
415
416def setup(app):
417    app.add_role('issue', issue_role)
418    app.add_role('gh', gh_issue_role)
419    app.add_directive('impl-detail', ImplementationDetail)
420    app.add_directive('versionadded', PyVersionChange, override=True)
421    app.add_directive('versionchanged', PyVersionChange, override=True)
422    app.add_directive('versionremoved', PyVersionChange, override=True)
423    app.add_directive('deprecated', PyVersionChange, override=True)
424    app.add_directive('deprecated-removed', DeprecatedRemoved)
425    app.add_builder(PydocTopicsBuilder)
426    app.add_object_type('opcode', 'opcode', '%s (opcode)', parse_opcode_signature)
427    app.add_object_type('pdbcommand', 'pdbcmd', '%s (pdb command)', parse_pdb_command)
428    app.add_object_type('monitoring-event', 'monitoring-event', '%s (monitoring event)', parse_monitoring_event)
429    app.add_directive_to_domain('py', 'decorator', PyDecoratorFunction)
430    app.add_directive_to_domain('py', 'decoratormethod', PyDecoratorMethod)
431    app.add_directive_to_domain('py', 'coroutinefunction', PyCoroutineFunction)
432    app.add_directive_to_domain('py', 'coroutinemethod', PyCoroutineMethod)
433    app.add_directive_to_domain('py', 'awaitablefunction', PyAwaitableFunction)
434    app.add_directive_to_domain('py', 'awaitablemethod', PyAwaitableMethod)
435    app.add_directive_to_domain('py', 'abstractmethod', PyAbstractMethod)
436    app.add_directive('miscnews', MiscNews)
437    app.add_css_file('sidebar-wrap.css')
438    app.connect('env-check-consistency', patch_pairindextypes)
439    return {'version': '1.0', 'parallel_read_safe': True}
440