#!/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]class PyEnumXRefRole(PyXRefRole):
"""
XRefRole for Enum/Flag members.
.. versionadded:: 0.4.0
.. autosummary-widths:: 40/100
"""
[docs] def process_link(
self,
env: BuildEnvironment,
refnode: Element,
has_explicit_title: bool,
title: str,
target: str,
) -> Tuple[str, str]:
"""
Called after parsing title and target text, and creating the reference node (given in ``refnode``).
This method can alter the reference node and must return a new (or the same)
``(title, target)`` tuple.
:param env:
:param refnode:
:param has_explicit_title:
:param title:
:param target:
:rtype:
.. latex:clearpage::
"""
refnode["py:module"] = env.ref_context.get("py:module")
refnode["py:class"] = env.ref_context.get("py:class")
if not has_explicit_title:
title = title.lstrip('.') # only has a meaning for the target
target = target.lstrip("~+") # only has a meaning for the title
# if the first character is a tilde, don't display the module/class
# parts of the contents
if title[0:1] == '~':
title = '.'.join(title[1:].split('.')[-2:])
elif title[0:1] == '+':
title = title[1:]
dot = title.rfind('.')
if dot != -1:
title = title[dot + 1:]
# if the first character is a dot, search more specific namespaces first
# else search builtins first
if target[0:1] == '.':
target = target[1:]
refnode["refspecific"] = True
return title, target
[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,
}