"""
Introspection utilities for discovering the tabular structure of an enumeration.
These functions determine which columns an enumeration exposes, how to fetch and
serialize each cell and where member docstrings come from. They do not depend on
docutils and only use Sphinx to read docstrings from source.
"""
import csv
import dataclasses
import inspect
import io
import json
import typing as t
from enum import Enum
from importlib import import_module
from sphinx.errors import PycodeError
from sphinx.pycode import ModuleAnalyzer
__all__ = [
"NAME",
"VALUE",
"column_docstrings",
"default_columns",
"format_value",
"import_enum",
"is_structured",
"member_docstrings",
"resolve",
"to_csv",
"to_json",
"to_json_value",
]
NAME = "name"
"""The pseudo-column that holds the member's name."""
VALUE = "value"
"""The pseudo-column that holds the member's value."""
[docs]
def import_enum(path: str, default_module: str | None = None) -> type[Enum]:
"""
Import an enumeration class from an import path string.
The path may separate the module from the class qualname with a ``:``
(``pkg.module:Outer.Enum``) or use dots throughout (``pkg.module.Outer.Enum``),
in which case the longest importable module prefix is used. If the path cannot
be resolved absolutely and ``default_module`` is given, the path is also tried
relative to that module.
:param path: The import path of the enumeration.
:param default_module: A module to resolve the path relative to if the path
cannot be resolved on its own (e.g. the current ``py:module``).
:raises ImportError: If the path cannot be resolved.
:raises TypeError: If the path resolves to something that is not an Enum class.
"""
candidates = [path]
if default_module and ":" not in path:
candidates.append(f"{default_module}.{path}")
errors: list[str] = []
for candidate in candidates:
try:
obj = _import_object(candidate)
except (ImportError, AttributeError) as err:
errors.append(f"{candidate}: {err}")
continue
if not (isinstance(obj, type) and issubclass(obj, Enum)):
raise TypeError(f"{candidate} is not an Enum class.")
return obj
raise ImportError(f"Unable to import enum {path!r}: " + "; ".join(errors))
def _import_object(path: str) -> t.Any:
if ":" in path:
module_path, _, qualname = path.partition(":")
obj: t.Any = import_module(module_path)
for attr in qualname.split("."):
obj = getattr(obj, attr)
return obj
parts = path.split(".")
for idx in range(len(parts) - 1, 0, -1):
try:
obj = import_module(".".join(parts[:idx]))
except ImportError:
continue
for attr in parts[idx:]:
obj = getattr(obj, attr)
return obj
raise ImportError(f"No module found in {path!r}")
[docs]
def member_docstrings(enum_cls: type[Enum]) -> dict[str, str]:
"""
Find the docstrings of an enumeration's members, keyed by member name.
Python does not give enum members their own docstrings (``member.__doc__`` is
the class docstring), so docstrings are found the same way autodoc finds them,
in order of precedence:
1. A ``__doc__`` attribute set explicitly on the member instance (e.g. by the
enum's ``__init__``).
2. A string literal immediately after the member's assignment, or a ``#:``
comment before it, in the enum's source code.
Members without a docstring are not included. Docstrings are dedented and
stripped but otherwise returned verbatim (they are usually reStructuredText).
"""
docs: dict[str, str] = {}
try:
analyzer = ModuleAnalyzer.for_module(enum_cls.__module__)
attr_docs = analyzer.find_attr_docs()
except PycodeError:
attr_docs = {}
for member in enum_cls:
explicit = getattr(member, "__dict__", {}).get("__doc__")
if isinstance(explicit, str) and explicit.strip():
docs[member.name] = inspect.cleandoc(explicit)
continue
lines = attr_docs.get((enum_cls.__qualname__, member.name))
if lines and "".join(lines).strip():
docs[member.name] = inspect.cleandoc("\n".join(lines))
return docs
def _attr_docs(cls: type) -> dict[tuple[str, str], list[str]]:
try:
return ModuleAnalyzer.for_module(cls.__module__).find_attr_docs()
except PycodeError:
return {}
def _column_doc(classes: t.Iterable[type], column: str) -> str | None:
for cls in classes:
if cls is object:
continue
field = getattr(cls, "__dataclass_fields__", {}).get(column)
# python 3.14+ supports dataclasses.field(doc=...)
explicit = getattr(field, "doc", None)
if isinstance(explicit, str) and explicit.strip():
return inspect.cleandoc(explicit)
lines = _attr_docs(cls).get((cls.__qualname__, column))
if lines and "".join(lines).strip():
return inspect.cleandoc("\n".join(lines))
attr = cls.__dict__.get(column)
if isinstance(attr, property) and attr.__doc__ and attr.__doc__.strip():
return inspect.cleandoc(attr.__doc__)
return None
[docs]
def column_docstrings(enum_cls: type[Enum], columns: t.Iterable[str]) -> dict[str, str]:
"""
Find descriptions of an enumeration's columns, keyed by column name.
Each column is looked up on the enum's classes (including any dataclass mixin
and its bases) and then, if the member values are dataclasses or named tuples,
on the value's classes. For each class, in order of precedence:
1. ``dataclasses.field(doc=...)`` (Python 3.14+).
2. A string literal immediately after the attribute, or a ``#:`` comment before
it, in source. This covers dataclass fields, named tuple fields and
enum-properties property annotations.
3. The docstring of a :class:`property` defined on the class.
The ``name`` and ``value`` pseudo-columns and dotted column paths have no
descriptions. Columns without a description are not included.
"""
classes: list[type] = list(enum_cls.__mro__)
first = next(iter(enum_cls), None)
if first is not None and is_structured(first.value):
classes.extend(type(first.value).__mro__)
docs = {}
for column in columns:
if column in (NAME, VALUE) or "." in column:
continue
doc = _column_doc(classes, column)
if doc:
docs[column] = doc
return docs
def is_structured(value: t.Any) -> bool:
"""
Structured values are dataclass instances and named tuples. Their fields are
exposed as individual columns rather than as a single value column.
"""
return _fields_of(value) is not None
def _fields_of(obj: t.Any) -> list[str] | None:
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
return [field.name for field in dataclasses.fields(obj)]
if isinstance(obj, tuple) and hasattr(obj, "_fields"):
return list(obj._fields) # pyright: ignore[reportAttributeAccessIssue]
return None
[docs]
def default_columns(enum_cls: type[Enum]) -> list[str]:
"""
Determine the default columns for an enumeration, in order:
1. ``name``
2. dataclass fields - if the enum mixes in a dataclass or its values are
dataclasses or named tuples.
3. ``value`` - only when the values are not structured (dataclass or
namedtuple), so it never appears alongside dataclass fields.
4. enum-properties properties - a special case for
:class:`enum_properties.EnumProperties` classes.
Duplicate column names are dropped, keeping the first occurrence.
"""
columns = [NAME]
members = list(enum_cls)
first = members[0] if members else None
if first is None or not is_structured(first.value):
columns.append(VALUE)
if first is not None:
# a dataclass mixin makes the member itself a dataclass instance
columns.extend(_fields_of(first) or _fields_of(first.value) or [])
columns.extend(str(prop) for prop in getattr(enum_cls, "_properties_", []) or [])
return list(dict.fromkeys(columns))
[docs]
def resolve(member: Enum, column: str) -> t.Any:
"""
Fetch the raw value of a column for the given member.
Columns may be dotted attribute paths (e.g. ``value.red``). The first
attribute is looked up on the member and then, if not found, on the member's
value.
:raises AttributeError: If the column cannot be resolved.
"""
first, *rest = column.split(".")
try:
obj = getattr(member, first)
except AttributeError:
obj = getattr(member.value, first)
for attr in rest:
obj = getattr(obj, attr)
return obj
def to_json_value(
value: t.Any, fallback: t.Callable[[t.Any], str] = format_value
) -> t.Any:
"""
Convert a cell value into a JSON serializable structure. Native JSON types are
preserved, containers and structured values are converted recursively and
anything else (including enum members) is converted to text with ``fallback``.
"""
if isinstance(value, Enum):
return fallback(value)
if value is None or isinstance(value, (bool, int, float, str)):
return value
fields = _fields_of(value)
if fields is not None:
return {
field: to_json_value(getattr(value, field), fallback) for field in fields
}
if isinstance(value, dict):
return {str(k): to_json_value(v, fallback) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [to_json_value(item, fallback) for item in value]
if isinstance(value, (set, frozenset)):
return [to_json_value(item, fallback) for item in sorted(value, key=str)]
return fallback(value)
def to_csv(headers: t.Sequence[str], rows: t.Iterable[t.Sequence[str]]) -> str:
"""Render a header row and rows of display text as CSV."""
buffer = io.StringIO()
writer = csv.writer(buffer, lineterminator="\n")
writer.writerow(headers)
writer.writerows(rows)
return buffer.getvalue()
def to_json(
names: t.Sequence[str],
columns: t.Sequence[str],
rows: t.Iterable[t.Sequence[t.Any]],
) -> str:
"""
Render rows of JSON serializable values as an object keyed by member name. Each
member maps to an object keyed by column. The ``name`` column is omitted from the
member objects because it is the key.
"""
return json.dumps(
{
name: {col: val for col, val in zip(columns, row) if col != NAME}
for name, row in zip(names, rows)
},
indent=2,
ensure_ascii=False,
)