diff --git a/doc/pydrake/pydrake_sphinx_extension.py b/doc/pydrake/pydrake_sphinx_extension.py index 64608432746b..436962b016ce 100644 --- a/doc/pydrake/pydrake_sphinx_extension.py +++ b/doc/pydrake/pydrake_sphinx_extension.py @@ -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 @@ -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 @@ -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): @@ -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): @@ -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. @@ -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)