• 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
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 translators
30from sphinx.util import status_iterator, logging
31from sphinx.util.nodes import split_explicit_title
32from sphinx.writers.text import TextWriter, TextTranslator
33from sphinx.writers.latex import LaTeXTranslator
34from sphinx.domains.python import PyModulelevel, PyClassmember
35
36# Support for checking for suspicious markup
37
38import suspicious
39
40
41ISSUE_URI = 'https://bugs.python.org/issue%s'
42SOURCE_URI = 'https://github.com/python/cpython/tree/3.8/%s'
43
44# monkey-patch reST parser to disable alphabetic and roman enumerated lists
45from docutils.parsers.rst.states import Body
46Body.enum.converters['loweralpha'] = \
47    Body.enum.converters['upperalpha'] = \
48    Body.enum.converters['lowerroman'] = \
49    Body.enum.converters['upperroman'] = lambda x: None
50
51
52# Support for marking up and linking to bugs.python.org issues
53
54def issue_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
55    issue = utils.unescape(text)
56    text = 'bpo-' + issue
57    refnode = nodes.reference(text, text, refuri=ISSUE_URI % issue)
58    return [refnode], []
59
60
61# Support for linking to Python source files easily
62
63def source_role(typ, rawtext, text, lineno, inliner, options={}, content=[]):
64    has_t, title, target = split_explicit_title(text)
65    title = utils.unescape(title)
66    target = utils.unescape(target)
67    refnode = nodes.reference(title, title, refuri=SOURCE_URI % target)
68    return [refnode], []
69
70
71# Support for marking up implementation details
72
73class ImplementationDetail(Directive):
74
75    has_content = True
76    required_arguments = 0
77    optional_arguments = 1
78    final_argument_whitespace = True
79
80    # This text is copied to templates/dummy.html
81    label_text = 'CPython implementation detail:'
82
83    def run(self):
84        pnode = nodes.compound(classes=['impl-detail'])
85        label = translators['sphinx'].gettext(self.label_text)
86        content = self.content
87        add_text = nodes.strong(label, label)
88        if self.arguments:
89            n, m = self.state.inline_text(self.arguments[0], self.lineno)
90            pnode.append(nodes.paragraph('', '', *(n + m)))
91        self.state.nested_parse(content, self.content_offset, pnode)
92        if pnode.children and isinstance(pnode[0], nodes.paragraph):
93            content = nodes.inline(pnode[0].rawsource, translatable=True)
94            content.source = pnode[0].source
95            content.line = pnode[0].line
96            content += pnode[0].children
97            pnode[0].replace_self(nodes.paragraph('', '', content,
98                                                  translatable=False))
99            pnode[0].insert(0, add_text)
100            pnode[0].insert(1, nodes.Text(' '))
101        else:
102            pnode.insert(0, nodes.paragraph('', '', add_text))
103        return [pnode]
104
105
106# Support for documenting platform availability
107
108class Availability(Directive):
109
110    has_content = False
111    required_arguments = 1
112    optional_arguments = 0
113    final_argument_whitespace = True
114
115    def run(self):
116        availability_ref = ':ref:`Availability <availability>`: '
117        pnode = nodes.paragraph(availability_ref + self.arguments[0],
118                                classes=["availability"],)
119        n, m = self.state.inline_text(availability_ref, self.lineno)
120        pnode.extend(n + m)
121        n, m = self.state.inline_text(self.arguments[0], self.lineno)
122        pnode.extend(n + m)
123        return [pnode]
124
125
126# Support for documenting audit event
127
128class AuditEvent(Directive):
129
130    has_content = True
131    required_arguments = 1
132    optional_arguments = 2
133    final_argument_whitespace = True
134
135    _label = [
136        "Raises an :ref:`auditing event <auditing>` {name} with no arguments.",
137        "Raises an :ref:`auditing event <auditing>` {name} with argument {args}.",
138        "Raises an :ref:`auditing event <auditing>` {name} with arguments {args}.",
139    ]
140
141    @property
142    def logger(self):
143        cls = type(self)
144        return logging.getLogger(cls.__module__ + "." + cls.__name__)
145
146    def run(self):
147        name = self.arguments[0]
148        if len(self.arguments) >= 2 and self.arguments[1]:
149            args = (a.strip() for a in self.arguments[1].strip("'\"").split(","))
150            args = [a for a in args if a]
151        else:
152            args = []
153
154        label = translators['sphinx'].gettext(self._label[min(2, len(args))])
155        text = label.format(name="``{}``".format(name),
156                            args=", ".join("``{}``".format(a) for a in args if a))
157
158        env = self.state.document.settings.env
159        if not hasattr(env, 'all_audit_events'):
160            env.all_audit_events = {}
161
162        new_info = {
163            'source': [],
164            'args': args
165        }
166        info = env.all_audit_events.setdefault(name, new_info)
167        if info is not new_info:
168            if not self._do_args_match(info['args'], new_info['args']):
169                self.logger.warn(
170                    "Mismatched arguments for audit-event {}: {!r} != {!r}"
171                    .format(name, info['args'], new_info['args'])
172                )
173
174        ids = []
175        try:
176            target = self.arguments[2].strip("\"'")
177        except (IndexError, TypeError):
178            target = None
179        if not target:
180            target = "audit_event_{}_{}".format(
181                re.sub(r'\W', '_', name),
182                len(info['source']),
183            )
184            ids.append(target)
185
186        info['source'].append((env.docname, target))
187
188        pnode = nodes.paragraph(text, classes=["audit-hook"], ids=ids)
189        if self.content:
190            self.state.nested_parse(self.content, self.content_offset, pnode)
191        else:
192            n, m = self.state.inline_text(text, self.lineno)
193            pnode.extend(n + m)
194
195        return [pnode]
196
197    # This list of sets are allowable synonyms for event argument names.
198    # If two names are in the same set, they are treated as equal for the
199    # purposes of warning. This won't help if number of arguments is
200    # different!
201    _SYNONYMS = [
202        {"file", "path", "fd"},
203    ]
204
205    def _do_args_match(self, args1, args2):
206        if args1 == args2:
207            return True
208        if len(args1) != len(args2):
209            return False
210        for a1, a2 in zip(args1, args2):
211            if a1 == a2:
212                continue
213            if any(a1 in s and a2 in s for s in self._SYNONYMS):
214                continue
215            return False
216        return True
217
218
219class audit_event_list(nodes.General, nodes.Element):
220    pass
221
222
223class AuditEventListDirective(Directive):
224
225    def run(self):
226        return [audit_event_list('')]
227
228
229# Support for documenting decorators
230
231class PyDecoratorMixin(object):
232    def handle_signature(self, sig, signode):
233        ret = super(PyDecoratorMixin, self).handle_signature(sig, signode)
234        signode.insert(0, addnodes.desc_addname('@', '@'))
235        return ret
236
237    def needs_arglist(self):
238        return False
239
240
241class PyDecoratorFunction(PyDecoratorMixin, PyModulelevel):
242    def run(self):
243        # a decorator function is a function after all
244        self.name = 'py:function'
245        return PyModulelevel.run(self)
246
247
248class PyDecoratorMethod(PyDecoratorMixin, PyClassmember):
249    def run(self):
250        self.name = 'py:method'
251        return PyClassmember.run(self)
252
253
254class PyCoroutineMixin(object):
255    def handle_signature(self, sig, signode):
256        ret = super(PyCoroutineMixin, self).handle_signature(sig, signode)
257        signode.insert(0, addnodes.desc_annotation('coroutine ', 'coroutine '))
258        return ret
259
260
261class PyAwaitableMixin(object):
262    def handle_signature(self, sig, signode):
263        ret = super(PyAwaitableMixin, self).handle_signature(sig, signode)
264        signode.insert(0, addnodes.desc_annotation('awaitable ', 'awaitable '))
265        return ret
266
267
268class PyCoroutineFunction(PyCoroutineMixin, PyModulelevel):
269    def run(self):
270        self.name = 'py:function'
271        return PyModulelevel.run(self)
272
273
274class PyCoroutineMethod(PyCoroutineMixin, PyClassmember):
275    def run(self):
276        self.name = 'py:method'
277        return PyClassmember.run(self)
278
279
280class PyAwaitableFunction(PyAwaitableMixin, PyClassmember):
281    def run(self):
282        self.name = 'py:function'
283        return PyClassmember.run(self)
284
285
286class PyAwaitableMethod(PyAwaitableMixin, PyClassmember):
287    def run(self):
288        self.name = 'py:method'
289        return PyClassmember.run(self)
290
291
292class PyAbstractMethod(PyClassmember):
293
294    def handle_signature(self, sig, signode):
295        ret = super(PyAbstractMethod, self).handle_signature(sig, signode)
296        signode.insert(0, addnodes.desc_annotation('abstractmethod ',
297                                                   'abstractmethod '))
298        return ret
299
300    def run(self):
301        self.name = 'py:method'
302        return PyClassmember.run(self)
303
304
305# Support for documenting version of removal in deprecations
306
307class DeprecatedRemoved(Directive):
308    has_content = True
309    required_arguments = 2
310    optional_arguments = 1
311    final_argument_whitespace = True
312    option_spec = {}
313
314    _deprecated_label = 'Deprecated since version {deprecated}, will be removed in version {removed}'
315    _removed_label = 'Deprecated since version {deprecated}, removed in version {removed}'
316
317    def run(self):
318        node = addnodes.versionmodified()
319        node.document = self.state.document
320        node['type'] = 'deprecated-removed'
321        version = (self.arguments[0], self.arguments[1])
322        node['version'] = version
323        env = self.state.document.settings.env
324        current_version = tuple(int(e) for e in env.config.version.split('.'))
325        removed_version = tuple(int(e) for e in self.arguments[1].split('.'))
326        if current_version < removed_version:
327            label = self._deprecated_label
328        else:
329            label = self._removed_label
330
331        label = translators['sphinx'].gettext(label)
332        text = label.format(deprecated=self.arguments[0], removed=self.arguments[1])
333        if len(self.arguments) == 3:
334            inodes, messages = self.state.inline_text(self.arguments[2],
335                                                      self.lineno+1)
336            para = nodes.paragraph(self.arguments[2], '', *inodes, translatable=False)
337            node.append(para)
338        else:
339            messages = []
340        if self.content:
341            self.state.nested_parse(self.content, self.content_offset, node)
342        if len(node):
343            if isinstance(node[0], nodes.paragraph) and node[0].rawsource:
344                content = nodes.inline(node[0].rawsource, translatable=True)
345                content.source = node[0].source
346                content.line = node[0].line
347                content += node[0].children
348                node[0].replace_self(nodes.paragraph('', '', content, translatable=False))
349            node[0].insert(0, nodes.inline('', '%s: ' % text,
350                                           classes=['versionmodified']))
351        else:
352            para = nodes.paragraph('', '',
353                                   nodes.inline('', '%s.' % text,
354                                                classes=['versionmodified']),
355                                   translatable=False)
356            node.append(para)
357        env = self.state.document.settings.env
358        env.get_domain('changeset').note_changeset(node)
359        return [node] + messages
360
361
362# Support for including Misc/NEWS
363
364issue_re = re.compile('(?:[Ii]ssue #|bpo-)([0-9]+)')
365whatsnew_re = re.compile(r"(?im)^what's new in (.*?)\??$")
366
367
368class MiscNews(Directive):
369    has_content = False
370    required_arguments = 1
371    optional_arguments = 0
372    final_argument_whitespace = False
373    option_spec = {}
374
375    def run(self):
376        fname = self.arguments[0]
377        source = self.state_machine.input_lines.source(
378            self.lineno - self.state_machine.input_offset - 1)
379        source_dir = getenv('PY_MISC_NEWS_DIR')
380        if not source_dir:
381            source_dir = path.dirname(path.abspath(source))
382        fpath = path.join(source_dir, fname)
383        self.state.document.settings.record_dependencies.add(fpath)
384        try:
385            with io.open(fpath, encoding='utf-8') as fp:
386                content = fp.read()
387        except Exception:
388            text = 'The NEWS file is not available.'
389            node = nodes.strong(text, text)
390            return [node]
391        content = issue_re.sub(r'`bpo-\1 <https://bugs.python.org/issue\1>`__',
392                               content)
393        content = whatsnew_re.sub(r'\1', content)
394        # remove first 3 lines as they are the main heading
395        lines = ['.. default-role:: obj', ''] + content.splitlines()[3:]
396        self.state_machine.insert_input(lines, fname)
397        return []
398
399
400# Support for building "topic help" for pydoc
401
402pydoc_topic_labels = [
403    'assert', 'assignment', 'async', 'atom-identifiers', 'atom-literals',
404    'attribute-access', 'attribute-references', 'augassign', 'await',
405    'binary', 'bitwise', 'bltin-code-objects', 'bltin-ellipsis-object',
406    'bltin-null-object', 'bltin-type-objects', 'booleans',
407    'break', 'callable-types', 'calls', 'class', 'comparisons', 'compound',
408    'context-managers', 'continue', 'conversions', 'customization', 'debugger',
409    'del', 'dict', 'dynamic-features', 'else', 'exceptions', 'execmodel',
410    'exprlists', 'floating', 'for', 'formatstrings', 'function', 'global',
411    'id-classes', 'identifiers', 'if', 'imaginary', 'import', 'in', 'integers',
412    'lambda', 'lists', 'naming', 'nonlocal', 'numbers', 'numeric-types',
413    'objects', 'operator-summary', 'pass', 'power', 'raise', 'return',
414    'sequence-types', 'shifting', 'slicings', 'specialattrs', 'specialnames',
415    'string-methods', 'strings', 'subscriptions', 'truth', 'try', 'types',
416    'typesfunctions', 'typesmapping', 'typesmethods', 'typesmodules',
417    'typesseq', 'typesseq-mutable', 'unary', 'while', 'with', 'yield'
418]
419
420
421class PydocTopicsBuilder(Builder):
422    name = 'pydoc-topics'
423
424    default_translator_class = TextTranslator
425
426    def init(self):
427        self.topics = {}
428        self.secnumbers = {}
429
430    def get_outdated_docs(self):
431        return 'all pydoc topics'
432
433    def get_target_uri(self, docname, typ=None):
434        return ''  # no URIs
435
436    def write(self, *ignored):
437        writer = TextWriter(self)
438        for label in status_iterator(pydoc_topic_labels,
439                                     'building topics... ',
440                                     length=len(pydoc_topic_labels)):
441            if label not in self.env.domaindata['std']['labels']:
442                self.env.logger.warn('label %r not in documentation' % label)
443                continue
444            docname, labelid, sectname = self.env.domaindata['std']['labels'][label]
445            doctree = self.env.get_and_resolve_doctree(docname, self)
446            document = new_document('<section node>')
447            document.append(doctree.ids[labelid])
448            destination = StringOutput(encoding='utf-8')
449            writer.write(document, destination)
450            self.topics[label] = writer.output
451
452    def finish(self):
453        f = open(path.join(self.outdir, 'topics.py'), 'wb')
454        try:
455            f.write('# -*- coding: utf-8 -*-\n'.encode('utf-8'))
456            f.write(('# Autogenerated by Sphinx on %s\n' % asctime()).encode('utf-8'))
457            f.write(('topics = ' + pformat(self.topics) + '\n').encode('utf-8'))
458        finally:
459            f.close()
460
461
462# Support for documenting Opcodes
463
464opcode_sig_re = re.compile(r'(\w+(?:\+\d)?)(?:\s*\((.*)\))?')
465
466
467def parse_opcode_signature(env, sig, signode):
468    """Transform an opcode signature into RST nodes."""
469    m = opcode_sig_re.match(sig)
470    if m is None:
471        raise ValueError
472    opname, arglist = m.groups()
473    signode += addnodes.desc_name(opname, opname)
474    if arglist is not None:
475        paramlist = addnodes.desc_parameterlist()
476        signode += paramlist
477        paramlist += addnodes.desc_parameter(arglist, arglist)
478    return opname.strip()
479
480
481# Support for documenting pdb commands
482
483pdbcmd_sig_re = re.compile(r'([a-z()!]+)\s*(.*)')
484
485# later...
486# pdbargs_tokens_re = re.compile(r'''[a-zA-Z]+  |  # identifiers
487#                                   [.,:]+     |  # punctuation
488#                                   [\[\]()]   |  # parens
489#                                   \s+           # whitespace
490#                                   ''', re.X)
491
492
493def parse_pdb_command(env, sig, signode):
494    """Transform a pdb command signature into RST nodes."""
495    m = pdbcmd_sig_re.match(sig)
496    if m is None:
497        raise ValueError
498    name, args = m.groups()
499    fullname = name.replace('(', '').replace(')', '')
500    signode += addnodes.desc_name(name, name)
501    if args:
502        signode += addnodes.desc_addname(' '+args, ' '+args)
503    return fullname
504
505
506def process_audit_events(app, doctree, fromdocname):
507    for node in doctree.traverse(audit_event_list):
508        break
509    else:
510        return
511
512    env = app.builder.env
513
514    table = nodes.table(cols=3)
515    group = nodes.tgroup(
516        '',
517        nodes.colspec(colwidth=30),
518        nodes.colspec(colwidth=55),
519        nodes.colspec(colwidth=15),
520        cols=3,
521    )
522    head = nodes.thead()
523    body = nodes.tbody()
524
525    table += group
526    group += head
527    group += body
528
529    row = nodes.row()
530    row += nodes.entry('', nodes.paragraph('', nodes.Text('Audit event')))
531    row += nodes.entry('', nodes.paragraph('', nodes.Text('Arguments')))
532    row += nodes.entry('', nodes.paragraph('', nodes.Text('References')))
533    head += row
534
535    for name in sorted(getattr(env, "all_audit_events", ())):
536        audit_event = env.all_audit_events[name]
537
538        row = nodes.row()
539        node = nodes.paragraph('', nodes.Text(name))
540        row += nodes.entry('', node)
541
542        node = nodes.paragraph()
543        for i, a in enumerate(audit_event['args']):
544            if i:
545                node += nodes.Text(", ")
546            node += nodes.literal(a, nodes.Text(a))
547        row += nodes.entry('', node)
548
549        node = nodes.paragraph()
550        backlinks = enumerate(sorted(set(audit_event['source'])), start=1)
551        for i, (doc, label) in backlinks:
552            if isinstance(label, str):
553                ref = nodes.reference("", nodes.Text("[{}]".format(i)), internal=True)
554                try:
555                    ref['refuri'] = "{}#{}".format(
556                        app.builder.get_relative_uri(fromdocname, doc),
557                        label,
558                    )
559                except NoUri:
560                    continue
561                node += ref
562        row += nodes.entry('', node)
563
564        body += row
565
566    for node in doctree.traverse(audit_event_list):
567        node.replace_self(table)
568
569
570def setup(app):
571    app.add_role('issue', issue_role)
572    app.add_role('source', source_role)
573    app.add_directive('impl-detail', ImplementationDetail)
574    app.add_directive('availability', Availability)
575    app.add_directive('audit-event', AuditEvent)
576    app.add_directive('audit-event-table', AuditEventListDirective)
577    app.add_directive('deprecated-removed', DeprecatedRemoved)
578    app.add_builder(PydocTopicsBuilder)
579    app.add_builder(suspicious.CheckSuspiciousMarkupBuilder)
580    app.add_object_type('opcode', 'opcode', '%s (opcode)', parse_opcode_signature)
581    app.add_object_type('pdbcommand', 'pdbcmd', '%s (pdb command)', parse_pdb_command)
582    app.add_object_type('2to3fixer', '2to3fixer', '%s (2to3 fixer)')
583    app.add_directive_to_domain('py', 'decorator', PyDecoratorFunction)
584    app.add_directive_to_domain('py', 'decoratormethod', PyDecoratorMethod)
585    app.add_directive_to_domain('py', 'coroutinefunction', PyCoroutineFunction)
586    app.add_directive_to_domain('py', 'coroutinemethod', PyCoroutineMethod)
587    app.add_directive_to_domain('py', 'awaitablefunction', PyAwaitableFunction)
588    app.add_directive_to_domain('py', 'awaitablemethod', PyAwaitableMethod)
589    app.add_directive_to_domain('py', 'abstractmethod', PyAbstractMethod)
590    app.add_directive('miscnews', MiscNews)
591    app.connect('doctree-resolved', process_audit_events)
592    return {'version': '1.0', 'parallel_read_safe': True}
593