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