"""This module contains **autodoc_pydantic**'s autodocumenters.
"""
import json
from typing import Any, Optional, Dict, List, Iterable, Callable
import sphinx
from docutils.statemachine import StringList
from pydantic import BaseSettings
from sphinx.ext.autodoc import (
MethodDocumenter,
ClassDocumenter,
AttributeDocumenter,
Documenter
)
from sphinx.util.docstrings import prepare_docstring
from sphinx.util.inspect import object_description
from sphinx.util.typing import get_type_hints, stringify
from sphinxcontrib.autodoc_pydantic.directives.options.enums import (
OptionsJsonErrorStrategy,
OptionsFieldDocPolicy,
OptionsSummaryListOrder
)
from sphinxcontrib.autodoc_pydantic.directives.options.definition import (
OPTIONS_MODEL,
OPTIONS_SETTINGS,
OPTIONS_FIELD,
OPTIONS_VALIDATOR,
OPTIONS_CONFIG,
OPTIONS_MERGED
)
from sphinxcontrib.autodoc_pydantic.directives.templates import TPL_COLLAPSE
from sphinxcontrib.autodoc_pydantic.inspection import ModelInspector, \
ValidatorFieldMap
from sphinxcontrib.autodoc_pydantic.directives.options.composites import (
AutoDocOptions
)
from sphinxcontrib.autodoc_pydantic.directives.utility import NONE
[docs]class PydanticAutoDoc:
"""Composite to provide single namespace to access all **autodoc_pydantic**
relevant documenter directive functionalities.
"""
def __init__(self, documenter: Documenter, is_child: bool):
self._documenter = documenter
self._is_child = is_child
self._inspect: Optional[ModelInspector] = None
self._options = AutoDocOptions(self._documenter)
@property
def options(self) -> AutoDocOptions:
"""Provides access to :obj:`PydanticDocumenterOptions` to handle
global and local configuration settings.
"""
return self._options
@property
def inspect(self) -> ModelInspector:
"""Provides :obj:`ModelInspector` to access all inspection methods.
You may wonder why this ``inspect`` is a property instead of a simple
attribute being defined in the ``__init__`` method of this class. The
reason is the following: auto-documenters do not have their ``object``
attribute being correctly set after instantiation which typically holds
a reference to the corresponding pydantic model and objects to be
documented. Instead, ``object`` is ``None`` after plain instantiation
(executing ``__init__``). However, this composite class is added during
instantiation of the autodocumenter for consistency reasons. Therefore,
``ModelInspector`` can't be created at instantiation time of this class
because the ``object`` is still ``None``. Hence, it is lazily created
once the inspection methods are first required. At this point in time,
it is guaranteed by the auto-documenter base class that ``object`` is
then already correctly provided and the ``ModelInspector`` works as
expected.
"""
if self._inspect:
return self._inspect
if self._is_child:
obj = self._documenter.parent
else:
obj = self._documenter.object
self._inspect = ModelInspector(obj)
return self._inspect
[docs] def get_field_name_or_alias(self, field_name: str):
"""If `field-swap-name-and-alias` is enabled, provide alias (if
present) for given field.
"""
if self.options.is_true("field-swap-name-and-alias"):
return self.inspect.fields.get_alias_or_name(field_name)
else:
return field_name
[docs]class PydanticModelDocumenter(ClassDocumenter):
"""Represents specialized Documenter subclass for pydantic models.
"""
objtype = 'pydantic_model'
directivetype = 'pydantic_model'
priority = 10 + ClassDocumenter.priority
option_spec = ClassDocumenter.option_spec.copy()
option_spec.update({**OPTIONS_MODEL, **OPTIONS_MERGED})
pyautodoc_pass_to_directive = (
"model-signature-prefix",
)
pyautodoc_set_default_option = (
"member-order",
"undoc-members"
)
pyautodoc_prefix = "model"
[docs] @classmethod
def can_document_member(cls,
member: Any,
membername: str,
isattr: bool,
parent: Any) -> bool:
"""Filter only pydantic models.
"""
is_val = super().can_document_member(member, membername, isattr,
parent)
is_model = ModelInspector.static.is_pydantic_model(member)
return is_val and is_model
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self.pydantic = PydanticAutoDoc(self, is_child=False)
[docs] def document_members(self, *args, **kwargs):
"""Modify member options before starting to document members.
"""
self.pydantic.options.set_members_all()
if self.options.get("undoc-members") is False:
self.options.pop("undoc-members")
if self.pydantic.options.is_false("show-config-member", True):
self.hide_config_member()
if self.pydantic.options.is_false("show-validator-members", True):
self.hide_validator_members()
super().document_members(*args, **kwargs)
[docs] def hide_config_member(self):
"""Add `Config` to `exclude_members` option.
"""
if "exclude-members" not in self.options:
self.options["exclude-members"] = {"Config"}
else:
self.options["exclude-members"].add("Config")
[docs] def hide_validator_members(self):
"""Add validator names to `exclude_members`.
"""
validators = self.pydantic.inspect.validators.names
if "exclude-members" not in self.options:
self.options["exclude-members"] = validators
else:
self.options["exclude-members"].update(validators)
[docs] def add_content(self,
more_content: Optional[StringList],
**kwargs
) -> None:
"""Delegate additional content creation.
"""
super().add_content(more_content, **kwargs)
# do not provide any additional info if documented as attribute
if self.doc_as_attr:
return
if self.pydantic.options.is_true("show-json", True):
self.add_collapsable_schema()
if self.pydantic.options.is_true("show-config-summary", True):
self.add_config_summary()
if self.pydantic.options.is_true("show-field-summary", True):
self.add_field_summary()
if self.pydantic.options.is_true("show-validator-summary", True):
self.add_validators_summary()
[docs] def add_collapsable_schema(self):
"""Adds collapse code block containing JSON schema.
"""
schema = self.pydantic.inspect.schema.sanitized
non_serializable = self.pydantic.inspect.fields.non_json_serializable
# handle non serializable fields
strategy = self.pydantic.options.get_value("show-json-error-strategy")
if non_serializable:
error_msg = (
f"JSON schema can't be generated for '{self.fullname}' "
f"because the following pydantic fields can't be serialized "
f"properly: {non_serializable}."
)
if strategy == OptionsJsonErrorStrategy.WARN:
logger = sphinx.util.logging.getLogger(__name__)
logger.warning(error_msg, location="autodoc_pydantic")
elif strategy == OptionsJsonErrorStrategy.RAISE:
raise sphinx.errors.ExtensionError(error_msg)
elif strategy != OptionsJsonErrorStrategy.COERCE:
raise sphinx.errors.ExtensionError(
f"Invalid option provided for 'show-json-error-strategy'. "
f"Allowed values are f{OptionsJsonErrorStrategy.values()}"
)
schema_rest = self._convert_json_schema_to_rest(schema)
source_name = self.get_sourcename()
for line in schema_rest:
self.add_line(line, source_name)
[docs] def add_config_summary(self):
"""Adds summary section describing the model configuration.
"""
if not self.pydantic.inspect.config.is_configured:
return
cfg_items = self.pydantic.inspect.config.items
source_name = self.get_sourcename()
self.add_line(":Config:", source_name)
for name, value in cfg_items.items():
line = f" - **{name}**: *{type(value).__name__} = {value}*"
self.add_line(line, source_name)
self.add_line("", source_name)
def _get_idx_mappings(self, members: Iterable[str]) -> Dict[str, int]:
"""Get index positions for given members while respecting
`OptionsSummaryListOrder`.
"""
sorted_members = self._sort_summary_list(members)
return {name: idx for idx, name in enumerate(sorted_members)}
def _get_reference_sort_func(self) -> Callable:
"""Helper function to create sorting function for instances of
`ValidatorFieldMap` which first sorts by validator name and second by
field name while respecting `OptionsSummaryListOrder`.
This is used for validator summary section.
"""
all_validators = self.pydantic.inspect.validators.names
all_fields = self.pydantic.inspect.fields.names
idx_validators = self._get_idx_mappings(all_validators)
idx_fields = self._get_idx_mappings(all_fields)
def sort_func(reference: ValidatorFieldMap):
return (
idx_validators.get(reference.validator_name, -1),
idx_fields.get(reference.field_name, -1)
)
return sort_func
def _get_validator_summary_references(self) -> List[ValidatorFieldMap]:
"""Filter and sort validator-field mappings for validator summary
section.
"""
references = self.pydantic.inspect.references.mappings
valid_members = self.pydantic.options.get_filtered_member_names()
valid_references = [reference for reference in references
if reference.validator_name in valid_members]
sort_func = self._get_reference_sort_func()
sorted_references = sorted(valid_references, key=sort_func)
return sorted_references
def _build_validator_summary_rest_line(self, reference: ValidatorFieldMap):
"""Generates reST line for validator-field mapping with references for
validator summary section.
"""
name = self.pydantic.get_field_name_or_alias(reference.field_name)
return (
f" - "
f":py:obj:`{reference.validator_name} <{reference.validator_ref}>`"
f" » "
f":py:obj:`{name} <{reference.field_ref}>`"
)
[docs] def add_validators_summary(self):
"""Adds summary section describing all validators with corresponding
fields.
"""
if not self.pydantic.inspect.validators:
return
sorted_references = self._get_validator_summary_references()
source_name = self.get_sourcename()
self.add_line(":Validators:", source_name)
for ref in sorted_references:
line = self._build_validator_summary_rest_line(ref)
self.add_line(line, source_name)
self.add_line("", source_name)
[docs] def add_field_summary(self):
"""Adds summary section describing all fields.
"""
if not self.pydantic.inspect.fields:
return
valid_fields = self._get_valid_fields()
sorted_fields = self._sort_summary_list(valid_fields)
source_name = self.get_sourcename()
self.add_line(":Fields:", source_name)
for field_name in sorted_fields:
line = self._get_field_summary_line(field_name)
self.add_line(line, source_name)
self.add_line("", source_name)
def _get_valid_fields(self) -> List[str]:
"""Returns all field names that are valid members of pydantic model.
"""
fields = self.pydantic.inspect.fields.names
valid_members = self.pydantic.options.get_filtered_member_names()
return [field for field in fields if field in valid_members]
def _sort_summary_list(self, names: Iterable[str]) -> List[str]:
"""Sort member names according to given sort order
`OptionsSummaryListOrder`.
"""
sort_order = self.pydantic.options.get_value(name="summary-list-order",
prefix=True,
force_availability=True)
if sort_order == OptionsSummaryListOrder.ALPHABETICAL:
def sort_func(name: str):
return name
elif sort_order == OptionsSummaryListOrder.BYSOURCE:
def sort_func(name: str):
name_with_class = f"{self.object_name}.{name}"
return self.analyzer.tagorder.get(name_with_class)
else:
raise ValueError(
f"Invalid value `{sort_order}` provided for "
f"`summary_list_order`. Valid options are: "
f"{OptionsSummaryListOrder.values()}")
return sorted(names, key=sort_func)
def _get_field_summary_line(self, field_name: str) -> str:
"""Get reST for field summary for given `member_name`.
"""
ref_func = self.pydantic.inspect.references.create_model_reference
name = self.pydantic.get_field_name_or_alias(field_name)
ref = ref_func(field_name)
typ = self._stringify_type(field_name)
return f" - :py:obj:`{name} ({typ}) <{ref}>`"
def _stringify_type(self, field_name: str) -> str:
"""Get proper string representation of type for given `member_nane`
relying on sphinx functionality.
"""
type_aliases = self.config.autodoc_type_aliases
annotations = get_type_hints(self.object, None, type_aliases)
return stringify(annotations.get(field_name, ""))
@staticmethod
def _convert_json_schema_to_rest(schema: Dict) -> List[str]:
"""Convert model's schema dict into reST.
"""
schema = json.dumps(schema, default=str, indent=3)
lines = [f" {line}" for line in schema.split("\n")]
lines = "\n".join(lines)
lines = TPL_COLLAPSE.format(lines).split("\n")
return lines
[docs]class PydanticSettingsDocumenter(PydanticModelDocumenter):
"""Represents specialized Documenter subclass for pydantic settings.
"""
objtype = 'pydantic_settings'
directivetype = 'pydantic_settings'
priority = 10 + ClassDocumenter.priority
option_spec = ClassDocumenter.option_spec.copy()
option_spec.update({**OPTIONS_SETTINGS, **OPTIONS_MERGED})
pyautodoc_pass_to_directive = (
"settings-signature-prefix",
)
pyautodoc_set_default_option = (
"member-order",
"undoc-members"
)
pyautodoc_prefix = "settings"
[docs] @classmethod
def can_document_member(cls,
member: Any,
membername: str,
isattr: bool,
parent: Any) -> bool:
"""Filter only pydantic models.
"""
is_val = super().can_document_member(member,
membername,
isattr,
parent)
if is_val:
return issubclass(member, BaseSettings)
else:
return False
[docs]class PydanticFieldDocumenter(AttributeDocumenter):
"""Represents specialized Documenter subclass for pydantic fields.
"""
objtype = 'pydantic_field'
directivetype = 'pydantic_field'
priority = 10 + AttributeDocumenter.priority
option_spec = dict(AttributeDocumenter.option_spec)
option_spec.update(OPTIONS_FIELD)
member_order = 0
pyautodoc_pass_to_directive = (
"field-signature-prefix",
"field-show-alias",
"field-swap-name-and-alias"
)
def __init__(self, *args):
super().__init__(*args)
self.pydantic = PydanticAutoDoc(self, is_child=True)
[docs] @classmethod
def can_document_member(cls,
member: Any,
membername: str,
isattr: bool,
parent: Any) -> bool:
"""Filter only pydantic fields.
"""
is_valid = super().can_document_member(member,
membername,
isattr,
parent)
is_field = ModelInspector.static.is_pydantic_field(
parent=parent.object,
field_name=membername
)
return is_valid and is_field and isattr
@property
def pydantic_field_name(self) -> str:
"""Provide the pydantic field name which refers to the member name of
the parent pydantic model.
"""
return self.objpath[-1]
@property
def needs_required_marker(self) -> bool:
"""Indicate if field should be marked as required.
"""
field_name = self.pydantic_field_name
is_required = self.pydantic.inspect.fields.is_required(field_name)
show_required = self.pydantic.options.is_true("field-show-required")
return is_required and show_required
@property
def needs_optional_marker(self) -> bool:
"""Indicate if field should be marked as optional.
"""
field_name = self.pydantic_field_name
check_func = self.pydantic.inspect.fields.has_default_factory
has_default_factory = check_func(field_name)
show_optional = self.pydantic.options.is_true("field-show-optional")
return has_default_factory and show_optional
[docs] def get_default_value(self) -> str:
"""Gets the default value of pydantic field as reST.
"""
field_name = self.pydantic_field_name
default = self.pydantic.inspect.fields.get(field_name).default
value = object_description(default)
return f' :value: {value}'
[docs] def add_default_value_or_marker(self):
"""Adds default value or a marker for field being required or optional.
"""
sourcename = self.get_sourcename()
show_default = self.pydantic.options.is_true("field-show-default")
if self.needs_required_marker:
self.add_line(' :required:', sourcename)
elif self.needs_optional_marker:
self.add_line(' :optional:', sourcename)
elif show_default:
self.add_line(self.get_default_value(), sourcename)
[docs] def add_alias(self):
"""Adds alias directive option.
"""
field_name = self.pydantic_field_name
field = self.pydantic.inspect.fields.get(field_name)
alias_given = field.alias != field_name
show_alias = self.pydantic.options.is_true("field-show-alias")
swap = self.pydantic.options.is_true("field-swap-name-and-alias")
alias_required = show_alias or swap
if alias_given and alias_required:
sourcename = self.get_sourcename()
self.add_line(' :alias: ' + field.alias, sourcename)
[docs] def add_content(self,
more_content: Optional[StringList],
**kwargs,
) -> None:
"""Delegate additional content creation.
"""
doc_policy = self.pydantic.options.get_value("field-doc-policy")
if doc_policy in (OptionsFieldDocPolicy.DOCSTRING,
OptionsFieldDocPolicy.BOTH,
None, NONE):
super().add_content(more_content, **kwargs)
if doc_policy in (OptionsFieldDocPolicy.BOTH,
OptionsFieldDocPolicy.DESCRIPTION):
self.add_description()
if self.pydantic.options.is_true("field-show-constraints"):
self.add_constraints()
if self.pydantic.options.is_true("field-list-validators"):
self.add_validators()
[docs] def add_constraints(self):
"""Adds section showing all defined constraints.
"""
field_name = self.pydantic_field_name
constraints = self.pydantic.inspect.fields.get_constraints(field_name)
if constraints:
source_name = self.get_sourcename()
self.add_line(":Constraints:", source_name)
for key, value in constraints.items():
line = f" - **{key}** = {value}"
self.add_line(line, source_name)
self.add_line("", source_name)
[docs] def add_description(self):
"""Adds description from schema if present.
"""
field_name = self.pydantic_field_name
func = self.pydantic.inspect.fields.get_property_from_field_info
description = func(field_name, "description")
if not description:
return
tabsize = self.directive.state.document.settings.tab_width
lines = prepare_docstring(description, tabsize=tabsize)
source_name = self.get_sourcename()
for line in lines:
self.add_line(line, source_name)
self.add_line("", source_name)
[docs] def add_validators(self):
"""Add section with all validators that process this field.
"""
field_name = self.pydantic_field_name
func = self.pydantic.inspect.references.filter_by_field_name
references = func(field_name)
if not references:
return
source_name = self.get_sourcename()
self.add_line(":Validated by:", source_name)
for reference in references:
field_name = reference.validator_name
ref = reference.validator_ref
line = f" - :py:obj:`{field_name} <{ref}>`"
self.add_line(line, source_name)
self.add_line("", source_name)
[docs]class PydanticValidatorDocumenter(MethodDocumenter):
"""Represents specialized Documenter subclass for pydantic validators.
"""
objtype = 'pydantic_validator'
directivetype = 'pydantic_validator'
member_order = 50
priority = 10 + MethodDocumenter.priority
option_spec = MethodDocumenter.option_spec.copy()
option_spec.update(OPTIONS_VALIDATOR)
pyautodoc_pass_to_directive = (
"validator-signature-prefix",
"validator-replace-signature",
"field-swap-name-and-alias"
)
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self.pydantic = PydanticAutoDoc(self, is_child=True)
[docs] @classmethod
def can_document_member(cls,
member: Any,
membername: str,
isattr: bool,
parent: Any) -> bool:
"""Filter only pydantic validators.
"""
is_val = super().can_document_member(member, membername, isattr,
parent)
is_validator = ModelInspector.static.is_validator_by_name(
membername,
parent.object)
return is_val and is_validator
[docs] def add_content(self,
more_content: Optional[StringList],
**kwargs
) -> None:
"""Optionally show validator content.
"""
super().add_content(more_content, **kwargs)
if self.pydantic.options.is_true("validator-list-fields"):
self.add_field_list()
def _build_field_list_rest_line(self, reference: ValidatorFieldMap):
"""Generates reST line for field reference for field list section.
"""
name = self.pydantic.get_field_name_or_alias(reference.field_name)
return (
f" - :py:obj:"
f"`{name} "
f"<{reference.field_ref}>`"
)
[docs] def add_field_list(self):
"""Adds a field list with all fields that are validated by this
validator.
"""
func = self.pydantic.inspect.references.filter_by_validator_name
references = func(self.object_name)
if not references:
return
source_name = self.get_sourcename()
self.add_line(":Validates:", source_name)
for reference in references:
line = self._build_field_list_rest_line(reference)
self.add_line(line, source_name)
self.add_line("", source_name)
[docs]class PydanticConfigClassDocumenter(ClassDocumenter):
"""Represents specialized Documenter subclass for pydantic model
configuration.
"""
objtype = 'pydantic_config'
directivetype = 'pydantic_config'
option_spec = ClassDocumenter.option_spec.copy()
option_spec.update(OPTIONS_CONFIG)
member_order = 100
priority = 10 + ClassDocumenter.priority
pyautodoc_pass_to_directive = (
"config-signature-prefix",
)
def __init__(self, *args: Any) -> None:
super().__init__(*args)
self.pydantic = PydanticAutoDoc(self, is_child=True)
[docs] @classmethod
def can_document_member(cls,
member: Any,
membername: str,
isattr: bool,
parent: Any) -> bool:
"""Filter only pydantic model configurations.
"""
is_val = super().can_document_member(member, membername, isattr,
parent)
is_parent_model = ModelInspector.static.is_pydantic_model(
parent.object)
is_config = membername == "Config"
is_class = isinstance(member, type)
return is_val and is_parent_model and is_config and is_class
[docs] def document_members(self, *args, **kwargs):
"""Modify member options before starting to document members.
"""
self.pydantic.options.set_members_all()
if self.options.get("members"):
self.options["undoc-members"] = True
# handle special case when Config is documented as an attribute
# in which case `all_members` defaults to True which has to be
# overruled by `autodoc_pydantic_config_members` app cfg
app_cfg = self.pydantic.options.get_app_cfg_by_name("members")
hide_members = app_cfg is False
no_members = bool(self.options.get("members")) is False
if hide_members and no_members:
super().document_members(all_members=False, **kwargs)
else:
super().document_members(*args, **kwargs)