Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 46 additions & 166 deletions doc/pydrake/pydrake_sphinx_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@

import re
from textwrap import indent
from typing import Any
import warnings

from docutils import nodes
from docutils.parsers.rst import Directive
from docutils.statemachine import ViewList
from sphinx import version_info as sphinx_version
import sphinx.domains.python as pydoc
from sphinx.ext import autodoc
from sphinx.locale import _
from sphinx.util.nodes import nested_parse_with_titles

from doc.doxygen_cxx.system_doxygen import system_yaml_to_html
Expand Down Expand Up @@ -54,14 +51,11 @@ def generate_sig_re(extended=False):
) # end of thing name
"""

# TODO(mwoehlke-kitware): Make unconditional when Jammy is no longer
# supported.
if sphinx_version[:3] >= (7, 1, 0):
# More recent versions of Sphinx capture an optional type parameters
# list. But we want to capture that as part of the name(s). However,
# we still need to provide a capture group, so 'capture' something that
# won't exist in order to make the number of capture groups correct.
expression += r"(!!dummy_tp_list!!)?"
# Sphinx captures an optional type parameters list as its own group, but
# we want to capture that as part of the name(s). Provide a dummy
# group here so the number of capture groups still matches what Sphinx
# expects when it unpacks the match.
expression += r"(!!dummy_tp_list!!)?"

expression += r"""(?: \((.*)\) # optional: arguments
(?:\s* -> \s* (.*))? # return annotation
Expand Down Expand Up @@ -185,30 +179,36 @@ def patch_resolve_name(original, self, *args, **kwargs):
return modname, repair_naive_name_split(objpath)


def patch_class_add_directive_header(original, self, sig):
"""Patches display of bases for classes to strip out pybind11 meta classes
from bases.
def _is_hidden_base(base) -> bool:
"""Returns True iff pybind11_object, the implementation base class that
pybind11 injects into every bound class, is the name of `base`"""
return base.__name__ == "pybind11_object"


def autodoc_process_bases(app, name, obj, options, bases):
"""Hides base classes from `bases`."""
bases[:] = [b for b in bases if not _is_hidden_base(b)]


def patch_class_hide_empty_bases(original, self, sig):
"""Wraps `ClassDocumenter.add_directive_header` to omit the "Bases: ..."
line entirely when every real base is hidden.

The `autodoc-process-bases` event only lets us edit the *contents* of the
bases list; Sphinx's own `add_directive_header` unconditionally emits the
"Bases:" line once `show_inheritance` is on. We temporarily disable the
latter for this instance once we know there are no bases left to show.
"""
if self.doc_as_attr:
self.directivetype = "attribute"
autodoc.Documenter.add_directive_header(self, sig)
# add inheritance info, if wanted
if not self.doc_as_attr and self.options.show_inheritance:
sourcename = self.get_sourcename()
self.add_line("", sourcename)
bases = getattr(self.object, "__bases__", None)
if not bases:
return
bases = [
b.__module__ in ("__builtin__", "builtins")
and f":class:`{b.__name__}`"
or f":class:`{b.__module__}.{b.__name__}`"
for b in bases
if b.__name__ != "pybind11_object"
]
if not bases:
return
self.add_line(_(" Bases: %s") % ", ".join(bases), sourcename)
bases = self.object.__bases__
if bases and all(_is_hidden_base(b) for b in bases):
show_inheritance = self.options.show_inheritance
self.options.show_inheritance = False
try:
original(self, sig)
finally:
self.options.show_inheritance = show_inheritance
else:
original(self, sig)


def autodoc_skip_member(app, what, name, obj, skip, options):
Expand All @@ -224,129 +224,15 @@ def autodoc_skip_member(app, what, name, obj, skip, options):
return None


def patch_document_members(original, self, all_members=False):
# type: (bool) -> None
"""Generate reST for member documentation.

If *all_members* is True, do all members, else those given by
*self.options.members*.

Note: This function is a patched version for Drake to add the functionality
of sorting the documented members using a custom key function.
The original code is from Sphinx 1.6.7 installed via the `python3-sphinx`
package. Debian patches the upstream version:
https://sources.debian.org/patches/sphinx/1.6.7-2/
However, this piece of code is not patched.
https://github.com/sphinx-doc/sphinx/blob/v1.6.7/sphinx/ext/autodoc.py#L996-L1057
Our upstream PR: https://github.com/sphinx-doc/sphinx/pull/7177
def patch_sort_members(original, self, documenters, order):
"""Adds a `bycustomfunction` member-order strategy, which sorts members
alphabetically by case-insensitive full name.
"""
# set current namespace for finding members
self.env.temp_data["autodoc:module"] = self.modname
if self.objpath:
self.env.temp_data["autodoc:class"] = self.objpath[0]

want_all = (
all_members
or self.options.inherited_members
or self.options.members is autodoc.ALL
)
# find out which members are documentable
members_check_module, members = self.get_object_members(want_all)

# This method changed after version 1.6.7.
# We accommodate the changes till version 2.4.4.
# https://github.com/sphinx-doc/sphinx/commit/6e1e35c98ac29397d4552caf72710ccf4bf98bea
if sphinx_version[:3] >= (1, 8, 0):
exclude_members_all = self.options.exclude_members is autodoc.ALL
else:
exclude_members_all = False
# remove members given by exclude-members
if self.options.exclude_members:
members = [
(membername, member)
for (membername, member) in members
if (
exclude_members_all
or membername not in self.options.exclude_members
)
]

# document non-skipped members
memberdocumenters = [] # type: List[Tuple[Documenter, bool]]
for mname, member, isattr in self.filter_members(members, want_all):
# This method changed after version 1.6.7.
# We accommodate the changes till version 2.4.4.
# https://github.com/sphinx-doc/sphinx/commit/5d6413b7120cfc6d3d0cc9367cfe8b6f7ee87523
if sphinx_version[:3] >= (1, 7, 0):
documenters = self.documenters
else:
documenters = autodoc.AutoDirective._registry
classes = [
cls
for cls in documenters.values()
if cls.can_document_member(member, mname, isattr, self)
]
if not classes:
# don't know how to document this member
continue
# prefer the documenter with the highest priority
classes.sort(key=lambda cls: cls.priority)
# give explicitly separated module name, so that members
# of inner classes can be documented
full_mname = self.modname + "::" + ".".join(self.objpath + [mname])
documenter = classes[-1](self.directive, full_mname, self.indent)
memberdocumenters.append((documenter, isattr))
member_order = (
self.options.member_order or self.env.config.autodoc_member_order
)
if member_order == "groupwise":
# sort by group; relies on stable sort to keep items in the
# same group sorted alphabetically
memberdocumenters.sort(key=lambda e: e[0].member_order)
elif member_order == "bysource" and self.analyzer:
# sort by source order, by virtue of the module analyzer
tagorder = self.analyzer.tagorder

def keyfunc(entry):
# type: (Tuple[Documenter, bool]) -> int
fullname = entry[0].name.split("::")[1]
return tagorder.get(fullname, len(tagorder))

memberdocumenters.sort(key=keyfunc)
# N.B. Patch for Drake starts here.
elif member_order == "bycustomfunction":

def custom_key(entry: tuple[autodoc.Documenter, bool]) -> Any:
result = self.env.app.emit_firstresult(
"autodoc-member-order-custom-function", entry[0]
)
if result is None:
raise RuntimeError(
"autodoc-member-order-custom-function "
"has not been specified by user"
)
return result

memberdocumenters.sort(key=custom_key)
# Patch ends here.

for documenter, isattr in memberdocumenters:
documenter.generate(
all_members=True,
real_modname=self.real_modname,
check_module=members_check_module and not isattr,
)

# reset current objects
self.env.temp_data["autodoc:module"] = None
self.env.temp_data["autodoc:class"] = None


def autodoc_member_order_function(app, documenter):
"""Let's sort the member full-names (`Class.member_name`) by lower-case."""
# N.B. This follows suite with the following 3.x code: https://git.io/Jv1CH
fullname = documenter.name.split("::")[1]
return fullname.lower()
if order != "bycustomfunction":
return original(self, documenters, order)
# N.B. This follows suit with the following 3.x code: https://git.io/Jv1CH
documenters.sort(key=lambda e: e[0].name.split("::")[1].lower())
return documenters


class PydrakeSystemDirective(Directive):
Expand Down Expand Up @@ -387,27 +273,21 @@ def _parse_rst(state, rst_text):

def setup(app):
"""Installs Drake-specific extensions and patches."""
if sphinx_version[:3] >= (1, 8, 0):
app.add_css_file("css/custom.css")
else:
app.add_stylesheet("css/custom.css")
app.add_css_file("css/custom.css")
# Add directive to process system doxygen.
app.add_directive("pydrake_system", PydrakeSystemDirective)
# Do not warn on Drake deprecations.
# TODO(eric.cousineau): See if there is a way to intercept this.
warnings.simplefilter("ignore", DrakeDeprecationWarning)
# Ignore `pybind11_object` as a base.
app.connect("autodoc-process-bases", autodoc_process_bases)
patch(
autodoc.ClassDocumenter,
"add_directive_header",
patch_class_add_directive_header,
patch_class_hide_empty_bases,
)
# Skip specific members.
app.connect("autodoc-skip-member", autodoc_skip_member)
app.add_event("autodoc-member-order-custom-function")
app.connect(
"autodoc-member-order-custom-function", autodoc_member_order_function
)
# Register directive so we can pretty-print template declarations.
pydoc.PythonDomain.directives["template"] = pydoc.PyClasslike
# Register autodocumentation for templates.
Expand All @@ -418,5 +298,5 @@ def setup(app):
pydoc.py_sig_re = generate_sig_re(extended=False)
patch(autodoc.ClassLevelDocumenter, "resolve_name", patch_resolve_name)
patch(autodoc.ModuleLevelDocumenter, "resolve_name", patch_resolve_name)
patch(autodoc.Documenter, "document_members", patch_document_members)
patch(autodoc.Documenter, "sort_members", patch_sort_members)
return dict(parallel_read_safe=True)