summaryrefslogtreecommitdiffstats
path: root/sphinx/ext/viewcode.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/ext/viewcode.py')
-rw-r--r--sphinx/ext/viewcode.py361
1 files changed, 361 insertions, 0 deletions
diff --git a/sphinx/ext/viewcode.py b/sphinx/ext/viewcode.py
new file mode 100644
index 0000000..c5fcda5
--- /dev/null
+++ b/sphinx/ext/viewcode.py
@@ -0,0 +1,361 @@
+"""Add links to module code in Python object descriptions."""
+
+from __future__ import annotations
+
+import posixpath
+import traceback
+from importlib import import_module
+from os import path
+from typing import TYPE_CHECKING, Any, cast
+
+from docutils import nodes
+from docutils.nodes import Element, Node
+
+import sphinx
+from sphinx import addnodes
+from sphinx.builders.html import StandaloneHTMLBuilder
+from sphinx.locale import _, __
+from sphinx.pycode import ModuleAnalyzer
+from sphinx.transforms.post_transforms import SphinxPostTransform
+from sphinx.util import logging
+from sphinx.util.display import status_iterator
+from sphinx.util.nodes import make_refnode
+
+if TYPE_CHECKING:
+ from collections.abc import Generator, Iterable
+
+ from sphinx.application import Sphinx
+ from sphinx.builders import Builder
+ from sphinx.environment import BuildEnvironment
+
+logger = logging.getLogger(__name__)
+
+
+OUTPUT_DIRNAME = '_modules'
+
+
+class viewcode_anchor(Element):
+ """Node for viewcode anchors.
+
+ This node will be processed in the resolving phase.
+ For viewcode supported builders, they will be all converted to the anchors.
+ For not supported builders, they will be removed.
+ """
+
+
+def _get_full_modname(modname: str, attribute: str) -> str | None:
+ try:
+ if modname is None:
+ # Prevents a TypeError: if the last getattr() call will return None
+ # then it's better to return it directly
+ return None
+ module = import_module(modname)
+
+ # Allow an attribute to have multiple parts and incidentally allow
+ # repeated .s in the attribute.
+ value = module
+ for attr in attribute.split('.'):
+ if attr:
+ value = getattr(value, attr)
+
+ return getattr(value, '__module__', None)
+ except AttributeError:
+ # sphinx.ext.viewcode can't follow class instance attribute
+ # then AttributeError logging output only verbose mode.
+ logger.verbose("Didn't find %s in %s", attribute, modname)
+ return None
+ except Exception as e:
+ # sphinx.ext.viewcode follow python domain directives.
+ # because of that, if there are no real modules exists that specified
+ # by py:function or other directives, viewcode emits a lot of warnings.
+ # It should be displayed only verbose mode.
+ logger.verbose(traceback.format_exc().rstrip())
+ logger.verbose('viewcode can\'t import %s, failed with error "%s"', modname, e)
+ return None
+
+
+def is_supported_builder(builder: Builder) -> bool:
+ if builder.format != 'html':
+ return False
+ if builder.name == 'singlehtml':
+ return False
+ if builder.name.startswith('epub') and not builder.config.viewcode_enable_epub:
+ return False
+ return True
+
+
+def doctree_read(app: Sphinx, doctree: Node) -> None:
+ env = app.builder.env
+ if not hasattr(env, '_viewcode_modules'):
+ env._viewcode_modules = {} # type: ignore[attr-defined]
+
+ def has_tag(modname: str, fullname: str, docname: str, refname: str) -> bool:
+ entry = env._viewcode_modules.get(modname, None) # type: ignore[attr-defined]
+ if entry is False:
+ return False
+
+ code_tags = app.emit_firstresult('viewcode-find-source', modname)
+ if code_tags is None:
+ try:
+ analyzer = ModuleAnalyzer.for_module(modname)
+ analyzer.find_tags()
+ except Exception:
+ env._viewcode_modules[modname] = False # type: ignore[attr-defined]
+ return False
+
+ code = analyzer.code
+ tags = analyzer.tags
+ else:
+ code, tags = code_tags
+
+ if entry is None or entry[0] != code:
+ entry = code, tags, {}, refname
+ env._viewcode_modules[modname] = entry # type: ignore[attr-defined]
+ _, tags, used, _ = entry
+ if fullname in tags:
+ used[fullname] = docname
+ return True
+
+ return False
+
+ for objnode in list(doctree.findall(addnodes.desc)):
+ if objnode.get('domain') != 'py':
+ continue
+ names: set[str] = set()
+ for signode in objnode:
+ if not isinstance(signode, addnodes.desc_signature):
+ continue
+ modname = signode.get('module')
+ fullname = signode.get('fullname')
+ refname = modname
+ if env.config.viewcode_follow_imported_members:
+ new_modname = app.emit_firstresult(
+ 'viewcode-follow-imported', modname, fullname,
+ )
+ if not new_modname:
+ new_modname = _get_full_modname(modname, fullname)
+ modname = new_modname
+ if not modname:
+ continue
+ fullname = signode.get('fullname')
+ if not has_tag(modname, fullname, env.docname, refname):
+ continue
+ if fullname in names:
+ # only one link per name, please
+ continue
+ names.add(fullname)
+ pagename = posixpath.join(OUTPUT_DIRNAME, modname.replace('.', '/'))
+ signode += viewcode_anchor(reftarget=pagename, refid=fullname, refdoc=env.docname)
+
+
+def env_merge_info(app: Sphinx, env: BuildEnvironment, docnames: Iterable[str],
+ other: BuildEnvironment) -> None:
+ if not hasattr(other, '_viewcode_modules'):
+ return
+ # create a _viewcode_modules dict on the main environment
+ if not hasattr(env, '_viewcode_modules'):
+ env._viewcode_modules = {} # type: ignore[attr-defined]
+ # now merge in the information from the subprocess
+ for modname, entry in other._viewcode_modules.items():
+ if modname not in env._viewcode_modules: # type: ignore[attr-defined]
+ env._viewcode_modules[modname] = entry # type: ignore[attr-defined]
+ else:
+ if env._viewcode_modules[modname]: # type: ignore[attr-defined]
+ used = env._viewcode_modules[modname][2] # type: ignore[attr-defined]
+ for fullname, docname in entry[2].items():
+ if fullname not in used:
+ used[fullname] = docname
+
+
+def env_purge_doc(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
+ modules = getattr(env, '_viewcode_modules', {})
+
+ for modname, entry in list(modules.items()):
+ if entry is False:
+ continue
+
+ code, tags, used, refname = entry
+ for fullname in list(used):
+ if used[fullname] == docname:
+ used.pop(fullname)
+
+ if len(used) == 0:
+ modules.pop(modname)
+
+
+class ViewcodeAnchorTransform(SphinxPostTransform):
+ """Convert or remove viewcode_anchor nodes depends on builder."""
+ default_priority = 100
+
+ def run(self, **kwargs: Any) -> None:
+ if is_supported_builder(self.app.builder):
+ self.convert_viewcode_anchors()
+ else:
+ self.remove_viewcode_anchors()
+
+ def convert_viewcode_anchors(self) -> None:
+ for node in self.document.findall(viewcode_anchor):
+ anchor = nodes.inline('', _('[source]'), classes=['viewcode-link'])
+ refnode = make_refnode(self.app.builder, node['refdoc'], node['reftarget'],
+ node['refid'], anchor)
+ node.replace_self(refnode)
+
+ def remove_viewcode_anchors(self) -> None:
+ for node in list(self.document.findall(viewcode_anchor)):
+ node.parent.remove(node)
+
+
+def get_module_filename(app: Sphinx, modname: str) -> str | None:
+ """Get module filename for *modname*."""
+ source_info = app.emit_firstresult('viewcode-find-source', modname)
+ if source_info:
+ return None
+ else:
+ try:
+ filename, source = ModuleAnalyzer.get_module_source(modname)
+ return filename
+ except Exception:
+ return None
+
+
+def should_generate_module_page(app: Sphinx, modname: str) -> bool:
+ """Check generation of module page is needed."""
+ module_filename = get_module_filename(app, modname)
+ if module_filename is None:
+ # Always (re-)generate module page when module filename is not found.
+ return True
+
+ builder = cast(StandaloneHTMLBuilder, app.builder)
+ basename = modname.replace('.', '/') + builder.out_suffix
+ page_filename = path.join(app.outdir, '_modules/', basename)
+
+ try:
+ if path.getmtime(module_filename) <= path.getmtime(page_filename):
+ # generation is not needed if the HTML page is newer than module file.
+ return False
+ except OSError:
+ pass
+
+ return True
+
+
+def collect_pages(app: Sphinx) -> Generator[tuple[str, dict[str, Any], str], None, None]:
+ env = app.builder.env
+ if not hasattr(env, '_viewcode_modules'):
+ return
+ if not is_supported_builder(app.builder):
+ return
+ highlighter = app.builder.highlighter # type: ignore[attr-defined]
+ urito = app.builder.get_relative_uri
+
+ modnames = set(env._viewcode_modules)
+
+ for modname, entry in status_iterator(
+ sorted(env._viewcode_modules.items()),
+ __('highlighting module code... '), "blue",
+ len(env._viewcode_modules),
+ app.verbosity, lambda x: x[0]):
+ if not entry:
+ continue
+ if not should_generate_module_page(app, modname):
+ continue
+
+ code, tags, used, refname = entry
+ # construct a page name for the highlighted source
+ pagename = posixpath.join(OUTPUT_DIRNAME, modname.replace('.', '/'))
+ # highlight the source using the builder's highlighter
+ if env.config.highlight_language in {'default', 'none'}:
+ lexer = env.config.highlight_language
+ else:
+ lexer = 'python'
+ linenos = 'inline' * env.config.viewcode_line_numbers
+ highlighted = highlighter.highlight_block(code, lexer, linenos=linenos)
+ # split the code into lines
+ lines = highlighted.splitlines()
+ # split off wrap markup from the first line of the actual code
+ before, after = lines[0].split('<pre>')
+ lines[0:1] = [before + '<pre>', after]
+ # nothing to do for the last line; it always starts with </pre> anyway
+ # now that we have code lines (starting at index 1), insert anchors for
+ # the collected tags (HACK: this only works if the tag boundaries are
+ # properly nested!)
+ max_index = len(lines) - 1
+ link_text = _('[docs]')
+ for name, docname in used.items():
+ type, start, end = tags[name]
+ backlink = urito(pagename, docname) + '#' + refname + '.' + name
+ lines[start] = (f'<div class="viewcode-block" id="{name}">\n'
+ f'<a class="viewcode-back" href="{backlink}">{link_text}</a>\n'
+ + lines[start])
+ lines[min(end, max_index)] += '</div>\n'
+
+ # try to find parents (for submodules)
+ parents = []
+ parent = modname
+ while '.' in parent:
+ parent = parent.rsplit('.', 1)[0]
+ if parent in modnames:
+ parents.append({
+ 'link': urito(pagename,
+ posixpath.join(OUTPUT_DIRNAME, parent.replace('.', '/'))),
+ 'title': parent})
+ parents.append({'link': urito(pagename, posixpath.join(OUTPUT_DIRNAME, 'index')),
+ 'title': _('Module code')})
+ parents.reverse()
+ # putting it all together
+ context = {
+ 'parents': parents,
+ 'title': modname,
+ 'body': (_('<h1>Source code for %s</h1>') % modname +
+ '\n'.join(lines)),
+ }
+ yield (pagename, context, 'page.html')
+
+ if not modnames:
+ return
+
+ html = ['\n']
+ # the stack logic is needed for using nested lists for submodules
+ stack = ['']
+ for modname in sorted(modnames):
+ if modname.startswith(stack[-1]):
+ stack.append(modname + '.')
+ html.append('<ul>')
+ else:
+ stack.pop()
+ while not modname.startswith(stack[-1]):
+ stack.pop()
+ html.append('</ul>')
+ stack.append(modname + '.')
+ relative_uri = urito(posixpath.join(OUTPUT_DIRNAME, 'index'),
+ posixpath.join(OUTPUT_DIRNAME, modname.replace('.', '/')))
+ html.append(f'<li><a href="{relative_uri}">{modname}</a></li>\n')
+ html.append('</ul>' * (len(stack) - 1))
+ context = {
+ 'title': _('Overview: module code'),
+ 'body': (_('<h1>All modules for which code is available</h1>') +
+ ''.join(html)),
+ }
+
+ yield (posixpath.join(OUTPUT_DIRNAME, 'index'), context, 'page.html')
+
+
+def setup(app: Sphinx) -> dict[str, Any]:
+ app.add_config_value('viewcode_import', None, False)
+ app.add_config_value('viewcode_enable_epub', False, False)
+ app.add_config_value('viewcode_follow_imported_members', True, False)
+ app.add_config_value('viewcode_line_numbers', False, 'env', (bool,))
+ app.connect('doctree-read', doctree_read)
+ app.connect('env-merge-info', env_merge_info)
+ app.connect('env-purge-doc', env_purge_doc)
+ app.connect('html-collect-pages', collect_pages)
+ # app.add_config_value('viewcode_include_modules', [], 'env')
+ # app.add_config_value('viewcode_exclude_modules', [], 'env')
+ app.add_event('viewcode-find-source')
+ app.add_event('viewcode-follow-imported')
+ app.add_post_transform(ViewcodeAnchorTransform)
+ return {
+ 'version': sphinx.__display_version__,
+ 'env_version': 1,
+ 'parallel_read_safe': True,
+ }