Source code for enum_tools.autoenum

#!/usr/bin/env python3
#
#  autoenum.py
"""
A Sphinx directive for documenting :class:`Enums <enum.Enum>` in Python.
"""
#
#  Copyright (c) 2020-2022 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
#  GNU Lesser General Public License for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#
#  Parts based on https://github.com/sphinx-doc/sphinx
#  |  Copyright (c) 2007-2020 by the Sphinx team (see AUTHORS file).
#  |  BSD Licensed
#  |  All rights reserved.
#  |
#  |  Redistribution and use in source and binary forms, with or without
#  |  modification, are permitted provided that the following conditions are
#  |  met:
#  |
#  |  * Redistributions of source code must retain the above copyright
#  |   notice, this list of conditions and the following disclaimer.
#  |
#  |  * Redistributions in binary form must reproduce the above copyright
#  |   notice, this list of conditions and the following disclaimer in the
#  |   documentation and/or other materials provided with the distribution.
#  |
#  |  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#  |  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
#  |  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
#  |  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
#  |  HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#  |  SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
#  |  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
#  |  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
#  |  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
#  |  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
#  |  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

# stdlib
from contextlib import suppress
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple, get_type_hints

# 3rd party
import sphinx  # nodep
from docutils.nodes import Element  # nodep
from sphinx.application import Sphinx  # nodep
from sphinx.domains import ObjType  # nodep
from sphinx.domains.python import PyClasslike, PyXRefRole  # nodep
from sphinx.environment import BuildEnvironment  # nodep
from sphinx.errors import PycodeError  # nodep
from sphinx.ext.autodoc import (  # nodep
		ALL,
		INSTANCEATTR,
		SUPPRESS,
		AttributeDocumenter,
		ClassDocumenter,
		ClassLevelDocumenter,
		Documenter,
		ObjectMember,
		logger
		)
from sphinx.locale import _  # nodep
from sphinx.pycode import ModuleAnalyzer  # nodep
from sphinx.util.inspect import memory_address_re, safe_getattr  # nodep
from sphinx_toolbox.more_autodoc import ObjectMembers  # nodep
from sphinx_toolbox.more_autodoc.typehints import format_annotation  # nodep
from sphinx_toolbox.utils import add_fallback_css_class  # nodep
from sphinx_toolbox.utils import unknown_module_warning  # nodep

try:
	# 3rd party
	from sphinx.util.typing import stringify_annotation as stringify_typehint  # type: ignore[attr-defined]
except ImportError:
	from sphinx.util.typing import stringify as stringify_typehint

# this package
from enum_tools import __version__, documentation
from enum_tools.utils import get_base_object, is_enum, is_flag

__all__ = ["EnumDocumenter", "EnumMemberDocumenter", "setup", "FlagDocumenter", "PyEnumXRefRole"]

documentation.INTERACTIVE = True

_filename_set_attribute = "filename_set" if sphinx.version_info < (4, 0) else "record_dependencies"


def _begin_generate(
		documenter: Documenter,
		real_modname: Optional[str] = None,
		check_module: bool = False,
		) -> Optional[str]:
	"""
	Boilerplate for the top of ``generate`` in :class:`sphinx.ext.autodoc.Documenter` subclasses.

	:param documenter:
	:param real_modname:
	:param check_module:

	:return: The ``sourcename``, or :py:obj:`None` if certain conditions are met,
		to indicate that the Documenter class should exit early.
	"""

	# Do not pass real_modname and use the name from the __module__
	# attribute of the class.
	# If a class gets imported into the module real_modname
	# the analyzer won't find the source of the class, if
	# it looks in real_modname.

	if not documenter.parse_name():
		# need a module to import
		unknown_module_warning(documenter)
		return None

	# now, import the module and get object to document
	if not documenter.import_object():
		return None

	# If there is no real module defined, figure out which to use.
	# The real module is used in the module analyzer to look up the module
	# where the attribute documentation would actually be found in.
	# This is used for situations where you have a module that collects the
	# functions and classes of internal submodules.
	guess_modname = documenter.get_real_modname()
	documenter.real_modname = real_modname or guess_modname

	# try to also get a source code analyzer for attribute docs
	try:
		documenter.analyzer = ModuleAnalyzer.for_module(documenter.real_modname)
		# parse right now, to get PycodeErrors on parsing (results will
		# be cached anyway)
		documenter.analyzer.find_attr_docs()

	except PycodeError as err:  # pragma: no cover
		logger.debug("[autodoc] module analyzer failed: %s", err)
		# no source file -- e.g. for builtin and C modules
		documenter.analyzer = None  # type: ignore[assignment]
		# at least add the module.__file__ as a dependency
		if hasattr(documenter.module, "__file__") and documenter.module.__file__:
			filename_set = getattr(documenter.directive, _filename_set_attribute)
			filename_set.add(documenter.module.__file__)
	else:
		filename_set = getattr(documenter.directive, _filename_set_attribute)
		filename_set.add(documenter.analyzer.srcname)

	if documenter.real_modname != guess_modname:
		# Add module to dependency list if target object is defined in other module.
		with suppress(PycodeError):
			analyzer = ModuleAnalyzer.for_module(guess_modname)
			filename_set = getattr(documenter.directive, _filename_set_attribute)
			filename_set.add(analyzer.srcname)

	# check __module__ of object (for members not given explicitly)
	if check_module:
		if not documenter.check_module():
			return None

	sourcename = documenter.get_sourcename()

	# make sure that the result starts with an empty line.  This is
	# necessary for some situations where another directive preprocesses
	# reST and no starting newline is present
	documenter.add_line('', sourcename)

	return sourcename


[docs]class EnumDocumenter(ClassDocumenter): r""" Sphinx autodoc :class:`~sphinx.ext.autodoc.Documenter` for documenting :class:`~enum.Enum`\s. """ objtype = "enum" directivetype = "enum" priority = 20 class_xref = ":class:`~enum.Enum`"
[docs] @classmethod def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any) -> bool: """ Called to see if a member can be documented by this documenter. :param member: :param membername: :param isattr: :param parent: """ return is_enum(member) and not is_flag(member)
[docs] def document_members(self, all_members: bool = False) -> None: """ Generate reST for member documentation. :param all_members: If :py:obj:`True`, document all members, otherwise document those given by ``self.options.members``. .. latex:clearpage:: """ if self.doc_as_attr: return # print(self.directive.result) # input("> ") # 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 ALL # find out which members are documentable members_check_module, members = self.get_object_members(want_all) non_enum_members: List[ObjectMember] = [] for member in members: if isinstance(member, ObjectMember): if member.__name__ not in self.object.__members__.keys(): non_enum_members.append(member) else: member_name = member[0] if member_name not in self.object.__members__.keys(): non_enum_members.append(ObjectMember(*member)) user_option_undoc_members = self.options.undoc_members # Document enums first self.options.undoc_members = True # type: ignore[attr-defined] enum_members = [ObjectMember(name=var.name, obj=var) for var in self.object] self._do_document_members( enum_members, want_all, members_check_module, description="Valid values are as follows:", ) # Document everything else self.options.undoc_members = user_option_undoc_members # type: ignore[attr-defined] methods_text = ( f"The {self.class_xref} and its members " f"{'also ' if enum_members else ''}have the following methods:" ) self._do_document_members( non_enum_members, want_all, members_check_module, description=methods_text, )
def _do_document_members( self, members: ObjectMembers, want_all: bool, members_check_module: bool, description: str, ) -> None: # remove members given by exclude-members if self.options.exclude_members: new_members = [] for member in members: if not isinstance(member, ObjectMember): member = ObjectMember(*member) if (self.options.exclude_members is ALL or member.__name__ not in self.options.exclude_members): new_members.append(member) members = new_members # document non-skipped members memberdocumenters: List[Tuple[Documenter, bool]] = [] description_added = False for (mname, member, isattr) in self.filter_members(members, want_all): if not description_added: self.add_line(description, self.sourcename) self.add_line('', self.sourcename) description_added = True # give explicitly separated module name, so that members # of inner classes can be documented full_mname = self.modname + "::" + '.'.join(self.objpath + [mname]) documenter: Documenter if isinstance(member, Enum) and member in self.object: documenter = EnumMemberDocumenter(self.directive, full_mname, self.indent) else: classes = [ cls for cls in self.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) 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: Tuple[Documenter, bool]) -> int: fullname = entry[0].name.split("::")[1] return tagorder.get(fullname, len(tagorder)) memberdocumenters.sort(key=keyfunc) 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 real_modname: str
[docs] def generate( self, more_content: Optional[Any] = None, real_modname: Optional[str] = None, check_module: bool = False, all_members: bool = False, ) -> None: """ Generate reST for the object given by *self.name*, and possibly for its members. :param more_content: Additional content to include in the reST output. :param real_modname: Module name to use to find attribute documentation. :param check_module: If :py:obj:`True`, only generate if the object is defined in the module name it is imported from. :param all_members: If :py:obj:`True`, document all members. """ ret = _begin_generate(self, real_modname, check_module) if ret is None: return sourcename = ret # Set sourcename as instance variable to avoid passing it around; it will get deleted later self.sourcename = sourcename # generate the directive header and options, if applicable self.add_directive_header("(value)") self.add_line('', sourcename) self.indent += self.content_indent # add all content (from docstrings, attribute docs etc.) self.add_content(more_content) member_type = get_base_object(self.object) if member_type is not object: # Show the type of the members self.add_line(f":Member Type: {format_annotation(member_type)}", self.sourcename) self.add_line('', self.sourcename) # document members, if possible self.document_members(all_members) del self.sourcename
[docs]class FlagDocumenter(EnumDocumenter): r""" Sphinx autodoc :class:`~sphinx.ext.autodoc.Documenter` for documenting :class:`~enum.Flag`\s. .. autosummary-widths:: 55/100 """ objtype = "flag" directivetype = "flag" priority = 15 class_xref = ":class:`~enum.Flag`"
[docs] @classmethod def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any) -> bool: """ Called to see if a member can be documented by this documenter. :param member: :param membername: :param isattr: :param parent: """ return is_flag(member)
[docs]class EnumMemberDocumenter(AttributeDocumenter): """ Sphinx autodoc :class:`~sphinx.ext.autodoc.Documenter` for documenting :class:`~enum.Enum` members. """
[docs] def import_object(self, raiseerror: bool = False) -> bool: """ Import the object given by ``self.modname`` and ``self.objpath`` and set it as ``self.object``. :param raiseerror: :returns: :py:obj:`True` if successful, :py:obj:`False` if an error occurred. .. latex:clearpage:: """ return Documenter.import_object(self, raiseerror=raiseerror)
[docs] def generate( self, more_content: Optional[Any] = None, real_modname: Optional[str] = None, check_module: bool = False, all_members: bool = False ) -> None: """ Generate reST for the object given by ``self.name``, and possibly for its members. :param more_content: Additional content to include in the reST output. :param real_modname: Module name to use to find attribute documentation. :param check_module: If :py:obj:`True`, only generate if the object is defined in the module name it is imported from. :param all_members: If :py:obj:`True`, document all members. .. versionchanged:: 0.8.0 Multiline docstrings are now correctly represented in the generated output. """ ret = _begin_generate(self, real_modname, check_module) if ret is None: return sourcename = ret # generate the directive header and options, if applicable self.add_directive_header('') self.add_line('', sourcename) # e.g. the module directive doesn't have content self.indent += self.content_indent # Add the value's docstring if self.object.__doc__ and self.object.__doc__ != self.object.__class__.__doc__: # Lines of multiline docstrings need to be added one by one. for line in self.object.__doc__.splitlines(): self.add_line(line, sourcename) self.add_line('', sourcename)
[docs] def add_directive_header(self, sig: str) -> None: """ Add the directive header for the Enum member. :param sig: """ ClassLevelDocumenter.add_directive_header(self, sig) sourcename = self.get_sourcename() if not self.options.annotation: # obtain type annotation for this attribute try: annotations = get_type_hints(self.parent) except NameError: # Failed to evaluate ForwardRef (maybe TYPE_CHECKING) annotations = safe_getattr(self.parent, "__annotations__", {}) except (TypeError, KeyError, AttributeError): # KeyError = a broken class found (refs: https://github.com/sphinx-doc/sphinx/issues/8084) # AttributeError is raised on 3.5.2 (fixed by 3.5.3) annotations = {} if self.objpath[-1] in annotations: objrepr = stringify_typehint(annotations.get(self.objpath[-1])) self.add_line(" :type: " + objrepr, sourcename) else: key = ('.'.join(self.objpath[:-1]), self.objpath[-1]) if self.analyzer and key in self.analyzer.annotations: self.add_line(" :type: " + self.analyzer.annotations[key], sourcename) elif self.options.annotation is SUPPRESS: pass else: self.add_line(" :annotation: %s" % self.options.annotation, sourcename) if not self.options.annotation: with suppress(Exception): if self.object is not INSTANCEATTR: # Workaround for https://github.com/sphinx-doc/sphinx/issues/9272 # which broke Enum displays in 4.1.0 objrepr = memory_address_re.sub('', repr(self.object)).replace('\n', ' ') self.add_line(f' :value: {objrepr}', self.get_sourcename())
[docs]class PyEnumXRefRole(PyXRefRole): """ XRefRole for Enum/Flag members. .. versionadded:: 0.4.0 .. autosummary-widths:: 40/100 """
[docs]def setup(app: Sphinx) -> Dict[str, Any]: """ Setup Sphinx Extension. :param app: """ app.registry.domains["py"].object_types["enum"] = ObjType(_("enum"), "enum", "class", "obj") app.add_directive_to_domain("py", "enum", PyClasslike) app.add_role_to_domain("py", "enum", PyXRefRole()) app.registry.domains["py"].object_types["flag"] = ObjType(_("flag"), "flag", "enum", "class", "obj") app.add_directive_to_domain("py", "flag", PyClasslike) app.add_role_to_domain("py", "flag", PyXRefRole()) app.add_role_to_domain("py", "enum:mem", PyEnumXRefRole()) app.add_role_to_domain("py", "enum:member", PyEnumXRefRole()) app.add_role_to_domain("py", "flag:mem", PyEnumXRefRole()) app.add_role_to_domain("py", "flag:member", PyEnumXRefRole()) app.add_autodocumenter(EnumDocumenter) app.add_autodocumenter(FlagDocumenter) app.connect("object-description-transform", add_fallback_css_class({"enum": "class", "flag": "class"})) return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, }