├── .github ├── FUNDING.yml ├── SECURITY.md ├── dependabot.yml ├── release.yml └── workflows │ ├── check.yaml │ └── release.yaml ├── .gitignore ├── .markdownlint.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── ignore-words.txt ├── pyproject.toml ├── src └── sphinx_autodoc_typehints │ ├── __init__.py │ ├── _parser.py │ ├── attributes_patch.py │ ├── patches.py │ └── py.typed ├── tests ├── conftest.py ├── roots │ ├── test-dummy │ │ ├── conf.py │ │ ├── dummy_module.py │ │ ├── dummy_module_future_annotations.py │ │ ├── dummy_module_simple.py │ │ ├── dummy_module_simple_default_role.py │ │ ├── dummy_module_simple_no_use_rtype.py │ │ ├── dummy_module_without_complete_typehints.py │ │ ├── export_module.py │ │ ├── future_annotations.rst │ │ ├── simple.rst │ │ ├── simple_default_role.rst │ │ ├── simple_no_use_rtype.rst │ │ ├── without_complete_typehints.rst │ │ ├── wrong_module_path.py │ │ └── wrong_module_path.rst │ ├── test-integration │ │ └── conf.py │ ├── test-resolve-typing-guard-tmp │ │ ├── conf.py │ │ ├── demo_typing_guard.py │ │ └── index.rst │ └── test-resolve-typing-guard │ │ ├── conf.py │ │ ├── demo_typing_guard.py │ │ ├── demo_typing_guard_dummy.py │ │ └── index.rst ├── test_integration.py ├── test_integration_autodoc_type_aliases.py ├── test_integration_issue_384.py ├── test_sphinx_autodoc_typehints.py └── test_version.py ├── tox.ini └── whitelist.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: "pypi/sphinx-autodoc-typehints" 2 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.18 + | :white_check_mark: | 8 | | < 1.18 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift 13 | will coordinate the fix and disclosure. 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot[bot] 5 | - pre-commit-ci[bot] 6 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ["main"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | schedule: 9 | - cron: "0 8 * * *" 10 | 11 | concurrency: 12 | group: check-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | env: 22 | - "3.14t" 23 | - "3.14" 24 | - "3.13" 25 | - "3.12" 26 | - "3.11" 27 | - type 28 | - dev 29 | - pkg_meta 30 | steps: 31 | - name: Install OS dependencies 32 | run: sudo apt-get install graphviz -y 33 | - uses: actions/checkout@v5 34 | with: 35 | fetch-depth: 0 36 | - name: Install the latest version of uv 37 | uses: astral-sh/setup-uv@v7 38 | with: 39 | enable-cache: true 40 | cache-dependency-glob: "pyproject.toml" 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | - name: Install tox 43 | run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv 44 | - name: Install Python 45 | if: startsWith(matrix.env, '3.') && matrix.env != '3.14' 46 | run: uv python install --python-preference only-managed ${{ matrix.env }} 47 | - name: Setup test suite 48 | run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }} 49 | - name: Run test suite 50 | run: tox run --skip-pkg-install -e ${{ matrix.env }} 51 | env: 52 | PYTEST_ADDOPTS: "-vv --durations=20" 53 | DIFF_AGAINST: HEAD 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | with: 15 | fetch-depth: 0 16 | - name: Install the latest version of uv 17 | uses: astral-sh/setup-uv@v7 18 | with: 19 | enable-cache: true 20 | cache-dependency-glob: "pyproject.toml" 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build package 23 | run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: ${{ env.dists-artifact-name }} 28 | path: dist/* 29 | 30 | release: 31 | needs: 32 | - build 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: release 36 | url: https://pypi.org/project/sphinx-autodoc-typehints/${{ github.ref_name }} 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v5 42 | with: 43 | name: ${{ env.dists-artifact-name }} 44 | path: dist/ 45 | - name: Publish to PyPI 46 | uses: pypa/gh-action-pypi-publish@v1.13.0 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.egg 3 | *.py[codz] 4 | *$py.class 5 | .tox 6 | .*_cache 7 | /src/sphinx_autodoc_typehints/version.py 8 | venv* 9 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | MD013: 2 | code_blocks: false 3 | headers: false 4 | line_length: 120 5 | tables: false 6 | 7 | MD046: 8 | style: fenced 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: "0.34.0" 9 | hooks: 10 | - id: check-github-workflows 11 | args: ["--verbose"] 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | additional_dependencies: ["tomli>=2.2.1"] 17 | - repo: https://github.com/tox-dev/tox-ini-fmt 18 | rev: "1.6.0" 19 | hooks: 20 | - id: tox-ini-fmt 21 | args: ["-p", "fix"] 22 | - repo: https://github.com/tox-dev/pyproject-fmt 23 | rev: "v2.10.0" 24 | hooks: 25 | - id: pyproject-fmt 26 | - repo: https://github.com/astral-sh/ruff-pre-commit 27 | rev: "v0.14.0" 28 | hooks: 29 | - id: ruff-format 30 | - id: ruff-check 31 | args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] 32 | - repo: https://github.com/rbubley/mirrors-prettier 33 | rev: "v3.6.2" # Use the sha / tag you want to point at 34 | hooks: 35 | - id: prettier 36 | additional_dependencies: 37 | - prettier@3.6.2 38 | - "@prettier/plugin-xml@3.4.2" 39 | - repo: meta 40 | hooks: 41 | - id: check-hooks-apply 42 | - id: check-useless-excludes 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-202x The sphinx-autodoc-typehints developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sphinx-autodoc-typehints 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/sphinx-autodoc-typehints?style=flat-square)](https://pypi.org/project/sphinx-autodoc-typehints/) 4 | [![Supported Python 5 | versions](https://img.shields.io/pypi/pyversions/sphinx-autodoc-typehints.svg)](https://pypi.org/project/sphinx-autodoc-typehints/) 6 | [![Downloads](https://pepy.tech/badge/sphinx-autodoc-typehints/month)](https://pepy.tech/project/sphinx-autodoc-typehints) 7 | [![check](https://github.com/tox-dev/sphinx-autodoc-typehints/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/sphinx-autodoc-typehints/actions/workflows/check.yaml) 8 | 9 | This extension allows you to use Python 3 annotations for documenting acceptable argument types and return value types 10 | of functions. See an example of the Sphinx render at the 11 | [pyproject-api docs](https://pyproject-api.readthedocs.io/latest/api.html). 12 | 13 | This allows you to use type hints in a very natural fashion, allowing you to migrate from this: 14 | 15 | ```python 16 | def format_unit(value, unit): 17 | """ 18 | Formats the given value as a human readable string using the given units. 19 | 20 | :param float|int value: a numeric value 21 | :param str unit: the unit for the value (kg, m, etc.) 22 | :rtype: str 23 | """ 24 | return f"{value} {unit}" 25 | ``` 26 | 27 | to this: 28 | 29 | ```python 30 | from typing import Union 31 | 32 | 33 | def format_unit(value: Union[float, int], unit: str) -> str: 34 | """ 35 | Formats the given value as a human readable string using the given units. 36 | 37 | :param value: a numeric value 38 | :param unit: the unit for the value (kg, m, etc.) 39 | """ 40 | return f"{value} {unit}" 41 | ``` 42 | 43 | ## Installation and setup 44 | 45 | First, use pip to download and install the extension: 46 | 47 | ```bash 48 | pip install sphinx-autodoc-typehints 49 | ``` 50 | 51 | Then, add the extension to your `conf.py`: 52 | 53 | ```python 54 | extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints"] 55 | ``` 56 | 57 | ## Options 58 | 59 | The following configuration options are accepted: 60 | 61 | - `typehints_fully_qualified` (default: `False`): if `True`, class names are always fully qualified (e.g. 62 | `module.for.Class`). If `False`, just the class name displays (e.g. `Class`) 63 | - `always_document_param_types` (default: `False`): If `False`, do not add type info for undocumented parameters. If 64 | `True`, add stub documentation for undocumented parameters to be able to add type info. 65 | - `always_use_bars_union ` (default: `False`): If `True`, display Union's using the | operator described in PEP 604. 66 | (e.g `X` | `Y` or `int` | `None`). If `False`, Unions will display with the typing in brackets. (e.g. `Union[X, Y]` 67 | or `Optional[int]`). Note that on 3.14 and later this will always be `True` and not configurable due the interpreter 68 | no longer differentiating between the two types, and we have no way to determine what the user used. 69 | - `typehints_document_rtype` (default: `True`): If `False`, never add an `:rtype:` directive. If `True`, add the 70 | `:rtype:` directive if no existing `:rtype:` is found. 71 | - `typehints_document_rtype_none` (default: `True`): If `False`, never add an `:rtype: None` directive. If `True`, add the `:rtype: None`. 72 | - `typehints_use_rtype` (default: `True`): Controls behavior when `typehints_document_rtype` is set to `True`. If 73 | `True`, document return type in the `:rtype:` directive. If `False`, document return type as part of the `:return:` 74 | directive, if present, otherwise fall back to using `:rtype:`. Use in conjunction with 75 | [napoleon_use_rtype](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#confval-napoleon_use_rtype) 76 | to avoid generation of duplicate or redundant return type information. 77 | - `typehints_defaults` (default: `None`): If `None`, defaults are not added. Otherwise, adds a default annotation: 78 | - `'comma'` adds it after the type, changing Sphinx’ default look to “**param** (_int_, default: `1`) -- text”. 79 | - `'braces'` adds `(default: ...)` after the type (useful for numpydoc like styles). 80 | - `'braces-after'` adds `(default: ...)` at the end of the parameter documentation text instead. 81 | 82 | - `simplify_optional_unions` (default: `True`): If `True`, optional parameters of type \"Union\[\...\]\" are simplified 83 | as being of type Union\[\..., None\] in the resulting documentation (e.g. Optional\[Union\[A, B\]\] -\> Union\[A, B, 84 | None\]). If `False`, the \"Optional\"-type is kept. Note: If `False`, **any** Union containing `None` will be 85 | displayed as Optional! Note: If an optional parameter has only a single type (e.g Optional\[A\] or Union\[A, None\]), 86 | it will **always** be displayed as Optional! 87 | - `typehints_formatter` (default: `None`): If set to a function, this function will be called with `annotation` as first 88 | argument and `sphinx.config.Config` argument second. The function is expected to return a string with reStructuredText 89 | code or `None` to fall back to the default formatter. 90 | - `typehints_use_signature` (default: `False`): If `True`, typehints for parameters in the signature are shown. 91 | - `typehints_use_signature_return` (default: `False`): If `True`, return annotations in the signature are shown. 92 | - `suppress_warnings`: sphinx-autodoc-typehints supports to suppress warning messages via Sphinx's `suppress_warnings`. It allows following additional warning types: 93 | - `sphinx_autodoc_typehints` 94 | - `sphinx_autodoc_typehints.comment` 95 | - `sphinx_autodoc_typehints.forward_reference` 96 | - `sphinx_autodoc_typehints.guarded_import` 97 | - `sphinx_autodoc_typehints.local_function` 98 | - `sphinx_autodoc_typehints.multiple_ast_nodes` 99 | 100 | ## How it works 101 | 102 | The extension listens to the `autodoc-process-signature` and `autodoc-process-docstring` Sphinx events. In the former, 103 | it strips the annotations from the function signature. In the latter, it injects the appropriate `:type argname:` and 104 | `:rtype:` directives into the docstring. 105 | 106 | Only arguments that have an existing `:param:` directive in the docstring get their respective `:type:` directives 107 | added. The `:rtype:` directive is added if and only if no existing `:rtype:` is found. 108 | 109 | ## Compatibility with sphinx.ext.napoleon 110 | 111 | To use [sphinx.ext.napoleon](http://www.sphinx-doc.org/en/stable/ext/napoleon.html) with sphinx-autodoc-typehints, make 112 | sure you load [sphinx.ext.napoleon](http://www.sphinx-doc.org/en/stable/ext/napoleon.html) first, **before** 113 | sphinx-autodoc-typehints. See [Issue 15](https://github.com/tox-dev/sphinx-autodoc-typehints/issues/15) on the issue 114 | tracker for more information. 115 | 116 | ## Dealing with circular imports 117 | 118 | Sometimes functions or classes from two different modules need to reference each other in their type annotations. This 119 | creates a circular import problem. The solution to this is the following: 120 | 121 | 1. Import only the module, not the classes/functions from it 122 | 2. Use forward references in the type annotations (e.g. `def methodname(self, param1: 'othermodule.OtherClass'):`) 123 | 124 | On Python 3.7, you can even use `from __future__ import annotations` and remove the quotes. 125 | -------------------------------------------------------------------------------- /ignore-words.txt: -------------------------------------------------------------------------------- 1 | master 2 | manuel 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.5", 5 | "hatchling>=1.27", 6 | ] 7 | 8 | [project] 9 | name = "sphinx-autodoc-typehints" 10 | description = "Type hints (PEP 484) support for the Sphinx autodoc extension" 11 | readme.content-type = "text/markdown" 12 | readme.file = "README.md" 13 | keywords = [ 14 | "environments", 15 | "isolated", 16 | "testing", 17 | "virtual", 18 | ] 19 | license = "MIT" 20 | maintainers = [ 21 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 22 | ] 23 | authors = [ 24 | { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, 25 | ] 26 | requires-python = ">=3.11" 27 | classifiers = [ 28 | "Development Status :: 5 - Production/Stable", 29 | "Framework :: Sphinx :: Extension", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3 :: Only", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Programming Language :: Python :: 3.14", 38 | "Topic :: Documentation :: Sphinx", 39 | ] 40 | dynamic = [ 41 | "version", 42 | ] 43 | dependencies = [ 44 | "sphinx>=8.2.3", 45 | ] 46 | optional-dependencies.docs = [ 47 | "furo>=2025.9.25", 48 | ] 49 | optional-dependencies.testing = [ 50 | "covdefaults>=2.3", 51 | "coverage>=7.10.7", 52 | "defusedxml>=0.7.1", # required by sphinx.testing 53 | "diff-cover>=9.7.1", 54 | "pytest>=8.4.2", 55 | "pytest-cov>=7", 56 | "sphobjinv>=2.3.1.3", 57 | "typing-extensions>=4.15", 58 | ] 59 | urls.Changelog = "https://github.com/tox-dev/sphinx-autodoc-typehints/releases" 60 | urls.Homepage = "https://github.com/tox-dev/sphinx-autodoc-typehints" 61 | urls.Source = "https://github.com/tox-dev/sphinx-autodoc-typehints" 62 | urls.Tracker = "https://github.com/tox-dev/sphinx-autodoc-typehints/issues" 63 | 64 | [tool.hatch] 65 | build.hooks.vcs.version-file = "src/sphinx_autodoc_typehints/version.py" 66 | version.source = "vcs" 67 | 68 | [tool.ruff] 69 | line-length = 120 70 | format.preview = true 71 | format.docstring-code-line-length = 100 72 | format.docstring-code-format = true 73 | lint.select = [ 74 | "ALL", 75 | ] 76 | lint.ignore = [ 77 | "ANN401", # allow Any as type annotation 78 | "COM812", # Conflict with formatter 79 | "CPY", # No copyright statements 80 | "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible 81 | "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible 82 | "DOC", # no sphinx support 83 | "ISC001", # Conflict with formatter 84 | "S104", # Possible binding to all interface 85 | ] 86 | lint.per-file-ignores."tests/**/*.py" = [ 87 | "D", # don't care about documentation in tests 88 | "FBT", # don't care about booleans as positional arguments in tests 89 | "INP001", # no implicit namespace 90 | "PLC2701", # private imports 91 | "PLR0913", # any number of arguments in tests 92 | "PLR0917", # any number of arguments in tests 93 | "PLR2004", # Magic value used in comparison, consider replacing with a constant variable 94 | "S101", # asserts allowed in tests 95 | "S603", # `subprocess` call: check for execution of untrusted input 96 | "UP006", # we test for old List/Tuple syntax 97 | "UP007", # we test for old Union syntax 98 | "UP045", # we test for old Optional syntax 99 | ] 100 | lint.isort = { known-first-party = [ 101 | "sphinx_autodoc_typehints", 102 | "tests", 103 | ], required-imports = [ 104 | "from __future__ import annotations", 105 | ] } 106 | lint.preview = true 107 | 108 | [tool.codespell] 109 | builtin = "clear,usage,en-GB_to_en-US" 110 | ignore-words = "ignore-words.txt" 111 | write-changes = true 112 | count = true 113 | 114 | [tool.pyproject-fmt] 115 | max_supported_python = "3.14" 116 | 117 | [tool.pytest.ini_options] 118 | testpaths = [ 119 | "tests", 120 | ] 121 | 122 | [tool.coverage] 123 | html.show_contexts = true 124 | html.skip_covered = false 125 | paths.source = [ 126 | "src", 127 | ".tox/*/lib/python*/site-packages", 128 | ".tox/pypy*/site-packages", 129 | ".tox\\*\\Lib\\site-packages", 130 | ".tox/*/.venv/lib/python*/site-packages", 131 | ".tox/pypy*/.venv/site-packages", 132 | ".tox\\*\\.venv\\Lib\\site-packages", 133 | "*/src", 134 | "*\\src", 135 | ] 136 | report.fail_under = 88 137 | report.omit = [ 138 | ] 139 | run.parallel = true 140 | run.plugins = [ 141 | "covdefaults", 142 | ] 143 | 144 | [tool.mypy] 145 | python_version = "3.11" 146 | strict = true 147 | exclude = "^(.*/roots/.*)|(tests/test_integration.*.py)$" 148 | overrides = [ 149 | { module = [ 150 | "sphobjinv.*", 151 | ], ignore_missing_imports = true }, 152 | ] 153 | -------------------------------------------------------------------------------- /src/sphinx_autodoc_typehints/__init__.py: -------------------------------------------------------------------------------- 1 | """Sphinx autodoc type hints.""" 2 | 3 | from __future__ import annotations 4 | 5 | import ast 6 | import importlib 7 | import inspect 8 | import re 9 | import sys 10 | import textwrap 11 | import types 12 | from dataclasses import dataclass 13 | from typing import TYPE_CHECKING, Any, AnyStr, ForwardRef, NewType, TypeVar, Union, get_type_hints 14 | 15 | from docutils import nodes 16 | from docutils.frontend import get_default_settings 17 | from sphinx.ext.autodoc.mock import mock 18 | from sphinx.parsers import RSTParser 19 | from sphinx.util import logging, rst 20 | from sphinx.util.inspect import TypeAliasForwardRef, stringify_signature 21 | from sphinx.util.inspect import signature as sphinx_signature 22 | 23 | from ._parser import parse 24 | from .patches import install_patches 25 | from .version import __version__ 26 | 27 | if TYPE_CHECKING: 28 | from ast import FunctionDef, Module, stmt 29 | from collections.abc import Callable 30 | 31 | from docutils.nodes import Node 32 | from docutils.parsers.rst import states 33 | from sphinx.application import Sphinx 34 | from sphinx.config import Config 35 | from sphinx.environment import BuildEnvironment 36 | from sphinx.ext.autodoc import Options 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | _PYDATA_ANNOTS_TYPING = { 40 | "Any", 41 | "AnyStr", 42 | "Callable", 43 | "ClassVar", 44 | "Literal", 45 | "NoReturn", 46 | "Optional", 47 | "Tuple", 48 | *({"Union"} if sys.version_info < (3, 14) else set()), 49 | } 50 | _PYDATA_ANNOTS_TYPES = { 51 | *("AsyncGeneratorType", "BuiltinFunctionType", "BuiltinMethodType"), 52 | *("CellType", "ClassMethodDescriptorType", "CoroutineType"), 53 | "EllipsisType", 54 | *("FrameType", "FunctionType"), 55 | *("GeneratorType", "GetSetDescriptorType"), 56 | "LambdaType", 57 | *("MemberDescriptorType", "MethodDescriptorType", "MethodType", "MethodWrapperType"), 58 | # NoneType is special, but included here for completeness' sake 59 | *("NoneType", "NotImplementedType"), 60 | "WrapperDescriptorType", 61 | } 62 | _PYDATA_ANNOTATIONS = { 63 | *(("typing", n) for n in _PYDATA_ANNOTS_TYPING), 64 | *(("types", n) for n in _PYDATA_ANNOTS_TYPES), 65 | } 66 | 67 | # types has a bunch of things like ModuleType where ModuleType.__module__ is 68 | # "builtins" and ModuleType.__name__ is "module", so we have to check for this. 69 | _TYPES_DICT = {getattr(types, name): name for name in types.__all__} 70 | # Prefer FunctionType to LambdaType (they are synonymous) 71 | _TYPES_DICT[types.FunctionType] = "FunctionType" 72 | 73 | 74 | class MyTypeAliasForwardRef(TypeAliasForwardRef): 75 | def __or__(self, value: Any) -> Any: 76 | return Union[self, value] # noqa: UP007 77 | 78 | 79 | def _get_types_type(obj: Any) -> str | None: 80 | try: 81 | return _TYPES_DICT.get(obj) 82 | except Exception: # noqa: BLE001 83 | # e.g. exception: unhashable type 84 | return None 85 | 86 | 87 | def get_annotation_module(annotation: Any) -> str: 88 | """ 89 | Get module for an annotation. 90 | 91 | :param annotation: 92 | :return: 93 | """ 94 | if annotation is None: 95 | return "builtins" 96 | if _get_types_type(annotation) is not None: 97 | return "types" 98 | is_new_type = isinstance(annotation, NewType) 99 | if ( 100 | is_new_type 101 | or isinstance(annotation, TypeVar) 102 | or type(annotation).__name__ in {"ParamSpec", "ParamSpecArgs", "ParamSpecKwargs"} 103 | ): 104 | return "typing" 105 | if hasattr(annotation, "__module__"): 106 | return annotation.__module__ # type: ignore[no-any-return] 107 | if hasattr(annotation, "__origin__"): 108 | return annotation.__origin__.__module__ # type: ignore[no-any-return] 109 | msg = f"Cannot determine the module of {annotation}" 110 | raise ValueError(msg) 111 | 112 | 113 | def _is_newtype(annotation: Any) -> bool: 114 | return isinstance(annotation, NewType) 115 | 116 | 117 | def get_annotation_class_name(annotation: Any, module: str) -> str: # noqa: C901, PLR0911 118 | """ 119 | Get class name for annotation. 120 | 121 | :param annotation: 122 | :param module: 123 | :return: 124 | """ 125 | # Special cases 126 | if annotation is None: 127 | return "None" 128 | if annotation is AnyStr: 129 | return "AnyStr" 130 | val = _get_types_type(annotation) 131 | if val is not None: 132 | return val 133 | if _is_newtype(annotation): 134 | return "NewType" 135 | 136 | if getattr(annotation, "__qualname__", None): 137 | return annotation.__qualname__ # type: ignore[no-any-return] 138 | if getattr(annotation, "_name", None): # Required for generic aliases on Python 3.7+ 139 | return annotation._name # type: ignore[no-any-return] # noqa: SLF001 140 | if module in {"typing", "typing_extensions"} and isinstance(getattr(annotation, "name", None), str): 141 | # Required for at least Pattern and Match 142 | return annotation.name # type: ignore[no-any-return] 143 | 144 | origin = getattr(annotation, "__origin__", None) 145 | if origin: 146 | if getattr(origin, "__qualname__", None): # Required for Protocol subclasses 147 | return origin.__qualname__ # type: ignore[no-any-return] 148 | if getattr(origin, "_name", None): # Required for Union on Python 3.7+ 149 | return origin._name # type: ignore[no-any-return] # noqa: SLF001 150 | 151 | annotation_cls = annotation if inspect.isclass(annotation) else type(annotation) 152 | return annotation_cls.__qualname__.lstrip("_") 153 | 154 | 155 | def get_annotation_args(annotation: Any, module: str, class_name: str) -> tuple[Any, ...]: # noqa: PLR0911 156 | """ 157 | Get annotation arguments. 158 | 159 | :param annotation: 160 | :param module: 161 | :param class_name: 162 | :return: 163 | """ 164 | try: 165 | original = getattr(sys.modules[module], class_name) 166 | except (KeyError, AttributeError): 167 | pass 168 | else: 169 | if annotation is original: 170 | return () # This is the original, not parametrized type 171 | 172 | # Special cases 173 | if class_name in {"Pattern", "Match"} and hasattr(annotation, "type_var"): # Python < 3.7 174 | return (annotation.type_var,) 175 | if class_name == "ClassVar" and hasattr(annotation, "__type__"): # ClassVar on Python < 3.7 176 | return (annotation.__type__,) 177 | if class_name == "TypeVar" and hasattr(annotation, "__constraints__"): 178 | return annotation.__constraints__ # type: ignore[no-any-return] 179 | if class_name == "NewType" and hasattr(annotation, "__supertype__"): 180 | return (annotation.__supertype__,) 181 | if class_name == "Literal" and hasattr(annotation, "__values__"): 182 | return annotation.__values__ # type: ignore[no-any-return] 183 | if class_name == "Generic": 184 | return annotation.__parameters__ # type: ignore[no-any-return] 185 | result = getattr(annotation, "__args__", ()) 186 | # 3.10 and earlier Tuple[()] returns ((), ) instead of () the tuple does 187 | return () if len(result) == 1 and result[0] == () else result # type: ignore[misc] 188 | 189 | 190 | def format_internal_tuple(t: tuple[Any, ...], config: Config, *, short_literals: bool = False) -> str: 191 | # An annotation can be a tuple, e.g., for numpy.typing: 192 | # In this case, format_annotation receives: 193 | # This solution should hopefully be general for *any* type that allows tuples in annotations 194 | fmt = [format_annotation(a, config, short_literals=short_literals) for a in t] 195 | if len(fmt) == 0: 196 | return "()" 197 | if len(fmt) == 1: 198 | return f"({fmt[0]}, )" 199 | return f"({', '.join(fmt)})" 200 | 201 | 202 | def fixup_module_name(config: Config, module: str) -> str: 203 | if getattr(config, "typehints_fixup_module_name", None): 204 | module = config.typehints_fixup_module_name(module) 205 | 206 | if module == "typing_extensions": 207 | module = "typing" 208 | 209 | if module == "_io": 210 | module = "io" 211 | return module 212 | 213 | 214 | def format_annotation(annotation: Any, config: Config, *, short_literals: bool = False) -> str: # noqa: C901, PLR0911, PLR0912, PLR0915, PLR0914 215 | """ 216 | Format the annotation. 217 | 218 | :param annotation: 219 | :param config: 220 | :param short_literals: Render :py:class:`Literals` in PEP 604 style (``|``). 221 | :return: 222 | """ 223 | typehints_formatter: Callable[..., str] | None = getattr(config, "typehints_formatter", None) 224 | if typehints_formatter is not None: 225 | formatted = typehints_formatter(annotation, config) 226 | if formatted is not None: 227 | return formatted 228 | 229 | # Special cases 230 | if isinstance(annotation, ForwardRef): 231 | return annotation.__forward_arg__ 232 | if annotation is None or annotation is type(None): 233 | return ":py:obj:`None`" 234 | if annotation is Ellipsis: 235 | return ":py:data:`...`" 236 | 237 | if isinstance(annotation, tuple): 238 | return format_internal_tuple(annotation, config) 239 | 240 | if isinstance(annotation, TypeAliasForwardRef): 241 | return annotation.name 242 | 243 | try: 244 | module = get_annotation_module(annotation) 245 | class_name = get_annotation_class_name(annotation, module) 246 | args = get_annotation_args(annotation, module, class_name) 247 | except ValueError: 248 | return str(annotation).strip("'") 249 | 250 | module = fixup_module_name(config, module) 251 | full_name = f"{module}.{class_name}" if module != "builtins" else class_name 252 | fully_qualified: bool = getattr(config, "typehints_fully_qualified", False) 253 | prefix = "" if fully_qualified or full_name == class_name else "~" 254 | role = "data" if (module, class_name) in _PYDATA_ANNOTATIONS else "class" 255 | args_format = "\\[{}]" 256 | formatted_args: str | None = "" 257 | 258 | always_use_bars_union: bool = getattr(config, "always_use_bars_union", True) 259 | is_bars_union = ( 260 | (sys.version_info >= (3, 14) and full_name == "typing.Union") 261 | or full_name == "types.UnionType" 262 | or (always_use_bars_union and type(annotation).__qualname__ == "_UnionGenericAlias") 263 | ) 264 | if is_bars_union: 265 | full_name = "" 266 | 267 | # Some types require special handling 268 | if full_name == "typing.NewType": 269 | args_format = f"\\(``{annotation.__name__}``, {{}})" 270 | role = "class" 271 | elif full_name in {"typing.TypeVar", "typing.ParamSpec"}: 272 | params = {k: getattr(annotation, f"__{k}__") for k in ("bound", "covariant", "contravariant")} 273 | params = {k: v for k, v in params.items() if v} 274 | if "bound" in params: 275 | params["bound"] = f" {format_annotation(params['bound'], config, short_literals=short_literals)}" 276 | args_format = f"\\(``{annotation.__name__}``{', {}' if args else ''}" 277 | if params: 278 | args_format += "".join(f", {k}={v}" for k, v in params.items()) 279 | args_format += ")" 280 | formatted_args = None if args else args_format 281 | elif full_name == "typing.Optional": 282 | args = tuple(x for x in args if x is not type(None)) 283 | elif full_name in {"typing.Union", "types.UnionType"} and type(None) in args: 284 | if len(args) == 2: # noqa: PLR2004 285 | full_name = "typing.Optional" 286 | role = "data" 287 | args = tuple(x for x in args if x is not type(None)) 288 | else: 289 | simplify_optional_unions: bool = getattr(config, "simplify_optional_unions", True) 290 | if not simplify_optional_unions: 291 | full_name = "typing.Optional" 292 | role = "data" 293 | args_format = f"\\[:py:data:`{prefix}typing.Union`\\[{{}}]]" 294 | args = tuple(x for x in args if x is not type(None)) 295 | elif full_name in {"typing.Callable", "collections.abc.Callable"} and args and args[0] is not ...: 296 | fmt = [format_annotation(arg, config, short_literals=short_literals) for arg in args] 297 | formatted_args = f"\\[\\[{', '.join(fmt[:-1])}], {fmt[-1]}]" 298 | elif full_name == "typing.Literal": 299 | if short_literals: 300 | return f"\\{' | '.join(f'``{arg!r}``' for arg in args)}" 301 | formatted_args = f"\\[{', '.join(f'``{arg!r}``' for arg in args)}]" 302 | elif is_bars_union: 303 | if not args: 304 | return f":py:{'class' if sys.version_info >= (3, 14) else 'data'}:`{prefix}typing.Union`" 305 | return " | ".join([format_annotation(arg, config, short_literals=short_literals) for arg in args]) 306 | 307 | if args and not formatted_args: 308 | try: 309 | iter(args) 310 | except TypeError: 311 | fmt = [format_annotation(args, config, short_literals=short_literals)] 312 | else: 313 | fmt = [format_annotation(arg, config, short_literals=short_literals) for arg in args] 314 | formatted_args = args_format.format(", ".join(fmt)) 315 | 316 | escape = "\\ " if formatted_args else "" 317 | return f":py:{role}:`{prefix}{full_name}`{escape}{formatted_args}" 318 | 319 | 320 | # reference: https://github.com/pytorch/pytorch/pull/46548/files 321 | def normalize_source_lines(source_lines: str) -> str: 322 | """ 323 | Normalize the source lines. 324 | 325 | It finds the indentation level of the function definition (`def`), then it indents all lines in the function body to 326 | a point at or greater than that level. This allows for comments and continued string literals that are at a lower 327 | indentation than the rest of the code. 328 | 329 | :param source_lines: source code 330 | :return: source lines that have been correctly aligned 331 | """ 332 | lines = source_lines.split("\n") 333 | 334 | def remove_prefix(text: str, prefix: str) -> str: 335 | return text[text.startswith(prefix) and len(prefix) :] 336 | 337 | # Find the line and line number containing the function definition 338 | for pos, line in enumerate(lines): 339 | if line.lstrip().startswith("def "): 340 | idx = pos 341 | whitespace_separator = "def" 342 | break 343 | if line.lstrip().startswith("async def"): 344 | idx = pos 345 | whitespace_separator = "async def" 346 | break 347 | 348 | else: 349 | return "\n".join(lines) 350 | fn_def = lines[idx] 351 | 352 | # Get a string representing the amount of leading whitespace 353 | whitespace = fn_def.split(whitespace_separator)[0] 354 | 355 | # Add this leading whitespace to all lines before and after the `def` 356 | aligned_prefix = [whitespace + remove_prefix(s, whitespace) for s in lines[:idx]] 357 | aligned_suffix = [whitespace + remove_prefix(s, whitespace) for s in lines[idx + 1 :]] 358 | 359 | # Put it together again 360 | aligned_prefix.append(fn_def) 361 | return "\n".join(aligned_prefix + aligned_suffix) 362 | 363 | 364 | def process_signature( # noqa: C901, PLR0913, PLR0917 365 | app: Sphinx, 366 | what: str, 367 | name: str, 368 | obj: Any, 369 | options: Options, # noqa: ARG001 370 | signature: str, # noqa: ARG001 371 | return_annotation: str, # noqa: ARG001 372 | ) -> tuple[str, None] | None: 373 | """ 374 | Process the signature. 375 | 376 | :param app: 377 | :param what: 378 | :param name: 379 | :param obj: 380 | :param options: 381 | :param signature: 382 | :param return_annotation: 383 | :return: 384 | """ 385 | if not callable(obj): 386 | return None 387 | 388 | original_obj = obj 389 | obj = getattr(obj, "__init__", getattr(obj, "__new__", None)) if inspect.isclass(obj) else obj 390 | if not getattr(obj, "__annotations__", None): # when has no annotation we cannot autodoc typehints so bail 391 | return None 392 | 393 | obj = inspect.unwrap(obj) 394 | sph_signature = sphinx_signature(obj, type_aliases=app.config["autodoc_type_aliases"]) 395 | typehints_formatter: Callable[..., str | None] | None = getattr(app.config, "typehints_formatter", None) 396 | 397 | def _get_formatted_annotation(annotation: TypeVar) -> TypeVar: 398 | if typehints_formatter is None: 399 | return annotation 400 | formatted_name = typehints_formatter(annotation) 401 | return annotation if not isinstance(formatted_name, str) else TypeVar(formatted_name) 402 | 403 | if app.config.typehints_use_signature_return: 404 | sph_signature = sph_signature.replace( 405 | return_annotation=_get_formatted_annotation(sph_signature.return_annotation) 406 | ) 407 | 408 | if app.config.typehints_use_signature: 409 | parameters = [ 410 | param.replace(annotation=_get_formatted_annotation(param.annotation)) 411 | for param in sph_signature.parameters.values() 412 | ] 413 | else: 414 | parameters = [param.replace(annotation=inspect.Parameter.empty) for param in sph_signature.parameters.values()] 415 | 416 | # if we have parameters we may need to delete first argument that's not documented, e.g. self 417 | start = 0 418 | if parameters: 419 | if inspect.isclass(original_obj) or (what == "method" and name.endswith(".__init__")): 420 | start = 1 421 | elif what == "method": 422 | # bail if it is a local method as we cannot determine if first argument needs to be deleted or not 423 | if "" in obj.__qualname__ and not _is_dataclass(name, what, obj.__qualname__): 424 | _LOGGER.warning( 425 | 'Cannot handle as a local function: "%s" (use @functools.wraps)', 426 | name, 427 | type="sphinx_autodoc_typehints", 428 | subtype="local_function", 429 | ) 430 | return None 431 | outer = inspect.getmodule(obj) 432 | for class_name in obj.__qualname__.split(".")[:-1]: 433 | outer = getattr(outer, class_name) 434 | method_name = obj.__name__ 435 | if method_name.startswith("__") and not method_name.endswith("__"): 436 | # when method starts with double underscore Python applies mangling -> prepend the class name 437 | method_name = f"_{obj.__qualname__.split('.')[-2]}{method_name}" 438 | method_object = outer.__dict__[method_name] if outer else obj 439 | if not isinstance(method_object, classmethod | staticmethod): 440 | start = 1 441 | 442 | sph_signature = sph_signature.replace(parameters=parameters[start:]) 443 | show_return_annotation = app.config.typehints_use_signature_return 444 | unqualified_typehints = not getattr(app.config, "typehints_fully_qualified", False) 445 | return ( 446 | stringify_signature( 447 | sph_signature, 448 | show_return_annotation=show_return_annotation, 449 | unqualified_typehints=unqualified_typehints, 450 | ).replace("\\", "\\\\"), 451 | None, 452 | ) 453 | 454 | 455 | def _is_dataclass(name: str, what: str, qualname: str) -> bool: 456 | # generated dataclass __init__() and class need extra checks, as the function operates on the generated class 457 | # and methods (not an instantiated dataclass object) it cannot be replaced by a call to 458 | # `dataclasses.is_dataclass()` => check manually for either generated __init__ or generated class 459 | return (what == "method" and name.endswith(".__init__")) or (what == "class" and qualname.endswith(".__init__")) 460 | 461 | 462 | def _future_annotations_imported(obj: Any) -> bool: 463 | annotations_ = getattr(inspect.getmodule(obj), "annotations", None) 464 | if annotations_ is None: 465 | return False 466 | 467 | # Make sure that annotations is imported from __future__ - defined in cpython/Lib/__future__.py 468 | # annotations become strings at runtime 469 | return bool(annotations_.compiler_flag == 0x1000000) # pragma: no cover # noqa: PLR2004 470 | 471 | 472 | def get_all_type_hints( 473 | autodoc_mock_imports: list[str], obj: Any, name: str, localns: dict[Any, MyTypeAliasForwardRef] 474 | ) -> dict[str, Any]: 475 | result = _get_type_hint(autodoc_mock_imports, name, obj, localns) 476 | if not result: 477 | result = backfill_type_hints(obj, name) 478 | try: 479 | obj.__annotations__ = result 480 | except (AttributeError, TypeError): 481 | pass 482 | else: 483 | result = _get_type_hint(autodoc_mock_imports, name, obj, localns) 484 | return result 485 | 486 | 487 | _TYPE_GUARD_IMPORT_RE = re.compile(r"\nif (typing.)?TYPE_CHECKING:[^\n]*([\s\S]*?)(?=\n\S)") 488 | _TYPE_GUARD_IMPORTS_RESOLVED = set() 489 | _TYPE_GUARD_IMPORTS_RESOLVED_GLOBALS_ID = set() 490 | 491 | 492 | def _should_skip_guarded_import_resolution(obj: Any) -> bool: 493 | if isinstance(obj, types.ModuleType): 494 | return False # Don't skip modules 495 | 496 | if not hasattr(obj, "__globals__"): 497 | return True # Skip objects without __globals__ 498 | 499 | if hasattr(obj, "__module__"): 500 | return obj.__module__ in _TYPE_GUARD_IMPORTS_RESOLVED or obj.__module__ in sys.builtin_module_names 501 | 502 | return id(obj.__globals__) in _TYPE_GUARD_IMPORTS_RESOLVED_GLOBALS_ID 503 | 504 | 505 | def _execute_guarded_code(autodoc_mock_imports: list[str], obj: Any, module_code: str) -> None: 506 | for _, part in _TYPE_GUARD_IMPORT_RE.findall(module_code): 507 | guarded_code = textwrap.dedent(part) 508 | try: 509 | try: 510 | with mock(autodoc_mock_imports): 511 | exec(guarded_code, getattr(obj, "__globals__", obj.__dict__)) # noqa: S102 512 | except ImportError as exc: 513 | # ImportError might have occurred because the module has guarded code as well, 514 | # so we recurse on the module. 515 | if exc.name: 516 | _resolve_type_guarded_imports(autodoc_mock_imports, importlib.import_module(exc.name)) 517 | 518 | # Retry the guarded code and see if it works now after resolving all nested type guards. 519 | with mock(autodoc_mock_imports): 520 | exec(guarded_code, getattr(obj, "__globals__", obj.__dict__)) # noqa: S102 521 | except Exception as exc: # noqa: BLE001 522 | _LOGGER.warning( 523 | "Failed guarded type import with %r", exc, type="sphinx_autodoc_typehints", subtype="guarded_import" 524 | ) 525 | 526 | 527 | def _resolve_type_guarded_imports(autodoc_mock_imports: list[str], obj: Any) -> None: 528 | if _should_skip_guarded_import_resolution(obj): 529 | return 530 | 531 | if hasattr(obj, "__globals__"): 532 | _TYPE_GUARD_IMPORTS_RESOLVED_GLOBALS_ID.add(id(obj.__globals__)) 533 | 534 | module = inspect.getmodule(obj) 535 | 536 | if module: 537 | try: 538 | module_code = inspect.getsource(module) 539 | except (TypeError, OSError): 540 | ... # no source code => no type guards 541 | else: 542 | _TYPE_GUARD_IMPORTS_RESOLVED.add(module.__name__) 543 | _execute_guarded_code(autodoc_mock_imports, obj, module_code) 544 | 545 | 546 | def _get_type_hint( 547 | autodoc_mock_imports: list[str], name: str, obj: Any, localns: dict[Any, MyTypeAliasForwardRef] 548 | ) -> dict[str, Any]: 549 | _resolve_type_guarded_imports(autodoc_mock_imports, obj) 550 | try: 551 | result = get_type_hints(obj, None, localns) 552 | except (AttributeError, TypeError, RecursionError) as exc: 553 | # TypeError - slot wrapper, PEP-563 when part of new syntax not supported 554 | # RecursionError - some recursive type definitions https://github.com/python/typing/issues/574 555 | if isinstance(exc, TypeError) and _future_annotations_imported(obj) and "unsupported operand type" in str(exc): 556 | result = obj.__annotations__ 557 | else: 558 | result = {} 559 | except NameError as exc: 560 | _LOGGER.warning( 561 | 'Cannot resolve forward reference in type annotations of "%s": %s', 562 | name, 563 | exc, 564 | type="sphinx_autodoc_typehints", 565 | subtype="forward_reference", 566 | ) 567 | result = obj.__annotations__ 568 | return result 569 | 570 | 571 | def backfill_type_hints(obj: Any, name: str) -> dict[str, Any]: # noqa: C901, PLR0911 572 | """ 573 | Backfill type hints. 574 | 575 | :param obj: the object 576 | :param name: the name 577 | :return: backfilled value 578 | """ 579 | parse_kwargs = {"type_comments": True} 580 | 581 | def _one_child(module: Module) -> stmt | None: 582 | children = module.body # use the body to ignore type comments 583 | if len(children) != 1: 584 | _LOGGER.warning( 585 | 'Did not get exactly one node from AST for "%s", got %s', 586 | name, 587 | len(children), 588 | type="sphinx_autodoc_typehints", 589 | subtype="multiple_ast_nodes", 590 | ) 591 | return None 592 | return children[0] 593 | 594 | try: 595 | code = textwrap.dedent(normalize_source_lines(inspect.getsource(obj))) 596 | obj_ast = ast.parse(code, **parse_kwargs) # type: ignore[call-overload] # dynamic kwargs 597 | except (OSError, TypeError, SyntaxError): 598 | return {} 599 | 600 | obj_ast = _one_child(obj_ast) 601 | if obj_ast is None: 602 | return {} 603 | 604 | try: 605 | type_comment = obj_ast.type_comment # type: ignore[attr-defined] 606 | except AttributeError: 607 | return {} 608 | 609 | if not type_comment: 610 | return {} 611 | 612 | try: 613 | comment_args_str, comment_returns = type_comment.split(" -> ") 614 | except ValueError: 615 | _LOGGER.warning( 616 | 'Unparseable type hint comment for "%s": Expected to contain ` -> `', 617 | name, 618 | type="sphinx_autodoc_typehints", 619 | subtype="comment", 620 | ) 621 | return {} 622 | 623 | rv = {} 624 | if comment_returns: 625 | rv["return"] = comment_returns 626 | 627 | args = load_args(obj_ast) # type: ignore[arg-type] 628 | comment_args = split_type_comment_args(comment_args_str) 629 | is_inline = len(comment_args) == 1 and comment_args[0] == "..." 630 | if not is_inline: 631 | if args and args[0].arg in {"self", "cls"} and len(comment_args) != len(args): 632 | comment_args.insert(0, None) # self/cls may be omitted in type comments, insert blank 633 | 634 | if len(args) != len(comment_args): 635 | _LOGGER.warning( 636 | 'Not enough type comments found on "%s"', name, type="sphinx_autodoc_typehints", subtype="comment" 637 | ) 638 | return rv 639 | 640 | for at, arg in enumerate(args): 641 | arg_key = getattr(arg, "arg", None) 642 | if arg_key is None: 643 | continue 644 | 645 | value = getattr(arg, "type_comment", None) if is_inline else comment_args[at] 646 | 647 | if value is not None: 648 | rv[arg_key] = value 649 | 650 | return rv 651 | 652 | 653 | def load_args(obj_ast: FunctionDef) -> list[Any]: 654 | func_args = obj_ast.args 655 | args = [] 656 | pos_only = getattr(func_args, "posonlyargs", None) 657 | if pos_only: 658 | args.extend(pos_only) 659 | 660 | args.extend(func_args.args) 661 | if func_args.vararg: 662 | args.append(func_args.vararg) 663 | 664 | args.extend(func_args.kwonlyargs) 665 | if func_args.kwarg: 666 | args.append(func_args.kwarg) 667 | 668 | return args 669 | 670 | 671 | def split_type_comment_args(comment: str) -> list[str | None]: 672 | def add(val: str) -> None: 673 | result.append(val.strip().lstrip("*")) # remove spaces, and var/kw arg marker 674 | 675 | comment = comment.strip().lstrip("(").rstrip(")") 676 | result: list[str | None] = [] 677 | if not comment: 678 | return result 679 | 680 | brackets, start_arg_at, at = 0, 0, 0 681 | for at, char in enumerate(comment): 682 | if char in {"[", "("}: 683 | brackets += 1 684 | elif char in {"]", ")"}: 685 | brackets -= 1 686 | elif char == "," and brackets == 0: 687 | add(comment[start_arg_at:at]) 688 | start_arg_at = at + 1 689 | 690 | add(comment[start_arg_at : at + 1]) 691 | return result 692 | 693 | 694 | def format_default(app: Sphinx, default: Any, is_annotated: bool) -> str | None: # noqa: FBT001 695 | if default is inspect.Parameter.empty: 696 | return None 697 | formatted = repr(default).replace("\\", "\\\\") 698 | 699 | if is_annotated: 700 | if app.config.typehints_defaults.startswith("braces"): 701 | return f" (default: ``{formatted}``)" 702 | return f", default: ``{formatted}``" 703 | if app.config.typehints_defaults == "braces-after": 704 | return f" (default: ``{formatted}``)" 705 | return f"default: ``{formatted}``" 706 | 707 | 708 | def process_docstring( # noqa: PLR0913, PLR0917 709 | app: Sphinx, 710 | what: str, 711 | name: str, 712 | obj: Any, 713 | options: Options | None, # noqa: ARG001 714 | lines: list[str], 715 | ) -> None: 716 | """ 717 | Process the docstring for an entry. 718 | 719 | :param app: the Sphinx app 720 | :param what: the target 721 | :param name: the name 722 | :param obj: the object 723 | :param options: the options 724 | :param lines: the lines 725 | :return: 726 | """ 727 | original_obj = obj 728 | obj = obj.fget if isinstance(obj, property) else obj 729 | if not callable(obj): 730 | return 731 | obj = obj.__init__ if inspect.isclass(obj) else obj 732 | obj = inspect.unwrap(obj) 733 | 734 | try: 735 | signature = sphinx_signature(obj, type_aliases=app.config["autodoc_type_aliases"]) 736 | except (ValueError, TypeError): 737 | signature = None 738 | 739 | localns = {key: MyTypeAliasForwardRef(value) for key, value in app.config["autodoc_type_aliases"].items()} 740 | type_hints = get_all_type_hints(app.config.autodoc_mock_imports, obj, name, localns) 741 | app.config._annotation_globals = getattr(obj, "__globals__", {}) # noqa: SLF001 742 | try: 743 | _inject_types_to_docstring(type_hints, signature, original_obj, app, what, name, lines) 744 | finally: 745 | delattr(app.config, "_annotation_globals") 746 | 747 | 748 | def _get_sphinx_line_keyword_and_argument(line: str) -> tuple[str, str | None] | None: 749 | """ 750 | Extract a keyword, and its optional argument out of a sphinx field option line. 751 | 752 | For example 753 | >>> _get_sphinx_line_keyword_and_argument(":param parameter:") 754 | ("param", "parameter") 755 | >>> _get_sphinx_line_keyword_and_argument(":return:") 756 | ("return", None) 757 | >>> _get_sphinx_line_keyword_and_argument("some invalid line") 758 | None 759 | """ 760 | param_line_without_description = line.split(":", maxsplit=2) 761 | if len(param_line_without_description) != 3: # noqa: PLR2004 762 | return None 763 | 764 | split_directive_and_name = param_line_without_description[1].split(maxsplit=1) 765 | if len(split_directive_and_name) != 2: # noqa: PLR2004 766 | if not len(split_directive_and_name): 767 | return None 768 | return split_directive_and_name[0], None 769 | 770 | return tuple(split_directive_and_name) # type: ignore[return-value] 771 | 772 | 773 | def _line_is_param_line_for_arg(line: str, arg_name: str) -> bool: 774 | """Return True if `line` is a valid parameter line for `arg_name`, false otherwise.""" 775 | keyword_and_name = _get_sphinx_line_keyword_and_argument(line) 776 | if keyword_and_name is None: 777 | return False 778 | 779 | keyword, doc_name = keyword_and_name 780 | if doc_name is None: 781 | return False 782 | 783 | if keyword not in {"param", "parameter", "arg", "argument"}: 784 | return False 785 | 786 | return any(doc_name == prefix + arg_name for prefix in ("", "\\*", "\\**", "\\*\\*")) 787 | 788 | 789 | def _inject_types_to_docstring( # noqa: PLR0913, PLR0917 790 | type_hints: dict[str, Any], 791 | signature: inspect.Signature | None, 792 | original_obj: Any, 793 | app: Sphinx, 794 | what: str, 795 | name: str, 796 | lines: list[str], 797 | ) -> None: 798 | if signature is not None: 799 | _inject_signature(type_hints, signature, app, lines) 800 | if "return" in type_hints: 801 | _inject_rtype(type_hints, original_obj, app, what, name, lines) 802 | 803 | 804 | def _inject_signature( 805 | type_hints: dict[str, Any], 806 | signature: inspect.Signature, 807 | app: Sphinx, 808 | lines: list[str], 809 | ) -> None: 810 | for arg_name in signature.parameters: 811 | annotation = type_hints.get(arg_name) 812 | 813 | default = signature.parameters[arg_name].default 814 | 815 | if arg_name.endswith("_"): 816 | arg_name = f"{arg_name[:-1]}\\_" # noqa: PLW2901 817 | 818 | insert_index = None 819 | for at, line in enumerate(lines): 820 | if _line_is_param_line_for_arg(line, arg_name): 821 | # Get the arg_name from the doc to match up for type in case it has a star prefix. 822 | # Line is in the correct format so this is guaranteed to return tuple[str, str]. 823 | func = _get_sphinx_line_keyword_and_argument 824 | _, arg_name = func(line) # type: ignore[assignment, misc] # noqa: PLW2901 825 | insert_index = at 826 | break 827 | 828 | if annotation is not None and insert_index is None and app.config.always_document_param_types: 829 | lines.append(f":param {arg_name}:") 830 | insert_index = len(lines) 831 | 832 | if insert_index is not None: 833 | if annotation is None: 834 | type_annotation = f":type {arg_name}: " 835 | else: 836 | short_literals = app.config.python_display_short_literal_types 837 | formatted_annotation = add_type_css_class( 838 | format_annotation(annotation, app.config, short_literals=short_literals) 839 | ) 840 | type_annotation = f":type {arg_name}: {formatted_annotation}" 841 | 842 | if app.config.typehints_defaults: 843 | formatted_default = format_default(app, default, annotation is not None) 844 | if formatted_default: 845 | type_annotation = _append_default(app, lines, insert_index, type_annotation, formatted_default) 846 | 847 | lines.insert(insert_index, type_annotation) 848 | 849 | 850 | def _append_default( 851 | app: Sphinx, lines: list[str], insert_index: int, type_annotation: str, formatted_default: str 852 | ) -> str: 853 | if app.config.typehints_defaults.endswith("after"): 854 | # advance the index to the end of the :param: paragraphs 855 | # (terminated by a line with no indentation) 856 | # append default to the last nonempty line 857 | nlines = len(lines) 858 | next_index = insert_index + 1 859 | append_index = insert_index # last nonempty line 860 | while next_index < nlines and (not lines[next_index] or lines[next_index].startswith(" ")): 861 | if lines[next_index]: 862 | append_index = next_index 863 | next_index += 1 864 | lines[append_index] += formatted_default 865 | 866 | else: # add to last param doc line 867 | type_annotation += formatted_default 868 | 869 | return type_annotation 870 | 871 | 872 | @dataclass 873 | class InsertIndexInfo: 874 | insert_index: int 875 | found_param: bool = False 876 | found_return: bool = False 877 | found_directive: bool = False 878 | 879 | 880 | # Sphinx allows so many synonyms... 881 | # See sphinx.domains.python.PyObject 882 | PARAM_SYNONYMS = ("param ", "parameter ", "arg ", "argument ", "keyword ", "kwarg ", "kwparam ") 883 | 884 | 885 | def node_line_no(node: Node) -> int | None: 886 | """ 887 | Get the 1-indexed line on which the node starts if possible. If not, return None. 888 | 889 | Descend through the first children until we locate one with a line number or return None if None of them have one. 890 | 891 | I'm not aware of any rst on which this returns None, to find out would require a more detailed analysis of the 892 | docutils rst parser source code. An example where the node doesn't have a line number but the first child does is 893 | all `definition_list` nodes. It seems like bullet_list and option_list get line numbers, but enum_list also doesn't. 894 | """ 895 | if node is None: 896 | return None 897 | 898 | while node.line is None and node.children: 899 | node = node.children[0] 900 | return node.line 901 | 902 | 903 | def tag_name(node: Node) -> str: 904 | return node.tagname # type:ignore[attr-defined,no-any-return] 905 | 906 | 907 | def get_insert_index(app: Sphinx, lines: list[str]) -> InsertIndexInfo | None: 908 | # 1. If there is an existing :rtype: anywhere, don't insert anything. 909 | if any(line.startswith(":rtype:") for line in lines): 910 | return None 911 | 912 | # 2. If there is a :returns: anywhere, either modify that line or insert 913 | # just before it. 914 | for at, line in enumerate(lines): 915 | if line.startswith((":return:", ":returns:")): 916 | return InsertIndexInfo(insert_index=at, found_return=True) 917 | 918 | # 3. Insert after the parameters. 919 | # To find the parameters, parse as a docutils tree. 920 | settings = get_default_settings(RSTParser) # type: ignore[arg-type] 921 | settings.env = app.env 922 | doc = parse("\n".join(lines), settings) 923 | 924 | # Find a top level child which is a field_list that contains a field whose 925 | # name starts with one of the PARAM_SYNONYMS. This is the parameter list. We 926 | # hope there is at most of these. 927 | for child in doc.children: 928 | if tag_name(child) != "field_list": 929 | continue 930 | 931 | if not any(c.children[0].astext().startswith(PARAM_SYNONYMS) for c in child.children): 932 | continue 933 | 934 | # Found it! Try to insert before the next sibling. If there is no next 935 | # sibling, insert at end. 936 | # If there is a next sibling but we can't locate a line number, insert 937 | # at end. (I don't know of any input where this happens.) 938 | next_sibling = child.next_node(descend=False, siblings=True) 939 | line_no = node_line_no(next_sibling) if next_sibling else None 940 | at = max(line_no - 2, 0) if line_no else len(lines) 941 | return InsertIndexInfo(insert_index=at, found_param=True) 942 | 943 | # 4. Insert before examples 944 | for child in doc.children: 945 | if tag_name(child) in {"literal_block", "paragraph", "field_list"}: 946 | continue 947 | line_no = node_line_no(child) 948 | at = max(line_no - 2, 0) if line_no else len(lines) 949 | if lines[at - 1]: # skip if something on this line 950 | break 951 | return InsertIndexInfo(insert_index=at, found_directive=True) 952 | 953 | # 5. Otherwise, insert at end 954 | return InsertIndexInfo(insert_index=len(lines)) 955 | 956 | 957 | def _inject_rtype( # noqa: C901, PLR0913, PLR0917 958 | type_hints: dict[str, Any], 959 | original_obj: Any, 960 | app: Sphinx, 961 | what: str, 962 | name: str, 963 | lines: list[str], 964 | ) -> None: 965 | if inspect.isclass(original_obj) or inspect.isdatadescriptor(original_obj): 966 | return 967 | if what == "method" and name.endswith(".__init__"): # avoid adding a return type for data class __init__ 968 | return 969 | if not app.config.typehints_document_rtype: 970 | return 971 | if not app.config.typehints_document_rtype_none and type_hints["return"] is types.NoneType: 972 | return 973 | 974 | r = get_insert_index(app, lines) 975 | if r is None: 976 | return 977 | 978 | insert_index = r.insert_index 979 | 980 | if not app.config.typehints_use_rtype and r.found_return and " -- " in lines[insert_index]: 981 | return 982 | 983 | short_literals = app.config.python_display_short_literal_types 984 | formatted_annotation = add_type_css_class( 985 | format_annotation(type_hints["return"], app.config, short_literals=short_literals) 986 | ) 987 | 988 | if r.found_param and insert_index < len(lines) and lines[insert_index].strip(): 989 | insert_index -= 1 990 | 991 | if insert_index == len(lines) and not r.found_param: 992 | # ensure that :rtype: doesn't get joined with a paragraph of text 993 | lines.append("") 994 | insert_index += 1 995 | if app.config.typehints_use_rtype or not r.found_return: 996 | line = f":rtype: {formatted_annotation}" 997 | lines.insert(insert_index, line) 998 | if r.found_directive: 999 | lines.insert(insert_index + 1, "") 1000 | else: 1001 | line = lines[insert_index] 1002 | lines[insert_index] = f":return: {formatted_annotation} --{line[line.find(' ') :]}" 1003 | 1004 | 1005 | def validate_config(app: Sphinx, env: BuildEnvironment, docnames: list[str]) -> None: # noqa: ARG001 1006 | valid = {None, "comma", "braces", "braces-after"} 1007 | if app.config.typehints_defaults not in valid | {False}: 1008 | msg = f"typehints_defaults needs to be one of {valid!r}, not {app.config.typehints_defaults!r}" 1009 | raise ValueError(msg) 1010 | 1011 | formatter = app.config.typehints_formatter 1012 | if formatter is not None and not callable(formatter): 1013 | msg = f"typehints_formatter needs to be callable or `None`, not {formatter}" 1014 | raise ValueError(msg) 1015 | 1016 | 1017 | def unescape(escaped: str) -> str: 1018 | # For some reason the string we get has a bunch of null bytes in it?? 1019 | # Remove them... 1020 | escaped = escaped.replace("\x00", "") 1021 | # For some reason the extra slash before spaces gets lost between the .rst 1022 | # source and when this directive is called. So don't replace "\" => 1023 | # "" 1024 | return re.sub(r"\\([^ ])", r"\1", escaped) 1025 | 1026 | 1027 | def add_type_css_class(type_rst: str) -> str: 1028 | return f":sphinx_autodoc_typehints_type:`{rst.escape(type_rst)}`" 1029 | 1030 | 1031 | def sphinx_autodoc_typehints_type_role( 1032 | _role: str, 1033 | _rawtext: str, 1034 | text: str, 1035 | _lineno: int, 1036 | inliner: states.Inliner, 1037 | _options: dict[str, Any] | None = None, 1038 | _content: list[str] | None = None, 1039 | ) -> tuple[list[Node], list[Node]]: 1040 | """ 1041 | Add css tag around rendered type. 1042 | 1043 | The body should be escaped rst. This renders its body as rst and wraps the 1044 | result in 1045 | """ 1046 | unescaped = unescape(text) 1047 | doc = parse(unescaped, inliner.document.settings) 1048 | n = nodes.inline(text) 1049 | n["classes"].append("sphinx_autodoc_typehints-type") 1050 | n += doc.children[0].children 1051 | return [n], [] 1052 | 1053 | 1054 | def setup(app: Sphinx) -> dict[str, bool]: 1055 | app.add_config_value("always_document_param_types", False, "html") # noqa: FBT003 1056 | app.add_config_value("typehints_fully_qualified", False, "env") # noqa: FBT003 1057 | app.add_config_value("typehints_document_rtype", True, "env") # noqa: FBT003 1058 | app.add_config_value("typehints_document_rtype_none", True, "env") # noqa: FBT003 1059 | app.add_config_value("typehints_use_rtype", True, "env") # noqa: FBT003 1060 | app.add_config_value("typehints_defaults", None, "env") 1061 | app.add_config_value("simplify_optional_unions", True, "env") # noqa: FBT003 1062 | app.add_config_value("always_use_bars_union", False, "env") # noqa: FBT003 1063 | app.add_config_value("typehints_formatter", None, "env") 1064 | app.add_config_value("typehints_use_signature", False, "env") # noqa: FBT003 1065 | app.add_config_value("typehints_use_signature_return", False, "env") # noqa: FBT003 1066 | app.add_config_value("typehints_fixup_module_name", None, "env") 1067 | app.add_role("sphinx_autodoc_typehints_type", sphinx_autodoc_typehints_type_role) 1068 | app.connect("env-before-read-docs", validate_config) # config may be changed after “config-inited” event 1069 | app.connect("autodoc-process-signature", process_signature) 1070 | app.connect("autodoc-process-docstring", process_docstring) 1071 | install_patches(app) 1072 | return {"parallel_read_safe": True, "parallel_write_safe": True} 1073 | 1074 | 1075 | __all__ = [ 1076 | "__version__", 1077 | "backfill_type_hints", 1078 | "format_annotation", 1079 | "get_annotation_args", 1080 | "get_annotation_class_name", 1081 | "get_annotation_module", 1082 | "normalize_source_lines", 1083 | "process_docstring", 1084 | "process_signature", 1085 | ] 1086 | -------------------------------------------------------------------------------- /src/sphinx_autodoc_typehints/_parser.py: -------------------------------------------------------------------------------- 1 | """Utilities for side-effect-free rST parsing.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from docutils.utils import new_document 8 | from sphinx.parsers import RSTParser 9 | from sphinx.util.docutils import sphinx_domains 10 | 11 | if TYPE_CHECKING: 12 | import optparse 13 | 14 | from docutils import nodes 15 | from docutils.frontend import Values 16 | from docutils.statemachine import StringList 17 | 18 | 19 | class _RstSnippetParser(RSTParser): 20 | @staticmethod 21 | def decorate(_content: StringList) -> None: 22 | """Override to skip processing rst_epilog/rst_prolog for typing.""" 23 | 24 | 25 | def parse(inputstr: str, settings: Values | optparse.Values) -> nodes.document: 26 | """Parse inputstr and return a docutils document.""" 27 | doc = new_document("", settings=settings) 28 | with sphinx_domains(settings.env): 29 | parser = _RstSnippetParser() 30 | parser.set_application(settings.env.app) 31 | parser.parse(inputstr, doc) 32 | return doc 33 | -------------------------------------------------------------------------------- /src/sphinx_autodoc_typehints/attributes_patch.py: -------------------------------------------------------------------------------- 1 | """Patch for attributes.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import partial 6 | from typing import TYPE_CHECKING, Any 7 | from unittest.mock import patch 8 | 9 | import sphinx.domains.python 10 | import sphinx.ext.autodoc 11 | from sphinx.domains.python import PyAttribute 12 | from sphinx.ext.autodoc import AttributeDocumenter 13 | 14 | from ._parser import parse 15 | 16 | if TYPE_CHECKING: 17 | from docutils.frontend import Values 18 | from sphinx.addnodes import desc_signature 19 | from sphinx.application import Sphinx 20 | 21 | # Defensively check for the things we want to patch 22 | _parse_annotation = getattr(sphinx.domains.python, "_parse_annotation", None) 23 | 24 | # We want to patch: 25 | # * sphinx.ext.autodoc.stringify_typehint (in sphinx < 6.1) 26 | # * sphinx.ext.autodoc.stringify_annotation (in sphinx >= 6.1) 27 | STRINGIFY_PATCH_TARGET = "" 28 | for target in ["stringify_typehint", "stringify_annotation"]: 29 | if hasattr(sphinx.ext.autodoc, target): 30 | STRINGIFY_PATCH_TARGET = f"sphinx.ext.autodoc.{target}" 31 | break 32 | 33 | # If we didn't locate both patch targets, we will just do nothing. 34 | OKAY_TO_PATCH = bool(_parse_annotation and STRINGIFY_PATCH_TARGET) 35 | 36 | # A label we inject to the type string so we know not to try to treat it as a 37 | # type annotation 38 | TYPE_IS_RST_LABEL = "--is-rst--" 39 | 40 | 41 | orig_add_directive_header = AttributeDocumenter.add_directive_header 42 | orig_handle_signature = PyAttribute.handle_signature 43 | 44 | 45 | def _stringify_annotation(app: Sphinx, annotation: Any, *args: Any, short_literals: bool = False, **kwargs: Any) -> str: # noqa: ARG001 46 | # Format the annotation with sphinx-autodoc-typehints and inject our magic prefix to tell our patched 47 | # PyAttribute.handle_signature to treat it as rst. 48 | from . import format_annotation # noqa: PLC0415 49 | 50 | return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config, short_literals=short_literals) 51 | 52 | 53 | def patch_attribute_documenter(app: Sphinx) -> None: 54 | """Instead of using stringify_typehint in `AttributeDocumenter.add_directive_header`, use `format_annotation`.""" 55 | 56 | def add_directive_header(*args: Any, **kwargs: Any) -> Any: 57 | with patch(STRINGIFY_PATCH_TARGET, partial(_stringify_annotation, app)): 58 | return orig_add_directive_header(*args, **kwargs) 59 | 60 | AttributeDocumenter.add_directive_header = add_directive_header # type:ignore[method-assign] 61 | 62 | 63 | def rst_to_docutils(settings: Values, rst: str) -> Any: 64 | """Convert rst to a sequence of docutils nodes.""" 65 | doc = parse(rst, settings) 66 | # Remove top level paragraph node so that there is no line break. 67 | return doc.children[0].children 68 | 69 | 70 | def patched_parse_annotation(settings: Values, typ: str, env: Any) -> Any: 71 | # if typ doesn't start with our label, use original function 72 | if not typ.startswith(TYPE_IS_RST_LABEL): 73 | assert _parse_annotation is not None # noqa: S101 74 | return _parse_annotation(typ, env) 75 | # Otherwise handle as rst 76 | typ = typ[len(TYPE_IS_RST_LABEL) :] 77 | return rst_to_docutils(settings, typ) 78 | 79 | 80 | def patched_handle_signature(self: PyAttribute, sig: str, signode: desc_signature) -> tuple[str, str]: 81 | target = "sphinx.domains.python._parse_annotation" 82 | new_func = partial(patched_parse_annotation, self.state.document.settings) 83 | with patch(target, new_func): 84 | return orig_handle_signature(self, sig, signode) 85 | 86 | 87 | def patch_attribute_handling(app: Sphinx) -> None: 88 | """Use format_signature to format class attribute type annotations.""" 89 | if not OKAY_TO_PATCH: 90 | return 91 | PyAttribute.handle_signature = patched_handle_signature # type:ignore[method-assign] 92 | patch_attribute_documenter(app) 93 | 94 | 95 | __all__ = ["patch_attribute_handling"] 96 | -------------------------------------------------------------------------------- /src/sphinx_autodoc_typehints/patches.py: -------------------------------------------------------------------------------- 1 | """Custom patches to make the world work.""" 2 | 3 | from __future__ import annotations 4 | 5 | from functools import lru_cache 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from docutils import nodes 9 | from docutils.parsers.rst.directives.admonitions import BaseAdmonition 10 | from docutils.parsers.rst.states import Body, Text 11 | from sphinx.ext.napoleon.docstring import GoogleDocstring 12 | 13 | from .attributes_patch import patch_attribute_handling 14 | 15 | if TYPE_CHECKING: 16 | from sphinx.application import Sphinx 17 | from sphinx.ext.autodoc import Options 18 | 19 | 20 | @lru_cache # A cute way to make sure the function only runs once. 21 | def fix_autodoc_typehints_for_overloaded_methods() -> None: 22 | """ 23 | sphinx-autodoc-typehints responds to the "autodoc-process-signature" event to remove types from the signature line. 24 | 25 | Normally, `FunctionDocumenter.format_signature` and `MethodDocumenter.format_signature` call 26 | `super().format_signature` which ends up going to `Documenter.format_signature`, and this last method emits the 27 | `autodoc-process-signature` event. However, if there are overloads, `FunctionDocumenter.format_signature` does 28 | something else and the event never occurs. Here we remove this alternative code path by brute force. 29 | 30 | See https://github.com/tox-dev/sphinx-autodoc-typehints/issues/296 31 | """ 32 | from sphinx.ext.autodoc import FunctionDocumenter, MethodDocumenter # noqa: PLC0415 33 | 34 | del FunctionDocumenter.format_signature 35 | del MethodDocumenter.format_signature 36 | 37 | 38 | def napoleon_numpy_docstring_return_type_processor( # noqa: PLR0913, PLR0917 39 | app: Sphinx, 40 | what: str, 41 | name: str, # noqa: ARG001 42 | obj: Any, # noqa: ARG001 43 | options: Options | None, # noqa: ARG001 44 | lines: list[str], 45 | ) -> None: 46 | """Insert a : under Returns: to tell napoleon not to look for a return type.""" 47 | if what not in {"function", "method"}: 48 | return 49 | if not getattr(app.config, "napoleon_numpy_docstring", False): 50 | return 51 | 52 | # Search for the returns header: 53 | # Returns: 54 | # -------- 55 | for pos, line in enumerate(lines[:-2]): 56 | if line.lower().strip(":") not in {"return", "returns"}: 57 | continue 58 | # Underline detection. 59 | chars = set(lines[pos + 1].strip()) 60 | # Napoleon allows the underline to consist of a bunch of weirder things... 61 | if len(chars) != 1 or next(iter(chars)) not in "=-~_*+#": 62 | continue 63 | pos += 2 # noqa: PLW2901 64 | break 65 | else: 66 | return 67 | 68 | lines.insert(pos, ":") 69 | 70 | 71 | def fix_napoleon_numpy_docstring_return_type(app: Sphinx) -> None: 72 | """If no return type is explicitly set, numpy docstrings will use the return type text as return types.""" 73 | # standard priority is 500. Setting priority to 499 ensures this runs before 74 | # napoleon's docstring processor. 75 | app.connect("autodoc-process-docstring", napoleon_numpy_docstring_return_type_processor, priority=499) 76 | 77 | 78 | def _patched_lookup_annotation(*_args: Any) -> str: 79 | """ 80 | GoogleDocstring._lookup_annotation sometimes adds incorrect type annotations to constructor parameters. 81 | 82 | Disable it so we can handle this on our own. 83 | """ 84 | return "" 85 | 86 | 87 | def _patch_google_docstring_lookup_annotation() -> None: 88 | """Fix issue https://github.com/tox-dev/sphinx-autodoc-typehints/issues/308.""" 89 | GoogleDocstring._lookup_annotation = _patched_lookup_annotation # type: ignore[assignment] # noqa: SLF001 90 | 91 | 92 | orig_base_admonition_run = BaseAdmonition.run 93 | 94 | 95 | def _patched_base_admonition_run(self: BaseAdmonition) -> Any: 96 | result = orig_base_admonition_run(self) 97 | result[0].line = self.lineno 98 | return result 99 | 100 | 101 | orig_text_indent = Text.indent 102 | 103 | 104 | def _patched_text_indent(self: Text, *args: Any) -> Any: 105 | _, line = self.state_machine.get_source_and_line() 106 | result = orig_text_indent(self, *args) # type: ignore[no-untyped-call] 107 | node = self.parent[-1] 108 | if node.tagname == "system_message": 109 | node = self.parent[-2] 110 | node.line = line 111 | return result 112 | 113 | 114 | def _patched_body_doctest( 115 | self: Body, _match: None, _context: None, next_state: str | None 116 | ) -> tuple[list[Any], str | None, list[Any]]: 117 | assert self.document.current_line is not None # noqa: S101 118 | line = self.document.current_line + 1 119 | data = "\n".join(self.state_machine.get_text_block()) 120 | n = nodes.doctest_block(data, data) 121 | n.line = line 122 | self.parent += n 123 | return [], next_state, [] 124 | 125 | 126 | def _patch_line_numbers() -> None: 127 | """ 128 | Make the rst parser put line numbers on more nodes. 129 | 130 | When the line numbers are missing, we have a hard time placing the :rtype:. 131 | """ 132 | Text.indent = _patched_text_indent # type: ignore[method-assign] 133 | BaseAdmonition.run = _patched_base_admonition_run # type: ignore[method-assign,assignment] 134 | Body.doctest = _patched_body_doctest # type: ignore[method-assign] 135 | 136 | 137 | def install_patches(app: Sphinx) -> None: 138 | """ 139 | Install the patches. 140 | 141 | :param app: the Sphinx app 142 | """ 143 | fix_autodoc_typehints_for_overloaded_methods() 144 | patch_attribute_handling(app) 145 | _patch_google_docstring_lookup_annotation() 146 | fix_napoleon_numpy_docstring_return_type(app) 147 | _patch_line_numbers() 148 | 149 | 150 | ___all__ = [ 151 | "install_patches", 152 | ] 153 | -------------------------------------------------------------------------------- /src/sphinx_autodoc_typehints/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/sphinx-autodoc-typehints/41100f97b15bf3e6016803805f9befb2937a3ee0/src/sphinx_autodoc_typehints/py.typed -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import shutil 5 | import sys 6 | from contextlib import suppress 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING 9 | 10 | import pytest 11 | from sphobjinv import Inventory 12 | 13 | if TYPE_CHECKING: 14 | from _pytest.config import Config 15 | 16 | pytest_plugins = "sphinx.testing.fixtures" 17 | collect_ignore = ["roots"] 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def inv(pytestconfig: Config) -> Inventory: 22 | cache_path = f"python{sys.version_info.major}.{sys.version_info.minor}/objects.inv" 23 | assert pytestconfig.cache is not None 24 | inv_dict = pytestconfig.cache.get(cache_path, None) 25 | if inv_dict is not None: 26 | return Inventory(inv_dict) 27 | 28 | url = f"https://docs.python.org/{sys.version_info.major}.{sys.version_info.minor}/objects.inv" 29 | inv = Inventory(url=url) 30 | pytestconfig.cache.set(cache_path, inv.json_dict()) 31 | return inv 32 | 33 | 34 | @pytest.fixture(autouse=True) 35 | def _remove_sphinx_projects(sphinx_test_tempdir: Path) -> None: 36 | # Remove any directory which appears to be a Sphinx project from 37 | # the temporary directory area. 38 | # See https://github.com/sphinx-doc/sphinx/issues/4040 39 | for entry in sphinx_test_tempdir.iterdir(): 40 | with suppress(PermissionError): 41 | if entry.is_dir() and Path(entry, "_build").exists(): 42 | shutil.rmtree(str(entry)) 43 | 44 | 45 | @pytest.fixture 46 | def rootdir() -> Path: 47 | return Path(str(Path(__file__).parent) or ".").absolute() / "roots" 48 | 49 | 50 | def pytest_ignore_collect(collection_path: Path, config: Config) -> bool | None: # noqa: ARG001 51 | version_re = re.compile(r"_py(\d)(\d)\.py$") 52 | match = version_re.search(collection_path.name) 53 | if match: 54 | version = tuple(int(x) for x in match.groups()) 55 | if sys.version_info < version: 56 | return True 57 | return None 58 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import sys 5 | 6 | # Make dummy_module.py available for autodoc. 7 | sys.path.insert(0, str(pathlib.Path(__file__).parent)) 8 | 9 | 10 | master_doc = "index" 11 | 12 | extensions = [ 13 | "sphinx.ext.autodoc", 14 | "sphinx.ext.napoleon", 15 | "sphinx_autodoc_typehints", 16 | ] 17 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/dummy_module.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | def undocumented_function(x: int) -> str: 7 | """Hi""" 8 | 9 | return str(x) 10 | 11 | 12 | @dataclass 13 | class DataClass: 14 | """Class docstring.""" 15 | 16 | x: int 17 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/dummy_module_future_annotations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def function_with_py310_annotations( 5 | self, # noqa: ANN001 6 | x: bool | None, 7 | y: int | str | float, # noqa: PYI041 8 | z: str | None = None, 9 | ) -> str: 10 | """ 11 | Method docstring. 12 | 13 | :param x: foo 14 | :param y: bar 15 | :param z: baz 16 | """ 17 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/dummy_module_simple.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def function(x: bool, y: int = 1) -> str: 5 | """ 6 | Function docstring. 7 | 8 | :param x: foo 9 | :param y: bar 10 | """ 11 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/dummy_module_simple_default_role.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def function(x: bool, y: int) -> str: 5 | """ 6 | Function docstring. 7 | 8 | :param x: `foo` 9 | :param y: ``bar`` 10 | """ 11 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/dummy_module_simple_no_use_rtype.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def function_no_returns(x: bool, y: int = 1) -> str: 5 | """ 6 | Function docstring. 7 | 8 | :param x: foo 9 | :param y: bar 10 | """ 11 | 12 | 13 | def function_returns_with_type(x: bool, y: int = 1) -> str: 14 | """ 15 | Function docstring. 16 | 17 | :param x: foo 18 | :param y: bar 19 | :returns: *CustomType* -- A string 20 | """ 21 | 22 | 23 | def function_returns_with_compound_type(x: bool, y: int = 1) -> str: 24 | """ 25 | Function docstring. 26 | 27 | :param x: foo 28 | :param y: bar 29 | :returns: Union[str, int] -- A string or int 30 | """ 31 | 32 | 33 | def function_returns_without_type(x: bool, y: int = 1) -> str: 34 | """ 35 | Function docstring. 36 | 37 | :param x: foo 38 | :param y: bar 39 | :returns: A string 40 | """ 41 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/dummy_module_without_complete_typehints.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def function_with_some_defaults_and_without_typehints(x, y=None): # noqa: ANN001, ANN201 5 | """ 6 | Function docstring. 7 | 8 | :param x: foo 9 | :param y: bar 10 | """ 11 | 12 | 13 | def function_with_some_defaults_and_some_typehints(x: int, y=None): # noqa: ANN001, ANN201 14 | """ 15 | Function docstring. 16 | 17 | :param x: foo 18 | :param y: bar 19 | """ 20 | 21 | 22 | def function_with_some_defaults_and_more_typehints(x: int, y=None) -> str: # noqa: ANN001 23 | """ 24 | Function docstring. 25 | 26 | :param x: foo 27 | :param y: bar 28 | """ 29 | 30 | 31 | def function_with_defaults_and_some_typehints(x: int = 0, y=None) -> str: # noqa: ANN001 32 | """ 33 | Function docstring. 34 | 35 | :param x: foo 36 | :param y: bar 37 | """ 38 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/export_module.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from wrong_module_path import A, f 4 | 5 | __all__ = ["A", "f"] 6 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/future_annotations.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Dummy Module 4 | ============ 5 | 6 | .. autofunction:: dummy_module_future_annotations.function_with_py310_annotations 7 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/simple.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Simple Module 4 | ============= 5 | 6 | .. autofunction:: dummy_module_simple.function 7 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/simple_default_role.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Simple Module 4 | ============= 5 | 6 | .. autofunction:: dummy_module_simple_default_role.function 7 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/simple_no_use_rtype.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Simple Module 4 | ============= 5 | 6 | .. autofunction:: dummy_module_simple_no_use_rtype.function_no_returns 7 | .. autofunction:: dummy_module_simple_no_use_rtype.function_returns_with_type 8 | .. autofunction:: dummy_module_simple_no_use_rtype.function_returns_with_compound_type 9 | .. autofunction:: dummy_module_simple_no_use_rtype.function_returns_without_type 10 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/without_complete_typehints.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | Simple Module 4 | ============= 5 | 6 | .. autofunction:: dummy_module_without_complete_typehints.function_with_some_defaults_and_without_typehints 7 | .. autofunction:: dummy_module_without_complete_typehints.function_with_some_defaults_and_some_typehints 8 | .. autofunction:: dummy_module_without_complete_typehints.function_with_some_defaults_and_more_typehints 9 | .. autofunction:: dummy_module_without_complete_typehints.function_with_defaults_and_some_typehints 10 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/wrong_module_path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class A: 5 | pass 6 | 7 | 8 | def f() -> A: 9 | pass 10 | -------------------------------------------------------------------------------- /tests/roots/test-dummy/wrong_module_path.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. class:: export_module.A 4 | 5 | .. autofunction:: export_module.f 6 | -------------------------------------------------------------------------------- /tests/roots/test-integration/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import sys 5 | 6 | # Make dummy_module.py available for autodoc. 7 | sys.path.insert(0, str(pathlib.Path(__file__).parent)) 8 | 9 | 10 | master_doc = "index" 11 | 12 | extensions = [ 13 | "sphinx.ext.autodoc", 14 | "sphinx.ext.napoleon", 15 | "sphinx_autodoc_typehints", 16 | ] 17 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard-tmp/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import sys 5 | 6 | master_doc = "index" 7 | sys.path.insert(0, str(pathlib.Path(__file__).parent)) 8 | extensions = [ 9 | "sphinx.ext.autodoc", 10 | "sphinx_autodoc_typehints", 11 | ] 12 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard-tmp/demo_typing_guard.py: -------------------------------------------------------------------------------- 1 | """Module demonstrating imports that are type guarded""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | 7 | from attrs import define 8 | 9 | 10 | @define() 11 | class SomeClass: 12 | """This class does something.""" 13 | 14 | date: datetime.date 15 | """Date to handle""" 16 | 17 | @classmethod 18 | def from_str(cls, input_value: str) -> SomeClass: 19 | """ 20 | Initialize from string 21 | 22 | :param input_value: Input 23 | :return: result 24 | """ 25 | return cls(input_value) 26 | 27 | @classmethod 28 | def from_date(cls, input_value: datetime.date) -> SomeClass: 29 | """ 30 | Initialize from date 31 | 32 | :param input_value: Input 33 | :return: result 34 | """ 35 | return cls(input_value) 36 | 37 | @classmethod 38 | def from_time(cls, input_value: datetime.time) -> SomeClass: 39 | """ 40 | Initialize from time 41 | 42 | :param input_value: Input 43 | :return: result 44 | """ 45 | return cls(input_value) 46 | 47 | def calculate_thing(self, number: float) -> datetime.timedelta: # noqa: PLR6301 48 | """ 49 | Calculate a thing 50 | 51 | :param number: Input 52 | :return: result 53 | """ 54 | return datetime.timedelta(number) 55 | 56 | 57 | __all__ = ["SomeClass"] 58 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard-tmp/index.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: demo_typing_guard 2 | :members: 3 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | import sys 5 | 6 | master_doc = "index" 7 | sys.path.insert(0, str(pathlib.Path(__file__).parent)) 8 | extensions = [ 9 | "sphinx.ext.autodoc", 10 | "sphinx_autodoc_typehints", 11 | ] 12 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard/demo_typing_guard.py: -------------------------------------------------------------------------------- 1 | """Module demonstrating imports that are type guarded""" 2 | 3 | from __future__ import annotations 4 | 5 | import typing 6 | from builtins import ValueError # handle does not have __module__ # noqa: A004 7 | from functools import cmp_to_key # has __module__ but cannot get module as is builtin 8 | from typing import TYPE_CHECKING 9 | 10 | from demo_typing_guard_dummy import AnotherClass 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Sequence 14 | from decimal import Decimal 15 | 16 | from demo_typing_guard_dummy import Literal # guarded by another `if TYPE_CHECKING` in demo_typing_guard_dummy 17 | 18 | 19 | if typing.TYPE_CHECKING: 20 | from typing import AnyStr 21 | 22 | 23 | if TYPE_CHECKING: # bad import 24 | from functools import missing # noqa: F401 25 | 26 | 27 | def a(f: Decimal, s: AnyStr) -> Sequence[AnyStr | Decimal]: 28 | """ 29 | Do. 30 | 31 | :param f: first 32 | :param s: second 33 | :return: result 34 | """ 35 | return [f, s] 36 | 37 | 38 | class SomeClass: 39 | """This class do something.""" 40 | 41 | def create(self, item: Decimal) -> None: 42 | """ 43 | Create something. 44 | 45 | :param item: the item in question 46 | """ 47 | 48 | if TYPE_CHECKING: # Classes doesn't have `__globals__` attribute 49 | 50 | def guarded(self, item: Decimal) -> None: 51 | """ 52 | Guarded method. 53 | 54 | :param item: some item 55 | """ 56 | 57 | 58 | def func(_x: Literal) -> None: ... 59 | 60 | 61 | __all__ = [ 62 | "AnotherClass", 63 | "SomeClass", 64 | "ValueError", 65 | "a", 66 | "cmp_to_key", 67 | ] 68 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard/demo_typing_guard_dummy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from viktor import AI # module part of autodoc_mock_imports # noqa: F401 6 | 7 | if TYPE_CHECKING: 8 | # Nested type guard 9 | from typing import Literal # noqa: F401 10 | 11 | 12 | class AnotherClass: 13 | """Another class is here""" 14 | -------------------------------------------------------------------------------- /tests/roots/test-resolve-typing-guard/index.rst: -------------------------------------------------------------------------------- 1 | .. automodule:: demo_typing_guard 2 | :members: 3 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from dataclasses import dataclass 6 | from inspect import isclass 7 | from pathlib import Path 8 | from textwrap import dedent, indent 9 | from typing import ( # no type comments 10 | TYPE_CHECKING, 11 | Any, 12 | Literal, 13 | NewType, 14 | Optional, 15 | TypeVar, 16 | Union, 17 | overload, 18 | ) 19 | 20 | import pytest 21 | 22 | if TYPE_CHECKING: 23 | from collections.abc import AsyncGenerator, Callable 24 | from io import StringIO 25 | from mailbox import Mailbox 26 | from types import CodeType, ModuleType 27 | 28 | from sphinx.testing.util import SphinxTestApp 29 | 30 | T = TypeVar("T") 31 | W = NewType("W", str) 32 | 33 | 34 | @dataclass 35 | class WarningInfo: 36 | """Properties and assertion methods for warnings.""" 37 | 38 | regexp: str 39 | type: str 40 | 41 | def assert_regexp(self, message: str) -> None: 42 | regexp = self.regexp 43 | msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {message!r}" 44 | assert re.search(regexp, message), msg 45 | 46 | def assert_type(self, message: str) -> None: 47 | expected = f"[{self.type}]" 48 | msg = f"Warning did not contain type and subtype.\n Expected: {expected}\n Input: {message}" 49 | assert expected in message, msg 50 | 51 | def assert_warning(self, message: str) -> None: 52 | self.assert_regexp(message) 53 | self.assert_type(message) 54 | 55 | 56 | def expected(expected: str, **options: dict[str, Any]) -> Callable[[T], T]: 57 | def dec(val: T) -> T: 58 | val.EXPECTED = expected 59 | val.OPTIONS = options 60 | return val 61 | 62 | return dec 63 | 64 | 65 | def warns(info: WarningInfo) -> Callable[[T], T]: 66 | def dec(val: T) -> T: 67 | val.WARNING = info 68 | return val 69 | 70 | return dec 71 | 72 | 73 | @expected("mod.get_local_function()") 74 | def get_local_function(): # noqa: ANN201 75 | def wrapper(self) -> str: # noqa: ANN001 76 | """ 77 | Wrapper 78 | """ 79 | 80 | return wrapper 81 | 82 | 83 | @warns(WarningInfo(regexp="Cannot handle as a local function", type="sphinx_autodoc_typehints.local_function")) 84 | @expected( 85 | """\ 86 | class mod.Class(x, y, z=None) 87 | 88 | Initializer docstring. 89 | 90 | Parameters: 91 | * **x** ("bool") -- foo 92 | 93 | * **y** ("int") -- bar 94 | 95 | * **z** ("str" | "None") -- baz 96 | 97 | class InnerClass 98 | 99 | Inner class. 100 | 101 | inner_method(x) 102 | 103 | Inner method. 104 | 105 | Parameters: 106 | **x** ("bool") -- foo 107 | 108 | Return type: 109 | "str" 110 | 111 | classmethod a_classmethod(x, y, z=None) 112 | 113 | Classmethod docstring. 114 | 115 | Parameters: 116 | * **x** ("bool") -- foo 117 | 118 | * **y** ("int") -- bar 119 | 120 | * **z** ("str" | "None") -- baz 121 | 122 | Return type: 123 | "str" 124 | 125 | a_method(x, y, z=None) 126 | 127 | Method docstring. 128 | 129 | Parameters: 130 | * **x** ("bool") -- foo 131 | 132 | * **y** ("int") -- bar 133 | 134 | * **z** ("str" | "None") -- baz 135 | 136 | Return type: 137 | "str" 138 | 139 | property a_property: str 140 | 141 | Property docstring 142 | 143 | static a_staticmethod(x, y, z=None) 144 | 145 | Staticmethod docstring. 146 | 147 | Parameters: 148 | * **x** ("bool") -- foo 149 | 150 | * **y** ("int") -- bar 151 | 152 | * **z** ("str" | "None") -- baz 153 | 154 | Return type: 155 | "str" 156 | 157 | locally_defined_callable_field() -> str 158 | 159 | Wrapper 160 | 161 | Return type: 162 | "str" 163 | """, 164 | ) 165 | class Class: 166 | """ 167 | Initializer docstring. 168 | 169 | :param x: foo 170 | :param y: bar 171 | :param z: baz 172 | """ 173 | 174 | def __init__(self, x: bool, y: int, z: Optional[str] = None) -> None: 175 | pass 176 | 177 | def a_method(self, x: bool, y: int, z: Optional[str] = None) -> str: 178 | """ 179 | Method docstring. 180 | 181 | :param x: foo 182 | :param y: bar 183 | :param z: baz 184 | """ 185 | 186 | def _private_method(self, x: str) -> str: 187 | """ 188 | Private method docstring. 189 | 190 | :param x: foo 191 | """ 192 | 193 | def __dunder_method(self, x: str) -> str: 194 | """ 195 | Dunder method docstring. 196 | 197 | :param x: foo 198 | """ 199 | 200 | def __magic_custom_method__(self, x: str) -> str: # noqa: PLW3201 201 | """ 202 | Magic dunder method docstring. 203 | 204 | :param x: foo 205 | """ 206 | 207 | @classmethod 208 | def a_classmethod(cls, x: bool, y: int, z: Optional[str] = None) -> str: 209 | """ 210 | Classmethod docstring. 211 | 212 | :param x: foo 213 | :param y: bar 214 | :param z: baz 215 | """ 216 | 217 | @staticmethod 218 | def a_staticmethod(x: bool, y: int, z: Optional[str] = None) -> str: 219 | """ 220 | Staticmethod docstring. 221 | 222 | :param x: foo 223 | :param y: bar 224 | :param z: baz 225 | """ 226 | 227 | @property 228 | def a_property(self) -> str: 229 | """ 230 | Property docstring 231 | """ 232 | 233 | class InnerClass: 234 | """ 235 | Inner class. 236 | """ 237 | 238 | def inner_method(self, x: bool) -> str: 239 | """ 240 | Inner method. 241 | 242 | :param x: foo 243 | """ 244 | 245 | def __dunder_inner_method(self, x: bool) -> str: 246 | """ 247 | Dunder inner method. 248 | 249 | :param x: foo 250 | """ 251 | 252 | locally_defined_callable_field = get_local_function() 253 | 254 | 255 | @expected( 256 | """\ 257 | exception mod.DummyException(message) 258 | 259 | Exception docstring 260 | 261 | Parameters: 262 | **message** ("str") -- blah 263 | """, 264 | ) 265 | class DummyException(Exception): # noqa: N818 266 | """ 267 | Exception docstring 268 | 269 | :param message: blah 270 | """ 271 | 272 | def __init__(self, message: str) -> None: 273 | super().__init__(message) 274 | 275 | 276 | @expected( 277 | """\ 278 | mod.function(x, y, z_=None) 279 | 280 | Function docstring. 281 | 282 | Parameters: 283 | * **x** ("bool") -- foo 284 | 285 | * **y** ("int") -- bar 286 | 287 | * **z_** ("str" | "None") -- baz 288 | 289 | Returns: 290 | something 291 | 292 | Return type: 293 | bytes 294 | """, 295 | ) 296 | def function(x: bool, y: int, z_: Optional[str] = None) -> str: 297 | """ 298 | Function docstring. 299 | 300 | :param x: foo 301 | :param y: bar 302 | :param z\\_: baz 303 | :return: something 304 | :rtype: bytes 305 | """ 306 | 307 | 308 | @expected( 309 | """\ 310 | mod.function_with_starred_documentation_param_names(*args, **kwargs) 311 | 312 | Function docstring. 313 | 314 | Usage: 315 | 316 | print(1) 317 | 318 | Parameters: 319 | * ***args** ("int") -- foo 320 | 321 | * ****kwargs** ("str") -- bar 322 | """, 323 | ) 324 | def function_with_starred_documentation_param_names(*args: int, **kwargs: str): # noqa: ANN201 325 | r""" 326 | Function docstring. 327 | 328 | Usage:: 329 | 330 | print(1) 331 | 332 | :param \*args: foo 333 | :param \**kwargs: bar 334 | """ 335 | 336 | 337 | @expected( 338 | """\ 339 | mod.function_with_escaped_default(x='\\\\x08') 340 | 341 | Function docstring. 342 | 343 | Parameters: 344 | **x** ("str") -- foo 345 | """, 346 | ) 347 | def function_with_escaped_default(x: str = "\b"): # noqa: ANN201 348 | """ 349 | Function docstring. 350 | 351 | :param x: foo 352 | """ 353 | 354 | 355 | @warns( 356 | WarningInfo( 357 | regexp="Cannot resolve forward reference in type annotations", type="sphinx_autodoc_typehints.forward_reference" 358 | ) 359 | ) 360 | @expected( 361 | """\ 362 | mod.function_with_unresolvable_annotation(x) 363 | 364 | Function docstring. 365 | 366 | Parameters: 367 | **x** (a.b.c) -- foo 368 | """, 369 | ) 370 | def function_with_unresolvable_annotation(x: a.b.c): # noqa: ANN201, F821 371 | """ 372 | Function docstring. 373 | 374 | :arg x: foo 375 | """ 376 | 377 | 378 | @expected( 379 | """\ 380 | mod.function_with_typehint_comment(x, y) 381 | 382 | Function docstring. 383 | 384 | Parameters: 385 | * **x** ("int") -- foo 386 | 387 | * **y** ("str") -- bar 388 | 389 | Return type: 390 | "None" 391 | """, 392 | ) 393 | def function_with_typehint_comment( # noqa: ANN201 394 | x, # type: int # noqa: ANN001 395 | y, # type: str # noqa: ANN001 396 | ): 397 | # type: (...) -> None 398 | """ 399 | Function docstring. 400 | 401 | :parameter x: foo 402 | :parameter y: bar 403 | """ 404 | 405 | 406 | @expected( 407 | """\ 408 | class mod.ClassWithTypehints(x) 409 | 410 | Class docstring. 411 | 412 | Parameters: 413 | **x** ("int") -- foo 414 | 415 | foo(x) 416 | 417 | Method docstring. 418 | 419 | Parameters: 420 | **x** ("str") -- foo 421 | 422 | Return type: 423 | "int" 424 | 425 | method_without_typehint(x) 426 | 427 | Method docstring. 428 | """, 429 | ) 430 | class ClassWithTypehints: 431 | """ 432 | Class docstring. 433 | 434 | :param x: foo 435 | """ 436 | 437 | def __init__( 438 | self, 439 | x, # type: int # noqa: ANN001 440 | ) -> None: 441 | # type: (...) -> None 442 | pass 443 | 444 | def foo( # noqa: ANN201, PLR6301 445 | self, 446 | x, # type: str # noqa: ANN001, ARG002 447 | ): 448 | # type: (...) -> int 449 | """ 450 | Method docstring. 451 | 452 | :arg x: foo 453 | """ 454 | return 42 455 | 456 | def method_without_typehint(self, x): # noqa: ANN001, ANN201, ARG002, PLR6301 457 | """ 458 | Method docstring. 459 | """ 460 | # test that multiline str can be correctly indented 461 | multiline_str = """ 462 | test 463 | """ 464 | return multiline_str # noqa: RET504 465 | 466 | 467 | @expected( 468 | """\ 469 | mod.function_with_typehint_comment_not_inline(x=None, *y, z, **kwargs) 470 | 471 | Function docstring. 472 | 473 | Parameters: 474 | * **x** ("str" | "bytes" | "None") -- foo 475 | 476 | * **y** ("str") -- bar 477 | 478 | * **z** ("bytes") -- baz 479 | 480 | * **kwargs** ("int") -- some kwargs 481 | 482 | Return type: 483 | "None" 484 | """, 485 | ) 486 | def function_with_typehint_comment_not_inline(x=None, *y, z, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN201 487 | # type: (Union[str, bytes, None], *str, bytes, **int) -> None 488 | """ 489 | Function docstring. 490 | 491 | :arg x: foo 492 | :argument y: bar 493 | :parameter z: baz 494 | :parameter kwargs: some kwargs 495 | """ 496 | 497 | 498 | @expected( 499 | """\ 500 | class mod.ClassWithTypehintsNotInline(x=None) 501 | 502 | Class docstring. 503 | 504 | Parameters: 505 | **x** ("Callable"[["int", "bytes"], "int"] | "None") -- foo 506 | 507 | foo(x=1) 508 | 509 | Method docstring. 510 | 511 | Parameters: 512 | **x** ("Callable"[["int", "bytes"], "int"]) -- foo 513 | 514 | Return type: 515 | "int" 516 | 517 | classmethod mk(x=None) 518 | 519 | Method docstring. 520 | 521 | Parameters: 522 | **x** ("Callable"[["int", "bytes"], "int"] | "None") -- foo 523 | 524 | Return type: 525 | "ClassWithTypehintsNotInline" 526 | """, 527 | ) 528 | class ClassWithTypehintsNotInline: 529 | """ 530 | Class docstring. 531 | 532 | :param x: foo 533 | """ 534 | 535 | def __init__(self, x=None) -> None: # type: (Optional[Callable[[int, bytes], int]]) -> None # noqa: ANN001 536 | pass 537 | 538 | def foo(self, x=1): # type: (Callable[[int, bytes], int]) -> int # noqa: ANN001, ANN201, PLR6301 539 | """ 540 | Method docstring. 541 | 542 | :param x: foo 543 | """ 544 | return x(1, b"") 545 | 546 | @classmethod 547 | def mk( # noqa: ANN206 548 | cls, 549 | x=None, # noqa: ANN001 550 | ): # type: (Optional[Callable[[int, bytes], int]]) -> ClassWithTypehintsNotInline 551 | """ 552 | Method docstring. 553 | 554 | :param x: foo 555 | """ 556 | return cls(x) 557 | 558 | 559 | @expected( 560 | """\ 561 | mod.undocumented_function(x) 562 | 563 | Hi 564 | 565 | Return type: 566 | "str" 567 | """, 568 | ) 569 | def undocumented_function(x: int) -> str: 570 | """Hi""" 571 | 572 | return str(x) 573 | 574 | 575 | @expected( 576 | """\ 577 | class mod.DataClass(x) 578 | 579 | Class docstring. 580 | """, 581 | ) 582 | @dataclass 583 | class DataClass: 584 | """Class docstring.""" 585 | 586 | x: int 587 | 588 | 589 | @expected( 590 | """\ 591 | class mod.Decorator(func) 592 | 593 | Initializer docstring. 594 | 595 | Parameters: 596 | **func** ("Callable"[["int", "str"], "str"]) -- function 597 | """, 598 | ) 599 | class Decorator: 600 | """ 601 | Initializer docstring. 602 | 603 | :param func: function 604 | """ 605 | 606 | def __init__(self, func: Callable[[int, str], str]) -> None: 607 | pass 608 | 609 | 610 | @expected( 611 | """\ 612 | mod.mocked_import(x) 613 | 614 | A docstring. 615 | 616 | Parameters: 617 | **x** ("Mailbox") -- function 618 | """, 619 | ) 620 | def mocked_import(x: Mailbox): # noqa: ANN201 621 | """ 622 | A docstring. 623 | 624 | :param x: function 625 | """ 626 | 627 | 628 | @expected( 629 | """\ 630 | mod.func_with_examples() 631 | 632 | A docstring. 633 | 634 | Return type: 635 | "int" 636 | 637 | -[ Examples ]- 638 | 639 | Here are a couple of examples of how to use this function. 640 | """, 641 | ) 642 | def func_with_examples() -> int: 643 | """ 644 | A docstring. 645 | 646 | .. rubric:: Examples 647 | 648 | Here are a couple of examples of how to use this function. 649 | """ 650 | 651 | 652 | @overload 653 | def func_with_overload(a: int, b: int) -> None: ... 654 | 655 | 656 | @overload 657 | def func_with_overload(a: str, b: str) -> None: ... 658 | 659 | 660 | @expected( 661 | """\ 662 | mod.func_with_overload(a, b) 663 | 664 | f does the thing. The arguments can either be ints or strings but 665 | they must both have the same type. 666 | 667 | Parameters: 668 | * **a** ("int" | "str") -- The first thing 669 | 670 | * **b** ("int" | "str") -- The second thing 671 | 672 | Return type: 673 | "None" 674 | """, 675 | ) 676 | def func_with_overload(a: Union[int, str], b: Union[int, str]) -> None: 677 | """ 678 | f does the thing. The arguments can either be ints or strings but they must 679 | both have the same type. 680 | 681 | Parameters 682 | ---------- 683 | a: 684 | The first thing 685 | b: 686 | The second thing 687 | """ 688 | 689 | 690 | @expected( 691 | """\ 692 | mod.func_literals_long_format(a, b) 693 | 694 | A docstring. 695 | 696 | Parameters: 697 | * **a** ("Literal"["'arg1'", "'arg2'"]) -- Argument that can 698 | take either of two literal values. 699 | 700 | * **b** ("Literal"["'arg1'", "'arg2'"]) -- Argument that can 701 | take either of two literal values. 702 | 703 | Return type: 704 | "None" 705 | """, 706 | ) 707 | def func_literals_long_format(a: Literal["arg1", "arg2"], b: Literal["arg1", "arg2"]) -> None: 708 | """ 709 | A docstring. 710 | 711 | :param a: Argument that can take either of two literal values. 712 | :param b: Argument that can take either of two literal values. 713 | """ 714 | 715 | 716 | @expected( 717 | """\ 718 | mod.func_literals_short_format(a, b) 719 | 720 | A docstring. 721 | 722 | Parameters: 723 | * **a** ("'arg1'" | "'arg2'") -- Argument that can take either 724 | of two literal values. 725 | 726 | * **b** ("'arg1'" | "'arg2'") -- Argument that can take either 727 | of two literal values. 728 | 729 | Return type: 730 | "None" 731 | """, 732 | python_display_short_literal_types=True, 733 | ) 734 | def func_literals_short_format(a: Literal["arg1", "arg2"], b: Literal["arg1", "arg2"]) -> None: 735 | """ 736 | A docstring. 737 | 738 | :param a: Argument that can take either of two literal values. 739 | :param b: Argument that can take either of two literal values. 740 | """ 741 | 742 | 743 | @expected( 744 | """\ 745 | class mod.TestClassAttributeDocs 746 | 747 | A class 748 | 749 | code: "CodeType" | "None" 750 | 751 | An attribute 752 | """, 753 | ) 754 | class TestClassAttributeDocs: 755 | """A class""" 756 | 757 | code: Optional[CodeType] 758 | """An attribute""" 759 | 760 | 761 | @expected( 762 | """\ 763 | mod.func_with_examples_and_returns_after() 764 | 765 | f does the thing. 766 | 767 | -[ Examples ]- 768 | 769 | Here is an example 770 | 771 | Return type: 772 | "int" 773 | 774 | Returns: 775 | The index of the widget 776 | """, 777 | ) 778 | def func_with_examples_and_returns_after() -> int: 779 | """ 780 | f does the thing. 781 | 782 | Examples 783 | -------- 784 | 785 | Here is an example 786 | 787 | :returns: The index of the widget 788 | """ 789 | 790 | 791 | @expected( 792 | """\ 793 | mod.func_with_parameters_and_stuff_after(a, b) 794 | 795 | A func 796 | 797 | Parameters: 798 | * **a** ("int") -- a tells us something 799 | 800 | * **b** ("int") -- b tells us something 801 | 802 | Return type: 803 | "int" 804 | 805 | More info about the function here. 806 | """, 807 | ) 808 | def func_with_parameters_and_stuff_after(a: int, b: int) -> int: 809 | """A func 810 | 811 | :param a: a tells us something 812 | :param b: b tells us something 813 | 814 | More info about the function here. 815 | """ 816 | 817 | 818 | @expected( 819 | """\ 820 | mod.func_with_rtype_in_weird_spot(a, b) 821 | 822 | A func 823 | 824 | Parameters: 825 | * **a** ("int") -- a tells us something 826 | 827 | * **b** ("int") -- b tells us something 828 | 829 | -[ Examples ]- 830 | 831 | Here is an example 832 | 833 | Returns: 834 | The index of the widget 835 | 836 | More info about the function here. 837 | 838 | Return type: 839 | int 840 | """, 841 | ) 842 | def func_with_rtype_in_weird_spot(a: int, b: int) -> int: 843 | """A func 844 | 845 | :param a: a tells us something 846 | :param b: b tells us something 847 | 848 | Examples 849 | -------- 850 | 851 | Here is an example 852 | 853 | :returns: The index of the widget 854 | 855 | More info about the function here. 856 | 857 | :rtype: int 858 | """ 859 | 860 | 861 | @expected( 862 | """\ 863 | mod.empty_line_between_parameters(a, b) 864 | 865 | A func 866 | 867 | Parameters: 868 | * **a** ("int") -- 869 | 870 | One of the following possibilities: 871 | 872 | * a 873 | 874 | * b 875 | 876 | * c 877 | 878 | * **b** ("int") -- 879 | 880 | Whatever else we have to say. 881 | 882 | There is more of it And here too 883 | 884 | Return type: 885 | "int" 886 | 887 | More stuff here. 888 | """, 889 | ) 890 | def empty_line_between_parameters(a: int, b: int) -> int: 891 | """A func 892 | 893 | :param a: One of the following possibilities: 894 | 895 | - a 896 | 897 | - b 898 | 899 | - c 900 | 901 | :param b: Whatever else we have to say. 902 | 903 | There is more of it And here too 904 | 905 | More stuff here. 906 | """ 907 | 908 | 909 | @expected( 910 | """\ 911 | mod.func_with_code_block() 912 | 913 | A docstring. 914 | 915 | You would say: 916 | 917 | print("some python code here") 918 | 919 | Return type: 920 | "int" 921 | 922 | -[ Examples ]- 923 | 924 | Here are a couple of examples of how to use this function. 925 | """, 926 | ) 927 | def func_with_code_block() -> int: 928 | """ 929 | A docstring. 930 | 931 | You would say: 932 | 933 | .. code-block:: 934 | 935 | print("some python code here") 936 | 937 | 938 | .. rubric:: Examples 939 | 940 | Here are a couple of examples of how to use this function. 941 | """ 942 | 943 | 944 | @expected( 945 | """ 946 | mod.func_with_definition_list() 947 | 948 | Some text and then a definition list. 949 | 950 | Return type: 951 | "int" 952 | 953 | abc 954 | x 955 | 956 | xyz 957 | something 958 | """, 959 | ) 960 | def func_with_definition_list() -> int: 961 | """Some text and then a definition list. 962 | 963 | abc 964 | x 965 | 966 | xyz 967 | something 968 | """ 969 | # See https://github.com/tox-dev/sphinx-autodoc-typehints/issues/302 970 | 971 | 972 | @expected( 973 | """\ 974 | mod.decorator_2(f) 975 | 976 | Run the decorated function with *asyncio.run*. 977 | 978 | Parameters: 979 | **f** ("Any") -- The function to wrap. 980 | 981 | Return type: 982 | "Any" 983 | 984 | -[ Examples ]- 985 | 986 | A 987 | """, 988 | ) 989 | def decorator_2(f: Any) -> Any: 990 | """Run the decorated function with `asyncio.run`. 991 | 992 | Parameters 993 | ---------- 994 | f 995 | The function to wrap. 996 | 997 | Examples 998 | -------- 999 | 1000 | .. code-block:: python 1001 | 1002 | A 1003 | """ 1004 | assert f is not None 1005 | 1006 | 1007 | @expected( 1008 | """ 1009 | class mod.ParamAndAttributeHaveSameName(blah) 1010 | 1011 | A Class 1012 | 1013 | Parameters: 1014 | **blah** ("CodeType") -- Description of parameter blah 1015 | 1016 | blah: "ModuleType" 1017 | 1018 | Description of attribute blah 1019 | 1020 | """, 1021 | ) 1022 | class ParamAndAttributeHaveSameName: 1023 | """ 1024 | A Class 1025 | 1026 | Parameters 1027 | ---------- 1028 | blah: 1029 | Description of parameter blah 1030 | """ 1031 | 1032 | def __init__(self, blah: CodeType) -> None: 1033 | pass 1034 | 1035 | blah: ModuleType 1036 | """Description of attribute blah""" 1037 | 1038 | 1039 | @expected( 1040 | """ 1041 | mod.napoleon_returns() 1042 | 1043 | A function. 1044 | 1045 | Return type: 1046 | "CodeType" 1047 | 1048 | Returns: 1049 | The info about the whatever. 1050 | """, 1051 | ) 1052 | def napoleon_returns() -> CodeType: 1053 | """ 1054 | A function. 1055 | 1056 | Returns 1057 | ------- 1058 | The info about the whatever. 1059 | """ 1060 | 1061 | 1062 | @expected( 1063 | """ 1064 | mod.google_docstrings(arg1, arg2) 1065 | 1066 | Summary line. 1067 | 1068 | Extended description of function. 1069 | 1070 | Parameters: 1071 | * **arg1** ("CodeType") -- Description of arg1 1072 | 1073 | * **arg2** ("ModuleType") -- Description of arg2 1074 | 1075 | Return type: 1076 | "CodeType" 1077 | 1078 | Returns: 1079 | Description of return value 1080 | 1081 | """, 1082 | ) 1083 | def google_docstrings(arg1: CodeType, arg2: ModuleType) -> CodeType: 1084 | """Summary line. 1085 | 1086 | Extended description of function. 1087 | 1088 | Args: 1089 | arg1: Description of arg1 1090 | arg2: Description of arg2 1091 | 1092 | Returns: 1093 | Description of return value 1094 | """ 1095 | 1096 | 1097 | @expected( 1098 | """ 1099 | mod.docstring_with_multiline_note_after_params(param) 1100 | 1101 | Do something. 1102 | 1103 | Parameters: 1104 | **param** ("int") -- A parameter. 1105 | 1106 | Return type: 1107 | "None" 1108 | 1109 | Note: 1110 | 1111 | Some notes. More notes 1112 | 1113 | """, 1114 | ) 1115 | def docstring_with_multiline_note_after_params(param: int) -> None: 1116 | """Do something. 1117 | 1118 | Args: 1119 | param: A parameter. 1120 | 1121 | Note: 1122 | 1123 | Some notes. 1124 | More notes 1125 | """ 1126 | 1127 | 1128 | @expected( 1129 | """ 1130 | mod.docstring_with_bullet_list_after_params(param) 1131 | 1132 | Do something. 1133 | 1134 | Parameters: 1135 | **param** ("int") -- A parameter. 1136 | 1137 | Return type: 1138 | "None" 1139 | 1140 | * A: B 1141 | 1142 | * C: D 1143 | 1144 | """, 1145 | ) 1146 | def docstring_with_bullet_list_after_params(param: int) -> None: 1147 | """Do something. 1148 | 1149 | Args: 1150 | param: A parameter. 1151 | 1152 | * A: B 1153 | * C: D 1154 | """ 1155 | 1156 | 1157 | @expected( 1158 | """ 1159 | mod.docstring_with_definition_list_after_params(param) 1160 | 1161 | Do something. 1162 | 1163 | Parameters: 1164 | **param** ("int") -- A parameter. 1165 | 1166 | Return type: 1167 | "None" 1168 | 1169 | Term 1170 | A description 1171 | 1172 | maybe multiple lines 1173 | 1174 | Next Term 1175 | Something about it 1176 | 1177 | """, 1178 | ) 1179 | def docstring_with_definition_list_after_params(param: int) -> None: 1180 | """Do something. 1181 | 1182 | Args: 1183 | param: A parameter. 1184 | 1185 | Term 1186 | A description 1187 | 1188 | maybe multiple lines 1189 | 1190 | Next Term 1191 | Something about it 1192 | """ 1193 | 1194 | 1195 | @expected( 1196 | """ 1197 | mod.docstring_with_enum_list_after_params(param) 1198 | 1199 | Do something. 1200 | 1201 | Parameters: 1202 | **param** ("int") -- A parameter. 1203 | 1204 | Return type: 1205 | "None" 1206 | 1207 | 1. A: B 1208 | 1209 | 2. C: D 1210 | 1211 | """, 1212 | ) 1213 | def docstring_with_enum_list_after_params(param: int) -> None: 1214 | """Do something. 1215 | 1216 | Args: 1217 | param: A parameter. 1218 | 1219 | 1. A: B 1220 | 2. C: D 1221 | """ 1222 | 1223 | 1224 | @warns(WarningInfo(regexp="Definition list ends without a blank line", type="docutils")) 1225 | @expected( 1226 | """ 1227 | mod.docstring_with_definition_list_after_params_no_blank_line(param) 1228 | 1229 | Do something. 1230 | 1231 | Parameters: 1232 | **param** ("int") -- A parameter. 1233 | 1234 | Return type: 1235 | "None" 1236 | 1237 | Term 1238 | A description 1239 | 1240 | maybe multiple lines 1241 | 1242 | Next Term 1243 | Something about it 1244 | 1245 | -[ Example ]- 1246 | """, 1247 | ) 1248 | def docstring_with_definition_list_after_params_no_blank_line(param: int) -> None: 1249 | """Do something. 1250 | 1251 | Args: 1252 | param: A parameter. 1253 | 1254 | Term 1255 | A description 1256 | 1257 | maybe multiple lines 1258 | 1259 | Next Term 1260 | Something about it 1261 | .. rubric:: Example 1262 | """ 1263 | 1264 | 1265 | @expected( 1266 | """ 1267 | mod.has_typevar(param) 1268 | 1269 | Do something. 1270 | 1271 | Parameters: 1272 | **param** ("TypeVar"("T")) -- A parameter. 1273 | 1274 | Return type: 1275 | "TypeVar"("T") 1276 | 1277 | """, 1278 | ) 1279 | def has_typevar(param: T) -> T: 1280 | """Do something. 1281 | 1282 | Args: 1283 | param: A parameter. 1284 | """ 1285 | return param 1286 | 1287 | 1288 | @expected( 1289 | """ 1290 | mod.has_newtype(param) 1291 | 1292 | Do something. 1293 | 1294 | Parameters: 1295 | **param** ("NewType"("W", "str")) -- A parameter. 1296 | 1297 | Return type: 1298 | "NewType"("W", "str") 1299 | 1300 | """, 1301 | ) 1302 | def has_newtype(param: W) -> W: 1303 | """Do something. 1304 | 1305 | Args: 1306 | param: A parameter. 1307 | """ 1308 | return param 1309 | 1310 | 1311 | AUTO_FUNCTION = ".. autofunction:: mod.{}" 1312 | AUTO_CLASS = """\ 1313 | .. autoclass:: mod.{} 1314 | :members: 1315 | """ 1316 | AUTO_EXCEPTION = """\ 1317 | .. autoexception:: mod.{} 1318 | :members: 1319 | """ 1320 | 1321 | 1322 | @expected( 1323 | """ 1324 | mod.typehints_use_signature(a: AsyncGenerator) -> AsyncGenerator 1325 | 1326 | Do something. 1327 | 1328 | Parameters: 1329 | **a** ("AsyncGenerator") -- blah 1330 | 1331 | Return type: 1332 | "AsyncGenerator" 1333 | 1334 | """, 1335 | typehints_use_signature=True, 1336 | typehints_use_signature_return=True, 1337 | ) 1338 | def typehints_use_signature(a: AsyncGenerator) -> AsyncGenerator: 1339 | """Do something. 1340 | 1341 | Args: 1342 | a: blah 1343 | """ 1344 | return a 1345 | 1346 | 1347 | @expected( 1348 | """ 1349 | mod.typehints_no_rtype_none() 1350 | 1351 | Do something. 1352 | 1353 | """, 1354 | typehints_document_rtype_none=False, 1355 | ) 1356 | def typehints_no_rtype_none() -> None: 1357 | """Do something.""" 1358 | 1359 | 1360 | prolog = """ 1361 | .. |test_node_start| replace:: {test_node_start} 1362 | """.format(test_node_start="test_start") 1363 | 1364 | 1365 | @expected( 1366 | """ 1367 | mod.docstring_with_multiline_note_after_params_prolog_replace(param) 1368 | 1369 | Do something. 1370 | 1371 | Parameters: 1372 | **param** ("int") -- A parameter. 1373 | 1374 | Return type: 1375 | "None" 1376 | 1377 | Note: 1378 | 1379 | Some notes. test_start More notes 1380 | 1381 | """, 1382 | rst_prolog=prolog, 1383 | ) 1384 | def docstring_with_multiline_note_after_params_prolog_replace(param: int) -> None: 1385 | """Do something. 1386 | 1387 | Args: 1388 | param: A parameter. 1389 | 1390 | Note: 1391 | 1392 | Some notes. |test_node_start| 1393 | More notes 1394 | """ 1395 | 1396 | 1397 | epilog = """ 1398 | .. |test_node_end| replace:: {test_node_end} 1399 | """.format(test_node_end="test_end") 1400 | 1401 | 1402 | @expected( 1403 | """ 1404 | mod.docstring_with_multiline_note_after_params_epilog_replace(param) 1405 | 1406 | Do something. 1407 | 1408 | Parameters: 1409 | **param** ("int") -- A parameter. 1410 | 1411 | Return type: 1412 | "None" 1413 | 1414 | Note: 1415 | 1416 | Some notes. test_end More notes 1417 | 1418 | """, 1419 | rst_epilog=epilog, 1420 | ) 1421 | def docstring_with_multiline_note_after_params_epilog_replace(param: int) -> None: 1422 | """Do something. 1423 | 1424 | Args: 1425 | param: A parameter. 1426 | 1427 | Note: 1428 | 1429 | Some notes. |test_node_end| 1430 | More notes 1431 | """ 1432 | 1433 | 1434 | @expected( 1435 | """ 1436 | mod.docstring_with_see_also() 1437 | 1438 | Test 1439 | 1440 | See also: more info at `_. 1441 | 1442 | Return type: 1443 | "str" 1444 | 1445 | """ 1446 | ) 1447 | def docstring_with_see_also() -> str: 1448 | """ 1449 | Test 1450 | 1451 | .. seealso:: more info at `_. 1452 | """ 1453 | return "" 1454 | 1455 | 1456 | @expected( 1457 | """ 1458 | mod.has_doctest1() 1459 | 1460 | Test that we place the return type correctly when the function has 1461 | a doctest. 1462 | 1463 | Return type: 1464 | "None" 1465 | 1466 | >>> this is a fake doctest 1467 | a 1468 | >>> more doctest 1469 | b 1470 | """ 1471 | ) 1472 | def has_doctest1() -> None: 1473 | r"""Test that we place the return type correctly when the function has a doctest. 1474 | 1475 | >>> this is a fake doctest 1476 | a 1477 | >>> more doctest 1478 | b 1479 | """ 1480 | 1481 | 1482 | Unformatted = TypeVar("Unformatted") 1483 | 1484 | 1485 | @warns(WarningInfo(regexp="cannot cache unpickleable configuration value: 'typehints_formatter'", type="config.cache")) 1486 | @expected( 1487 | """ 1488 | mod.typehints_formatter_applied_to_signature(param: Formatted) -> Formatted 1489 | 1490 | Do nothing 1491 | 1492 | Parameters: 1493 | **param** (Formatted) -- A parameter 1494 | 1495 | Return type: 1496 | Formatted 1497 | 1498 | Returns: 1499 | The return value 1500 | """, 1501 | typehints_use_signature=True, 1502 | typehints_use_signature_return=True, 1503 | typehints_formatter=lambda _, __=None: "Formatted", 1504 | ) 1505 | def typehints_formatter_applied_to_signature(param: Unformatted) -> Unformatted: 1506 | """ 1507 | Do nothing 1508 | 1509 | Args: 1510 | param: A parameter 1511 | 1512 | Returns: 1513 | The return value 1514 | """ 1515 | return param 1516 | 1517 | 1518 | # Config settings for each test run. 1519 | # Config Name: Sphinx Options as Dict. 1520 | configs = { 1521 | "default_conf": {}, 1522 | "prolog_conf": {"rst_prolog": prolog}, 1523 | "epilog_conf": { 1524 | "rst_epilog": epilog, 1525 | }, 1526 | "bothlog_conf": { 1527 | "rst_prolog": prolog, 1528 | "rst_epilog": epilog, 1529 | }, 1530 | } 1531 | 1532 | 1533 | @pytest.mark.parametrize("val", [x for x in globals().values() if hasattr(x, "EXPECTED")]) 1534 | @pytest.mark.parametrize("conf_run", ["default_conf", "prolog_conf", "epilog_conf", "bothlog_conf"]) 1535 | @pytest.mark.sphinx("text", testroot="integration") 1536 | def test_integration( 1537 | app: SphinxTestApp, status: StringIO, warning: StringIO, monkeypatch: pytest.MonkeyPatch, val: Any, conf_run: str 1538 | ) -> None: 1539 | if isclass(val) and issubclass(val, BaseException): 1540 | template = AUTO_EXCEPTION 1541 | elif isclass(val): 1542 | template = AUTO_CLASS 1543 | else: 1544 | template = AUTO_FUNCTION 1545 | 1546 | (Path(app.srcdir) / "index.rst").write_text(template.format(val.__name__)) 1547 | app.config.__dict__.update(configs[conf_run]) 1548 | app.config.__dict__.update(val.OPTIONS) 1549 | app.config.always_use_bars_union = True 1550 | monkeypatch.setitem(sys.modules, "mod", sys.modules[__name__]) 1551 | app.build() 1552 | assert "build succeeded" in status.getvalue() # Build succeeded 1553 | 1554 | warning_info: Union[WarningInfo, None] = getattr(val, "WARNING", None) 1555 | value = warning.getvalue().strip() 1556 | if warning_info: 1557 | warning_info.assert_warning(value) 1558 | else: 1559 | assert not value 1560 | 1561 | result = (Path(app.srcdir) / "_build/text/index.txt").read_text() 1562 | 1563 | expected = val.EXPECTED 1564 | try: 1565 | assert result.strip() == dedent(expected).strip() 1566 | except Exception: 1567 | indented = indent(f'"""\n{result}\n"""', " " * 4) 1568 | print(f"@expected(\n{indented}\n)\n") # noqa: T201 1569 | raise 1570 | -------------------------------------------------------------------------------- /tests/test_integration_autodoc_type_aliases.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from pathlib import Path 6 | from textwrap import dedent, indent 7 | from typing import TYPE_CHECKING, Any, Literal, NewType, TypeVar 8 | 9 | import pytest 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Callable 13 | from io import StringIO 14 | 15 | from sphinx.testing.util import SphinxTestApp 16 | 17 | T = TypeVar("T") 18 | W = NewType("W", str) 19 | 20 | 21 | def expected(expected: str, **options: dict[str, Any]) -> Callable[[T], T]: 22 | def dec(val: T) -> T: 23 | val.EXPECTED = expected 24 | val.OPTIONS = options 25 | return val 26 | 27 | return dec 28 | 29 | 30 | def warns(pattern: str) -> Callable[[T], T]: 31 | def dec(val: T) -> T: 32 | val.WARNING = pattern 33 | return val 34 | 35 | return dec 36 | 37 | 38 | ArrayLike = Literal["test"] 39 | 40 | 41 | class _SchemaMeta(type): # noqa: PLW1641 42 | def __eq__(cls, other: object) -> bool: 43 | return True 44 | 45 | 46 | class Schema(metaclass=_SchemaMeta): 47 | pass 48 | 49 | 50 | @expected( 51 | """ 52 | mod.f(s) 53 | 54 | Do something. 55 | 56 | Parameters: 57 | **s** ("Schema") -- Some schema. 58 | 59 | Return type: 60 | "Schema" 61 | """ 62 | ) 63 | def f(s: Schema) -> Schema: 64 | """ 65 | Do something. 66 | 67 | Args: 68 | s: Some schema. 69 | """ 70 | return s 71 | 72 | 73 | class AliasedClass: ... 74 | 75 | 76 | @expected( 77 | """ 78 | mod.g(s) 79 | 80 | Do something. 81 | 82 | Parameters: 83 | **s** ("Class Alias") -- Some schema. 84 | 85 | Return type: 86 | "Class Alias" 87 | """ 88 | ) 89 | def g(s: AliasedClass) -> AliasedClass: 90 | """ 91 | Do something. 92 | 93 | Args: 94 | s: Some schema. 95 | """ 96 | return s 97 | 98 | 99 | @expected( 100 | f"""\ 101 | mod.function(x, y) 102 | 103 | Function docstring. 104 | 105 | Parameters: 106 | * **x** ({'Array | "None"' if sys.version_info >= (3, 14) else '"Optional"[Array]'}) -- foo 107 | 108 | * **y** ("Schema") -- boo 109 | 110 | Returns: 111 | something 112 | 113 | Return type: 114 | bytes 115 | 116 | """, 117 | ) 118 | def function(x: ArrayLike | None, y: Schema) -> str: 119 | """ 120 | Function docstring. 121 | 122 | :param x: foo 123 | :param y: boo 124 | :return: something 125 | :rtype: bytes 126 | """ 127 | 128 | 129 | # Config settings for each test run. 130 | # Config Name: Sphinx Options as Dict. 131 | configs = {"default_conf": {"autodoc_type_aliases": {"ArrayLike": "Array", "AliasedClass": '"Class Alias"'}}} 132 | 133 | 134 | @pytest.mark.parametrize("val", [x for x in globals().values() if hasattr(x, "EXPECTED")]) 135 | @pytest.mark.parametrize("conf_run", list(configs.keys())) 136 | @pytest.mark.sphinx("text", testroot="integration") 137 | def test_integration( 138 | app: SphinxTestApp, status: StringIO, warning: StringIO, monkeypatch: pytest.MonkeyPatch, val: Any, conf_run: str 139 | ) -> None: 140 | template = ".. autofunction:: mod.{}" 141 | 142 | (Path(app.srcdir) / "index.rst").write_text(template.format(val.__name__)) 143 | app.config.__dict__.update(configs[conf_run]) 144 | app.config.__dict__.update(val.OPTIONS) 145 | monkeypatch.setitem(sys.modules, "mod", sys.modules[__name__]) 146 | app.build() 147 | assert "build succeeded" in status.getvalue() # Build succeeded 148 | 149 | regexp = getattr(val, "WARNING", None) 150 | value = warning.getvalue().strip() 151 | if regexp: 152 | msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}" 153 | assert re.search(regexp, value), msg 154 | elif not re.search(r"WARNING: Inline strong start-string without end-string.", value): 155 | assert not value 156 | 157 | result = (Path(app.srcdir) / "_build/text/index.txt").read_text() 158 | 159 | expected = val.EXPECTED 160 | try: 161 | assert result.strip() == dedent(expected).strip() 162 | except Exception: 163 | indented = indent(f'"""\n{result}\n"""', " " * 4) 164 | print(f"@expected(\n{indented}\n)\n") # noqa: T201 165 | raise 166 | -------------------------------------------------------------------------------- /tests/test_integration_issue_384.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from pathlib import Path 6 | from textwrap import dedent, indent 7 | from typing import TYPE_CHECKING, Any, NewType, TypeVar 8 | 9 | import pytest 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Callable 13 | from io import StringIO 14 | 15 | from sphinx.testing.util import SphinxTestApp 16 | 17 | T = TypeVar("T") 18 | W = NewType("W", str) 19 | 20 | 21 | def expected(expected: str, **options: dict[str, Any]) -> Callable[[T], T]: 22 | def dec(val: T) -> T: 23 | val.EXPECTED = expected 24 | val.OPTIONS = options 25 | return val 26 | 27 | return dec 28 | 29 | 30 | def warns(pattern: str) -> Callable[[T], T]: 31 | def dec(val: T) -> T: 32 | val.WARNING = pattern 33 | return val 34 | 35 | return dec 36 | 37 | 38 | @expected( 39 | """\ 40 | mod.function(x=5, y=10, z=15) 41 | 42 | Function docstring. 43 | 44 | Parameters: 45 | * **x** ("int") -- optional specifier line 2 (default: "5") 46 | 47 | * **y** ("int") -- 48 | 49 | another optional line 4 50 | 51 | second paragraph for y (default: "10") 52 | 53 | * **z** ("int") -- yet another optional s line 6 (default: "15") 54 | 55 | Returns: 56 | something 57 | 58 | Return type: 59 | bytes 60 | 61 | """, 62 | ) 63 | def function(x: int = 5, y: int = 10, z: int = 15) -> str: 64 | """ 65 | Function docstring. 66 | 67 | :param x: optional specifier 68 | line 2 69 | :param y: another optional 70 | line 4 71 | 72 | second paragraph for y 73 | 74 | :param z: yet another optional s 75 | line 6 76 | 77 | :return: something 78 | :rtype: bytes 79 | """ 80 | 81 | 82 | # Config settings for each test run. 83 | # Config Name: Sphinx Options as Dict. 84 | configs = {"default_conf": {"typehints_defaults": "braces-after"}} 85 | 86 | 87 | @pytest.mark.parametrize("val", [x for x in globals().values() if hasattr(x, "EXPECTED")]) 88 | @pytest.mark.parametrize("conf_run", list(configs.keys())) 89 | @pytest.mark.sphinx("text", testroot="integration") 90 | def test_integration( 91 | app: SphinxTestApp, status: StringIO, warning: StringIO, monkeypatch: pytest.MonkeyPatch, val: Any, conf_run: str 92 | ) -> None: 93 | template = ".. autofunction:: mod.{}" 94 | 95 | (Path(app.srcdir) / "index.rst").write_text(template.format(val.__name__)) 96 | app.config.__dict__.update(configs[conf_run]) 97 | app.config.__dict__.update(val.OPTIONS) 98 | monkeypatch.setitem(sys.modules, "mod", sys.modules[__name__]) 99 | app.build() 100 | assert "build succeeded" in status.getvalue() # Build succeeded 101 | 102 | regexp = getattr(val, "WARNING", None) 103 | value = warning.getvalue().strip() 104 | if regexp: 105 | msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}" 106 | assert re.search(regexp, value), msg 107 | else: 108 | assert not value 109 | 110 | result = (Path(app.srcdir) / "_build/text/index.txt").read_text() 111 | 112 | expected = val.EXPECTED 113 | try: 114 | assert result.strip() == dedent(expected).strip() 115 | except Exception: 116 | indented = indent(f'"""\n{result}\n"""', " " * 4) 117 | print(f"@expected(\n{indented}\n)\n") # noqa: T201 118 | raise 119 | -------------------------------------------------------------------------------- /tests/test_sphinx_autodoc_typehints.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | import types 6 | import typing 7 | from collections.abc import Callable, Mapping 8 | from functools import cmp_to_key 9 | from io import StringIO 10 | from pathlib import Path 11 | from textwrap import dedent, indent 12 | from types import EllipsisType, FrameType, FunctionType, ModuleType, NotImplementedType, TracebackType 13 | from typing import ( # noqa: UP035 14 | IO, 15 | Any, 16 | AnyStr, 17 | Dict, 18 | Generic, 19 | List, 20 | NewType, 21 | Optional, 22 | Tuple, 23 | Type, 24 | TypeVar, 25 | Union, 26 | ) 27 | from unittest.mock import create_autospec, patch 28 | 29 | import pytest 30 | import typing_extensions 31 | from sphinx.application import Sphinx 32 | from sphinx.config import Config 33 | 34 | from sphinx_autodoc_typehints import ( 35 | _resolve_type_guarded_imports, 36 | backfill_type_hints, 37 | format_annotation, 38 | get_annotation_args, 39 | get_annotation_class_name, 40 | get_annotation_module, 41 | normalize_source_lines, 42 | process_docstring, 43 | ) 44 | 45 | if typing.TYPE_CHECKING: 46 | from sphinx.testing.util import SphinxTestApp 47 | from sphobjinv import Inventory 48 | 49 | T = TypeVar("T") 50 | U_co = TypeVar("U_co", covariant=True) 51 | V_contra = TypeVar("V_contra", contravariant=True) 52 | X = TypeVar("X", str, int) 53 | Y = TypeVar("Y", bound=str) 54 | Z = TypeVar("Z", bound="A") 55 | S = TypeVar("S", bound="miss") # type: ignore[name-defined] # miss not defined on purpose # noqa: F821 56 | W = NewType("W", str) 57 | P = typing_extensions.ParamSpec("P") 58 | P_args = P.args # type:ignore[attr-defined] 59 | P_kwargs = P.kwargs # type:ignore[attr-defined] 60 | P_co = typing_extensions.ParamSpec("P_co", covariant=True) # type: ignore[misc] 61 | P_contra = typing_extensions.ParamSpec("P_contra", contravariant=True) # type: ignore[misc] 62 | P_bound = typing_extensions.ParamSpec("P_bound", bound=str) # type: ignore[misc] 63 | 64 | # Mypy does not support recursive type aliases, but 65 | # other type checkers do. 66 | RecList = Union[int, List["RecList"]] 67 | MutualRecA = Union[bool, List["MutualRecB"]] 68 | MutualRecB = Union[str, List["MutualRecA"]] 69 | 70 | 71 | class A: 72 | def get_type(self) -> type: 73 | return type(self) 74 | 75 | class Inner: ... 76 | 77 | 78 | class B(Generic[T]): 79 | name = "Foo" # This is set to make sure the correct class name ("B") is picked up 80 | 81 | 82 | class C(B[str]): ... 83 | 84 | 85 | class D(typing_extensions.Protocol): ... 86 | 87 | 88 | class E(typing_extensions.Protocol[T]): # type: ignore[misc] 89 | ... 90 | 91 | 92 | class Slotted: 93 | __slots__ = () 94 | 95 | 96 | class Metaclass(type): ... 97 | 98 | 99 | class HintedMethods: 100 | @classmethod 101 | def from_magic(cls) -> typing_extensions.Self: # type: ignore[empty-body] 102 | ... 103 | 104 | def method(self) -> typing_extensions.Self: # type: ignore[empty-body] 105 | ... 106 | 107 | 108 | PY312_PLUS = sys.version_info >= (3, 12) 109 | 110 | 111 | @pytest.mark.parametrize( 112 | ("annotation", "module", "class_name", "args"), 113 | [ 114 | pytest.param(str, "builtins", "str", (), id="str"), 115 | pytest.param(None, "builtins", "None", (), id="None"), 116 | pytest.param(ModuleType, "types", "ModuleType", (), id="ModuleType"), 117 | pytest.param(FunctionType, "types", "FunctionType", (), id="FunctionType"), 118 | pytest.param(types.CodeType, "types", "CodeType", (), id="CodeType"), 119 | pytest.param(types.CoroutineType, "types", "CoroutineType", (), id="CoroutineType"), 120 | pytest.param(Any, "typing", "Any", (), id="Any"), 121 | pytest.param(AnyStr, "typing", "AnyStr", (), id="AnyStr"), 122 | pytest.param(Dict, "typing", "Dict", (), id="Dict"), 123 | pytest.param(Dict[str, int], "typing", "Dict", (str, int), id="Dict_parametrized"), 124 | pytest.param(Dict[T, int], "typing", "Dict", (T, int), id="Dict_typevar"), # type: ignore[valid-type] 125 | pytest.param(Tuple, "typing", "Tuple", (), id="Tuple"), 126 | pytest.param(Tuple[str, int], "typing", "Tuple", (str, int), id="Tuple_parametrized"), 127 | pytest.param(Union[str, int], "typing", "Union", (str, int), id="Union"), 128 | pytest.param(Callable, "collections.abc", "Callable", (), id="Callable"), 129 | pytest.param(Callable[..., str], "collections.abc", "Callable", (..., str), id="Callable_returntype"), 130 | pytest.param( 131 | Callable[[int, str], str], "collections.abc", "Callable", (int, str, str), id="Callable_all_types" 132 | ), 133 | pytest.param( 134 | Callable[[int, str], str], 135 | "collections.abc", 136 | "Callable", 137 | (int, str, str), 138 | id="collections.abc.Callable_all_types", 139 | ), 140 | pytest.param(re.Pattern, "re", "Pattern", (), id="Pattern"), 141 | pytest.param(re.Pattern[str], "re", "Pattern", (str,), id="Pattern_parametrized"), 142 | pytest.param(re.Match, "re", "Match", (), id="Match"), 143 | pytest.param(re.Match[str], "re", "Match", (str,), id="Match_parametrized"), 144 | pytest.param(IO, "typing", "IO", (), id="IO"), 145 | pytest.param(W, "typing", "NewType", (str,), id="W"), 146 | pytest.param(P, "typing", "ParamSpec", (), id="P"), 147 | pytest.param(P_args, "typing", "ParamSpecArgs", (), id="P_args"), 148 | pytest.param(P_kwargs, "typing", "ParamSpecKwargs", (), id="P_kwargs"), 149 | pytest.param(Metaclass, __name__, "Metaclass", (), id="Metaclass"), 150 | pytest.param(Slotted, __name__, "Slotted", (), id="Slotted"), 151 | pytest.param(A, __name__, "A", (), id="A"), 152 | pytest.param(B, __name__, "B", (), id="B"), 153 | pytest.param(C, __name__, "C", (), id="C"), 154 | pytest.param(D, __name__, "D", (), id="D"), 155 | pytest.param(E, __name__, "E", (), id="E"), 156 | pytest.param(E[int], __name__, "E", (int,), id="E_parametrized"), 157 | pytest.param(A.Inner, __name__, "A.Inner", (), id="Inner"), 158 | ], 159 | ) 160 | def test_parse_annotation(annotation: Any, module: str, class_name: str, args: tuple[Any, ...]) -> None: 161 | got_mod = get_annotation_module(annotation) 162 | got_cls = get_annotation_class_name(annotation, module) 163 | got_args = get_annotation_args(annotation, module, class_name) 164 | assert (got_mod, got_cls, got_args) == (module, class_name, args) 165 | 166 | 167 | _CASES = [ 168 | pytest.param(str, ":py:class:`str`", id="str"), 169 | pytest.param(int, ":py:class:`int`", id="int"), 170 | pytest.param(StringIO, ":py:class:`~io.StringIO`", id="StringIO"), 171 | pytest.param(EllipsisType, ":py:data:`~types.EllipsisType`", id="EllipsisType"), 172 | pytest.param(FunctionType, ":py:data:`~types.FunctionType`", id="FunctionType"), 173 | pytest.param(FrameType, ":py:data:`~types.FrameType`", id="FrameType"), 174 | pytest.param(ModuleType, ":py:class:`~types.ModuleType`", id="ModuleType"), 175 | pytest.param(NotImplementedType, ":py:data:`~types.NotImplementedType`", id="NotImplementedType"), 176 | pytest.param(TracebackType, ":py:class:`~types.TracebackType`", id="TracebackType"), 177 | pytest.param(type(None), ":py:obj:`None`", id="type None"), 178 | pytest.param(type, ":py:class:`type`", id="type"), 179 | pytest.param(Callable, ":py:class:`~collections.abc.Callable`", id="abc-Callable"), 180 | pytest.param(Type, ":py:class:`~typing.Type`", id="typing-Type"), 181 | pytest.param(Type[A], rf":py:class:`~typing.Type`\ \[:py:class:`~{__name__}.A`]", id="typing-A"), 182 | pytest.param(Any, ":py:data:`~typing.Any`", id="Any"), 183 | pytest.param(AnyStr, ":py:data:`~typing.AnyStr`", id="AnyStr"), 184 | pytest.param(Generic[T], r":py:class:`~typing.Generic`\ \[:py:class:`~typing.TypeVar`\ \(``T``)]", id="Generic"), # type: ignore[index] 185 | pytest.param(Mapping, ":py:class:`~collections.abc.Mapping`", id="Mapping"), 186 | pytest.param( 187 | Mapping[T, int], # type: ignore[valid-type] 188 | r":py:class:`~collections.abc.Mapping`\ \[:py:class:`~typing.TypeVar`\ \(``T``), :py:class:`int`]", 189 | id="Mapping-T-int", 190 | ), 191 | pytest.param( 192 | Mapping[str, V_contra], # type: ignore[valid-type] 193 | r":py:class:`~collections.abc.Mapping`\ \[:py:class:`str`, :py:class:`~typing.TypeVar`\ \(" 194 | "``V_contra``, contravariant=True)]", 195 | id="Mapping-T-int-contra", 196 | ), 197 | pytest.param( 198 | Mapping[T, U_co], # type: ignore[valid-type] 199 | r":py:class:`~collections.abc.Mapping`\ \[:py:class:`~typing.TypeVar`\ \(``T``), " 200 | r":py:class:`~typing.TypeVar`\ \(``U_co``, covariant=True)]", 201 | id="Mapping-T-int-co", 202 | ), 203 | pytest.param( 204 | Mapping[str, bool], 205 | r":py:class:`~collections.abc.Mapping`\ \[:py:class:`str`, :py:class:`bool`]", 206 | id="Mapping-str-bool", 207 | ), 208 | pytest.param(Dict, ":py:class:`~typing.Dict`", id="Dict"), 209 | pytest.param( 210 | Dict[T, int], # type: ignore[valid-type] 211 | r":py:class:`~typing.Dict`\ \[:py:class:`~typing.TypeVar`\ \(``T``), :py:class:`int`]", 212 | id="Dict-T-int", 213 | ), 214 | pytest.param( 215 | Dict[str, V_contra], # type: ignore[valid-type] 216 | r":py:class:`~typing.Dict`\ \[:py:class:`str`, :py:class:`~typing.TypeVar`\ \(``V_contra``, " 217 | r"contravariant=True)]", 218 | id="Dict-T-int-contra", 219 | ), 220 | pytest.param( 221 | Dict[T, U_co], # type: ignore[valid-type] 222 | r":py:class:`~typing.Dict`\ \[:py:class:`~typing.TypeVar`\ \(``T``)," 223 | r" :py:class:`~typing.TypeVar`\ \(``U_co``, covariant=True)]", 224 | id="Dict-T-int-co", 225 | ), 226 | pytest.param( 227 | Dict[str, bool], 228 | r":py:class:`~typing.Dict`\ \[:py:class:`str`, :py:class:`bool`]", 229 | id="Dict-str-bool", 230 | ), 231 | pytest.param(Tuple, ":py:data:`~typing.Tuple`", id="Tuple"), 232 | pytest.param( 233 | Tuple[str, bool], 234 | r":py:data:`~typing.Tuple`\ \[:py:class:`str`, :py:class:`bool`]", 235 | id="Tuple-str-bool", 236 | ), 237 | pytest.param( 238 | Tuple[int, int, int], 239 | r":py:data:`~typing.Tuple`\ \[:py:class:`int`, :py:class:`int`, :py:class:`int`]", 240 | id="Tuple-int-int-int", 241 | ), 242 | pytest.param( 243 | Tuple[str, ...], 244 | r":py:data:`~typing.Tuple`\ \[:py:class:`str`, :py:data:`...`]", 245 | id="Tuple-str-Ellipsis", 246 | ), 247 | pytest.param(Union, f":py:{'class' if sys.version_info >= (3, 14) else 'data'}:`~typing.Union`", id="Union"), 248 | pytest.param( 249 | types.UnionType, f":py:{'class' if sys.version_info >= (3, 14) else 'data'}:`~typing.Union`", id="UnionType" 250 | ), 251 | pytest.param( 252 | Union[str, bool], 253 | ":py:class:`str` | :py:class:`bool`" 254 | if sys.version_info >= (3, 14) 255 | else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`]", 256 | id="Union-str-bool", 257 | ), 258 | pytest.param( 259 | Union[str, bool, None], 260 | ":py:class:`str` | :py:class:`bool` | :py:obj:`None`" 261 | if sys.version_info >= (3, 14) 262 | else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`, :py:obj:`None`]", 263 | id="Union-str-bool-None", 264 | ), 265 | pytest.param( 266 | Union[str, Any], 267 | ":py:class:`str` | :py:data:`~typing.Any`" 268 | if sys.version_info >= (3, 14) 269 | else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:data:`~typing.Any`]", 270 | id="Union-str-Any", 271 | ), 272 | pytest.param( 273 | Optional[str], 274 | ":py:class:`str` | :py:obj:`None`" 275 | if sys.version_info >= (3, 14) 276 | else r":py:data:`~typing.Optional`\ \[:py:class:`str`]", 277 | id="Optional-str", 278 | ), 279 | pytest.param( 280 | Union[str, None], 281 | ":py:class:`str` | :py:obj:`None`" 282 | if sys.version_info >= (3, 14) 283 | else r":py:data:`~typing.Optional`\ \[:py:class:`str`]", 284 | id="Optional-str-None", 285 | ), 286 | pytest.param( 287 | type[T] | types.UnionType, 288 | ":py:class:`type`\\ \\[:py:class:`~typing.TypeVar`\\ \\(``T``)] | " 289 | f":py:{'class' if sys.version_info >= (3, 14) else 'data'}:`~typing.Union`", 290 | id="typevar union bar uniontype", 291 | ), 292 | pytest.param( 293 | Optional[str | bool], 294 | ":py:class:`str` | :py:class:`bool` | :py:obj:`None`" 295 | if sys.version_info >= (3, 14) 296 | else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`, :py:obj:`None`]", 297 | id="Optional-Union-str-bool", 298 | ), 299 | pytest.param( 300 | RecList, 301 | ":py:class:`int` | :py:class:`~typing.List`\\ \\[RecList]" 302 | if sys.version_info >= (3, 14) 303 | else r":py:data:`~typing.Union`\ \[:py:class:`int`, :py:class:`~typing.List`\ \[RecList]]", 304 | id="RecList", 305 | ), 306 | pytest.param( 307 | MutualRecA, 308 | ":py:class:`bool` | :py:class:`~typing.List`\\ \\[MutualRecB]" 309 | if sys.version_info >= (3, 14) 310 | else r":py:data:`~typing.Union`\ \[:py:class:`bool`, :py:class:`~typing.List`\ \[MutualRecB]]", 311 | id="MutualRecA", 312 | ), 313 | pytest.param(Callable, ":py:class:`~collections.abc.Callable`", id="Callable"), 314 | pytest.param( 315 | Callable[..., int], 316 | r":py:class:`~collections.abc.Callable`\ \[:py:data:`...`, :py:class:`int`]", 317 | id="Callable-Ellipsis-int", 318 | ), 319 | pytest.param( 320 | Callable[[int], int], 321 | r":py:class:`~collections.abc.Callable`\ \[\[:py:class:`int`], :py:class:`int`]", 322 | id="Callable-int-int", 323 | ), 324 | pytest.param( 325 | Callable[[int, str], bool], 326 | r":py:class:`~collections.abc.Callable`\ \[\[:py:class:`int`, :py:class:`str`], :py:class:`bool`]", 327 | id="Callable-int-str-bool", 328 | ), 329 | pytest.param( 330 | Callable[[int, str], None], 331 | r":py:class:`~collections.abc.Callable`\ \[\[:py:class:`int`, :py:class:`str`], :py:obj:`None`]", 332 | id="Callable-int-str", 333 | ), 334 | pytest.param( 335 | Callable[[T], T], 336 | r":py:class:`~collections.abc.Callable`\ \[\[:py:class:`~typing.TypeVar`\ \(``T``)]," 337 | r" :py:class:`~typing.TypeVar`\ \(``T``)]", 338 | id="Callable-T-T", 339 | ), 340 | pytest.param( 341 | Callable[[int, str], bool], 342 | r":py:class:`~collections.abc.Callable`\ \[\[:py:class:`int`, :py:class:`str`], :py:class:`bool`]", 343 | id="Callable-int-str-bool", 344 | ), 345 | pytest.param(re.Pattern, ":py:class:`~re.Pattern`", id="Pattern"), 346 | pytest.param(re.Pattern[str], r":py:class:`~re.Pattern`\ \[:py:class:`str`]", id="Pattern-str"), 347 | pytest.param(IO, ":py:class:`~typing.IO`", id="IO"), 348 | pytest.param(IO[str], r":py:class:`~typing.IO`\ \[:py:class:`str`]", id="IO-str"), 349 | pytest.param(Metaclass, f":py:class:`~{__name__}.Metaclass`", id="Metaclass"), 350 | pytest.param(A, f":py:class:`~{__name__}.A`", id="A"), 351 | pytest.param(B, f":py:class:`~{__name__}.B`", id="B"), 352 | pytest.param(B[int], rf":py:class:`~{__name__}.B`\ \[:py:class:`int`]", id="B-int"), 353 | pytest.param(C, f":py:class:`~{__name__}.C`", id="C"), 354 | pytest.param(D, f":py:class:`~{__name__}.D`", id="D"), 355 | pytest.param(E, f":py:class:`~{__name__}.E`", id="E"), 356 | pytest.param(E[int], rf":py:class:`~{__name__}.E`\ \[:py:class:`int`]", id="E-int"), 357 | pytest.param(W, r":py:class:`~typing.NewType`\ \(``W``, :py:class:`str`)", id="W"), 358 | pytest.param(T, r":py:class:`~typing.TypeVar`\ \(``T``)", id="T"), 359 | pytest.param(U_co, r":py:class:`~typing.TypeVar`\ \(``U_co``, covariant=True)", id="U-co"), 360 | pytest.param(V_contra, r":py:class:`~typing.TypeVar`\ \(``V_contra``, contravariant=True)", id="V-contra"), 361 | pytest.param(X, r":py:class:`~typing.TypeVar`\ \(``X``, :py:class:`str`, :py:class:`int`)", id="X"), 362 | pytest.param(Y, r":py:class:`~typing.TypeVar`\ \(``Y``, bound= :py:class:`str`)", id="Y"), 363 | pytest.param(Z, r":py:class:`~typing.TypeVar`\ \(``Z``, bound= A)", id="Z"), 364 | pytest.param(S, r":py:class:`~typing.TypeVar`\ \(``S``, bound= miss)", id="S"), 365 | # ParamSpec should behave like TypeVar, except for missing constraints 366 | pytest.param( 367 | P, rf":py:class:`~typing.ParamSpec`\ \(``P``{', bound= :py:obj:`None`' if PY312_PLUS else ''})", id="P" 368 | ), 369 | pytest.param( 370 | P_co, 371 | rf":py:class:`~typing.ParamSpec`\ \(``P_co``{', bound= :py:obj:`None`' if PY312_PLUS else ''}, covariant=True)", 372 | id="P_co", 373 | ), 374 | pytest.param( 375 | P_contra, 376 | rf":py:class:`~typing.ParamSpec`\ \(``P_contra``{', bound= :py:obj:`None`' if PY312_PLUS else ''}" 377 | ", contravariant=True)", 378 | id="P-contra", 379 | ), 380 | pytest.param(P_bound, r":py:class:`~typing.ParamSpec`\ \(``P_bound``, bound= :py:class:`str`)", id="P-bound"), 381 | # ## These test for correct internal tuple rendering, even if not all are valid Tuple types 382 | # Zero-length tuple remains 383 | pytest.param(Tuple[()], ":py:data:`~typing.Tuple`", id="Tuple-p"), 384 | # Internal single tuple with simple types is flattened in the output 385 | pytest.param(Tuple[int,], r":py:data:`~typing.Tuple`\ \[:py:class:`int`]", id="Tuple-p-int"), 386 | pytest.param( 387 | Tuple[int, int], 388 | r":py:data:`~typing.Tuple`\ \[:py:class:`int`, :py:class:`int`]", 389 | id="Tuple-p-int-int", 390 | ), 391 | # Ellipsis in single tuple also gets flattened 392 | pytest.param( 393 | Tuple[int, ...], 394 | r":py:data:`~typing.Tuple`\ \[:py:class:`int`, :py:data:`...`]", 395 | id="Tuple-p-Ellipsis", 396 | ), 397 | ] 398 | 399 | 400 | @pytest.mark.parametrize(("annotation", "expected_result"), _CASES) 401 | def test_format_annotation(inv: Inventory, annotation: Any, expected_result: str) -> None: 402 | conf = create_autospec(Config, _annotation_globals=globals(), always_use_bars_union=False) 403 | result = format_annotation(annotation, conf) 404 | assert result == expected_result 405 | 406 | # Test with the "simplify_optional_unions" flag turned off: 407 | if re.match(r"^:py:data:`~typing\.Union`\\\[.*``None``.*]", expected_result): 408 | # strip None - argument and copy string to avoid conflicts with 409 | # subsequent tests 410 | expected_result_not_simplified = expected_result.replace(", ``None``", "") 411 | # encapsulate Union in typing.Optional 412 | expected_result_not_simplified += ":py:data:`~typing.Optional`\\ \\[" 413 | expected_result_not_simplified += "]" 414 | conf = create_autospec( 415 | Config, 416 | simplify_optional_unions=False, 417 | _annotation_globals=globals(), 418 | always_use_bars_union=False, 419 | ) 420 | assert format_annotation(annotation, conf) == expected_result_not_simplified 421 | 422 | # Test with the "fully_qualified" flag turned on 423 | if "typing" in expected_result_not_simplified: 424 | expected_result_not_simplified = expected_result_not_simplified.replace("~typing", "typing") 425 | conf = create_autospec( 426 | Config, 427 | typehints_fully_qualified=True, 428 | simplify_optional_unions=False, 429 | _annotation_globals=globals(), 430 | ) 431 | assert format_annotation(annotation, conf) == expected_result_not_simplified 432 | 433 | # Test with the "fully_qualified" flag turned on 434 | if "typing" in expected_result or __name__ in expected_result: 435 | expected_result = expected_result.replace("~typing", "typing") 436 | expected_result = expected_result.replace("~collections.abc", "collections.abc") 437 | expected_result = expected_result.replace("~numpy", "numpy") 438 | expected_result = expected_result.replace("~" + __name__, __name__) 439 | conf = create_autospec( 440 | Config, 441 | typehints_fully_qualified=True, 442 | _annotation_globals=globals(), 443 | always_use_bars_union=False, 444 | ) 445 | assert format_annotation(annotation, conf) == expected_result 446 | 447 | # Test for the correct role (class vs data) using the official Sphinx inventory 448 | if ( 449 | result.count(":py:") == 1 450 | and ("typing" in result or "types" in result) 451 | and (match := re.match(r"^:py:(?Pclass|data|func):`~(?P[^`]+)`", result)) 452 | ): 453 | name = match.group("name") 454 | expected_role = next((o.role for o in inv.objects if o.name == name), None) 455 | if expected_role and expected_role == "function": 456 | expected_role = "func" 457 | assert match.group("role") == expected_role 458 | 459 | 460 | @pytest.mark.parametrize( 461 | ("annotation", "expected_result"), 462 | [ 463 | ("int | float", ":py:class:`int` | :py:class:`float`"), 464 | ("int | float | None", ":py:class:`int` | :py:class:`float` | :py:obj:`None`"), 465 | ("Union[int, float]", ":py:class:`int` | :py:class:`float`"), 466 | ("Union[int, float, None]", ":py:class:`int` | :py:class:`float` | :py:obj:`None`"), 467 | ("Optional[int | float]", ":py:class:`int` | :py:class:`float` | :py:obj:`None`"), 468 | ("Optional[Union[int, float]]", ":py:class:`int` | :py:class:`float` | :py:obj:`None`"), 469 | ("Union[int | float, str]", ":py:class:`int` | :py:class:`float` | :py:class:`str`"), 470 | ("Union[int, float] | str", ":py:class:`int` | :py:class:`float` | :py:class:`str`"), 471 | ], 472 | ) 473 | def test_always_use_bars_union(annotation: str, expected_result: str) -> None: 474 | conf = create_autospec(Config, always_use_bars_union=True) 475 | result = format_annotation(eval(annotation), conf) # noqa: S307 476 | assert result == expected_result 477 | 478 | 479 | @pytest.mark.parametrize("library", [typing, typing_extensions], ids=["typing", "typing_extensions"]) 480 | @pytest.mark.parametrize( 481 | ("annotation", "params", "expected_result"), 482 | [ 483 | pytest.param("ClassVar", int, ":py:data:`~typing.ClassVar`\\ \\[:py:class:`int`]", id="ClassVar"), 484 | pytest.param("NoReturn", None, ":py:data:`~typing.NoReturn`", id="NoReturn"), 485 | pytest.param("Literal", ("a", 1), ":py:data:`~typing.Literal`\\ \\[``'a'``, ``1``]", id="Literal"), 486 | pytest.param("Type", None, ":py:class:`~typing.Type`", id="Type-none"), 487 | pytest.param("Type", (A,), rf":py:class:`~typing.Type`\ \[:py:class:`~{__name__}.A`]", id="Type-A"), 488 | ], 489 | ) 490 | def test_format_annotation_both_libs(library: ModuleType, annotation: str, params: Any, expected_result: str) -> None: 491 | try: 492 | annotation_cls = getattr(library, annotation) 493 | except AttributeError: 494 | pytest.skip(f"{annotation} not available in the {library.__name__} module") 495 | return # pragma: no cover 496 | 497 | ann = annotation_cls if params is None else annotation_cls[params] 498 | result = format_annotation(ann, create_autospec(Config)) 499 | assert result == expected_result 500 | 501 | 502 | def test_process_docstring_slot_wrapper() -> None: 503 | lines: list[str] = [] 504 | config = create_autospec( 505 | Config, 506 | typehints_fully_qualified=False, 507 | simplify_optional_unions=False, 508 | typehints_formatter=None, 509 | autodoc_mock_imports=[], 510 | ) 511 | app: Sphinx = create_autospec(Sphinx, config=config) 512 | process_docstring(app, "class", "SlotWrapper", Slotted, None, lines) 513 | assert not lines 514 | 515 | 516 | def set_python_path() -> None: 517 | test_path = Path(__file__).parent 518 | # Add test directory to sys.path to allow imports of dummy module. 519 | if str(test_path) not in sys.path: 520 | sys.path.insert(0, str(test_path)) 521 | 522 | 523 | @pytest.mark.parametrize("always_document_param_types", [True, False], ids=["doc_param_type", "no_doc_param_type"]) 524 | @pytest.mark.sphinx("text", testroot="dummy") 525 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 526 | def test_always_document_param_types( 527 | app: SphinxTestApp, 528 | status: StringIO, 529 | warning: StringIO, 530 | always_document_param_types: bool, 531 | ) -> None: 532 | set_python_path() 533 | 534 | app.config.always_document_param_types = always_document_param_types # create flag 535 | app.config.autodoc_mock_imports = ["mailbox"] # create flag 536 | 537 | # Prevent "document isn't included in any toctree" warnings 538 | for f in Path(app.srcdir).glob("*.rst"): 539 | f.unlink() 540 | (Path(app.srcdir) / "index.rst").write_text( 541 | dedent( 542 | """ 543 | .. autofunction:: dummy_module.undocumented_function 544 | 545 | .. autoclass:: dummy_module.DataClass 546 | :undoc-members: 547 | :special-members: __init__ 548 | """, 549 | ), 550 | ) 551 | 552 | app.build() 553 | 554 | assert "build succeeded" in status.getvalue() # Build succeeded 555 | assert not warning.getvalue().strip() 556 | 557 | format_args = {} 558 | for indentation_level in range(2): 559 | key = f"undoc_params_{indentation_level}" 560 | if always_document_param_types: 561 | format_args[key] = indent('\n\n Parameters:\n **x** ("int")', " " * indentation_level) 562 | else: 563 | format_args[key] = "" 564 | 565 | contents = (Path(app.srcdir) / "_build/text/index.txt").read_text() 566 | expected_contents = """\ 567 | dummy_module.undocumented_function(x) 568 | 569 | Hi{undoc_params_0} 570 | 571 | Return type: 572 | "str" 573 | 574 | class dummy_module.DataClass(x) 575 | 576 | Class docstring.{undoc_params_0} 577 | 578 | __init__(x){undoc_params_1} 579 | """ 580 | expected_contents = dedent(expected_contents).format(**format_args) 581 | assert contents == expected_contents 582 | 583 | 584 | @pytest.mark.sphinx("text", testroot="dummy") 585 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 586 | def test_sphinx_output_future_annotations(app: SphinxTestApp, status: StringIO) -> None: 587 | set_python_path() 588 | 589 | app.config.master_doc = "future_annotations" # create flag 590 | app.build() 591 | 592 | assert "build succeeded" in status.getvalue() # Build succeeded 593 | 594 | contents = (Path(app.srcdir) / "_build/text/future_annotations.txt").read_text() 595 | expected_contents = """\ 596 | Dummy Module 597 | ************ 598 | 599 | dummy_module_future_annotations.function_with_py310_annotations(self, x, y, z=None) 600 | 601 | Method docstring. 602 | 603 | Parameters: 604 | * **x** ("bool" | "None") -- foo 605 | 606 | * **y** ("int" | "str" | "float") -- bar 607 | 608 | * **z** ("str" | "None") -- baz 609 | 610 | Return type: 611 | "str" 612 | """ 613 | expected_contents = dedent(expected_contents) 614 | expected_contents = dedent(expected_contents) 615 | assert contents == expected_contents 616 | 617 | 618 | @pytest.mark.sphinx("pseudoxml", testroot="dummy") 619 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 620 | def test_sphinx_output_default_role(app: SphinxTestApp, status: StringIO) -> None: 621 | set_python_path() 622 | 623 | app.config.master_doc = "simple_default_role" # create flag 624 | app.config.default_role = "literal" 625 | app.build() 626 | 627 | assert "build succeeded" in status.getvalue() # Build succeeded 628 | 629 | contents_lines = ( 630 | (Path(app.srcdir) / "_build/pseudoxml/simple_default_role.pseudoxml").read_text(encoding="utf-8").splitlines() 631 | ) 632 | list_item_idxs = [i for i, line in enumerate(contents_lines) if line.strip() == ""] 633 | foo_param = dedent("\n".join(contents_lines[list_item_idxs[0] : list_item_idxs[1]])) 634 | expected_foo_param = """\ 635 | 636 | 637 | 638 | x 639 | ( 640 | 641 | 642 | bool 643 | ) 644 | \N{EN DASH}\N{SPACE} 645 | 646 | foo 647 | """.rstrip() 648 | expected_foo_param = dedent(expected_foo_param) 649 | assert foo_param == expected_foo_param 650 | 651 | 652 | @pytest.mark.parametrize( 653 | ("defaults_config_val", "expected"), 654 | [ 655 | (None, '("int") -- bar'), 656 | ("comma", '("int", default: "1") -- bar'), 657 | ("braces", '("int" (default: "1")) -- bar'), 658 | ("braces-after", '("int") -- bar (default: "1")'), 659 | ("comma-after", Exception("needs to be one of")), 660 | ], 661 | ) 662 | @pytest.mark.sphinx("text", testroot="dummy") 663 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 664 | def test_sphinx_output_defaults( 665 | app: SphinxTestApp, 666 | status: StringIO, 667 | defaults_config_val: str, 668 | expected: str | Exception, 669 | ) -> None: 670 | set_python_path() 671 | 672 | app.config.master_doc = "simple" # create flag 673 | app.config.typehints_defaults = defaults_config_val # create flag 674 | if isinstance(expected, Exception): 675 | with pytest.raises(Exception, match=re.escape(str(expected))): 676 | app.build() 677 | return 678 | app.build() 679 | assert "build succeeded" in status.getvalue() 680 | 681 | contents = (Path(app.srcdir) / "_build/text/simple.txt").read_text() 682 | expected_contents = f"""\ 683 | Simple Module 684 | ************* 685 | 686 | dummy_module_simple.function(x, y=1) 687 | 688 | Function docstring. 689 | 690 | Parameters: 691 | * **x** ("bool") -- foo 692 | 693 | * **y** {expected} 694 | 695 | Return type: 696 | "str" 697 | """ 698 | assert contents == dedent(expected_contents) 699 | 700 | 701 | @pytest.mark.parametrize( 702 | ("formatter_config_val", "expected"), 703 | [ 704 | (None, ['("bool") -- foo', '("int") -- bar', '"str"']), 705 | (lambda ann, conf: "Test", ["(Test) -- foo", "(Test) -- bar", "Test"]), # noqa: ARG005 706 | ("some string", Exception("needs to be callable or `None`")), 707 | ], 708 | ) 709 | @pytest.mark.sphinx("text", testroot="dummy") 710 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 711 | def test_sphinx_output_formatter( 712 | app: SphinxTestApp, 713 | status: StringIO, 714 | formatter_config_val: str, 715 | expected: tuple[str, ...] | Exception, 716 | ) -> None: 717 | set_python_path() 718 | 719 | app.config.master_doc = "simple" # create flag 720 | app.config.typehints_formatter = formatter_config_val # create flag 721 | if isinstance(expected, Exception): 722 | with pytest.raises(Exception, match=re.escape(str(expected))): 723 | app.build() 724 | return 725 | app.build() 726 | assert "build succeeded" in status.getvalue() 727 | 728 | contents = (Path(app.srcdir) / "_build/text/simple.txt").read_text() 729 | expected_contents = f"""\ 730 | Simple Module 731 | ************* 732 | 733 | dummy_module_simple.function(x, y=1) 734 | 735 | Function docstring. 736 | 737 | Parameters: 738 | * **x** {expected[0]} 739 | 740 | * **y** {expected[1]} 741 | 742 | Return type: 743 | {expected[2]} 744 | """ 745 | assert contents == dedent(expected_contents) 746 | 747 | 748 | def test_normalize_source_lines_async_def() -> None: 749 | source = """ 750 | async def async_function(): 751 | class InnerClass: 752 | def __init__(self): ... 753 | """ 754 | 755 | expected = """ 756 | async def async_function(): 757 | class InnerClass: 758 | def __init__(self): ... 759 | """ 760 | 761 | assert normalize_source_lines(dedent(source)) == dedent(expected) 762 | 763 | 764 | def test_normalize_source_lines_def_starting_decorator_parameter() -> None: 765 | source = """ 766 | @_with_parameters( 767 | _Parameter("self", _Parameter.POSITIONAL_OR_KEYWORD), 768 | *_proxy_instantiation_parameters, 769 | _project_id, 770 | _Parameter( 771 | "node_numbers", 772 | _Parameter.POSITIONAL_OR_KEYWORD, 773 | default=None, 774 | annotation=Optional[Iterable[int]], 775 | ), 776 | ) 777 | def __init__(bound_args): # noqa: N805 778 | ... 779 | """ 780 | 781 | expected = """ 782 | @_with_parameters( 783 | _Parameter("self", _Parameter.POSITIONAL_OR_KEYWORD), 784 | *_proxy_instantiation_parameters, 785 | _project_id, 786 | _Parameter( 787 | "node_numbers", 788 | _Parameter.POSITIONAL_OR_KEYWORD, 789 | default=None, 790 | annotation=Optional[Iterable[int]], 791 | ), 792 | ) 793 | def __init__(bound_args): # noqa: N805 794 | ... 795 | """ 796 | 797 | assert normalize_source_lines(dedent(source)) == dedent(expected) 798 | 799 | 800 | @pytest.mark.parametrize("obj", [cmp_to_key, 1]) 801 | def test_default_no_signature(obj: Any) -> None: 802 | config = create_autospec( 803 | Config, 804 | typehints_fully_qualified=False, 805 | simplify_optional_unions=False, 806 | typehints_formatter=None, 807 | autodoc_mock_imports=[], 808 | ) 809 | app: Sphinx = create_autospec(Sphinx, config=config) 810 | lines: list[str] = [] 811 | process_docstring(app, "what", "name", obj, None, lines) 812 | assert lines == [] 813 | 814 | 815 | @pytest.mark.parametrize("method", [HintedMethods.from_magic, HintedMethods().method]) 816 | def test_bound_class_method(method: FunctionType) -> None: 817 | config = create_autospec( 818 | Config, 819 | typehints_fully_qualified=False, 820 | simplify_optional_unions=False, 821 | typehints_document_rtype=False, 822 | always_document_param_types=True, 823 | typehints_defaults=True, 824 | typehints_formatter=None, 825 | autodoc_mock_imports=[], 826 | ) 827 | app: Sphinx = create_autospec(Sphinx, config=config) 828 | process_docstring(app, "class", method.__qualname__, method, None, []) 829 | 830 | 831 | def test_syntax_error_backfill() -> None: 832 | # Regression test for #188 833 | # fmt: off 834 | def func(x): # type: ignore[no-untyped-def] # noqa: ANN001, ANN202 835 | return x 836 | 837 | # fmt: on 838 | backfill_type_hints(func, "func") 839 | 840 | 841 | @pytest.mark.sphinx("text", testroot="resolve-typing-guard") 842 | def test_resolve_typing_guard_imports(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None: 843 | set_python_path() 844 | app.config.autodoc_mock_imports = ["viktor"] # create flag 845 | app.build() 846 | out = status.getvalue() 847 | assert "build succeeded" in out 848 | err = warning.getvalue() 849 | r = re.compile(r"WARNING: Failed guarded type import") 850 | assert len(r.findall(err)) == 1 851 | pat = r'WARNING: Failed guarded type import with ImportError\("cannot import name \'missing\' from \'functools\'' 852 | assert re.search(pat, err) 853 | 854 | 855 | @pytest.mark.sphinx("text", testroot="resolve-typing-guard-tmp") 856 | def test_resolve_typing_guard_attrs_imports(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None: 857 | set_python_path() 858 | app.build() 859 | assert "build succeeded" in status.getvalue() 860 | assert not warning.getvalue() 861 | 862 | 863 | def test_no_source_code_type_guard() -> None: 864 | from csv import Error # noqa: PLC0415 865 | 866 | _resolve_type_guarded_imports([], Error) 867 | 868 | 869 | @pytest.mark.sphinx("text", testroot="dummy") 870 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 871 | def test_sphinx_output_formatter_no_use_rtype(app: SphinxTestApp, status: StringIO) -> None: 872 | set_python_path() 873 | app.config.master_doc = "simple_no_use_rtype" # create flag 874 | app.config.typehints_use_rtype = False 875 | app.build() 876 | assert "build succeeded" in status.getvalue() 877 | text_path = Path(app.srcdir) / "_build" / "text" / "simple_no_use_rtype.txt" 878 | text_contents = text_path.read_text().replace("–", "--") # noqa: RUF001 # keep ambiguous EN DASH 879 | expected_contents = """\ 880 | Simple Module 881 | ************* 882 | 883 | dummy_module_simple_no_use_rtype.function_no_returns(x, y=1) 884 | 885 | Function docstring. 886 | 887 | Parameters: 888 | * **x** ("bool") -- foo 889 | 890 | * **y** ("int") -- bar 891 | 892 | Return type: 893 | "str" 894 | 895 | dummy_module_simple_no_use_rtype.function_returns_with_type(x, y=1) 896 | 897 | Function docstring. 898 | 899 | Parameters: 900 | * **x** ("bool") -- foo 901 | 902 | * **y** ("int") -- bar 903 | 904 | Returns: 905 | *CustomType* -- A string 906 | 907 | dummy_module_simple_no_use_rtype.function_returns_with_compound_type(x, y=1) 908 | 909 | Function docstring. 910 | 911 | Parameters: 912 | * **x** ("bool") -- foo 913 | 914 | * **y** ("int") -- bar 915 | 916 | Returns: 917 | Union[str, int] -- A string or int 918 | 919 | dummy_module_simple_no_use_rtype.function_returns_without_type(x, y=1) 920 | 921 | Function docstring. 922 | 923 | Parameters: 924 | * **x** ("bool") -- foo 925 | 926 | * **y** ("int") -- bar 927 | 928 | Returns: 929 | "str" -- A string 930 | """ 931 | assert text_contents == dedent(expected_contents) 932 | 933 | 934 | @pytest.mark.sphinx("text", testroot="dummy") 935 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 936 | def test_sphinx_output_with_use_signature(app: SphinxTestApp, status: StringIO) -> None: 937 | set_python_path() 938 | app.config.master_doc = "simple" # create flag 939 | app.config.typehints_use_signature = True 940 | app.build() 941 | assert "build succeeded" in status.getvalue() 942 | text_path = Path(app.srcdir) / "_build" / "text" / "simple.txt" 943 | text_contents = text_path.read_text().replace("–", "--") # noqa: RUF001 # keep ambiguous EN DASH 944 | expected_contents = """\ 945 | Simple Module 946 | ************* 947 | 948 | dummy_module_simple.function(x: bool, y: int = 1) 949 | 950 | Function docstring. 951 | 952 | Parameters: 953 | * **x** ("bool") -- foo 954 | 955 | * **y** ("int") -- bar 956 | 957 | Return type: 958 | "str" 959 | """ 960 | assert text_contents == dedent(expected_contents) 961 | 962 | 963 | @pytest.mark.sphinx("text", testroot="dummy") 964 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 965 | def test_sphinx_output_with_use_signature_return(app: SphinxTestApp, status: StringIO) -> None: 966 | set_python_path() 967 | app.config.master_doc = "simple" # create flag 968 | app.config.typehints_use_signature_return = True 969 | app.build() 970 | assert "build succeeded" in status.getvalue() 971 | text_path = Path(app.srcdir) / "_build" / "text" / "simple.txt" 972 | text_contents = text_path.read_text().replace("–", "--") # noqa: RUF001 # keep ambiguous EN DASH 973 | expected_contents = """\ 974 | Simple Module 975 | ************* 976 | 977 | dummy_module_simple.function(x, y=1) -> str 978 | 979 | Function docstring. 980 | 981 | Parameters: 982 | * **x** ("bool") -- foo 983 | 984 | * **y** ("int") -- bar 985 | 986 | Return type: 987 | "str" 988 | """ 989 | assert text_contents == dedent(expected_contents) 990 | 991 | 992 | @pytest.mark.sphinx("text", testroot="dummy") 993 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 994 | def test_sphinx_output_with_use_signature_and_return(app: SphinxTestApp, status: StringIO) -> None: 995 | set_python_path() 996 | app.config.master_doc = "simple" # create flag 997 | app.config.typehints_use_signature = True 998 | app.config.typehints_use_signature_return = True 999 | app.build() 1000 | assert "build succeeded" in status.getvalue() 1001 | text_path = Path(app.srcdir) / "_build" / "text" / "simple.txt" 1002 | text_contents = text_path.read_text().replace("–", "--") # noqa: RUF001 # keep ambiguous EN DASH 1003 | expected_contents = """\ 1004 | Simple Module 1005 | ************* 1006 | 1007 | dummy_module_simple.function(x: bool, y: int = 1) -> str 1008 | 1009 | Function docstring. 1010 | 1011 | Parameters: 1012 | * **x** ("bool") -- foo 1013 | 1014 | * **y** ("int") -- bar 1015 | 1016 | Return type: 1017 | "str" 1018 | """ 1019 | assert text_contents == dedent(expected_contents) 1020 | 1021 | 1022 | @pytest.mark.sphinx("text", testroot="dummy") 1023 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 1024 | def test_default_annotation_without_typehints(app: SphinxTestApp, status: StringIO) -> None: 1025 | set_python_path() 1026 | app.config.master_doc = "without_complete_typehints" # create flag 1027 | app.config.typehints_defaults = "comma" 1028 | app.build() 1029 | assert "build succeeded" in status.getvalue() 1030 | text_path = Path(app.srcdir) / "_build" / "text" / "without_complete_typehints.txt" 1031 | text_contents = text_path.read_text().replace("–", "--") # noqa: RUF001 # keep ambiguous EN DASH 1032 | expected_contents = """\ 1033 | Simple Module 1034 | ************* 1035 | 1036 | dummy_module_without_complete_typehints.function_with_some_defaults_and_without_typehints(x, y=None) 1037 | 1038 | Function docstring. 1039 | 1040 | Parameters: 1041 | * **x** -- foo 1042 | 1043 | * **y** (default: "None") -- bar 1044 | 1045 | dummy_module_without_complete_typehints.function_with_some_defaults_and_some_typehints(x, y=None) 1046 | 1047 | Function docstring. 1048 | 1049 | Parameters: 1050 | * **x** ("int") -- foo 1051 | 1052 | * **y** (default: "None") -- bar 1053 | 1054 | dummy_module_without_complete_typehints.function_with_some_defaults_and_more_typehints(x, y=None) 1055 | 1056 | Function docstring. 1057 | 1058 | Parameters: 1059 | * **x** ("int") -- foo 1060 | 1061 | * **y** (default: "None") -- bar 1062 | 1063 | Return type: 1064 | "str" 1065 | 1066 | dummy_module_without_complete_typehints.function_with_defaults_and_some_typehints(x=0, y=None) 1067 | 1068 | Function docstring. 1069 | 1070 | Parameters: 1071 | * **x** ("int", default: "0") -- foo 1072 | 1073 | * **y** (default: "None") -- bar 1074 | 1075 | Return type: 1076 | "str" 1077 | """ 1078 | assert text_contents == dedent(expected_contents) 1079 | 1080 | 1081 | @pytest.mark.sphinx("text", testroot="dummy") 1082 | @patch("sphinx.writers.text.MAXWIDTH", 2000) 1083 | def test_wrong_module_path(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None: 1084 | set_python_path() 1085 | 1086 | app.config.master_doc = "wrong_module_path" # create flag 1087 | app.config.default_role = "literal" 1088 | app.config.nitpicky = True 1089 | app.config.nitpick_ignore = {("py:data", "typing.Optional")} 1090 | 1091 | def fixup_module_name(mod: str) -> str: 1092 | if not mod.startswith("wrong_module_path"): 1093 | return mod 1094 | return "export_module" + mod.removeprefix("wrong_module_path") 1095 | 1096 | app.config.suppress_warnings = ["config.cache"] 1097 | app.config.typehints_fixup_module_name = fixup_module_name 1098 | app.build() 1099 | 1100 | assert "build succeeded" in status.getvalue() # Build succeeded 1101 | assert not warning.getvalue().strip() 1102 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def test_version() -> None: 5 | from sphinx_autodoc_typehints import __version__ # noqa: PLC0415 6 | 7 | assert __version__ 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.30.2 4 | tox-uv>=1.28 5 | env_list = 6 | fix 7 | 3.14 8 | 3.13 9 | 3.12 10 | 3.11 11 | type 12 | 3.14t 13 | pkg_meta 14 | skip_missing_interpreters = true 15 | 16 | [testenv] 17 | description = run the unit tests with pytest under {base_python} 18 | package = wheel 19 | wheel_build_env = .pkg 20 | extras = 21 | numpy 22 | testing 23 | type-comment 24 | pass_env = 25 | DIFF_AGAINST 26 | PYTEST_* 27 | set_env = 28 | COVERAGE_FILE = {work_dir}/.coverage.{env_name} 29 | commands = 30 | python -m pytest {tty:--color=yes} {posargs: \ 31 | --cov {env_site_packages_dir}{/}sphinx_autodoc_typehints --cov {tox_root}{/}tests \ 32 | --cov-config=pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ 33 | --cov-report html:{env_tmp_dir}{/}htmlcov --cov-report xml:{work_dir}{/}coverage.{env_name}.xml \ 34 | --junitxml {work_dir}{/}junit.{env_name}.xml \ 35 | tests} 36 | diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {work_dir}{/}coverage.{env_name}.xml --fail-under 100 37 | 38 | [testenv:fix] 39 | description = format the code base to adhere to our styles, and complain about what we cannot do automatically 40 | skip_install = true 41 | deps = 42 | pre-commit-uv>=4.1.5 43 | commands = 44 | pre-commit run --all-files --show-diff-on-failure 45 | 46 | [testenv:type] 47 | description = run type check on code base 48 | deps = 49 | mypy==1.18.2 50 | types-docutils>=0.22.2.20250924 51 | commands = 52 | mypy src 53 | mypy tests 54 | 55 | [testenv:pkg_meta] 56 | description = check that the long description is valid 57 | skip_install = true 58 | deps = 59 | check-wheel-contents>=0.6.3 60 | twine>=6.2 61 | uv>=0.8.22 62 | commands = 63 | uv build --sdist --wheel --out-dir {env_tmp_dir} . 64 | twine check {env_tmp_dir}{/}* 65 | check-wheel-contents --no-config {env_tmp_dir} 66 | 67 | [testenv:dev] 68 | description = generate a DEV environment 69 | package = editable 70 | commands = 71 | uv pip tree 72 | python -c 'import sys; print(sys.executable)' 73 | -------------------------------------------------------------------------------- /whitelist.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/sphinx-autodoc-typehints/41100f97b15bf3e6016803805f9befb2937a3ee0/whitelist.txt --------------------------------------------------------------------------------