From 134c3cb00ec961e3294fb2b81c690e84a7a8202f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franz=20W=C3=B6llert?= Date: Mon, 22 Apr 2024 11:27:46 +0200 Subject: [PATCH] ci: Integrate `mypy` type checking (#259) refactor: restructure sphinx app cfg values in `__init__` refactor: rename `PydanticDirectiveBase` to `PydanticDirectiveMixin` for better clarity ci: fix broken tox environment naming convention preventing correct version selection --- .github/actions/invoke-tox/action.yml | 4 +- .github/workflows/tests.yml | 16 +- pyproject.toml | 9 ++ sphinxcontrib/autodoc_pydantic/__init__.py | 145 +++++++++++------- .../directives/autodocumenters.py | 76 +++++---- .../autodoc_pydantic/directives/directives.py | 53 ++++--- .../directives/options/composites.py | 15 +- .../directives/options/definition.py | 14 +- .../autodoc_pydantic/directives/templates.py | 8 +- sphinxcontrib/autodoc_pydantic/inspection.py | 32 ++-- tox.ini | 13 +- 11 files changed, 241 insertions(+), 144 deletions(-) diff --git a/.github/actions/invoke-tox/action.yml b/.github/actions/invoke-tox/action.yml index c8fc164a..7bfb8303 100644 --- a/.github/actions/invoke-tox/action.yml +++ b/.github/actions/invoke-tox/action.yml @@ -42,7 +42,9 @@ runs: shell: bash - name: Invoke Tox - run: tox -e ${{ inputs.tox-environment }} + run: | + TOX_ENV_WITHOUT_DOTS=$(echo ${{ inputs.tox-environment }} | sed 's/\.//g') + tox -e $TOX_ENV_WITHOUT_DOTS shell: bash - name: Code Coverage diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d1331a85..210e1ab4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,7 @@ jobs: uses: ./.github/actions/invoke-tox with: python-version: ${{ matrix.python_version }} - tox-environment: pydantic${{ matrix.pydantic_version }}-sphinx${{ matrix.sphinx_version }} + tox-environment: py${{ matrix.python_version }}-pydantic${{ matrix.pydantic_version }}-sphinx${{ matrix.sphinx_version }} install-graphviz: true codacy: ${{ secrets.CODACY_PROJECT_TOKEN }} @@ -122,4 +122,16 @@ jobs: - name: Setup Test Environment and Run Tox uses: ./.github/actions/invoke-tox with: - tox-environment: formatter \ No newline at end of file + tox-environment: formatter + + type-checker: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Test Environment and Run Tox + uses: ./.github/actions/invoke-tox + with: + tox-environment: type-checker + install-graphviz: true \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1fafb03e..b6d52e1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,11 @@ myst-parser = {version = "^2.0", optional = true } pytest = {version = "^8.0.0", optional = true } coverage = { version ="^7", optional = true } +# extras type checking +mypy = { version = "^1.9", optional = true } +types-docutils = { version = "^0.20", optional = true } +typing-extensions = { version = "^4.11", markers = "python_version <= '3.9'", optional = true } + # extras linting/formatting ruff = { version = "^0.3", optional = true } @@ -59,6 +64,10 @@ test = ["pytest", linting = ["ruff"] +type_checking = ["mypy", + "types-docutils", + "typing-extensions"] + erdantic = ["erdantic"] [build-system] diff --git a/sphinxcontrib/autodoc_pydantic/__init__.py b/sphinxcontrib/autodoc_pydantic/__init__.py index 588b6557..83aa4b58 100644 --- a/sphinxcontrib/autodoc_pydantic/__init__.py +++ b/sphinxcontrib/autodoc_pydantic/__init__.py @@ -3,13 +3,14 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal try: from importlib.metadata import version except ModuleNotFoundError: - from importlib_metadata import version + from importlib_metadata import version # type: ignore[no-redef,import-not-found] +from pydantic import BaseModel from sphinx.domains import ObjType from sphinxcontrib.autodoc_pydantic.directives.autodocumenters import ( @@ -37,6 +38,88 @@ from sphinx.application import Sphinx +PRE = 'autodoc_pydantic_' + + +class AppConfig(BaseModel): + name: str + default: Any + types: type + rebuild: Literal['env'] = 'env' + + +APP_CONFIGURATIONS = [ + # settings + AppConfig(name=f'{PRE}settings_show_json', default=True, types=bool), + AppConfig( + name=f'{PRE}settings_show_json_error_strategy', + default=OptionsJsonErrorStrategy.WARN, + types=str, + ), + AppConfig(name=f'{PRE}settings_show_config_summary', default=True, types=bool), + AppConfig(name=f'{PRE}settings_show_validator_members', default=True, types=bool), + AppConfig(name=f'{PRE}settings_show_validator_summary', default=True, types=bool), + AppConfig(name=f'{PRE}settings_show_field_summary', default=True, types=bool), + AppConfig( + name=f'{PRE}settings_summary_list_order', + default=OptionsSummaryListOrder.ALPHABETICAL, + types=str, + ), + AppConfig(name=f'{PRE}settings_hide_paramlist', default=True, types=bool), + AppConfig(name=f'{PRE}settings_hide_reused_validator', default=True, types=bool), + AppConfig(name=f'{PRE}settings_undoc_members', default=True, types=bool), + AppConfig(name=f'{PRE}settings_members', default=True, types=bool), + AppConfig(name=f'{PRE}settings_member_order', default='groupwise', types=str), + AppConfig( + name=f'{PRE}settings_signature_prefix', + default='pydantic settings', + types=str, + ), + # model + AppConfig(name=f'{PRE}model_show_json', default=True, types=bool), + AppConfig( + name=f'{PRE}model_show_json_error_strategy', + default=OptionsJsonErrorStrategy.WARN, + types=str, + ), + AppConfig(name=f'{PRE}model_show_config_summary', default=True, types=bool), + AppConfig(name=f'{PRE}model_show_validator_members', default=True, types=bool), + AppConfig(name=f'{PRE}model_show_validator_summary', default=True, types=bool), + AppConfig(name=f'{PRE}model_show_field_summary', default=True, types=bool), + AppConfig( + name=f'{PRE}model_summary_list_order', + default=OptionsSummaryListOrder.ALPHABETICAL, + types=str, + ), + AppConfig(name=f'{PRE}model_hide_paramlist', default=True, types=bool), + AppConfig(name=f'{PRE}model_hide_reused_validator', default=True, types=bool), + AppConfig(name=f'{PRE}model_undoc_members', default=True, types=bool), + AppConfig(name=f'{PRE}model_members', default=True, types=bool), + AppConfig(name=f'{PRE}model_member_order', default='groupwise', types=str), + AppConfig(name=f'{PRE}model_signature_prefix', default='pydantic model', types=str), + AppConfig(name=f'{PRE}model_erdantic_figure', default=False, types=bool), + AppConfig(name=f'{PRE}model_erdantic_figure_collapsed', default=True, types=bool), + # validator + AppConfig(name=f'{PRE}validator_signature_prefix', default='validator', types=str), + AppConfig(name=f'{PRE}validator_replace_signature', default=True, types=bool), + AppConfig(name=f'{PRE}validator_list_fields', default=False, types=bool), + # field + AppConfig(name=f'{PRE}field_list_validators', default=True, types=bool), + AppConfig( + name=f'{PRE}field_doc_policy', default=OptionsFieldDocPolicy.BOTH, types=str + ), + AppConfig(name=f'{PRE}field_show_constraints', default=True, types=bool), + AppConfig(name=f'{PRE}field_show_alias', default=True, types=bool), + AppConfig(name=f'{PRE}field_show_default', default=True, types=bool), + AppConfig(name=f'{PRE}field_show_required', default=True, types=bool), + AppConfig(name=f'{PRE}field_show_optional', default=True, types=bool), + AppConfig(name=f'{PRE}field_swap_name_and_alias', default=False, types=bool), + AppConfig(name=f'{PRE}field_signature_prefix', default='field', types=str), + # general + AppConfig(name=f'{PRE}add_fallback_css_class', default=True, types=bool), +] + + def add_css_file(app: Sphinx, *_) -> None: # noqa: ANN002 """Adds custom css to HTML output.""" @@ -71,57 +154,13 @@ def add_domain_object_types(app: Sphinx) -> None: def add_configuration_values(app: Sphinx) -> None: """Adds all configuration values to sphinx application.""" - stem = 'autodoc_pydantic_' - add = app.add_config_value - json_strategy = OptionsJsonErrorStrategy.WARN - summary_list_order = OptionsSummaryListOrder.ALPHABETICAL - - # ruff: noqa: FBT003 - add(f'{stem}settings_show_json', True, True, bool) - add(f'{stem}settings_show_json_error_strategy', json_strategy, True, str) - add(f'{stem}settings_show_config_summary', True, True, bool) - add(f'{stem}settings_show_validator_members', True, True, bool) - add(f'{stem}settings_show_validator_summary', True, True, bool) - add(f'{stem}settings_show_field_summary', True, True, bool) - add(f'{stem}settings_summary_list_order', summary_list_order, True, str) - add(f'{stem}settings_hide_paramlist', True, True, bool) - add(f'{stem}settings_hide_reused_validator', True, True, bool) - add(f'{stem}settings_undoc_members', True, True, bool) - add(f'{stem}settings_members', True, True, bool) - add(f'{stem}settings_member_order', 'groupwise', True, str) - add(f'{stem}settings_signature_prefix', 'pydantic settings', True, str) - - add(f'{stem}model_show_json', True, True, bool) - add(f'{stem}model_show_json_error_strategy', json_strategy, True, str) - add(f'{stem}model_show_config_summary', True, True, bool) - add(f'{stem}model_show_validator_members', True, True, bool) - add(f'{stem}model_show_validator_summary', True, True, bool) - add(f'{stem}model_show_field_summary', True, True, bool) - add(f'{stem}model_summary_list_order', summary_list_order, True, str) - add(f'{stem}model_hide_paramlist', True, True, bool) - add(f'{stem}model_hide_reused_validator', True, True, bool) - add(f'{stem}model_undoc_members', True, True, bool) - add(f'{stem}model_members', True, True, bool) - add(f'{stem}model_member_order', 'groupwise', True, str) - add(f'{stem}model_signature_prefix', 'pydantic model', True, str) - add(f'{stem}model_erdantic_figure', False, True, bool) - add(f'{stem}model_erdantic_figure_collapsed', True, True, bool) - - add(f'{stem}validator_signature_prefix', 'validator', True, str) - add(f'{stem}validator_replace_signature', True, True, bool) - add(f'{stem}validator_list_fields', False, True, bool) - - add(f'{stem}field_list_validators', True, True, bool) - add(f'{stem}field_doc_policy', OptionsFieldDocPolicy.BOTH, True, str) - add(f'{stem}field_show_constraints', True, True, bool) - add(f'{stem}field_show_alias', True, True, bool) - add(f'{stem}field_show_default', True, True, bool) - add(f'{stem}field_show_required', True, True, bool) - add(f'{stem}field_show_optional', True, True, bool) - add(f'{stem}field_swap_name_and_alias', False, True, bool) - add(f'{stem}field_signature_prefix', 'field', True, str) - - add(f'{stem}add_fallback_css_class', True, True, bool) + for config in APP_CONFIGURATIONS: + app.add_config_value( + name=config.name, + default=config.default, + types=config.types, + rebuild=config.rebuild, + ) def add_directives_and_autodocumenters(app: Sphinx) -> None: diff --git a/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py b/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py index 7da8f8da..34e47815 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py +++ b/sphinxcontrib/autodoc_pydantic/directives/autodocumenters.py @@ -16,13 +16,15 @@ ) from sphinx.util.docstrings import prepare_docstring from sphinx.util.inspect import object_description -from sphinx.util.typing import get_type_hints +from sphinx.util.typing import OptionSpec, get_type_hints try: from sphinx.util.typing import stringify_annotation except ImportError: # fall back to older name for older versions of Sphinx - from sphinx.util.typing import stringify as stringify_annotation + from sphinx.util.typing import ( # type: ignore[no-redef] + stringify as stringify_annotation, + ) from sphinxcontrib.autodoc_pydantic.directives.options.composites import AutoDocOptions from sphinxcontrib.autodoc_pydantic.directives.options.definition import ( @@ -48,11 +50,6 @@ ValidatorFieldMap, ) -try: - import erdantic as erd -except ImportError: - erd = None - if TYPE_CHECKING: from docutils.statemachine import StringList from pydantic import BaseModel @@ -69,16 +66,16 @@ def __init__(self, documenter: Documenter, is_child: bool) -> None: # noqa: FBT self._is_child = is_child self._inspect: ModelInspector | None = None self._options = AutoDocOptions(self._documenter) - self._model: BaseModel | None = None + self._model: type[BaseModel] | None = None @property - def model(self) -> BaseModel: + def model(self) -> type[BaseModel]: """Lazily load pydantic model after initialization. For more, please read `inspect` doc string. """ - if self._model: + if self._model is not None: return self._model if self._is_child: @@ -142,7 +139,7 @@ def get_non_inherited_members(self) -> set[str]: """ object_members = self._documenter.get_object_members(want_all=True)[1] - return {x.__name__ for x in object_members} + return {x.__name__ for x in object_members} # type: ignore[union-attr] def get_base_class_names(self) -> list[str]: return [x.__name__ for x in self.model.__mro__] @@ -333,8 +330,10 @@ def add_erdantic_figure(self) -> None: pydantic model. """ - source_name = self.get_sourcename() - if erd is None: + + try: + import erdantic as erd + except ImportError as e: error_msg = ( 'erdantic is not installed, you need to install it before ' 'creating an Entity Relationship Diagram for ' @@ -342,8 +341,9 @@ def add_erdantic_figure(self) -> None: 'https://autodoc-pydantic.readthedocs.io/' 'en/stable/users/installation.html' ) - raise RuntimeError(error_msg) + raise ImportError(error_msg) from e + source_name = self.get_sourcename() # Graphviz [DOT language](https://graphviz.org/doc/info/lang.html) figure_dot = ( erd.to_dot(self.object, graph_attr={'label': ''}) @@ -459,7 +459,7 @@ def add_validators_summary(self) -> None: self.add_line('', source_name) - def _get_base_model_validators(self) -> list[str]: + def _get_base_model_validators(self) -> list[ValidatorFieldMap]: """Return the validators on the model being documented""" result = [] @@ -482,7 +482,7 @@ def _get_base_model_validators(self) -> list[str]: result.append(ref) return result - def _get_inherited_validators(self) -> list[str]: + def _get_inherited_validators(self) -> list[ValidatorFieldMap]: """Return the validators on inherited fields to be documented, if any""" @@ -544,6 +544,25 @@ def _get_inherited_fields(self) -> list[str]: base_class_fields = self.pydantic.get_non_inherited_members() return [field for field in fields if field not in base_class_fields] + def _get_tagorder(self, name: str) -> int | None: + """Get tagorder for given `name`.""" + + if self.analyzer is None: + return None + + if name in self.analyzer.tagorder: + return self.analyzer.tagorder.get(name) + + for base in self.pydantic.get_base_class_names(): + name_with_class = f'{base}.{name}' + if name_with_class in self.analyzer.tagorder: + return self.analyzer.tagorder.get(name_with_class) + + if name == ASTERISK_FIELD_NAME: + return -1 + + return None + def _sort_summary_list(self, names: Iterable[str]) -> list[str]: """Sort member names according to given sort order `OptionsSummaryListOrder`. @@ -559,20 +578,15 @@ def sort_func(name: str) -> str: return name elif sort_order == OptionsSummaryListOrder.BYSOURCE: - def sort_func(name: str) -> int: - if name in self.analyzer.tagorder: - return self.analyzer.tagorder.get(name) - for base in self.pydantic.get_base_class_names(): - name_with_class = f'{base}.{name}' - if name_with_class in self.analyzer.tagorder: - return self.analyzer.tagorder.get(name_with_class) - # a pseudo-field name used by root validators - if name == ASTERISK_FIELD_NAME: - return -1 + def sort_func(name: str) -> int: # type: ignore[misc] + tagorder = self._get_tagorder(name) # catch cases where field is not found in tagorder - msg = f'Field {name} in {self.object_name} not found in tagorder' - raise ValueError(msg) + if tagorder is None: + msg = f'Field {name} in {self.object_name} not found in tagorder' + raise ValueError(msg) + + return tagorder try: return sorted(names, key=sort_func) @@ -605,8 +619,8 @@ def _stringify_type(self, field_name: str) -> str: @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')] + schema_str = json.dumps(schema, default=str, indent=3) + lines = [f' {line}' for line in schema_str.split('\n')] lines = ['.. code-block:: json', '', *lines] return to_collapsable( lines, @@ -661,7 +675,7 @@ class PydanticFieldDocumenter(AttributeDocumenter): objtype = 'pydantic_field' directivetype = 'pydantic_field' priority = 10 + AttributeDocumenter.priority - option_spec: ClassVar[dict] = dict(AttributeDocumenter.option_spec) + option_spec: ClassVar[OptionSpec] = dict(AttributeDocumenter.option_spec) option_spec.update(OPTIONS_FIELD) member_order = 0 diff --git a/sphinxcontrib/autodoc_pydantic/directives/directives.py b/sphinxcontrib/autodoc_pydantic/directives/directives.py index 427403fb..febe6e17 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/directives.py +++ b/sphinxcontrib/autodoc_pydantic/directives/directives.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import Tuple +from typing import TYPE_CHECKING, Tuple import sphinx -from docutils.nodes import Text +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 @@ -23,10 +23,13 @@ ) from sphinxcontrib.autodoc_pydantic.inspection import ModelInspector, ValidatorFieldMap +if TYPE_CHECKING: + from sphinx.util.typing import OptionSpec + TUPLE_STR = Tuple[str, str] -class PydanticDirectiveBase: +class PydanticDirectiveMixin: """Base class for pydantic directive providing common functionality.""" config_name: str @@ -36,7 +39,7 @@ def __init__(self, *args) -> None: # noqa: ANN002 super().__init__(*args) self.pyautodoc = DirectiveOptions(self) - def get_signature_prefix(self, *_) -> str | list[Text]: # noqa: ANN002 + 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' @@ -44,18 +47,18 @@ def get_signature_prefix(self, *_) -> str | list[Text]: # noqa: ANN002 value = prefix or self.default_prefix # account for changed signature in sphinx 4.3, see #62 - if sphinx.version_info >= (4, 3): - from sphinx.addnodes import desc_sig_space + if sphinx.version_info < (4, 3): + return f'{value} ' # type: ignore[return-value] - return [Text(value), desc_sig_space()] + from sphinx.addnodes import desc_sig_space - return f'{value} ' + return [Text(value), desc_sig_space()] -class PydanticModel(PydanticDirectiveBase, PyClasslike): +class PydanticModel(PydanticDirectiveMixin, PyClasslike): """Specialized directive for pydantic models.""" - option_spec = PyClasslike.option_spec.copy() + option_spec: OptionSpec = PyClasslike.option_spec.copy() # type: ignore[misc] option_spec.update( { '__doc_disable_except__': option_list_like, @@ -67,10 +70,10 @@ class PydanticModel(PydanticDirectiveBase, PyClasslike): default_prefix = 'class' -class PydanticSettings(PydanticDirectiveBase, PyClasslike): +class PydanticSettings(PydanticDirectiveMixin, PyClasslike): """Specialized directive for pydantic settings.""" - option_spec = PyClasslike.option_spec.copy() + option_spec: OptionSpec = PyClasslike.option_spec.copy() # type: ignore[misc] option_spec.update( { '__doc_disable_except__': option_list_like, @@ -82,10 +85,10 @@ class PydanticSettings(PydanticDirectiveBase, PyClasslike): default_prefix = 'class' -class PydanticField(PydanticDirectiveBase, PyAttribute): +class PydanticField(PydanticDirectiveMixin, PyAttribute): """Specialized directive for pydantic fields.""" - option_spec = PyAttribute.option_spec.copy() + option_spec: OptionSpec = PyAttribute.option_spec.copy() # type: ignore[misc] option_spec.update( { 'alias': unchanged, @@ -107,7 +110,12 @@ def get_field_name(self, sig: str) -> str: """ - return py_sig_re.match(sig).groups()[1] + 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] def add_required(self, signode: desc_signature) -> None: """Add `[Required]` if directive option `required` is set.""" @@ -137,13 +145,11 @@ def add_alias_or_name(self, sig: str, signode: desc_signature) -> None: value = self.get_field_name(sig) else: prefix = 'alias' - value = self.options.get('alias') + value = self.options.get('alias', '') signode += desc_annotation('', f" ({prefix} '{value}')") - def _find_desc_name_node( - self, sig: str, signode: desc_signature - ) -> desc_name | None: + 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. @@ -154,7 +160,6 @@ def _find_desc_name_node( 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 @@ -181,9 +186,9 @@ def swap_name_and_alias(self, sig: str, signode: desc_signature) -> None: location='autodoc_pydantic', ) else: - text_node = Text(self.options.get('alias')) + text_node = Text(self.options.get('alias', '')) text_node.parent = name_node - name_node.children[0] = text_node + name_node.children = [text_node] def handle_signature(self, sig: str, signode: desc_signature) -> TUPLE_STR: """Optionally call add alias method.""" @@ -199,10 +204,10 @@ def handle_signature(self, sig: str, signode: desc_signature) -> TUPLE_STR: return fullname, prefix -class PydanticValidator(PydanticDirectiveBase, PyMethod): +class PydanticValidator(PydanticDirectiveMixin, PyMethod): """Specialized directive for pydantic validators.""" - option_spec = PyMethod.option_spec.copy() + option_spec: OptionSpec = PyMethod.option_spec.copy() # type: ignore[misc] option_spec.update( { 'validator-replace-signature': option_default_true, diff --git a/sphinxcontrib/autodoc_pydantic/directives/options/composites.py b/sphinxcontrib/autodoc_pydantic/directives/options/composites.py index 2658a958..61086fa2 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/options/composites.py +++ b/sphinxcontrib/autodoc_pydantic/directives/options/composites.py @@ -7,15 +7,12 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Any +from typing import Any -from sphinx.ext.autodoc import ALL, Documenter, Options +from sphinx.ext.autodoc import ALL, Options from sphinxcontrib.autodoc_pydantic.directives.utility import NONE -if TYPE_CHECKING: - from docutils.parsers.rst import Directive - class DirectiveOptions: """Composite class providing methods to manage getting and setting @@ -33,7 +30,7 @@ class DirectiveOptions: """ - def __init__(self, parent: Documenter | Directive) -> None: + def __init__(self, parent: Any) -> None: # noqa: ANN401 self.parent = parent self.parent.options = Options(self.parent.options) self.add_default_options() @@ -45,8 +42,7 @@ def add_default_options(self) -> None: for option in options: self.set_default_option(option) - @staticmethod - def determine_app_cfg_name(name: str) -> str: + def determine_app_cfg_name(self, name: str) -> str: """Provide full app environment configuration name for given option name while converting "-" to "_". @@ -288,7 +284,8 @@ def wrapped(*args, **kwargs): # noqa: ANN002, ANN003, ANN202 return result - self.parent.add_directive_header = wrapped + # see https://github.com/python/mypy/issues/2427 + self.parent.add_directive_header = wrapped # type: ignore[method-assign] def pass_option_to_directive(self, name: str) -> None: """Pass an autodoc option through to the generated directive.""" diff --git a/sphinxcontrib/autodoc_pydantic/directives/options/definition.py b/sphinxcontrib/autodoc_pydantic/directives/options/definition.py index ebff7af1..b0984c33 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/options/definition.py +++ b/sphinxcontrib/autodoc_pydantic/directives/options/definition.py @@ -1,5 +1,9 @@ """This module contains the autodocumenter's option definitions.""" +from __future__ import annotations + +from typing import Callable + from docutils.parsers.rst.directives import unchanged from sphinxcontrib.autodoc_pydantic.directives.options.enums import ( @@ -14,7 +18,7 @@ option_one_of_factory, ) -OPTIONS_FIELD = { +OPTIONS_FIELD: dict[str, Callable] = { 'field-show-default': option_default_true, 'field-show-required': option_default_true, 'field-show-optional': option_default_true, @@ -28,7 +32,7 @@ } """Represents added directive options for :class:`PydanticFieldDocumenter`.""" -OPTIONS_VALIDATOR = { +OPTIONS_VALIDATOR: dict[str, Callable] = { 'validator-replace-signature': option_default_true, 'validator-list-fields': option_default_true, 'validator-signature-prefix': unchanged, @@ -38,9 +42,9 @@ """Represents added directive options for :class:`PydanticValidatorDocumenter`. """ -OPTIONS_MERGED = {**OPTIONS_FIELD, **OPTIONS_VALIDATOR} +OPTIONS_MERGED: dict[str, Callable] = {**OPTIONS_FIELD, **OPTIONS_VALIDATOR} -OPTIONS_MODEL = { +OPTIONS_MODEL: dict[str, Callable] = { 'model-show-json': option_default_true, 'model-show-json-error-strategy': option_one_of_factory( OptionsJsonErrorStrategy.values(), @@ -64,7 +68,7 @@ } """Represents added directive options for :class:`PydanticModelDocumenter`.""" -OPTIONS_SETTINGS = { +OPTIONS_SETTINGS: dict[str, Callable] = { 'settings-show-json': option_default_true, 'settings-show-json-error-strategy': option_one_of_factory( OptionsJsonErrorStrategy.values(), diff --git a/sphinxcontrib/autodoc_pydantic/directives/templates.py b/sphinxcontrib/autodoc_pydantic/directives/templates.py index d846d6ed..40cfbba7 100644 --- a/sphinxcontrib/autodoc_pydantic/directives/templates.py +++ b/sphinxcontrib/autodoc_pydantic/directives/templates.py @@ -31,10 +31,10 @@ def to_collapsable(lines: list[str], title: str, css_class: str) -> list[str]: """ - lines = '\n'.join(lines) - lines = TPL_COLLAPSE.format( - lines=lines, + lines_joined = '\n'.join(lines) + lines_formatted = TPL_COLLAPSE.format( + lines=lines_joined, summary=title, details_class=css_class, ) - return lines.split('\n') + return lines_formatted.split('\n') diff --git a/sphinxcontrib/autodoc_pydantic/inspection.py b/sphinxcontrib/autodoc_pydantic/inspection.py index b4ac2ffd..00dd3750 100644 --- a/sphinxcontrib/autodoc_pydantic/inspection.py +++ b/sphinxcontrib/autodoc_pydantic/inspection.py @@ -13,6 +13,11 @@ from collections import defaultdict from typing import TYPE_CHECKING, Any, Callable, NamedTuple, TypeVar +try: + from typing import TypeGuard +except ImportError: + from typing_extensions import TypeGuard + from pydantic import BaseModel, ConfigDict, PydanticInvalidForJsonSchema, create_model from pydantic_settings import BaseSettings @@ -273,7 +278,7 @@ def __init__(self, parent: ModelInspector) -> None: super().__init__(parent) self.items = self._get_values_per_type() - def _get_values_per_type(self) -> dict[str, str]: + def _get_values_per_type(self) -> dict[str, Any]: """Get the configuration values from any pydantic model. Behavior of configuration values varies between `BaseModel` and @@ -285,14 +290,15 @@ def _get_values_per_type(self) -> dict[str, str]: """ - values = self.model.model_config + cfg = self.model.model_config if issubclass(self.model, BaseSettings): default = tuple(BaseSettings.model_config.items()) - available = tuple(values.items()) - + available = tuple(cfg.items()) result = [given for given in available if given not in default] values = dict(result) + else: + values = dict(cfg) return values @@ -394,14 +400,14 @@ def sanitized(self) -> dict: reordered_schema.update(schema) return reordered_schema - def create_sanitized_model(self) -> BaseModel: + def create_sanitized_model(self) -> type[BaseModel]: """Generates a new pydantic model from the original one while substituting invalid fields with typevars. """ invalid_fields = self._parent.fields.non_json_serializable - new = {name: (TypeVar(name), None) for name in invalid_fields} + new: dict[str, Any] = {name: (TypeVar(name), None) for name in invalid_fields} return create_model(self.model.__name__, __base__=self.model, **new) @@ -409,7 +415,7 @@ class StaticInspector: """Namespace under `ModelInspector` for static methods.""" @staticmethod - def is_pydantic_model(obj: Any) -> bool: # noqa: ANN401 + def is_pydantic_model(obj: Any) -> TypeGuard[type[BaseModel]]: # noqa: ANN401 """Determine if object is a valid pydantic model.""" try: @@ -463,17 +469,17 @@ def get_field_validator_mapping(self) -> dict[str, list[ValidatorAdapter]]: """ - mapping = defaultdict(list) + mapping: dict[str, list[Any]] = defaultdict(list) decorators = self.model.__pydantic_decorators__ # field validators - for validator in decorators.field_validators.values(): - for field in validator.info.fields: - mapping[field].append(ValidatorAdapter(func=validator.func)) + for field_validator in decorators.field_validators.values(): + for field in field_validator.info.fields: + mapping[field].append(ValidatorAdapter(func=field_validator.func)) # model validators - for validator in decorators.model_validators.values(): - mapping['*'].append(ValidatorAdapter(func=validator.func)) + for model_validator in decorators.model_validators.values(): + mapping['*'].append(ValidatorAdapter(func=model_validator.func)) return mapping diff --git a/tox.ini b/tox.ini index 5373001f..d709763f 100644 --- a/tox.ini +++ b/tox.ini @@ -12,7 +12,7 @@ commands = coverage report -m coverage xml -[testenv:pydantic{20,21,22,23,24,25,26,27,latest}-sphinx{40,45,53,62,70,71,72,latest}] +[testenv:py{37,38,39,310,311,312}-pydantic{20,21,22,23,24,25,26,27,latest}-sphinx{40,45,53,62,70,71,72,latest}] description = "Test specific historical stable versions." deps = pydantic20: pydantic~=2.0.0 @@ -71,13 +71,22 @@ deps = git+https://github.com/drivendataorg/erdantic.git [testenv:linter] +description = "Run linters on the codebase." skip_sdist = true skip_install = true deps = ruff commands = ruff check sphinxcontrib [testenv:formatter] +description = "Check correct formatting of the codebase." skip_sdist = true skip_install = true deps = ruff -commands = ruff format --diff \ No newline at end of file +commands = ruff format --diff + +[testenv:type-checker] +description = "Type check the codebase." +extras = + type_checking + erdantic +commands = mypy sphinxcontrib/ --explicit-package-bases \ No newline at end of file