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