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