Source code for sphinxcontrib.autodoc_pydantic.directives.directives

"""This module contains **autodoc_pydantic**'s directives."""

from __future__ import annotations

from typing import TYPE_CHECKING, Tuple

import sphinx
from docutils.nodes import Node, Text
from docutils.parsers.rst.directives import unchanged
from sphinx.addnodes import desc_annotation, desc_name, desc_signature, pending_xref
from sphinx.domains.python import PyAttribute, PyClasslike, PyMethod, py_sig_re

from sphinxcontrib.autodoc_pydantic.directives.options.composites import (
    DirectiveOptions,
)
from sphinxcontrib.autodoc_pydantic.directives.options.validators import (
    option_default_true,
    option_list_like,
)
from sphinxcontrib.autodoc_pydantic.directives.utility import (
    create_field_href,
    remove_node_by_tagname,
)
from sphinxcontrib.autodoc_pydantic.inspection import ModelInspector, ValidatorFieldMap

if TYPE_CHECKING:
    from sphinx.util.typing import OptionSpec

TUPLE_STR = Tuple[str, str]


[docs] class PydanticDirectiveMixin: """Base class for pydantic directive providing common functionality.""" config_name: str def __init__(self, *args) -> None: # noqa: ANN002 super().__init__(*args) self.pyautodoc = DirectiveOptions(self)
[docs] def get_signature_prefix(self, *_) -> list[Node]: # noqa: ANN002 """Overwrite original signature prefix with custom pydantic ones.""" config_name = f'{self.config_name}-signature-prefix' prefix = self.pyautodoc.get_value(config_name) # empty prefix should not add any nodes if prefix == '': return [] # account for changed signature in sphinx 4.3, see #62 if sphinx.version_info < (4, 3): return f'{prefix} ' # type: ignore[return-value] from sphinx.addnodes import desc_sig_space return [Text(prefix), desc_sig_space()]
[docs] class PydanticModel(PydanticDirectiveMixin, PyClasslike): """Specialized directive for pydantic models.""" option_spec: OptionSpec = PyClasslike.option_spec.copy() # type: ignore[misc] option_spec.update( { '__doc_disable_except__': option_list_like, 'model-signature-prefix': unchanged, } ) config_name = 'model'
[docs] class PydanticSettings(PydanticDirectiveMixin, PyClasslike): """Specialized directive for pydantic settings.""" option_spec: OptionSpec = PyClasslike.option_spec.copy() # type: ignore[misc] option_spec.update( { '__doc_disable_except__': option_list_like, 'settings-signature-prefix': unchanged, } ) config_name = 'settings'
[docs] class PydanticField(PydanticDirectiveMixin, PyAttribute): """Specialized directive for pydantic fields.""" option_spec: OptionSpec = PyAttribute.option_spec.copy() # type: ignore[misc] option_spec.update( { 'alias': unchanged, 'field-show-alias': option_default_true, 'field-swap-name-and-alias': option_default_true, 'required': option_default_true, 'optional': option_default_true, '__doc_disable_except__': option_list_like, 'field-signature-prefix': unchanged, } ) config_name = 'field'
[docs] def get_field_name(self, sig: str) -> str: """Get field name from signature. Borrows implementation from `PyObject.handle_signature`. """ result = py_sig_re.match(sig) if not result: msg = f'Cannot find field name in signature: {sig} for {self.name}' raise ValueError(msg) return result.groups()[1]
[docs] def add_required(self, signode: desc_signature) -> None: """Add `[Required]` if directive option `required` is set.""" if self.options.get('required'): signode += desc_annotation('', ' [Required]')
[docs] def add_optional(self, signode: desc_signature) -> None: """Add `[Optional]` if directive option `optional` is set.""" if self.options.get('optional'): signode += desc_annotation('', ' [Optional]')
[docs] def add_alias_or_name(self, sig: str, signode: desc_signature) -> None: """Add alias or name to signature. Alias is added if `show-alias` is enabled. Name is added if both `show-alias` and `swap-name-and-alias` is enabled. """ if not self.pyautodoc.get_value('field-show-alias'): return if self.pyautodoc.is_true('field-swap-name-and-alias'): prefix = 'name' value = self.get_field_name(sig) else: prefix = 'alias' value = self.options.get('alias', '') signode += desc_annotation('', f" ({prefix} '{value}')")
def _find_desc_name_node(self, sig: str, signode: desc_signature) -> Node | None: """Return `desc_name` node from `signode` that contains the field name. This is used to replace the name with the alias. """ name = self.get_field_name(sig) for node in signode.children: has_correct_text = node.astext() == name is_desc_name = isinstance(node, desc_name) if has_correct_text and is_desc_name: return node return None
[docs] def swap_name_and_alias(self, sig: str, signode: desc_signature) -> None: """Replaces name with alias if `swap-name-and-alias` is enabled. Requires to replace existing `addnodes.desc_name` because name node is added within `handle_signature` and this can't be intercepted or overwritten otherwise. """ if not self.pyautodoc.get_value('field-swap-name-and-alias'): return name_node = self._find_desc_name_node(sig, signode) if not name_node: logger = sphinx.util.logging.getLogger(__name__) logger.warning( "Field's `desc_name` node can't be located to " 'swap name with alias.', location='autodoc_pydantic', ) else: text_node = Text(self.options.get('alias', '')) text_node.parent = name_node name_node.children = [text_node]
[docs] def handle_signature(self, sig: str, signode: desc_signature) -> TUPLE_STR: """Optionally call add alias method.""" fullname, prefix = super().handle_signature(sig, signode) self.add_required(signode) self.add_optional(signode) if self.options.get('alias') is not None: self.add_alias_or_name(sig, signode) self.swap_name_and_alias(sig, signode) return fullname, prefix
[docs] class PydanticValidator(PydanticDirectiveMixin, PyMethod): """Specialized directive for pydantic validators.""" option_spec: OptionSpec = PyMethod.option_spec.copy() # type: ignore[misc] option_spec.update( { 'validator-replace-signature': option_default_true, '__doc_disable_except__': option_list_like, 'validator-signature-prefix': unchanged, 'field-swap-name-and-alias': option_default_true, } ) config_name = 'validator'
[docs] def get_field_href_from_mapping( self, inspector: ModelInspector, mapping: ValidatorFieldMap ) -> pending_xref: """Generate the field reference node aka `pending_xref` from given validator-field `mapping` while respecting field name/alias swap possibility. """ name = mapping.field_name if self.pyautodoc.is_true('field-swap-name-and-alias'): name = inspector.fields.get_alias_or_name(mapping.field_name) return create_field_href(name=name, ref=mapping.field_ref, env=self.env)
[docs] def replace_return_node(self, signode: desc_signature) -> None: """Replaces the return node with references to validated fields.""" remove_node_by_tagname(signode.children, 'desc_parameterlist') # replace nodes class_name = 'autodoc_pydantic_validator_arrow' signode += desc_annotation('', ' » ', classes=[class_name]) # get imports, names and fields of validator name = signode['fullname'].split('.')[-1] inspector = ModelInspector.from_child_signode(signode) mappings = inspector.references.filter_by_validator_name(name) # add field reference nodes signode += self.get_field_href_from_mapping( inspector=inspector, mapping=mappings[0] ) for mapping in mappings[1:]: signode += desc_annotation('', ', ') signode += self.get_field_href_from_mapping( inspector=inspector, mapping=mapping )
[docs] def handle_signature(self, sig: str, signode: desc_signature) -> TUPLE_STR: """Optionally call replace return node method.""" fullname, prefix = super().handle_signature(sig, signode) if self.pyautodoc.get_value('validator-replace-signature'): self.replace_return_node(signode) return fullname, prefix